Files
mnote/scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js
T

400 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
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 {
setupWorkspaceAccess,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
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 CITATION_OPEN_SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-citation-open.png");
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 EXPECTED_RESOURCE = "新页面233155/image copy 6.png";
const DOCUMENT_ID = `local-md:${OWNER_REL.replaceAll("/", "~2F")}`;
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 WeKnora 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 assertKnowledgeRagDescriptor(context) {
const response = await context.request.get(`${BASE_URL}/api/page-ai/agents/descriptors?profile=reasonix`, {
headers: {
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": "user",
accept: "application/json",
},
});
assert(response.ok(), `descriptor API 失败: ${response.status()} ${await response.text()}`);
const payload = await response.json();
const reasonix = (payload.descriptors || []).find((descriptor) => descriptor.agentId === "reasonix");
assert(reasonix, "descriptor 缺少 Reasonix");
assert.equal(reasonix.capabilityStates?.knowledge_rag?.enabled, true, "Reasonix descriptor 应声明 knowledge_rag enabled");
assert((reasonix.capabilities || []).includes("knowledge_rag"), "Reasonix descriptor capabilities 应包含 knowledge_rag");
const toolNames = new Set((reasonix.tools || []).map((tool) => tool.name));
for (const toolName of ["mnote.knowledge_rag.status", "mnote.knowledge_rag.query", "mnote.knowledge_rag.open_reference"]) {
assert(toolNames.has(toolName), `Reasonix descriptor 缺少 ${toolName}`);
}
}
async function knowledgeRagStatus(context) {
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
const response = await context.request.get(`${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
assert(response.ok(), `knowledge-rag status 失败: ${response.status()} ${await response.text()}`);
return await response.json();
}
async function ensureKnowledgeRagSourceIndexed(context) {
const response = await context.request.post(`${BASE_URL}/api/knowledge-rag/ingest`, {
data: {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sources: [{ sourcePath: EXPECTED_RESOURCE }],
},
});
assert(response.ok(), `knowledge-rag ingest 失败: ${response.status()} ${await response.text()}`);
const startedAt = Date.now();
let lastEntry = null;
while (Date.now() - startedAt < 90_000) {
const payload = await knowledgeRagStatus(context);
lastEntry = (payload.registry?.entries || []).find((entry) => entry.sourceRootRelativePath === EXPECTED_RESOURCE) || null;
if (lastEntry?.indexedAtMs && lastEntry?.lightRagDocId && !lastEntry?.stale && !lastEntry?.deletedAtMs) return lastEntry;
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
throw new Error(`等待目标资料入库超时: ${JSON.stringify(lastEntry, null, 2)}`);
}
async function assertKnowledgeRagQueryReturnsCitationMarkdown(context) {
const response = await context.request.post(`${BASE_URL}/api/knowledge-rag/query`, {
data: {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query: "这个图片资料在资料库里是什么内容?",
mode: "mix",
topK: 8,
chunkTopK: 8,
includeChunkContent: true,
sourcePaths: [EXPECTED_RESOURCE],
},
});
assert(response.ok(), `knowledge-rag query 失败: ${response.status()} ${await response.text()}`);
const payload = await response.json();
const references = Array.isArray(payload.references) ? payload.references : [];
assert(
references.some((reference) => String(reference.citationMarkdown || "").trim() && String(reference.citationUrl || "").includes("resourceTab=")),
`query 输出缺少 citationMarkdownPage AI 最终回答 smoke 不能继续: ${JSON.stringify(references, null, 2).slice(0, 3000)}`,
);
}
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);
}
async function clickNewestAssistantCitation(page, initialCount, expectedResource) {
const total = await page
.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')
.count();
const latestIndex = Math.max(initialCount, total - 1);
const latestMessage = page
.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')
.nth(latestIndex);
const citationLink = latestMessage.locator('a[data-page-ai-citation-link="true"]').first();
await citationLink.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const popupPromise = page.waitForEvent("popup", { timeout: 5_000 }).catch(() => null);
await citationLink.click({ timeout: UI_TIMEOUT_MS });
const openedPage = (await popupPromise) || page;
await openedPage.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
await openedPage.waitForFunction(
(resourcePath) => {
const panel = document.querySelector(
`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(resourcePath)}"]`,
);
return panel && !panel.hidden;
},
expectedResource,
{ timeout: UI_TIMEOUT_MS },
);
await openedPage.waitForTimeout(1500);
const state = await openedPage.evaluate((resourcePath) => {
const panel = document.querySelector(
`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(resourcePath)}"]`,
);
const activeTab = document.querySelector(".mnote-main-tab.is-active, [data-mnote-tab-kind].is-active");
const image = panel ? panel.querySelector("img, [data-mnote-image-viewer], [data-mnote-resource-image]") : null;
return {
url: location.href,
openedInPopup: window.opener != null,
activeTabText: activeTab ? activeTab.textContent.trim().slice(0, 120) : "",
activeTabKind: activeTab ? activeTab.getAttribute("data-mnote-tab-kind") : "",
panelVisible: !!panel && !panel.hidden,
panelResourcePath: panel ? panel.getAttribute("data-resource-path") : "",
panelLocator: panel ? panel.getAttribute("data-mnote-evidence-locator") : "",
panelBlockId: panel ? panel.getAttribute("data-mnote-evidence-block-id") : "",
imageVisible: !!image,
};
}, expectedResource);
await openedPage.screenshot({ path: CITATION_OPEN_SCREENSHOT_PATH, fullPage: true });
assert.equal(state.panelVisible, true, `点击 AI citation 后未打开资源 panel: ${JSON.stringify(state, null, 2)}`);
assert.equal(state.panelResourcePath, expectedResource, `点击 AI citation 后资源路径不匹配: ${JSON.stringify(state, null, 2)}`);
assert(
state.url.includes("resourceTab=") || state.url.includes("resourcePath="),
`点击 AI citation 后 URL 缺少资源定位参数: ${JSON.stringify(state, null, 2)}`,
);
return state;
}
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 });
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);
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId: ACTOR_ID,
email: "mnote.e2e@example.com",
username: ACTOR_ID,
displayName: ACTOR_ID,
role: "user",
workspaceId: WORKSPACE_ID,
workspaceName: "MNote E2E Space",
rootPath: ROOT_PATH,
rootUri: ROOT_URI,
capabilities: ["ai"],
timeoutMs: UI_TIMEOUT_MS,
});
await assertKnowledgeRagDescriptor(context);
await ensureKnowledgeRagSourceIndexed(context);
await assertKnowledgeRagQueryReturnsCitationMarkdown(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 参数设为 ["${EXPECTED_RESOURCE}"]。`,
"最终只输出一句中文结论,必须包含返回的 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 citationOpenState = await clickNewestAssistantCitation(page, assistantCount, "新页面233155/image copy 6.png");
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"),
citationOpenState,
pageErrors,
consoleErrors,
screenshots: {
answer: SCREENSHOT_PATH,
citationOpen: CITATION_OPEN_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);
});