1233 lines
58 KiB
JavaScript
1233 lines
58 KiB
JavaScript
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
|
||
};
|
||
}
|