集成 OpenHub 与 WeKnora Page AI
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { request } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const FIXTURE_DIR = process.env.MNOTE_TASK772_FIXTURE_DIR || "knowledge-rag-fixtures-7-68";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task772-weknora-ingest-search-open-reference-e2e");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SKIP_PATH = path.join(OUTPUT_DIR, "skip.json");
|
||||
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK772_WEKNORA_TIMEOUT_MS || 240_000);
|
||||
const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK772_WEKNORA_POLL_MS || 5_000);
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function envList(name) {
|
||||
return String(process.env[name] || "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function configuredKnowledgeBaseIds() {
|
||||
return envList("MNOTE_WEKNORA_KNOWLEDGE_BASE_IDS").concat(envList("MNOTE_WEKNORA_KNOWLEDGE_BASE_ID"));
|
||||
}
|
||||
|
||||
function hasWeKnoraApiKey() {
|
||||
return Boolean(String(process.env.MNOTE_WEKNORA_API_KEY || process.env.WEKNORA_API_KEY || "").trim());
|
||||
}
|
||||
|
||||
function writeJson(filePath, payload) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function skip(reason, details = {}) {
|
||||
const payload = {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
...details,
|
||||
};
|
||||
writeJson(SKIP_PATH, payload);
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
function failLayer(layer, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.layer = layer;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function apiJson(context, method, url, data) {
|
||||
const response = await context.fetch(url, {
|
||||
method,
|
||||
data,
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
"x-mnote-workspace-id": WORKSPACE_ID,
|
||||
accept: "application/json",
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch (_) {
|
||||
payload = { rawText: text };
|
||||
}
|
||||
return { ok: response.ok(), status: response.status(), payload, text };
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
const response = await context.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function status(context) {
|
||||
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
|
||||
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_status", `knowledge-rag status 失败: ${result.status}`, {
|
||||
response: result.payload || result.text,
|
||||
});
|
||||
}
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
function assertWeKnoraStatus(statusPayload, expectedKbIds) {
|
||||
const activeProvider = statusPayload?.providerConfig?.active || statusPayload?.provider;
|
||||
assert.equal(activeProvider, "weknora", `MNote knowledge provider 不是 weknora: ${JSON.stringify(statusPayload?.providerConfig || statusPayload, null, 2)}`);
|
||||
const serverKbIds = statusPayload?.providerConfig?.weknora?.knowledgeBaseIds || [];
|
||||
assert(serverKbIds.length > 0, `MNote 3000 未加载 WeKnora KB id: ${JSON.stringify(statusPayload?.providerConfig || statusPayload, null, 2)}`);
|
||||
for (const kbId of expectedKbIds) {
|
||||
assert(serverKbIds.includes(kbId), `MNote 3000 未加载当前 WeKnora KB id ${kbId}: ${JSON.stringify(serverKbIds)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ingest(context, sourcePath) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
force: true,
|
||||
sources: [{ sourcePath }],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_ingest_http", `knowledge-rag ingest HTTP 失败: ${result.status}`, {
|
||||
response: result.payload || result.text,
|
||||
});
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.provider, "weknora", `ingest provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
|
||||
if (payload?.ok !== true || payload?.retryRequired) {
|
||||
throw failLayer("weknora_ingest_provider", "WeKnora ingest 未成功完成 provider upload", {
|
||||
response: payload,
|
||||
});
|
||||
}
|
||||
const configured = Array.isArray(payload.configuredSources) ? payload.configuredSources : [];
|
||||
const item = configured.find((source) => source.sourceRootRelativePath === sourcePath);
|
||||
assert(item, `ingest 结果缺少目标 source: ${JSON.stringify(configured, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(item.provider, "weknora", `ingest source provider 应为 weknora: ${JSON.stringify(item, null, 2)}`);
|
||||
assert.equal(item.mappingStatus, "provider_mapped", `ingest 应写回 provider_mapped: ${JSON.stringify(item, null, 2)}`);
|
||||
assert(item.providerKnowledgeBaseId, `ingest 缺少 providerKnowledgeBaseId: ${JSON.stringify(item, null, 2)}`);
|
||||
assert(item.providerKnowledgeId, `ingest 缺少 providerKnowledgeId: ${JSON.stringify(item, null, 2)}`);
|
||||
assert(item.upload?.attempted === true, `ingest 应真实调用 WeKnora upload: ${JSON.stringify(item, null, 2)}`);
|
||||
return { payload, item };
|
||||
}
|
||||
|
||||
async function search(context, query, sourcePath) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query,
|
||||
mode: "hybrid",
|
||||
topK: 10,
|
||||
chunkTopK: 10,
|
||||
includeChunkContent: true,
|
||||
sourcePaths: [sourcePath],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_search_http", `knowledge-rag search HTTP 失败: ${result.status}`, {
|
||||
response: result.payload || result.text,
|
||||
});
|
||||
}
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
function referenceProviderIds(reference) {
|
||||
return {
|
||||
knowledgeBaseId: reference?.providerIds?.knowledgeBaseId || reference?.providerKnowledgeBaseId || "",
|
||||
knowledgeId: reference?.providerIds?.knowledgeId || reference?.providerKnowledgeId || "",
|
||||
chunkId: reference?.providerIds?.chunkId || reference?.providerChunkId || reference?.chunkId || "",
|
||||
};
|
||||
}
|
||||
|
||||
function findTargetReference(searchPayload, ingestItem, marker, sourcePath) {
|
||||
const references = Array.isArray(searchPayload?.references) ? searchPayload.references : [];
|
||||
return references.find((reference) => {
|
||||
const ids = referenceProviderIds(reference);
|
||||
const quote = `${reference.displayQuote || ""}\n${reference.rawQuote || ""}\n${reference.quote || ""}`;
|
||||
return reference.provider === "weknora"
|
||||
&& ids.knowledgeId === ingestItem.providerKnowledgeId
|
||||
&& (reference.sourceRootRelativePath === sourcePath || quote.includes(marker));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
async function waitForSearchHit(context, marker, sourcePath, ingestItem) {
|
||||
const startedAt = Date.now();
|
||||
let lastPayload = null;
|
||||
let lastError = null;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
try {
|
||||
lastPayload = await search(context, marker, sourcePath);
|
||||
const reference = findTargetReference(lastPayload, ingestItem, marker, sourcePath);
|
||||
if (reference) return { payload: lastPayload, reference };
|
||||
} catch (error) {
|
||||
lastError = {
|
||||
layer: error.layer || "search_poll",
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
};
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw failLayer("weknora_search_timeout", "等待 WeKnora search 返回目标 chunk 超时", {
|
||||
marker,
|
||||
sourcePath,
|
||||
providerKnowledgeId: ingestItem.providerKnowledgeId,
|
||||
lastError,
|
||||
lastPayload,
|
||||
});
|
||||
}
|
||||
|
||||
function rawWeKnoraChunkFromReference(reference) {
|
||||
const raw = reference?.reference;
|
||||
assert(raw && typeof raw === "object" && !Array.isArray(raw), `search reference 缺少原始 WeKnora chunk: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
return { provider: "weknora", ...raw };
|
||||
}
|
||||
|
||||
function assertMappedReference(reference, ingestItem, sourcePath, label) {
|
||||
assert.equal(reference?.provider, "weknora", `${label} provider 应为 weknora: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
const ids = referenceProviderIds(reference);
|
||||
assert(ids.knowledgeBaseId, `${label} 缺少 provider knowledgeBaseId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
assert(ids.knowledgeId, `${label} 缺少 provider knowledgeId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
assert(ids.chunkId, `${label} 缺少 provider chunkId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(ids.knowledgeId, ingestItem.providerKnowledgeId, `${label} provider knowledgeId 未保留 ingest 映射: ${JSON.stringify(ids)}`);
|
||||
assert.equal(reference.filePath ?? null, null, `${label} 不应把 WeKnora provider filename 伪造成 filePath: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(reference.sourceRootRelativePath, sourcePath, `${label} 应通过 registry 映射回 sourceRootRelativePath: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(reference.citationDiagnostics?.providerFilenameIsNotLocalPath, true, `${label} 应声明 provider filename 不是本地路径真相: ${JSON.stringify(reference.citationDiagnostics, null, 2)}`);
|
||||
assert.equal(reference.openAction?.params?.path, sourcePath, `${label} openAction 应使用 registry path,不使用 provider filename: ${JSON.stringify(reference.openAction, null, 2)}`);
|
||||
}
|
||||
|
||||
async function openReference(context, rawChunk) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/open-reference`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
reference: rawChunk,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_open_reference_http", `knowledge-rag open-reference HTTP 失败: ${result.status}`, {
|
||||
response: result.payload || result.text,
|
||||
});
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.ok, true, `open-reference ok 应为 true: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(payload?.provider, "weknora", `open-reference provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const expectedKbIds = configuredKnowledgeBaseIds();
|
||||
if (expectedKbIds.length === 0 || !hasWeKnoraApiKey()) {
|
||||
console.warn(JSON.stringify({
|
||||
warning: "local_weknora_env_not_visible",
|
||||
message: "当前 smoke shell 未看到 WeKnora env;继续以 MNote 3000 status 和真实 API 调用为准。",
|
||||
requiredAny: {
|
||||
knowledgeBaseId: ["MNOTE_WEKNORA_KNOWLEDGE_BASE_ID", "MNOTE_WEKNORA_KNOWLEDGE_BASE_IDS"],
|
||||
apiKey: ["MNOTE_WEKNORA_API_KEY", "WEKNORA_API_KEY"],
|
||||
},
|
||||
hasKnowledgeBaseId: expectedKbIds.length > 0,
|
||||
hasApiKey: hasWeKnoraApiKey(),
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
if (!fs.existsSync(ROOT_PATH)) {
|
||||
throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`);
|
||||
}
|
||||
|
||||
const context = await request.newContext({ baseURL: BASE_URL });
|
||||
try {
|
||||
await signIn(context);
|
||||
const statusPayload = await status(context);
|
||||
try {
|
||||
assertWeKnoraStatus(statusPayload, expectedKbIds);
|
||||
} catch (error) {
|
||||
skip("mnote_weknora_provider_or_kb_not_configured", {
|
||||
assertion: error.message,
|
||||
providerConfig: statusPayload?.providerConfig || null,
|
||||
health: statusPayload?.health || null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const marker = `task772 weknora e2e marker ${startedAt}`;
|
||||
const sourceName = `task772-weknora-e2e-${startedAt}.md`;
|
||||
const sourcePath = `${FIXTURE_DIR}/${sourceName}`;
|
||||
const absoluteSourcePath = path.join(ROOT_PATH, sourcePath);
|
||||
fs.mkdirSync(path.dirname(absoluteSourcePath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
absoluteSourcePath,
|
||||
[
|
||||
"# Task 772 WeKnora E2E",
|
||||
"",
|
||||
marker,
|
||||
"",
|
||||
"This markdown file is created by the MNote task772 smoke to verify real WeKnora ingest, search, and open-reference mapping.",
|
||||
"The provider filename must not become a forged local file path; MNote should map back through its source registry.",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const ingestResult = await ingest(context, sourcePath);
|
||||
const searchResult = await waitForSearchHit(context, marker, sourcePath, ingestResult.item);
|
||||
assert.equal(searchResult.payload?.provider, "weknora", `search provider 应为 weknora: ${JSON.stringify(searchResult.payload, null, 2).slice(0, 3000)}`);
|
||||
assertMappedReference(searchResult.reference, ingestResult.item, sourcePath, "search reference");
|
||||
|
||||
const rawChunk = rawWeKnoraChunkFromReference(searchResult.reference);
|
||||
const openPayload = await openReference(context, rawChunk);
|
||||
assertMappedReference(openPayload.reference, ingestResult.item, sourcePath, "open-reference");
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sourcePath,
|
||||
absoluteSourcePath,
|
||||
marker,
|
||||
provider: "weknora",
|
||||
providerKnowledgeBaseId: ingestResult.item.providerKnowledgeBaseId,
|
||||
providerKnowledgeId: ingestResult.item.providerKnowledgeId,
|
||||
search: {
|
||||
resultCount: Array.isArray(searchResult.payload.results) ? searchResult.payload.results.length : 0,
|
||||
referenceCount: Array.isArray(searchResult.payload.references) ? searchResult.payload.references.length : 0,
|
||||
providerIds: referenceProviderIds(searchResult.reference),
|
||||
citationUrl: searchResult.reference.citationUrl || null,
|
||||
filePath: searchResult.reference.filePath ?? null,
|
||||
sourceRootRelativePath: searchResult.reference.sourceRootRelativePath || null,
|
||||
},
|
||||
openReference: {
|
||||
providerIds: referenceProviderIds(openPayload.reference),
|
||||
citationUrl: openPayload.reference?.citationUrl || null,
|
||||
filePath: openPayload.reference?.filePath ?? null,
|
||||
sourceRootRelativePath: openPayload.reference?.sourceRootRelativePath || null,
|
||||
openAction: openPayload.reference?.openAction || null,
|
||||
},
|
||||
};
|
||||
writeJson(RESULT_PATH, result);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await context.dispose().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const payload = {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
layer: error.layer || "unexpected",
|
||||
error: error.stack || error.message || String(error),
|
||||
details: error.details || null,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
};
|
||||
writeJson(FAILURE_PATH, payload);
|
||||
console.error(JSON.stringify(payload, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user