Files
mnote/scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js
T
lix-2026 9551d4c1dc feat(rag): harden post-LightRAG runtime
Retire legacy OCR/media/evidence fallbacks, add local-folder event bus and Page Aggregate guards, and archive completed design checklists.

Validation: cargo test -p mnote-web -- --test-threads=1; cargo test --workspace -- --test-threads=1; git diff --check; codegraph sync .; codegraph_status.
2026-06-07 10:35:21 +08:00

279 lines
12 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = process.env.MNOTE_REPO_ROOT || "/mnt/Data1T/mnote";
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
const OUTPUT_DIR = path.join(ROOT, "tmp", "task530-knowledge-rag-page-ai-final-answer-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-answer.png");
const CONTROL_PLANE_DB =
process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR_ID = "mnote-e2e";
const WORKSPACE_ID = "local-ws:mnote-e2e:my-space";
const ROOT_PATH = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = `file://${ROOT_PATH}`;
const OWNER_REL = "knowledge-rag-fixtures-7-50/PageAiKnowledgeRagSmoke.md";
const DOCUMENT_ID = `local-md:${OWNER_REL.replaceAll("/", "~2F")}`;
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function ensureGrant() {
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(ACTOR_ID)}, 'mnote.e2e@example.com', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(ACTOR_ID)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ACTOR_ID)}, 'MNote E2E Space', 'personal', ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES ('grant_task530_knowledge_rag', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
function ensureOwnerPage() {
const ownerPath = path.join(ROOT_PATH, OWNER_REL);
fs.mkdirSync(path.dirname(ownerPath), { recursive: true });
if (!fs.existsSync(ownerPath)) {
fs.writeFileSync(
ownerPath,
["# Page AI Knowledge RAG Smoke", "", "This page is a stable Page AI smoke target for LightRAG retrieval.", ""].join("\n"),
"utf8",
);
}
}
async function signIn(context) {
const response = await context.request.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
account: ACTOR_ID,
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
}
async function selectReasonix(page) {
await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix",
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function newestAssistantText(page, initialCount) {
return await page.evaluate((countBefore) => {
const nodes = Array.from(
document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'),
);
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
const content = node?.querySelector(".wolai-page-ai-message-text");
return content ? content.textContent || "" : "";
}, initialCount);
}
async function newestAssistantLinks(page, initialCount) {
return await page.evaluate((countBefore) => {
const nodes = Array.from(
document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'),
);
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
if (!node) return [];
return Array.from(node.querySelectorAll("a")).map((anchor) => ({
text: anchor.textContent || "",
href: anchor.getAttribute("href") || "",
}));
}, initialCount);
}
function summarizeRun(body) {
return {
workspaceId: body.workspaceId || "",
documentId: body.documentId || "",
sourceKind: body.sourceKind || "",
rootUri: body.rootUri || "",
agentId: body.agentId || "",
profile: body.profile || "",
acpRuntime: body.acpRuntime || "",
contextRefs: Array.isArray(body.contextRefs)
? body.contextRefs.map((item) => (typeof item === "string" ? item : item?.kind || "")).filter(Boolean)
: [],
allowedRootCount: Array.isArray(body.allowedRoots) ? body.allowedRoots.length : 0,
mnoteKnowledgeRagDisabled: body.skillPreferences?.mnote?.["mnote-knowledge-rag"] === false,
message: body.message || "",
};
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
ensureGrant();
ensureOwnerPage();
const browser = await chromium.launch({
headless: process.env.MNOTE_PAGE_AI_VERIFY_HEADED !== "1",
executablePath: fs.existsSync(CHROME) ? CHROME : undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const capturedRuns = [];
const consoleErrors = [];
const pageErrors = [];
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) {
consoleErrors.push({ type: message.type(), text: message.text() });
}
});
page.on("pageerror", (error) => pageErrors.push(String(error?.message || error)));
page.on("request", (request) => {
if (!request.url().includes("/api/hermes/client/runs") || request.method() !== "POST") return;
try {
capturedRuns.push(JSON.parse(request.postData() || "{}"));
} catch {
capturedRuns.push({ raw: request.postData() || "" });
}
});
try {
await signIn(context);
const documentUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(DOCUMENT_ID)}`);
documentUrl.searchParams.set("sourceKind", "local_folder");
documentUrl.searchParams.set("rootUri", ROOT_URI);
documentUrl.searchParams.set("workspaceId", WORKSPACE_ID);
await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await selectReasonix(page);
const assistantCount = await page
.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')
.count();
const prompt = [
"请调用 mnote_knowledge_rag_query 检索资料库。",
"问题:这个图片资料在资料库里是什么内容?调用工具时请把 sourcePaths 参数设为 [\"新页面233155/image copy 6.png\"]。",
"最终只输出一句中文结论,必须包含返回的 citationMarkdown 链接;如果 locatorDegraded=true,必须说明来源定位降级,不要描述检索过程,不要输出 raw JSON,不要编造页码或 bbox。",
].join("\n");
await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "completed",
null,
{ timeout: Math.max(UI_TIMEOUT_MS, 180_000) },
);
await page.waitForFunction(
(countBefore) => {
const nodes = Array.from(
document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'),
);
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
const text = node?.querySelector(".wolai-page-ai-message-text")?.textContent || "";
return text.includes("来源定位降级") && text.includes("image copy 6.png");
},
assistantCount,
{ timeout: Math.max(UI_TIMEOUT_MS, 180_000) },
);
const assistantText = await newestAssistantText(page, assistantCount);
const assistantLinks = await newestAssistantLinks(page, assistantCount);
assert(assistantText.includes("image copy 6.png"), `可见回答缺少当前资料来源文件名: ${assistantText}`);
assert(assistantText.includes("来源定位降级"), `可见回答缺少 degraded citation 口径: ${assistantText}`);
assert(!/\bp\.\d+\b/i.test(assistantText), `degraded citation 不应编造页码: ${assistantText}`);
assert(!/bbox/i.test(assistantText), `degraded citation 不应编造 bbox: ${assistantText}`);
assert(
assistantLinks.some((link) => link.text.includes("来源定位降级") && link.href.includes("resourceTab=")),
`可见回答缺少可点击 resourceTab citation: ${JSON.stringify(assistantLinks)}`,
);
assert(!assistantText.includes("citationMarkdown"), `可见回答泄漏工具字段名: ${assistantText}`);
assert(!assistantText.includes('"raw"'), `可见回答泄漏 raw JSON: ${assistantText}`);
assert(!assistantText.includes("mnote_knowledge_rag_query"), `可见回答泄漏工具名: ${assistantText}`);
assert(!/让我|我来|我先|查询返回|找到了|检索资料库/.test(assistantText), `可见回答包含检索过程叙述: ${assistantText}`);
assert(
capturedRuns.some((body) => body.acpRuntime === "reasonix" && body.profile === "reasonix"),
"未捕获到 Reasonix Page AI run",
);
assert(
capturedRuns.every((body) => body.sourceKind === "local_folder" && body.rootUri === ROOT_URI),
"Page AI run 未保持 local_folder/rootUri 上下文",
);
assert(
capturedRuns.some((body) => body.skillPreferences?.mnote?.["mnote-knowledge-rag"] !== false),
"Page AI run 禁用了 mnote-knowledge-rag",
);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
const result = {
ok: true,
baseUrl: BASE_URL,
workspaceId: WORKSPACE_ID,
rootUri: ROOT_URI,
documentId: DOCUMENT_ID,
assistantText,
assistantLinks,
capturedRuns: capturedRuns.map(summarizeRun),
capturedRunsFullPath: path.join(OUTPUT_DIR, "captured-runs-full.json"),
pageErrors,
consoleErrors,
screenshot: SCREENSHOT_PATH,
};
fs.writeFileSync(
path.join(OUTPUT_DIR, "captured-runs-full.json"),
`${JSON.stringify(capturedRuns, null, 2)}\n`,
"utf8",
);
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
await page.screenshot({ path: path.join(OUTPUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined);
fs.writeFileSync(
path.join(OUTPUT_DIR, "failure.json"),
`${JSON.stringify(
{
ok: false,
error: error instanceof Error ? error.stack || error.message : String(error),
capturedRuns,
pageErrors,
consoleErrors,
},
null,
2,
)}\n`,
"utf8",
);
throw error;
} finally {
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error.stack || error.message || String(error));
process.exit(1);
});