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 }; }