Files
ai-drama-platform/server/knowledge.mjs
T

1233 lines
58 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.
import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
import { addAudit, addUsage, hasPermission, httpError, requireEntitlement, requirePermission, requireProjectWritable } from "./tenant.mjs";
import { importScript } from "./production.mjs";
const now = () => new Date().toISOString();
const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
function parseJson(value, fallback) {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function unique(values) {
return [...new Set((values || []).map((item) => String(item || "").trim()).filter(Boolean))];
}
function normalizeTags(value) {
if (Array.isArray(value)) return unique(value).slice(0, 24);
return unique(String(value || "").split(/[,,、\s]+/)).slice(0, 24);
}
function normalizeRightsStatus(value) {
const status = String(value || "needs-evidence").trim();
if (["needs-evidence", "submitted", "approved", "rejected", "expired"].includes(status)) return status;
return "needs-evidence";
}
function normalizeProvenance(body = {}, fallback = {}) {
const source = body.provenance && typeof body.provenance === "object" ? body.provenance : {};
const metadata = body.metadata && typeof body.metadata === "object" ? body.metadata : {};
return {
sourceLabel: String(source.sourceLabel || metadata.sourceLabel || fallback.sourceLabel || "").trim(),
author: String(source.author || metadata.author || fallback.author || "").trim(),
rightsOwner: String(source.rightsOwner || metadata.rightsOwner || fallback.rightsOwner || "").trim(),
evidenceRef: String(source.evidenceRef || metadata.evidenceRef || fallback.evidenceRef || "").trim(),
licenseNote: String(source.licenseNote || metadata.licenseNote || fallback.licenseNote || "").trim(),
sourceUrl: String(source.sourceUrl || metadata.sourceUrl || fallback.sourceUrl || "").trim(),
importedFrom: String(source.importedFrom || metadata.importedFrom || fallback.importedFrom || "manual").trim()
};
}
function approxTokens(text) {
const value = String(text || "").trim();
return Math.max(1, Math.ceil(value.length / 1.8));
}
function splitSections(content) {
const normalized = String(content || "").replace(/\r/g, "").trim();
const lines = normalized.split("\n");
const headingPattern = /^(第[0-9一二三四五六七八九十百零]+[章节集幕]|chapter\s*\d+|CHAPTER\s*\d+)/i;
const sections = [];
let currentTitle = "";
let buffer = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (headingPattern.test(line)) {
if (currentTitle || buffer.length) sections.push({ title: currentTitle || `片段 ${sections.length + 1}`, content: buffer.join("\n").trim() });
currentTitle = line;
buffer = [];
} else {
buffer.push(rawLine);
}
}
if (currentTitle || buffer.length) sections.push({ title: currentTitle || `片段 ${sections.length + 1}`, content: buffer.join("\n").trim() });
if (sections.length) return sections.filter((section) => section.content);
const paragraphs = normalized.split(/\n\s*\n/).map((item) => item.trim()).filter(Boolean);
if (!paragraphs.length) return [];
const grouped = [];
for (let index = 0; index < paragraphs.length; index += 3) {
grouped.push({
title: `片段 ${grouped.length + 1}`,
content: paragraphs.slice(index, index + 3).join("\n\n")
});
}
return grouped;
}
function extractEntities(text) {
const value = String(text || "");
const characters = unique([...value.matchAll(/([\u4e00-\u9fa5]{2,4})[::]/g)].map((match) => match[1]));
const locationKeywords = ["地铁口", "玻璃连廊", "雨棚", "教室", "客厅", "街道", "医院", "仓库", "山路", "门口", "旧城区", "天台", "楼道"];
const propKeywords = ["手机", "雨伞", "蓝伞", "雨披", "黄色雨披", "路锥", "警戒线", "钥匙", "刀", "书包", "项链", "文件", "录音笔", "相机"];
const locations = unique(locationKeywords.filter((item) => value.includes(item)));
const props = unique(propKeywords.filter((item) => value.includes(item)));
return { characters, locations, props };
}
function keywordsForText(text, entities) {
const base = String(text || "").replace(/[,。!?、:“”"'()()【】\[\]\s]+/g, " ").trim().split(" ").filter(Boolean);
return unique([...(entities.characters || []), ...(entities.locations || []), ...(entities.props || []), ...base.filter((item) => item.length >= 2).slice(0, 6)]).slice(0, 10);
}
function chunkTypeForContent(text, sectionIndex, paragraphIndex) {
const value = String(text || "");
const dialogueMatches = [...value.matchAll(/^[\u4e00-\u9fa5]{2,4}[::]/gm)];
if (dialogueMatches.length >= 2) return "dialogue";
if (/设定|规则|传说|前史|背景/.test(value)) return "lore";
if (paragraphIndex === 0) return sectionIndex === 0 ? "chapter" : "scene";
return "scene";
}
function analyzeKnowledgeText(content) {
const sections = splitSections(content);
const chunks = [];
const chapterSummary = [];
let chunkIndex = 1;
for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex += 1) {
const section = sections[sectionIndex];
const parts = section.content.split(/\n\s*\n/).map((item) => item.trim()).filter(Boolean);
const merged = [];
for (const part of parts) {
if (!merged.length) {
merged.push(part);
continue;
}
if (merged[merged.length - 1].length < 180) merged[merged.length - 1] = `${merged[merged.length - 1]}\n${part}`;
else merged.push(part);
}
const startChunk = chunkIndex;
for (let paragraphIndex = 0; paragraphIndex < merged.length; paragraphIndex += 1) {
const body = merged[paragraphIndex];
const entities = extractEntities(body);
chunks.push({
id: `chunk-${chunkIndex}`,
chunkIndex,
chunkType: chunkTypeForContent(body, sectionIndex, paragraphIndex),
heading: section.title || `片段 ${sectionIndex + 1}`,
content: body,
tokenEstimate: approxTokens(body),
keywords: keywordsForText(body, entities),
entities,
metadata: {
sectionIndex: sectionIndex + 1,
paragraphIndex: paragraphIndex + 1,
sceneHint: paragraphIndex === 0 ? "段首情境建立" : /[::]/.test(body) ? "对白块" : "叙事块"
}
});
chunkIndex += 1;
}
chapterSummary.push({
id: `section-${sectionIndex + 1}`,
title: section.title || `片段 ${sectionIndex + 1}`,
words: section.content.replace(/\s/g, "").length,
chunkCount: chunkIndex - startChunk
});
}
const allEntities = chunks.reduce((accumulator, chunk) => ({
characters: [...accumulator.characters, ...(chunk.entities.characters || [])],
locations: [...accumulator.locations, ...(chunk.entities.locations || [])],
props: [...accumulator.props, ...(chunk.entities.props || [])]
}), { characters: [], locations: [], props: [] });
const text = String(content || "").trim();
return {
parser: "local-rule-v2",
summary: text.slice(0, 120),
chapterCount: chapterSummary.length,
chunkCount: chunks.length,
chapters: chapterSummary,
entities: {
characters: unique(allEntities.characters),
locations: unique(allEntities.locations),
props: unique(allEntities.props)
},
chunks
};
}
function scanKnowledgeGovernance({ title = "", content = "", sourceType = "", rightsStatus = "needs-evidence", provenance = {}, tags = [] } = {}) {
const text = `${title}\n${content}`.toLowerCase();
const issues = [];
const pushIssue = (severity, code, message, matches = []) => issues.push({ severity, code, message, matches: unique(matches).slice(0, 8) });
const normalizedRights = normalizeRightsStatus(rightsStatus);
const evidenceRef = String(provenance.evidenceRef || provenance.sourceLabel || "").trim();
if (normalizedRights === "rejected" || normalizedRights === "expired") {
pushIssue("blocking", "rights_not_usable", "素材版权状态不可用于商用生产。");
} else if (normalizedRights !== "approved") {
pushIssue("review", "rights_needs_evidence", "素材尚未批准商用使用,正式生产前需要补充来源/授权证据。");
}
if (!evidenceRef) {
pushIssue("review", "provenance_evidence_missing", "缺少来源或授权证据引用。");
}
const ipTerms = ["迪士尼", "漫威", "哈利波特", "火影忍者", "海贼王", "斗罗大陆", "狐妖小红娘", "三体", "庆余年", "盗墓笔记", "鬼吹灯", "原神", "王者荣耀"];
const ipMatches = ipTerms.filter((term) => text.includes(term.toLowerCase()));
if (ipMatches.length) pushIssue("review", "known_ip_reference", "文本含有已知商业 IP 或游戏/影视/小说名称,需要确认不是仿作或未授权改编。", ipMatches);
const personaTerms = ["仿明星", "明星脸", "真人脸", "照着某人", "像某明星", "某某同款", "高仿演员", "数字替身"];
const personaMatches = personaTerms.filter((term) => text.includes(term.toLowerCase()));
if (personaMatches.length) pushIssue("review", "persona_likeness_risk", "文本含有真人形象或仿冒表达,后续角色/视频生成需要权利人授权。", personaMatches);
const layoutTerms = ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "九宫格", "故事板"];
const layoutMatches = layoutTerms.filter((term) => text.includes(term.toLowerCase()));
if (layoutMatches.length) pushIssue("warn", "single_frame_policy_risk", "素材或提示中含一图多画面表达,进入画面生成前必须改写为单一完整画面。", layoutMatches);
const sensitiveTerms = ["血腥特写", "未成年人裸露", "自残教程", "诈骗话术", "真实身份证", "银行卡号"];
const sensitiveMatches = sensitiveTerms.filter((term) => text.includes(term.toLowerCase()));
if (sensitiveMatches.length) pushIssue("blocking", "safety_sensitive_content", "文本含高风险安全或隐私内容,不能直接进入自动生成。", sensitiveMatches);
const score = issues.reduce((sum, issue) => sum + (issue.severity === "blocking" ? 60 : issue.severity === "review" ? 24 : 10), 0);
const status = issues.some((issue) => issue.severity === "blocking") ? "blocked" : issues.some((issue) => issue.severity === "review") ? "review" : issues.some((issue) => issue.severity === "warn") ? "warn" : "pass";
return {
scanner: "local-governance-v1",
status,
score: Math.min(100, score),
rightsStatus: normalizedRights,
sourceType,
tags,
issues,
checks: {
provenanceEvidence: Boolean(evidenceRef),
commercialRightsApproved: normalizedRights === "approved",
knownIpReferences: ipMatches.length,
personaLikenessRisks: personaMatches.length,
singleFramePolicyRisks: layoutMatches.length,
safetySensitiveMatches: sensitiveMatches.length
},
scannedAt: now()
};
}
function normalizeGovernanceDecision(value) {
const decision = String(value || "submitted").trim();
if (["submitted", "approved", "rejected", "needs-revision"].includes(decision)) return decision;
return "submitted";
}
function normalizeDocumentStatus(value, fallback = "indexed") {
const status = String(value || fallback || "indexed").trim();
if (["ingested", "indexed", "draft", "active", "archived"].includes(status)) return status;
return fallback || "indexed";
}
function serializeKnowledgeReviewRow(row) {
if (!row) return null;
return {
id: row.id,
documentId: row.document_id,
decision: row.decision,
rightsStatus: row.rights_status,
riskStatus: row.risk_status,
notes: row.notes || "",
evidenceRef: row.evidence_ref || "",
provenance: parseJson(row.provenance_json, {}),
risk: parseJson(row.risk_json, {}),
reviewerUserId: row.reviewer_user_id || "",
createdAt: row.created_at || ""
};
}
function latestKnowledgeReview(documentId) {
return serializeKnowledgeReviewRow(dbGet(
`SELECT *
FROM knowledge_governance_reviews
WHERE document_id = ?
ORDER BY created_at DESC
LIMIT 1`,
[documentId]
));
}
function knowledgeVersionCount(documentId) {
const row = dbGet("SELECT COUNT(*) AS count FROM knowledge_document_versions WHERE document_id = ?", [documentId]);
return Number(row?.count || 0);
}
function nextKnowledgeVersionNumber(documentId) {
const row = dbGet("SELECT COALESCE(MAX(version_number), 0) + 1 AS version_number FROM knowledge_document_versions WHERE document_id = ?", [documentId]);
return Number(row?.version_number || 1);
}
function serializeKnowledgeDocumentRow(row, chunks = null) {
const analysis = parseJson(row.analysis_json, {});
const metadata = parseJson(row.metadata_json, {});
const provenance = parseJson(row.provenance_json, {});
const tags = parseJson(row.tags_json, []);
const risk = parseJson(row.risk_json, {});
const versionCount = row.version_count === undefined ? knowledgeVersionCount(row.id) : Number(row.version_count || 0);
return {
...row,
sourceType: row.source_type || "",
projectId: row.project_id || "",
scopeMode: row.scope_mode || "workspace",
rightsStatus: row.rights_status || "needs-evidence",
provenance,
tags,
risk,
riskStatus: risk.status || "unscanned",
riskScore: Number(risk.score || 0),
currentVersionNumber: Number(row.current_version_number || 1),
versionCount,
latestReview: latestKnowledgeReview(row.id),
summary: row.summary || "",
chunkCount: Number(row.chunk_count || chunks?.length || 0),
analysis,
metadata,
chunks: chunks || undefined
};
}
function serializeKnowledgeVersionRow(row) {
return {
id: row.id,
documentId: row.document_id,
versionNumber: Number(row.version_number || 0),
title: row.title || "",
sourceType: row.source_type || "novel",
language: row.language || "zh-CN",
content: row.content || "",
summary: row.summary || "",
analysis: parseJson(row.analysis_json, {}),
provenance: parseJson(row.provenance_json, {}),
tags: parseJson(row.tags_json, []),
risk: parseJson(row.risk_json, {}),
metadata: parseJson(row.metadata_json, {}),
createdBy: row.created_by || "",
createdAt: row.created_at || ""
};
}
function writeKnowledgeChunks(documentId, chunks, timestamp) {
for (const chunk of chunks) {
dbRun(
`INSERT INTO knowledge_chunks(
id, document_id, chunk_index, chunk_type, heading, content, token_estimate,
keywords_json, entities_json, metadata_json, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[`${documentId}-${chunk.chunkIndex}`, documentId, chunk.chunkIndex, chunk.chunkType, chunk.heading, chunk.content, chunk.tokenEstimate, JSON.stringify(chunk.keywords), JSON.stringify(chunk.entities), JSON.stringify(chunk.metadata), timestamp]
);
}
}
function insertKnowledgeVersion(context, document, versionNumber, timestamp, metadataPatch = {}) {
const metadata = document.metadata && typeof document.metadata === "object" ? document.metadata : parseJson(document.metadata_json, {});
const analysis = document.analysis && typeof document.analysis === "object" ? document.analysis : parseJson(document.analysis_json, {});
const provenance = document.provenance && typeof document.provenance === "object" ? document.provenance : parseJson(document.provenance_json, {});
const tags = Array.isArray(document.tags) ? document.tags : parseJson(document.tags_json, []);
const risk = document.risk && typeof document.risk === "object" ? document.risk : parseJson(document.risk_json, {});
dbRun(
`INSERT INTO knowledge_document_versions(
id, document_id, version_number, title, source_type, language, content, summary,
analysis_json, provenance_json, tags_json, risk_json, metadata_json, created_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
String(metadataPatch.id || `${document.id}-v${versionNumber}`),
document.id,
versionNumber,
document.title,
document.sourceType || document.source_type || "novel",
document.language || "zh-CN",
document.content || "",
document.summary || "",
JSON.stringify({ ...analysis, chunks: undefined }),
JSON.stringify(provenance),
JSON.stringify(tags),
JSON.stringify(risk),
JSON.stringify({ ...metadata, ...metadataPatch }),
context.user.id,
timestamp
]
);
}
function assertKnowledgeDocumentUsable(document) {
const risk = document.risk || parseJson(document.risk_json, {});
const rightsStatus = document.rightsStatus || document.rights_status || "needs-evidence";
if (["rejected", "expired"].includes(rightsStatus)) {
throw httpError(409, "knowledge_rights_not_usable", "该素材版权状态不可用于商用生产,请先完成授权治理", { documentId: document.id, rightsStatus });
}
if (risk.status === "blocked") {
throw httpError(409, "knowledge_risk_blocked", "该素材风险扫描为阻断状态,不能进入生成或剧本生产", { documentId: document.id, risk });
}
}
function summarizePackGovernance(chunks) {
const summary = chunks.reduce((accumulator, chunk) => {
const rightsStatus = chunk.rightsStatus || "needs-evidence";
const riskStatus = chunk.risk?.status || "unscanned";
accumulator.rights[rightsStatus] = (accumulator.rights[rightsStatus] || 0) + 1;
accumulator.risk[riskStatus] = (accumulator.risk[riskStatus] || 0) + 1;
if (["rejected", "expired"].includes(rightsStatus) || riskStatus === "blocked") accumulator.blocking += 1;
if (rightsStatus !== "approved" || ["review", "warn"].includes(riskStatus)) accumulator.review += 1;
return accumulator;
}, { rights: {}, risk: {}, blocking: 0, review: 0 });
return {
status: summary.blocking ? "blocked" : summary.review ? "review" : "pass",
rights: summary.rights,
risk: summary.risk,
blockingCount: summary.blocking,
reviewCount: summary.review
};
}
function assertPackGovernanceUsable(pack) {
const governance = pack?.governance || pack?.metadata?.governance || {};
if (governance.status === "blocked" || Number(governance.blockingCount || 0) > 0) {
throw httpError(409, "knowledge_pack_blocked", "上下文包包含版权不可用或风险阻断素材,不能进入生产任务", { packId: pack.id, governance });
}
}
function scopedKnowledgeQuery(context) {
const params = [context.organization.id, context.workspace.id];
let clause = "kd.organization_id = ? AND kd.workspace_id = ?";
if (context.project?.id) {
clause += " AND (kd.project_id IS NULL OR kd.project_id = ?)";
params.push(context.project.id);
} else {
clause += " AND kd.project_id IS NULL";
}
return { clause, params };
}
function scopedKnowledgePackQuery(context) {
const params = [context.organization.id, context.workspace.id];
let clause = "kcp.organization_id = ? AND kcp.workspace_id = ?";
if (context.project?.id) {
clause += " AND (kcp.project_id IS NULL OR kcp.project_id = ?)";
params.push(context.project.id);
} else {
clause += " AND kcp.project_id IS NULL";
}
return { clause, params };
}
function placeholders(values) {
return values.map(() => "?").join(",");
}
function likePattern(value) {
return `%${String(value || "").trim().replace(/[\\%_]/g, "\\$&").slice(0, 100)}%`;
}
function knowledgeTerms(query) {
const normalized = String(query || "").trim();
if (!normalized) return [];
const split = normalized.split(/[\s,,。;;、/|]+/).map((item) => item.trim()).filter(Boolean);
return unique([normalized, ...split]).slice(0, 8);
}
function normalizeKnowledgeChunk(row) {
const entities = parseJson(row.entities_json, {});
const keywords = parseJson(row.keywords_json, []);
const risk = parseJson(row.risk_json, {});
return {
id: row.id,
documentId: row.document_id,
documentTitle: row.document_title || row.title || "",
chunkIndex: Number(row.chunk_index || 0),
chunkType: row.chunk_type || "scene",
heading: row.heading || "",
content: row.content || "",
tokenEstimate: Number(row.token_estimate || approxTokens(row.content)),
keywords,
entities,
metadata: parseJson(row.metadata_json, {}),
sourceType: row.source_type || "",
language: row.language || "zh-CN",
scopeMode: row.scope_mode || "workspace",
rightsStatus: row.rights_status || "needs-evidence",
risk,
riskStatus: risk.status || "unscanned",
projectId: row.project_id || "",
organizationId: row.organization_id || "",
workspaceId: row.workspace_id || "",
updatedAt: row.updated_at || row.created_at || ""
};
}
function flattenEntities(entities = {}) {
return unique([...(entities.characters || []), ...(entities.locations || []), ...(entities.props || [])]);
}
function scoreKnowledgeChunk(chunk, terms) {
if (!terms.length) return 1;
const haystacks = {
title: `${chunk.documentTitle}`.toLowerCase(),
heading: `${chunk.heading}`.toLowerCase(),
content: `${chunk.content}`.toLowerCase(),
keywords: (chunk.keywords || []).join(" ").toLowerCase(),
entities: flattenEntities(chunk.entities).join(" ").toLowerCase()
};
let score = 0;
for (const rawTerm of terms) {
const term = rawTerm.toLowerCase();
if (!term) continue;
if (haystacks.title.includes(term)) score += 80;
if (haystacks.heading.includes(term)) score += 64;
if (haystacks.keywords.includes(term)) score += 44;
if (haystacks.entities.includes(term)) score += 38;
if (haystacks.content.includes(term)) score += 26;
}
if (chunk.chunkType === "dialogue") score += 5;
if (chunk.scopeMode === "project") score += 3;
return score;
}
function snippetFor(content, terms, maxLength = 180) {
const text = String(content || "").replace(/\s+/g, " ").trim();
if (text.length <= maxLength) return text;
const lower = text.toLowerCase();
const term = terms.map((item) => item.toLowerCase()).find((item) => item && lower.includes(item));
if (!term) return `${text.slice(0, maxLength - 1)}…`;
const index = lower.indexOf(term);
const start = Math.max(0, index - Math.floor(maxLength / 3));
const end = Math.min(text.length, start + maxLength);
return `${start > 0 ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`;
}
function serializeContextPackRow(row) {
const chunkIds = parseJson(row.chunk_ids_json, []);
const citations = parseJson(row.citations_json, []);
const chunks = parseJson(row.chunks_json, []);
const metadata = parseJson(row.metadata_json, {});
return {
schema: "ai-drama.knowledge-context-pack.v1",
id: row.id,
name: row.name || "",
query: row.query || "",
sourceType: row.source_type || "mixed",
projectId: row.project_id || "",
scopeMode: row.scope_mode || "workspace",
maxTokens: Number(row.max_tokens || 0),
tokenEstimate: Number(row.token_estimate || 0),
selectedCount: chunks.length || chunkIds.length,
chunkIds,
citations,
chunks,
promptContext: row.prompt_context || "",
governance: metadata.governance || {},
status: row.status || "active",
metadata,
createdBy: row.created_by || "",
createdAt: row.created_at || "",
updatedAt: row.updated_at || ""
};
}
function knowledgeDocumentDetail(context, documentId) {
const { clause, params } = scopedKnowledgeQuery(context);
const row = dbGet(
`SELECT kd.*,
(SELECT COUNT(*) FROM knowledge_chunks kc WHERE kc.document_id = kd.id) AS chunk_count,
(SELECT COUNT(*) FROM knowledge_document_versions kdv WHERE kdv.document_id = kd.id) AS version_count
FROM knowledge_documents kd
WHERE kd.id = ? AND ${clause}`,
[documentId, ...params]
);
if (!row) return null;
const chunks = dbAll(
`SELECT * FROM knowledge_chunks
WHERE document_id = ?
ORDER BY chunk_index`,
[documentId]
).map((chunk) => ({
...chunk,
chunkIndex: Number(chunk.chunk_index || 0),
tokenEstimate: Number(chunk.token_estimate || 0),
keywords: parseJson(chunk.keywords_json, []),
entities: parseJson(chunk.entities_json, {}),
metadata: parseJson(chunk.metadata_json, {})
}));
return serializeKnowledgeDocumentRow(row, chunks);
}
export function listKnowledgeDocuments(context) {
requirePermission(context, "script:read");
const { clause, params } = scopedKnowledgeQuery(context);
const rows = dbAll(
`SELECT kd.*,
(SELECT COUNT(*) FROM knowledge_chunks kc WHERE kc.document_id = kd.id) AS chunk_count,
(SELECT COUNT(*) FROM knowledge_document_versions kdv WHERE kdv.document_id = kd.id) AS version_count
FROM knowledge_documents kd
WHERE ${clause}
ORDER BY kd.updated_at DESC, kd.created_at DESC`,
params
);
const documents = rows.map((row) => serializeKnowledgeDocumentRow(row));
return {
documents,
summary: {
total: documents.length,
workspaceScoped: documents.filter((item) => item.scopeMode === "workspace").length,
projectScoped: documents.filter((item) => item.scopeMode === "project").length,
chunks: documents.reduce((sum, item) => sum + Number(item.chunkCount || 0), 0),
rightsApproved: documents.filter((item) => item.rightsStatus === "approved").length,
rightsNeedsEvidence: documents.filter((item) => item.rightsStatus !== "approved").length,
riskBlocked: documents.filter((item) => item.riskStatus === "blocked").length,
riskReview: documents.filter((item) => item.riskStatus === "review").length,
versions: documents.reduce((sum, item) => sum + Number(item.versionCount || 0), 0)
}
};
}
export function getKnowledgeDocument(context, documentId) {
requirePermission(context, "script:read");
const document = knowledgeDocumentDetail(context, documentId);
if (!document) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId });
return { document };
}
export function searchKnowledge(context, body = {}) {
requirePermission(context, "script:read");
const query = String(body.query || body.q || "").trim().slice(0, 120);
const sourceType = String(body.sourceType || body.source_type || "all").trim();
const scopeMode = String(body.scopeMode || body.scope_mode || "all").trim();
const limit = Math.max(1, Math.min(80, Number(body.limit || 24)));
const terms = knowledgeTerms(query);
const { clause, params } = scopedKnowledgeQuery(context);
const where = [clause, "kd.status <> 'archived'"];
const queryParams = [...params];
if (sourceType && sourceType !== "all") {
where.push("kd.source_type = ?");
queryParams.push(sourceType);
}
if (["workspace", "project"].includes(scopeMode)) {
where.push("kd.scope_mode = ?");
queryParams.push(scopeMode);
}
if (terms.length) {
const termClauses = [];
for (const term of terms) {
termClauses.push("(kd.title LIKE ? ESCAPE '\\' OR kd.summary LIKE ? ESCAPE '\\' OR kc.heading LIKE ? ESCAPE '\\' OR kc.content LIKE ? ESCAPE '\\' OR kc.keywords_json LIKE ? ESCAPE '\\' OR kc.entities_json LIKE ? ESCAPE '\\')");
const pattern = likePattern(term);
queryParams.push(pattern, pattern, pattern, pattern, pattern, pattern);
}
where.push(`(${termClauses.join(" OR ")})`);
}
const rows = dbAll(
`SELECT kc.*, kd.title AS document_title, kd.source_type, kd.language, kd.scope_mode,
kd.rights_status, kd.risk_json,
kd.project_id, kd.organization_id, kd.workspace_id, kd.updated_at
FROM knowledge_chunks kc
JOIN knowledge_documents kd ON kd.id = kc.document_id
WHERE ${where.join(" AND ")}
ORDER BY kd.updated_at DESC, kc.chunk_index ASC
LIMIT ?`,
[...queryParams, Math.max(limit * 8, 80)]
);
const ranked = rows
.map((row) => {
const chunk = normalizeKnowledgeChunk(row);
const score = scoreKnowledgeChunk(chunk, terms);
return {
...chunk,
score,
snippet: snippetFor(chunk.content, terms),
citationKey: ""
};
})
.filter((item) => !terms.length || item.score > 0)
.sort((left, right) => right.score - left.score || right.updatedAt.localeCompare(left.updatedAt) || left.chunkIndex - right.chunkIndex)
.slice(0, limit)
.map((item, index) => ({ ...item, citationKey: `K${index + 1}` }));
const documentIds = new Set(ranked.map((item) => item.documentId));
return {
query,
scopeMode,
sourceType,
total: ranked.length,
results: ranked,
summary: {
documents: documentIds.size,
chunks: ranked.length,
tokenEstimate: ranked.reduce((sum, item) => sum + Number(item.tokenEstimate || 0), 0),
types: ranked.reduce((accumulator, item) => ({ ...accumulator, [item.chunkType]: (accumulator[item.chunkType] || 0) + 1 }), {})
}
};
}
function knowledgeChunksForIds(context, chunkIds) {
const ids = unique(chunkIds).slice(0, 80);
if (!ids.length) return [];
const { clause, params } = scopedKnowledgeQuery(context);
const rows = dbAll(
`SELECT kc.*, kd.title AS document_title, kd.source_type, kd.language, kd.scope_mode,
kd.rights_status, kd.risk_json,
kd.project_id, kd.organization_id, kd.workspace_id, kd.updated_at
FROM knowledge_chunks kc
JOIN knowledge_documents kd ON kd.id = kc.document_id
WHERE kc.id IN (${placeholders(ids)}) AND ${clause}
ORDER BY kd.updated_at DESC, kc.chunk_index ASC`,
[...ids, ...params]
).map(normalizeKnowledgeChunk);
const byId = new Map(rows.map((row) => [row.id, row]));
const ordered = ids.map((id) => byId.get(id)).filter(Boolean);
if (ordered.length !== ids.length) throw httpError(422, "knowledge_chunk_scope_invalid", "知识片段不存在,或不属于当前组织/工作区/项目作用域", { requested: ids.length, matched: ordered.length });
return ordered;
}
function trimChunkForBudget(chunk, remainingTokens) {
const allowedTokens = Math.max(40, Number(remainingTokens || 0));
if (chunk.tokenEstimate <= allowedTokens) return chunk;
const allowedChars = Math.max(120, Math.floor(allowedTokens * 1.8));
return {
...chunk,
content: `${chunk.content.slice(0, allowedChars).trim()}…`,
tokenEstimate: approxTokens(chunk.content.slice(0, allowedChars))
};
}
function selectPackChunks(chunks, maxTokens) {
const selected = [];
let tokenEstimate = 0;
for (const chunk of chunks) {
const remaining = maxTokens - tokenEstimate;
if (remaining <= 0) break;
const candidate = selected.length ? chunk : trimChunkForBudget(chunk, remaining);
if (candidate.tokenEstimate > remaining && selected.length) continue;
selected.push(candidate);
tokenEstimate += Number(candidate.tokenEstimate || 0);
}
return { chunks: selected, tokenEstimate };
}
function packPromptContext(name, chunks, citations, governance = {}) {
const blocks = chunks.map((chunk, index) => {
const citation = citations[index];
return [
`## [${citation.key}] ${chunk.heading || `片段 ${chunk.chunkIndex}`}`,
`来源:《${chunk.documentTitle}》 / ${chunk.sourceType || "素材"} / chunk ${chunk.chunkIndex} / rights=${citation.rightsStatus || "needs-evidence"} / risk=${citation.riskStatus || "unscanned"}`,
`内容:${chunk.content}`
].join("\n");
});
return [
`# 知识库上下文包:${name}`,
"使用要求:基于引用素材做原创改编;保留人物、道具、地点和时间线连续性;生成画面仍必须是一张完整单画面,不得输出多格、拼图或分屏。",
`治理摘要:${governance.status || "unscanned"};未批准/需复核片段 ${governance.reviewCount || 0};阻断片段 ${governance.blockingCount || 0}。`,
...blocks
].join("\n\n");
}
function packPayload(context, body = {}, chunks, tokenEstimate, persist) {
const name = String(body.name || body.title || (body.query ? `检索包:${body.query}` : "知识库上下文包")).trim().slice(0, 80);
const query = String(body.query || "").trim().slice(0, 120);
const scopeMode = String(body.scopeMode || body.scope_mode || (context.project ? "project" : "workspace")).trim();
const maxTokens = Math.max(100, Math.min(20000, Number(body.maxTokens || body.max_tokens || 1600)));
const sourceType = String(body.sourceType || body.source_type || "mixed").trim();
const citations = chunks.map((chunk, index) => ({
key: `K${index + 1}`,
documentId: chunk.documentId,
documentTitle: chunk.documentTitle,
chunkId: chunk.id,
chunkIndex: chunk.chunkIndex,
heading: chunk.heading,
sourceType: chunk.sourceType,
scopeMode: chunk.scopeMode,
rightsStatus: chunk.rightsStatus || "needs-evidence",
riskStatus: chunk.riskStatus || chunk.risk?.status || "unscanned",
projectId: chunk.projectId || null
}));
const compactChunks = chunks.map((chunk, index) => ({
citationKey: citations[index].key,
id: chunk.id,
documentId: chunk.documentId,
documentTitle: chunk.documentTitle,
chunkIndex: chunk.chunkIndex,
chunkType: chunk.chunkType,
heading: chunk.heading,
content: chunk.content,
tokenEstimate: chunk.tokenEstimate,
keywords: chunk.keywords,
entities: chunk.entities,
rightsStatus: chunk.rightsStatus || "needs-evidence",
riskStatus: chunk.riskStatus || chunk.risk?.status || "unscanned"
}));
const governance = summarizePackGovernance(chunks);
return {
schema: "ai-drama.knowledge-context-pack.v1",
id: String(body.id || makeId(persist ? "knowledge-pack" : "knowledge-pack-preview")),
name,
query,
sourceType,
scopeMode: ["workspace", "project"].includes(scopeMode) ? scopeMode : "workspace",
projectId: scopeMode === "project" ? context.project?.id || "" : "",
maxTokens,
tokenEstimate,
selectedCount: compactChunks.length,
chunkIds: compactChunks.map((chunk) => chunk.id),
citations,
chunks: compactChunks,
promptContext: packPromptContext(name, chunks, citations, governance),
governance,
metadata: body.metadata && typeof body.metadata === "object" ? { ...body.metadata, governance } : { governance }
};
}
export function createKnowledgeContextPack(context, body = {}, options = {}) {
const persist = options.persist !== false;
requirePermission(context, persist ? "script:edit" : "script:read");
if (persist) requireEntitlement(context, "limit.knowledge_context_packs", 1);
const maxTokens = Math.max(100, Math.min(20000, Number(body.maxTokens || body.max_tokens || 1600)));
const chunkIds = Array.isArray(body.chunkIds || body.chunk_ids) ? body.chunkIds || body.chunk_ids : [];
const sourceChunks = chunkIds.length
? knowledgeChunksForIds(context, chunkIds)
: searchKnowledge(context, { ...body, limit: Math.max(1, Math.min(40, Number(body.limit || 12))) }).results;
if (!sourceChunks.length) throw httpError(404, "knowledge_context_empty", "没有可用于上下文包的知识片段");
const selected = selectPackChunks(sourceChunks, maxTokens);
const blocked = selected.chunks.filter((chunk) => ["rejected", "expired"].includes(chunk.rightsStatus || "") || chunk.risk?.status === "blocked");
if (blocked.length) {
throw httpError(409, "knowledge_context_blocked", "选中的知识片段包含版权不可用或风险阻断素材,不能创建生产上下文包", {
blocked: blocked.map((chunk) => ({ id: chunk.id, documentId: chunk.documentId, rightsStatus: chunk.rightsStatus, riskStatus: chunk.risk?.status || "unscanned" }))
});
}
const containsProjectScopedChunk = selected.chunks.some((chunk) => chunk.scopeMode === "project" || chunk.projectId);
const requestedScopeMode = String(body.scopeMode || body.scope_mode || "workspace").trim();
const effectiveScopeMode = containsProjectScopedChunk ? "project" : requestedScopeMode;
if (effectiveScopeMode === "project" && !context.project) throw httpError(400, "knowledge_project_required", "项目级上下文包必须绑定当前项目");
const pack = packPayload(context, { ...body, maxTokens, scopeMode: effectiveScopeMode }, selected.chunks, selected.tokenEstimate, persist);
if (persist) {
const timestamp = now();
withTransaction(() => {
dbRun(
`INSERT INTO knowledge_context_packs(
id, organization_id, workspace_id, project_id, scope_mode, name, query, source_type, max_tokens,
token_estimate, chunk_ids_json, citations_json, chunks_json, prompt_context, status,
metadata_json, created_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)`,
[
pack.id,
context.organization.id,
context.workspace.id,
pack.scopeMode === "project" ? context.project?.id || null : null,
pack.scopeMode,
pack.name,
pack.query,
pack.sourceType,
pack.maxTokens,
pack.tokenEstimate,
JSON.stringify(pack.chunkIds),
JSON.stringify(pack.citations),
JSON.stringify(pack.chunks),
pack.promptContext,
JSON.stringify(pack.metadata),
context.user.id,
timestamp,
timestamp
]
);
});
addAudit({ context, action: "knowledge.context_pack.created", targetType: "knowledge_context_pack", targetId: pack.id, metadata: { name: pack.name, chunkCount: pack.selectedCount, tokenEstimate: pack.tokenEstimate } });
addUsage({ context, kind: "knowledge-context-pack", units: pack.selectedCount, unitName: "chunks", metadata: { packId: pack.id, tokenEstimate: pack.tokenEstimate } });
}
return { pack, packs: persist ? listKnowledgeContextPacks(context).packs : undefined };
}
export function listKnowledgeContextPacks(context) {
requirePermission(context, "script:read");
const { clause, params } = scopedKnowledgePackQuery(context);
const rows = dbAll(
`SELECT *
FROM knowledge_context_packs kcp
WHERE ${clause} AND kcp.status = 'active'
ORDER BY kcp.updated_at DESC, kcp.created_at DESC
LIMIT 80`,
params
).map(serializeContextPackRow);
return {
packs: rows,
summary: {
total: rows.length,
chunks: rows.reduce((sum, item) => sum + (item.chunkIds?.length || 0), 0),
tokenEstimate: rows.reduce((sum, item) => sum + Number(item.tokenEstimate || 0), 0)
}
};
}
export function getKnowledgeContextPack(context, packId) {
requirePermission(context, "script:read");
const { clause, params } = scopedKnowledgePackQuery(context);
const row = dbGet(
`SELECT *
FROM knowledge_context_packs kcp
WHERE kcp.id = ? AND ${clause}`,
[packId, ...params]
);
if (!row) throw httpError(404, "knowledge_context_pack_not_found", "知识库上下文包不存在或不属于当前作用域", { packId });
return { pack: serializeContextPackRow(row) };
}
export function materializeKnowledgeContextPack(context, packId, body = {}) {
requirePermission(context, "script:edit");
requireProjectWritable(context);
if (!context.project) throw httpError(400, "project_required", "上下文包送入剧本工厂时必须绑定项目");
const { pack } = getKnowledgeContextPack(context, packId);
const chunks = Array.isArray(pack.chunks) ? pack.chunks : [];
assertPackGovernanceUsable(pack);
if (!chunks.length) throw httpError(422, "knowledge_context_pack_empty", "上下文包没有可导入的片段");
const content = [
`# ${pack.name}`,
pack.citations?.length ? `引用:${pack.citations.map((item) => `[${item.key}]《${item.documentTitle}》/${item.heading}`).join(";")}` : "",
...chunks.map((chunk) => [`## ${chunk.citationKey || ""} ${chunk.heading || chunk.id}`.trim(), chunk.content].join("\n"))
].filter(Boolean).join("\n\n");
const scriptImport = importScript(context, {
title: String(body.title || `${pack.name} · 剧本草稿`).trim(),
sourceType: `知识库上下文包/${pack.sourceType || "mixed"}`,
content,
episodeId: body.episodeId || undefined,
episodeTitle: body.episodeTitle || undefined,
metadata: {
origin: "knowledge_context_pack",
knowledgePackId: pack.id,
knowledgePackName: pack.name,
knowledgeChunkIds: pack.chunkIds || [],
knowledgeCitations: pack.citations || [],
knowledgeGovernance: pack.governance || {}
}
});
addAudit({ context, action: "knowledge.context_pack.materialized", targetType: "knowledge_context_pack", targetId: packId, metadata: { scriptDocumentId: scriptImport.document?.id || "", chunkCount: chunks.length } });
return {
sourcePack: pack,
importedScript: scriptImport.document,
graph: scriptImport.graph
};
}
export function resolveKnowledgeContextForJob(context, body = {}) {
const packId = String(body.knowledgePackId || body.knowledge_pack_id || "").trim();
const inlinePack = body.knowledgePack && typeof body.knowledgePack === "object" ? body.knowledgePack : null;
const chunkIds = Array.isArray(body.knowledgeChunkIds || body.knowledge_chunk_ids) ? body.knowledgeChunkIds || body.knowledge_chunk_ids : [];
const query = String(body.knowledgeQuery || body.knowledge_query || "").trim();
if (!packId && !inlinePack && !chunkIds.length && !query) return null;
requirePermission(context, "script:read");
if (packId) {
const pack = getKnowledgeContextPack(context, packId).pack;
assertPackGovernanceUsable(pack);
return pack;
}
if (inlinePack) {
return {
schema: "ai-drama.knowledge-context-pack.v1",
id: String(inlinePack.id || "inline-knowledge-pack"),
name: String(inlinePack.name || "内联知识库上下文包"),
tokenEstimate: Number(inlinePack.tokenEstimate || 0),
chunkIds: Array.isArray(inlinePack.chunkIds) ? inlinePack.chunkIds : [],
citations: Array.isArray(inlinePack.citations) ? inlinePack.citations : [],
chunks: Array.isArray(inlinePack.chunks) ? inlinePack.chunks : [],
promptContext: String(inlinePack.promptContext || ""),
sourceType: String(inlinePack.sourceType || "inline"),
scopeMode: String(inlinePack.scopeMode || "workspace")
};
}
return createKnowledgeContextPack(context, {
name: body.knowledgePackName || body.knowledge_pack_name || (query ? `任务上下文:${query}` : "任务上下文包"),
query,
chunkIds,
maxTokens: body.knowledgeMaxTokens || body.knowledge_max_tokens || 1600,
sourceType: body.knowledgeSourceType || body.knowledge_source_type || "mixed",
scopeMode: body.knowledgeScopeMode || body.knowledge_scope_mode || "workspace",
metadata: { transient: true, jobKind: body.kind || "" }
}, { persist: false }).pack;
}
export function importKnowledgeDocument(context, body = {}) {
requirePermission(context, "script:edit");
requireEntitlement(context, "limit.knowledge_documents", 1);
const content = String(body.content || "").replace(/\r/g, "").trim();
if (content.length < 30) throw httpError(400, "knowledge_content_required", "导入知识库的文本至少需要 30 个字符");
const scopeMode = String(body.scopeMode || body.scope_mode || "workspace").trim();
if (!["workspace", "project"].includes(scopeMode)) throw httpError(400, "knowledge_scope_invalid", "知识库作用域只能是 workspace 或 project");
if (scopeMode === "project" && !context.project) throw httpError(400, "knowledge_project_required", "项目级知识库必须绑定当前项目");
const analysis = analyzeKnowledgeText(content);
const title = String(body.title || analysis.chapters[0]?.title || "未命名素材").trim();
if (!title) throw httpError(400, "knowledge_title_required", "知识库标题不能为空");
const sourceType = String(body.sourceType || body.source_type || "novel").trim();
const language = String(body.language || "zh-CN").trim();
const metadata = body.metadata && typeof body.metadata === "object" ? body.metadata : {};
const rightsStatus = normalizeRightsStatus(body.rightsStatus || body.rights_status || metadata.rightsStatus || metadata.rights);
const provenance = normalizeProvenance(body, metadata);
const tags = normalizeTags(body.tags || metadata.tags || []);
const risk = scanKnowledgeGovernance({ title, content, sourceType, rightsStatus, provenance, tags });
const id = String(body.id || makeId("knowledge"));
const timestamp = now();
withTransaction(() => {
dbRun(
`INSERT INTO knowledge_documents(
id, organization_id, workspace_id, project_id, scope_mode, title, source_type, language,
content, status, rights_status, summary, analysis_json, provenance_json, tags_json, risk_json,
metadata_json, current_version_number, created_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'indexed', ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`,
[id, context.organization.id, context.workspace.id, scopeMode === "project" ? context.project?.id || null : null, scopeMode, title, sourceType, language, content, rightsStatus, analysis.summary, JSON.stringify({ ...analysis, chunks: undefined }), JSON.stringify(provenance), JSON.stringify(tags), JSON.stringify(risk), JSON.stringify(metadata), context.user.id, timestamp, timestamp]
);
writeKnowledgeChunks(id, analysis.chunks, timestamp);
insertKnowledgeVersion(context, {
id,
title,
sourceType,
language,
content,
summary: analysis.summary,
analysis,
provenance,
tags,
risk,
metadata
}, 1, timestamp, { action: "ingest" });
});
addAudit({ context, action: "knowledge.document.ingested", targetType: "knowledge_document", targetId: id, metadata: { title, scopeMode, sourceType, chunkCount: analysis.chunkCount, rightsStatus, riskStatus: risk.status, riskScore: risk.score } });
addUsage({ context, kind: "knowledge-ingest", units: analysis.chunkCount, unitName: "chunks", metadata: { documentId: id, sourceType, parser: analysis.parser } });
return { document: knowledgeDocumentDetail(context, id), summary: listKnowledgeDocuments(context).summary };
}
export function updateKnowledgeDocument(context, documentId, body = {}) {
requirePermission(context, "script:edit");
const existing = knowledgeDocumentDetail(context, documentId);
if (!existing) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId });
const title = body.title === undefined ? existing.title : String(body.title || "").trim();
if (!title) throw httpError(400, "knowledge_title_required", "知识库标题不能为空");
const sourceType = body.sourceType === undefined && body.source_type === undefined ? existing.sourceType || existing.source_type || "novel" : String(body.sourceType || body.source_type || "novel").trim();
const language = body.language === undefined ? existing.language || "zh-CN" : String(body.language || "zh-CN").trim();
const content = body.content === undefined ? existing.content || "" : String(body.content || "").replace(/\r/g, "").trim();
if (content.length < 30) throw httpError(400, "knowledge_content_required", "知识库正文至少需要 30 个字符");
const status = normalizeDocumentStatus(body.status, existing.status || "indexed");
const rightsInput = body.rightsStatus ?? body.rights_status ?? existing.rightsStatus;
const rightsStatus = normalizeRightsStatus(rightsInput);
const provenance = normalizeProvenance(body, existing.provenance);
const tags = body.tags === undefined ? existing.tags || [] : normalizeTags(body.tags);
const metadata = body.metadata && typeof body.metadata === "object" ? { ...(existing.metadata || {}), ...body.metadata } : existing.metadata || {};
const contentChanged = content !== existing.content || title !== existing.title || sourceType !== (existing.sourceType || existing.source_type) || language !== existing.language;
const analysis = contentChanged ? analyzeKnowledgeText(content) : existing.analysis || analyzeKnowledgeText(content);
const risk = scanKnowledgeGovernance({ title, content, sourceType, rightsStatus, provenance, tags });
const versionNumber = nextKnowledgeVersionNumber(documentId);
const timestamp = now();
withTransaction(() => {
dbRun(
`UPDATE knowledge_documents
SET title = ?, source_type = ?, language = ?, content = ?, status = ?, rights_status = ?,
summary = ?, analysis_json = ?, provenance_json = ?, tags_json = ?, risk_json = ?,
metadata_json = ?, current_version_number = ?, updated_at = ?
WHERE id = ?`,
[title, sourceType, language, content, status, rightsStatus, analysis.summary, JSON.stringify({ ...analysis, chunks: undefined }), JSON.stringify(provenance), JSON.stringify(tags), JSON.stringify(risk), JSON.stringify(metadata), versionNumber, timestamp, documentId]
);
if (contentChanged) {
dbRun("DELETE FROM knowledge_chunks WHERE document_id = ?", [documentId]);
writeKnowledgeChunks(documentId, analysis.chunks, timestamp);
}
insertKnowledgeVersion(context, {
id: documentId,
title,
sourceType,
language,
content,
summary: analysis.summary,
analysis,
provenance,
tags,
risk,
metadata
}, versionNumber, timestamp, { action: "update", contentChanged });
});
addAudit({ context, action: "knowledge.document.updated", targetType: "knowledge_document", targetId: documentId, metadata: { title, versionNumber, contentChanged, rightsStatus, riskStatus: risk.status } });
return { document: knowledgeDocumentDetail(context, documentId), summary: listKnowledgeDocuments(context).summary };
}
export function reviewKnowledgeDocument(context, documentId, body = {}) {
const decision = normalizeGovernanceDecision(body.decision);
if (["approved", "rejected"].includes(decision)) requirePermission(context, "compliance:manage");
else if (!hasPermission(context, "script:edit") && !hasPermission(context, "compliance:manage") && !hasPermission(context, "asset:edit")) {
throw httpError(403, "permission_denied", "缺少提交素材治理证据的权限", { permission: "script:edit" });
}
const existing = knowledgeDocumentDetail(context, documentId);
if (!existing) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId });
const requestedRights = body.rightsStatus ?? body.rights_status;
const rightsStatus = normalizeRightsStatus(requestedRights || (decision === "approved" ? "approved" : decision === "rejected" ? "rejected" : "submitted"));
const provenance = normalizeProvenance(body, existing.provenance);
if (body.evidenceRef || body.evidence_ref) provenance.evidenceRef = String(body.evidenceRef || body.evidence_ref || "").trim();
const tags = body.tags === undefined ? existing.tags || [] : normalizeTags(body.tags);
const risk = scanKnowledgeGovernance({
title: existing.title,
content: existing.content,
sourceType: existing.sourceType || existing.source_type,
rightsStatus,
provenance,
tags
});
const reviewId = String(body.id || makeId("knowledge-review"));
const timestamp = now();
withTransaction(() => {
dbRun(
`INSERT INTO knowledge_governance_reviews(
id, document_id, organization_id, workspace_id, project_id, decision, rights_status,
risk_status, notes, evidence_ref, provenance_json, risk_json, reviewer_user_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
reviewId,
documentId,
context.organization.id,
context.workspace.id,
existing.projectId || null,
decision,
rightsStatus,
risk.status,
String(body.notes || "").trim(),
provenance.evidenceRef || "",
JSON.stringify(provenance),
JSON.stringify(risk),
context.user.id,
timestamp
]
);
dbRun(
`UPDATE knowledge_documents
SET rights_status = ?, provenance_json = ?, tags_json = ?, risk_json = ?, updated_at = ?
WHERE id = ?`,
[rightsStatus, JSON.stringify(provenance), JSON.stringify(tags), JSON.stringify(risk), timestamp, documentId]
);
});
addAudit({ context, action: `knowledge.document.review.${decision}`, targetType: "knowledge_document", targetId: documentId, metadata: { reviewId, rightsStatus, riskStatus: risk.status, evidenceRef: provenance.evidenceRef || "" } });
return { document: knowledgeDocumentDetail(context, documentId), review: latestKnowledgeReview(documentId), summary: listKnowledgeDocuments(context).summary };
}
export function archiveKnowledgeDocument(context, documentId) {
requirePermission(context, "script:edit");
const existing = knowledgeDocumentDetail(context, documentId);
if (!existing) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId });
const timestamp = now();
dbRun("UPDATE knowledge_documents SET status = 'archived', updated_at = ? WHERE id = ?", [timestamp, documentId]);
addAudit({ context, action: "knowledge.document.archived", targetType: "knowledge_document", targetId: documentId, metadata: { previousStatus: existing.status } });
return { document: knowledgeDocumentDetail(context, documentId), summary: listKnowledgeDocuments(context).summary };
}
export function restoreKnowledgeDocument(context, documentId) {
requirePermission(context, "script:edit");
const existing = knowledgeDocumentDetail(context, documentId);
if (!existing) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId });
const timestamp = now();
dbRun("UPDATE knowledge_documents SET status = 'indexed', updated_at = ? WHERE id = ?", [timestamp, documentId]);
addAudit({ context, action: "knowledge.document.restored", targetType: "knowledge_document", targetId: documentId, metadata: { previousStatus: existing.status } });
return { document: knowledgeDocumentDetail(context, documentId), summary: listKnowledgeDocuments(context).summary };
}
export function listKnowledgeDocumentVersions(context, documentId) {
requirePermission(context, "script:read");
const existing = knowledgeDocumentDetail(context, documentId);
if (!existing) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId });
const versions = dbAll(
`SELECT *
FROM knowledge_document_versions
WHERE document_id = ?
ORDER BY version_number DESC`,
[documentId]
).map(serializeKnowledgeVersionRow);
return { document: existing, versions };
}
export function restoreKnowledgeDocumentVersion(context, documentId, versionKey) {
requirePermission(context, "script:edit");
const existing = knowledgeDocumentDetail(context, documentId);
if (!existing) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId });
const version = dbGet(
`SELECT *
FROM knowledge_document_versions
WHERE document_id = ? AND (id = ? OR CAST(version_number AS TEXT) = ?)
LIMIT 1`,
[documentId, String(versionKey), String(versionKey)]
);
if (!version) throw httpError(404, "knowledge_version_not_found", "知识库文档版本不存在", { documentId, versionKey });
const serialized = serializeKnowledgeVersionRow(version);
const analysis = analyzeKnowledgeText(serialized.content);
const risk = scanKnowledgeGovernance({
title: serialized.title,
content: serialized.content,
sourceType: serialized.sourceType,
rightsStatus: existing.rightsStatus,
provenance: serialized.provenance,
tags: serialized.tags
});
const versionNumber = nextKnowledgeVersionNumber(documentId);
const timestamp = now();
withTransaction(() => {
dbRun(
`UPDATE knowledge_documents
SET title = ?, source_type = ?, language = ?, content = ?, summary = ?, analysis_json = ?,
provenance_json = ?, tags_json = ?, risk_json = ?, current_version_number = ?, status = 'indexed', updated_at = ?
WHERE id = ?`,
[serialized.title, serialized.sourceType, serialized.language, serialized.content, analysis.summary, JSON.stringify({ ...analysis, chunks: undefined }), JSON.stringify(serialized.provenance), JSON.stringify(serialized.tags), JSON.stringify(risk), versionNumber, timestamp, documentId]
);
dbRun("DELETE FROM knowledge_chunks WHERE document_id = ?", [documentId]);
writeKnowledgeChunks(documentId, analysis.chunks, timestamp);
insertKnowledgeVersion(context, {
id: documentId,
title: serialized.title,
sourceType: serialized.sourceType,
language: serialized.language,
content: serialized.content,
summary: analysis.summary,
analysis,
provenance: serialized.provenance,
tags: serialized.tags,
risk,
metadata: { ...serialized.metadata, restoredFromVersion: serialized.versionNumber }
}, versionNumber, timestamp, { action: "restore-version", restoredFromVersion: serialized.versionNumber, restoredFromVersionId: serialized.id });
});
addAudit({ context, action: "knowledge.document.version_restored", targetType: "knowledge_document", targetId: documentId, metadata: { restoredFromVersion: serialized.versionNumber, newVersionNumber: versionNumber } });
return { document: knowledgeDocumentDetail(context, documentId), versions: listKnowledgeDocumentVersions(context, documentId).versions };
}
export function materializeKnowledgeDocument(context, documentId, body = {}) {
requirePermission(context, "script:edit");
requireProjectWritable(context);
if (!context.project) throw httpError(400, "project_required", "把知识库文档送入剧本工厂时必须绑定项目");
const document = knowledgeDocumentDetail(context, documentId);
if (!document) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId });
assertKnowledgeDocumentUsable(document);
const selectedChunkIds = Array.isArray(body.chunkIds) ? new Set(body.chunkIds.map((item) => String(item))) : null;
const selectedChunks = selectedChunkIds ? document.chunks.filter((chunk) => selectedChunkIds.has(chunk.id)) : document.chunks;
const content = selectedChunks.length ? selectedChunks.map((chunk) => `${chunk.heading}\n${chunk.content}`.trim()).join("\n\n") : document.content;
const scriptImport = importScript(context, {
title: String(body.title || `${document.title} · 剧本草稿`).trim(),
sourceType: `知识库/${document.source_type || "novel"}`,
content,
episodeId: body.episodeId || undefined,
episodeTitle: body.episodeTitle || undefined,
metadata: {
origin: "knowledge_document",
knowledgeDocumentId: document.id,
knowledgeDocumentTitle: document.title,
knowledgeChunkIds: selectedChunks.map((chunk) => chunk.id),
knowledgeGovernance: {
rightsStatus: document.rightsStatus || document.rights_status || "needs-evidence",
riskStatus: document.risk?.status || document.riskStatus || "unscanned"
}
}
});
addAudit({ context, action: "knowledge.document.materialized", targetType: "knowledge_document", targetId: documentId, metadata: { scriptDocumentId: scriptImport.document?.id || "", chunkCount: selectedChunks.length } });
return {
sourceDocument: document,
importedScript: scriptImport.document,
graph: scriptImport.graph
};
}