CI / backend (push) Canceled after 0s
之前知识只能"存",不能"找"。这一版把链路补上:切片 → 向量 → 打分 → 带来源返回。
- chunk 表 + 迁移 0006:正文按标题归段切、代码块整块保留、超长按句切、
相邻片重叠 80 字;RLS 与 GRANT 与其它租户表一致
- 向量提供方做成协议:内置 hash(字符二元组,零依赖可复现,作为基线)
与 OpenAI 兼容端点两种,换实现不动业务代码
- 检索是混合打分:向量 0.6 + pg_trgm 0.4。专有编号、单号这类必须原样命中的词
靠词法兜,换个说法表达的同一件事靠向量兜
- 三条纪律:只搜 published(过滤写在 SQL 里)、低于阈值宁可不返回、
每条命中都带来源标题与 item_id
- 发布时自动建索引,改完正文用 POST /knowledge/{id}/reindex 重建
- Web 知识页加检索框与命中卡片;新增 e2e/retrieval.mjs 6 项
为什么不是 pgvector:本机 Docker 起不来、fnsvr-01 没有该扩展且明确不动。
先用 real[] + SQL 余弦把链路跑通,将来换列类型与索引即可,接口不变。
后端 79 passed,e2e smoke/knowledge/richtext/retrieval 全绿。
121 lines
4.6 KiB
JavaScript
121 lines
4.6 KiB
JavaScript
/**
|
|
* 检索 e2e:发布的知识能被搜到,草稿搜不到,结果能点回原文。
|
|
*
|
|
* 只验证"用户能不能用上",切片与打分逻辑由后端测试覆盖。
|
|
*/
|
|
import { chromium } from "playwright-core";
|
|
|
|
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
|
|
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
|
|
const stamp = String(Date.now()).slice(-7);
|
|
const phone = `134${stamp}0`;
|
|
|
|
let failures = 0;
|
|
function check(name, ok, extra = "") {
|
|
console.log(`${ok ? "PASS" : "FAIL"} ${name}${extra ? ` — ${extra}` : ""}`);
|
|
if (!ok) failures += 1;
|
|
}
|
|
|
|
async function call(method, path, body, token) {
|
|
const res = await fetch(`${API}${path}`, {
|
|
method,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
...(method === "GET" ? {} : { "idempotency-key": `e2e-${Math.random()}` }),
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const text = await res.text();
|
|
const json = text ? JSON.parse(text) : {};
|
|
if (json.code !== 0) throw new Error(`${method} ${path} -> ${json.code} ${json.message}`);
|
|
return json.data;
|
|
}
|
|
|
|
const browser = await chromium.launch({
|
|
executablePath:
|
|
process.env.CHROME ??
|
|
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
|
|
});
|
|
const consoleErrors = [];
|
|
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
const page = await context.newPage();
|
|
page.on("console", (m) => {
|
|
if (m.type() === "error") consoleErrors.push(m.text());
|
|
});
|
|
page.on("response", (r) => {
|
|
if (r.status() >= 400) consoleErrors.push(`${r.status()} ${r.url()}`);
|
|
});
|
|
|
|
try {
|
|
await call("POST", "/auth/sms/send", { phone });
|
|
const login = await call("POST", "/auth/login", { phone, code: "123456" });
|
|
const token = login.tokens.access_token;
|
|
const ws = await call("POST", "/workspaces", { name: `检索验收 ${stamp}` }, token);
|
|
|
|
const published = await call(
|
|
"POST",
|
|
`/workspaces/${ws.id}/knowledge`,
|
|
{
|
|
title: "快递单号对不上怎么处理",
|
|
doc_type: "howto",
|
|
body_md:
|
|
"## 步骤\n\n先在顺丰后台按手机号反查单号,再和发货记录逐一核对。\n\n如果差超过两天,直接找客服开异常件工单。",
|
|
},
|
|
token,
|
|
);
|
|
await call(
|
|
"POST",
|
|
`/workspaces/${ws.id}/knowledge/${published.id}/status`,
|
|
{ action: "publish", version: published.version },
|
|
token,
|
|
);
|
|
|
|
const draft = await call(
|
|
"POST",
|
|
`/workspaces/${ws.id}/knowledge`,
|
|
{ title: "还没核对的草稿", doc_type: "note", body_md: "这条写的是异常件的赔付口径,尚未定稿。" },
|
|
token,
|
|
);
|
|
|
|
await page.addInitScript(
|
|
([access, refresh]) => {
|
|
localStorage.setItem("dadao.access", access);
|
|
localStorage.setItem("dadao.refresh", refresh);
|
|
},
|
|
[token, login.tokens.refresh_token],
|
|
);
|
|
await page.goto(`${WEB}/w/${ws.id}/knowledge`, { waitUntil: "networkidle" });
|
|
|
|
const box = page.getByPlaceholder(/搜标题或正文/);
|
|
await box.fill("异常件单号核对");
|
|
await box.press("Enter");
|
|
await page.getByText(/命中 \d+ 段/).waitFor({ timeout: 10_000 });
|
|
const summary = await page.getByText(/命中 \d+ 段/).first().textContent();
|
|
check("检索返回了结果", /命中 [1-9]\d* 段/.test(summary ?? ""), summary ?? "");
|
|
check("命中里带标题", await page.getByText("快递单号对不上怎么处理", { exact: false }).first().isVisible());
|
|
check("草稿不会出现在检索结果里", !(await page.getByText("还没核对的草稿", { exact: false }).count()));
|
|
await page.screenshot({ path: "e2e/artifacts/retrieval-hits.png", fullPage: false });
|
|
|
|
await page.getByRole("button", { name: "打开原文", exact: true }).first().click();
|
|
await page.getByText(/已发布 · 版本 \d+/, { exact: false }).waitFor({ timeout: 10_000 });
|
|
check("命中能点回原文", true);
|
|
await page.getByRole("button", { name: "关闭", exact: true }).click();
|
|
|
|
await box.fill("量子纠缠");
|
|
await box.press("Enter");
|
|
await page.getByText("没有检索到相关内容", { exact: false }).waitFor({ timeout: 10_000 });
|
|
check("不相干的关键词宁可返回空", true);
|
|
|
|
check("浏览器控制台与网络无报错", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
|
|
} catch (err) {
|
|
failures += 1;
|
|
console.log(`FAIL 脚本异常 — ${err.message}`);
|
|
await page.screenshot({ path: "e2e/artifacts/retrieval-failure.png" }).catch(() => {});
|
|
console.log((await page.textContent("body"))?.slice(0, 600));
|
|
} finally {
|
|
await browser.close();
|
|
console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILED`);
|
|
process.exit(failures === 0 ? 0 : 1);
|
|
}
|