Files
ai-drama-platform/scripts/smoke-knowledge-rag.mjs

175 lines
8.1 KiB
JavaScript

import assert from "node:assert/strict";
import { rm } from "node:fs/promises";
import { resolve } from "node:path";
import { dbGet, dbRun, withTransaction } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const projectRoot = resolve(import.meta.dirname, "..");
const runId = `smoke-knowledge-rag-${Date.now()}`;
const organizationId = "org-studio-lab";
const workspaceId = "ws-local-aidrama";
const projectId = "thunder-mouth";
const tempProjectId = `${runId}-project`;
const projectDocId = `${runId}-doc`;
const packId = `${runId}-pack`;
const scope = {
"x-organization-id": organizationId,
"x-workspace-id": workspaceId,
"x-project-id": projectId
};
async function request(path, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
function expectOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
const quotaBefore = dbGet(
"SELECT id, used_value FROM quota_allocations WHERE organization_id = ? AND workspace_id = ? AND metric = 'clip' ORDER BY updated_at DESC LIMIT 1",
[organizationId, workspaceId]
);
const createdJobIds = [];
const createdScriptIds = [];
function cleanup() {
withTransaction(() => {
for (const jobId of createdJobIds) {
dbRun("DELETE FROM job_dependencies WHERE job_id = ? OR depends_on_job_id = ?", [jobId, jobId]);
dbRun("DELETE FROM job_attempts WHERE job_id = ?", [jobId]);
dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ?", [`%${jobId}%`]);
dbRun("DELETE FROM audit_logs WHERE target_type = 'generation_job' AND target_id = ?", [jobId]);
dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]);
}
dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ? OR metadata_json LIKE ?", [`%${packId}%`, `%${projectDocId}%`]);
dbRun("DELETE FROM audit_logs WHERE target_id IN (?, ?, ?)", [packId, projectDocId, tempProjectId]);
for (const scriptId of createdScriptIds) dbRun("DELETE FROM script_documents WHERE id = ?", [scriptId]);
dbRun("DELETE FROM knowledge_context_packs WHERE id = ?", [packId]);
dbRun("DELETE FROM knowledge_chunks WHERE document_id = ?", [projectDocId]);
dbRun("DELETE FROM knowledge_documents WHERE id = ?", [projectDocId]);
dbRun("DELETE FROM projects WHERE id = ?", [tempProjectId]);
if (quotaBefore?.id) dbRun("UPDATE quota_allocations SET used_value = ? WHERE id = ?", [quotaBefore.used_value, quotaBefore.id]);
});
}
try {
const login = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
}), "owner login");
const headers = { authorization: `Bearer ${login.session.token}`, ...scope };
expectOk(await request("/api/projects", {
method: "POST",
headers,
body: JSON.stringify({ id: tempProjectId, name: `知识隔离 ${runId.slice(-6)}`, type: "AI 漫剧" })
}), "create temp project");
const uniqueTerm = `验收暗号${runId.slice(-6)}`;
const imported = expectOk(await request("/api/knowledge/library/import", {
method: "POST",
headers,
body: JSON.stringify({
id: projectDocId,
title: `接口验收知识库 ${runId.slice(-6)}`,
sourceType: "novel",
scopeMode: "project",
content: [
"第一章 临港暗号",
`唐夏把${uniqueTerm}写在蓝伞内侧,陈宇只能在地铁口的远景镜头里看见伞柄反光。`,
"",
"唐夏:不要正脸说太久,雨声会盖住口型。",
"陈宇:那就用背影和环境插入镜头,把暗号留到下一段。"
].join("\n")
})
}), "import project knowledge");
assert.equal(imported.document.id, projectDocId, "导入接口必须返回指定文档 id");
assert.ok(imported.document.chunkCount >= 1, "导入后必须生成知识片段");
const projectSearch = expectOk(await request(`/api/knowledge/search?q=${encodeURIComponent(uniqueTerm)}&scopeMode=project`, { headers }), "project knowledge search");
assert.ok(projectSearch.results.some((item) => item.documentId === projectDocId), "当前项目必须能检索自己的项目级知识");
const otherProjectHeaders = { ...headers, "x-project-id": tempProjectId };
const isolatedSearch = expectOk(await request(`/api/knowledge/search?q=${encodeURIComponent(uniqueTerm)}&scopeMode=project`, { headers: otherProjectHeaders }), "isolated project search");
assert.equal(isolatedSearch.results.length, 0, "其他项目不能检索 thunder-mouth 的项目级知识");
const seedSearch = expectOk(await request("/api/knowledge/search?q=%E8%93%9D%E4%BC%9E%20%E5%9C%B0%E9%93%81%E5%8F%A3&scopeMode=workspace&limit=6", { headers }), "seed knowledge search");
assert.ok(seedSearch.results.length >= 2, "种子小说应能检索出多个知识片段");
const chunkIds = seedSearch.results.slice(0, 2).map((item) => item.id);
const packCreated = expectOk(await request("/api/knowledge/context-packs", {
method: "POST",
headers,
body: JSON.stringify({
id: packId,
name: `回归知识包 ${runId.slice(-6)}`,
query: "蓝伞 地铁口",
sourceType: "novel",
scopeMode: "workspace",
maxTokens: 700,
chunkIds
})
}), "create context pack");
assert.equal(packCreated.pack.id, packId, "上下文包必须使用指定 id");
assert.equal(packCreated.pack.citations.length, chunkIds.length, "上下文包必须保留每个片段引用");
assert.ok(packCreated.pack.promptContext.includes("[K1]"), "上下文包 prompt 必须包含引用编号");
const packList = expectOk(await request("/api/knowledge/context-packs", { headers }), "list context packs");
assert.ok(packList.packs.some((item) => item.id === packId), "上下文包列表必须包含刚创建的包");
const materialized = expectOk(await request(`/api/knowledge/context-packs/${encodeURIComponent(packId)}/materialize`, {
method: "POST",
headers,
body: JSON.stringify({ title: `回归知识包剧本 ${runId.slice(-6)}` })
}), "materialize context pack");
createdScriptIds.push(materialized.importedScript.id);
assert.ok(materialized.importedScript.content.includes("[K1]"), "包入剧本必须保留引用编号");
const preview = expectOk(await request("/api/jobs/preview", {
method: "POST",
headers,
body: JSON.stringify({
kind: "场景草稿",
workflowKey: "script-pipeline",
operationKey: "scene-draft",
adapter: "owned-model-platform",
knowledgePackId: packId,
inputs: { prompt: "基于引用包生成连续短剧分镜草稿。" }
})
}), "preview job with context pack");
assert.equal(preview.preview.knowledge.id, packId, "任务预览必须包含上下文包");
assert.ok(preview.preview.inputs.knowledgeContext.includes("知识库上下文包"), "任务输入必须包含上下文正文");
const createdJob = expectOk(await request("/api/jobs", {
method: "POST",
headers,
body: JSON.stringify({
kind: "场景草稿",
workflowKey: "script-pipeline",
operationKey: "scene-draft",
adapter: "owned-model-platform",
knowledgePackId: packId,
output: `storage/jobs/${runId}/scene-draft.json`,
inputs: { prompt: "基于引用包生成连续短剧分镜草稿。" }
})
}), "create job with context pack");
createdJobIds.push(createdJob.job.id);
assert.equal(createdJob.job.request.knowledge.id, packId, "已创建任务合同必须保存上下文包");
assert.equal(createdJob.job.request.knowledge.citations.length, chunkIds.length, "已创建任务合同必须保存引用列表");
console.log(`knowledge rag smoke passed: search=${seedSearch.results.length}, pack=${packId}, job=${createdJob.job.id}`);
} finally {
cleanup();
await rm(resolve(projectRoot, "storage", "jobs", runId), { recursive: true, force: true });
for (const jobId of createdJobIds) {
await rm(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true, force: true });
}
}