diff --git a/scripts/decision-center-workspace-contract-test.ts b/scripts/decision-center-workspace-contract-test.ts index 5dec3b1d..3b0e82e6 100644 --- a/scripts/decision-center-workspace-contract-test.ts +++ b/scripts/decision-center-workspace-contract-test.ts @@ -1,4 +1,10 @@ import { readFileSync } from "node:fs"; +import { + buildDocTypeTree, + docTypeLabels, + extractDocMetadata, + extractDocNo, +} from "../src/components/frontend/src/decision-center.tsx"; type JsonRecord = Record; @@ -14,19 +20,73 @@ function includesAll(text: string, snippets: string[]): boolean { return snippets.every((snippet) => text.includes(snippet)); } +function assertEqual(actual: T, expected: T, message: string, detail: JsonRecord = {}): void { + assertCondition(Object.is(actual, expected), message, { ...detail, actual, expected }); +} + +function groupCount(records: JsonRecord[], type: string): number { + return buildDocTypeTree(records).find((group) => group.type === type)?.nodes.length || 0; +} + export function runDecisionCenterWorkspaceContract(): JsonRecord { const frontend = source("src/components/frontend/src/decision-center.tsx"); const css = source("src/components/frontend/public/style.css"); + const titleDcsn = { + id: "title-dcsn", + title: "DC-DCSN-P0-2026-001 决策记录", + tags: [], + }; + const tagGoal = { + id: "tag-goal", + title: "目标文书", + tags: ["doc-no:DC-GOAL-P0-2026-002"], + }; + const bodyRprt = { + id: "body-rprt", + title: "报告正文", + body: "DC-RPRT-P2-2026-003\n\n报告正文第一段。", + tags: [], + }; + const tagRetr = { + id: "tag-retr", + title: "复盘文书", + tags: ["doc-type:RETR"], + }; + const structuredPlan = { + id: "structured-plan", + title: "结构化计划文书", + docType: "PLAN", + priority: "P1", + year: 2026, + sequence: 4, + }; + + assertEqual(extractDocNo(titleDcsn), "DC-DCSN-P0-2026-001", "title prefix must extract complete DCSN doc number"); + assertEqual(extractDocMetadata(titleDcsn).docType, "DCSN", "title prefix must extract DCSN doc type"); + assertEqual(extractDocNo(tagGoal), "DC-GOAL-P0-2026-002", "doc-no tag must extract complete GOAL doc number"); + assertEqual(extractDocMetadata(tagGoal).docType, "GOAL", "doc-no tag must extract GOAL doc type"); + assertEqual(extractDocNo(bodyRprt), "DC-RPRT-P2-2026-003", "body first paragraph must fallback extract complete RPRT doc number"); + assertEqual(extractDocMetadata(bodyRprt).priority, "P2", "body fallback must extract RPRT priority"); + assertEqual(extractDocMetadata(tagRetr).docType, "RETR", "doc-type tag must extract RETR doc type"); + assertEqual(docTypeLabels.RETR, "复盘", "RETR label must be 复盘"); + assertEqual(extractDocNo(structuredPlan), "DC-PLAN-P1-2026-004", "structured fields must compose complete PLAN doc number"); + assertEqual(groupCount([titleDcsn, tagGoal, bodyRprt, tagRetr, structuredPlan], "DCSN"), 1, "DCSN records must be grouped by parsed type"); + assertEqual(groupCount([titleDcsn, tagGoal, bodyRprt, tagRetr, structuredPlan], "GOAL"), 1, "GOAL records must be grouped by parsed type"); + assertEqual(groupCount([titleDcsn, tagGoal, bodyRprt, tagRetr, structuredPlan], "RPRT"), 1, "RPRT records must be grouped by parsed type"); + assertEqual(groupCount([titleDcsn, tagGoal, bodyRprt, tagRetr, structuredPlan], "RETR"), 1, "RETR records must be grouped by parsed type"); + assertEqual(groupCount([titleDcsn, tagGoal, bodyRprt, tagRetr, structuredPlan], "PLAN"), 1, "PLAN records must be grouped by structured type"); + assertCondition( includesAll(frontend, [ - "function extractDocNo(record: any): string", - "function buildDocTypeTree(records: any[]): Array<{ type: DocTypeCode", + "export function extractDocMetadata(record: any): DocMetadata", + "export function extractDocNo(record: any): string", + "export function buildDocTypeTree(records: any[]): Array<{ type: DocTypeCode", "function buildTagGroups(records: any[]): Array<{ tag: string", "DOC_TYPE_CODES = [\"DCSN\", \"GOAL\", \"PLAN\", \"RPRT\", \"ACTN\", \"ISSU\", \"RETR\", \"RQST\", \"RESP\", \"MINS\"]", "docTypeLabels: Record", ]), - "frontend must implement doc-no extraction, doc-type tree, and tag grouping", + "frontend must implement exported doc metadata extraction, doc-type tree, and tag grouping", ); assertCondition( @@ -118,11 +178,11 @@ export function runDecisionCenterWorkspaceContract(): JsonRecord { assertCondition( includesAll(frontend, [ - "extractDocNo(record)", - "title.match(/^([A-Z]{2,5})[-−]?(\\d+)/u)", - "doc-no:([A-Z]{2,5})[-−]?(\\d+)", + "extractDocMetadata(record)", + "recordTagValues(record, \"doc-no\")", + "firstBodyWindow(record)", ]), - "extractDocNo must extract from title prefix and doc-no tag", + "extractDocNo must use metadata extraction from title, doc-no tag, and body fallback", ); assertCondition( @@ -148,7 +208,7 @@ export function runDecisionCenterWorkspaceContract(): JsonRecord { "workspace-css-layout", "workspace-css-components", "three-column-layout-50-percent", - "doc-no-regex-from-title", + "doc-metadata-fixtures", "type-tag-grouping-toggle", ], }; @@ -156,4 +216,4 @@ export function runDecisionCenterWorkspaceContract(): JsonRecord { if (import.meta.main) { process.stdout.write(`${JSON.stringify(runDecisionCenterWorkspaceContract(), null, 2)}\n`); -} \ No newline at end of file +} diff --git a/src/components/frontend/src/decision-center.tsx b/src/components/frontend/src/decision-center.tsx index ee43dfca..925453e2 100644 --- a/src/components/frontend/src/decision-center.tsx +++ b/src/components/frontend/src/decision-center.tsx @@ -16,23 +16,65 @@ const recordLevels = ["all", "G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "no const recordStatuses = ["all", "active", "blocked", "parked", "done"]; const diaryWeekdays = ["一", "二", "三", "四", "五", "六", "日"]; -const DOC_TYPE_CODES = ["DCSN", "GOAL", "PLAN", "RPRT", "ACTN", "ISSU", "RETR", "RQST", "RESP", "MINS"] as const; -type DocTypeCode = typeof DOC_TYPE_CODES[number]; +export const DOC_TYPE_CODES = ["DCSN", "GOAL", "PLAN", "RPRT", "ACTN", "ISSU", "RETR", "RQST", "RESP", "MINS"] as const; +export type DocTypeCode = typeof DOC_TYPE_CODES[number]; -const docTypeLabels: Record = { +export const docTypeLabels: Record = { DCSN: "决策/决议", GOAL: "目标", PLAN: "计划", RPRT: "报告", ACTN: "行动", ISSU: "问题", - RETR: "检索", - RQST: "请求", - RESP: "响应", + RETR: "复盘", + RQST: "请示", + RESP: "批复/答复", MINS: "会议纪要", }; const docTypeOrder: DocTypeCode[] = ["DCSN", "GOAL", "PLAN", "RPRT", "ACTN", "ISSU", "RETR", "RQST", "RESP", "MINS"]; +const docTypeAliases: Record = { + DECISION: "DCSN", + DECISIONS: "DCSN", + GOALS: "GOAL", + PLANS: "PLAN", + REPORT: "RPRT", + REPORTS: "RPRT", + ACTION: "ACTN", + ACTIONS: "ACTN", + ISSUE: "ISSU", + ISSUES: "ISSU", + RETROSPECTIVE: "RETR", + RETROSPECTIVES: "RETR", + RETRO: "RETR", + REQUEST: "RQST", + REQUESTS: "RQST", + RESPONSE: "RESP", + RESPONSES: "RESP", + REPLY: "RESP", + MINUTES: "MINS", + MEETINGMINUTES: "MINS", +}; +const DOC_NO_CORE_PATTERN = "DC[-−–—]([A-Z]{2,5})[-−–—]([A-Z][0-9])[-−–—](\\d{4})[-−–—](\\d{1,6})"; +const DOC_NO_PATTERN = new RegExp(`\\b${DOC_NO_CORE_PATTERN}\\b`, "iu"); +const DOC_NO_PREFIX_PATTERN = new RegExp(`^\\s*(?:#+\\s*)?${DOC_NO_CORE_PATTERN}(?=\\s|[::,,.;;/\\\\]|$)`, "iu"); + +export interface DocMetadata { + docNo: string; + docType: DocTypeCode | ""; + docTypeLabel: string; + priority: string; + year: string; + sequence: string; +} + +interface ParsedDocNo { + docNo: string; + docType: DocTypeCode; + priority: string; + year: string; + sequence: string; +} const requirementViews = [ { id: "all", label: "全部需求" }, { id: "external-goal", label: "外部目标" }, @@ -224,41 +266,129 @@ function tagsText(value: any): string { return Array.isArray(value) ? value.join(", ") : String(value || ""); } -function extractDocNo(record: any): string { - const title = String(record?.title || ""); - const match = title.match(/^([A-Z]{2,5})[-−]?(\d+)/u); - if (match) return `${match[1]}-${match[2]}`; - const tags = Array.isArray(record?.tags) ? record.tags : []; - for (const tag of tags) { - const tagStr = String(tag || ""); - const m = tagStr.match(/^doc-no:([A-Z]{2,5})[-−]?(\d+)/iu); - if (m) return `${m[1]}-${m[2]}`; - } - return ""; +function normalizeDocToken(value: any): string { + return String(value || "").trim().toUpperCase(); } -function buildDocTypeTree(records: any[]): Array<{ type: DocTypeCode; label: string; nodes: any[] }> { +function normalizeDocType(value: any): DocTypeCode | "" { + const token = normalizeDocToken(value).replace(/[^A-Z0-9]+/gu, ""); + if (DOC_TYPE_CODES.includes(token as DocTypeCode)) return token as DocTypeCode; + return docTypeAliases[token] || ""; +} + +function normalizePriority(value: any): string { + const match = normalizeDocToken(value).match(/\b([PG][0-3])\b/u); + return match?.[1] || ""; +} + +function normalizeYear(value: any): string { + const match = String(value || "").match(/\b(20\d{2}|19\d{2})\b/u); + return match?.[1] || ""; +} + +function normalizeSequence(value: any): string { + const match = String(value || "").trim().match(/^\D*(\d{1,6})\D*$/u); + if (!match) return ""; + return match[1].padStart(3, "0"); +} + +function parseDocNo(value: any): ParsedDocNo | null { + const match = String(value || "").match(DOC_NO_PATTERN); + if (!match) return null; + const docType = normalizeDocType(match[1]); + const priority = normalizePriority(match[2]); + const year = normalizeYear(match[3]); + const sequence = normalizeSequence(match[4]); + if (!docType || !priority || !year || !sequence) return null; + return { + docNo: `DC-${docType}-${priority}-${year}-${sequence}`, + docType, + priority, + year, + sequence, + }; +} + +function parseDocNoPrefix(value: any): ParsedDocNo | null { + const match = String(value || "").match(DOC_NO_PREFIX_PATTERN); + return match ? parseDocNo(match[0]) : null; +} + +function firstBodyWindow(record: any): string { + const body = recordStringField(record, ["body", "content", "markdown", "summary"]); + if (!body) return ""; + const lines = body.split(/\r?\n/u).slice(0, 8).join("\n"); + const paragraphs = body.split(/\r?\n\s*\r?\n/u).slice(0, 2).join("\n\n"); + return `${lines}\n${paragraphs}`.slice(0, 1600); +} + +function mergeParsedDocNo(metadata: DocMetadata, parsed: ParsedDocNo | null): void { + if (!parsed) return; + if (!metadata.docNo) metadata.docNo = parsed.docNo; + if (!metadata.docType) metadata.docType = parsed.docType; + if (!metadata.priority) metadata.priority = parsed.priority; + if (!metadata.year) metadata.year = parsed.year; + if (!metadata.sequence) metadata.sequence = parsed.sequence; +} + +function completeDocNoFromParts(metadata: DocMetadata): string { + return metadata.docType && metadata.priority && metadata.year && metadata.sequence + ? `DC-${metadata.docType}-${metadata.priority}-${metadata.year}-${metadata.sequence}` + : ""; +} + +export function extractDocMetadata(record: any): DocMetadata { + const metadata: DocMetadata = { + docNo: "", + docType: "", + docTypeLabel: "", + priority: "", + year: "", + sequence: "", + }; + + mergeParsedDocNo(metadata, parseDocNo(recordStringField(record, ["docNo", "documentNo", "documentNumber", "documentId"]))); + const structuredDocType = normalizeDocType(recordStringField(record, ["docType", "documentType", "documentKind"])); + const structuredPriority = normalizePriority(recordStringField(record, ["priority", "docPriority", "documentPriority"])); + const structuredYear = normalizeYear(recordStringField(record, ["year", "docYear", "documentYear"])); + const structuredSequence = normalizeSequence(recordStringField(record, ["sequence", "seq", "docSequence", "documentSequence"])); + if (structuredDocType) metadata.docType = structuredDocType; + if (structuredPriority) metadata.priority = structuredPriority; + if (structuredYear) metadata.year = structuredYear; + if (structuredSequence) metadata.sequence = structuredSequence; + + const docNoFromParts = completeDocNoFromParts(metadata); + if (!metadata.docNo && docNoFromParts) metadata.docNo = docNoFromParts; + + mergeParsedDocNo(metadata, parseDocNoPrefix(record?.title)); + mergeParsedDocNo(metadata, parseDocNo(recordStringField(record, ["title", "name"]))); + + for (const tagValue of recordTagValues(record, "doc-no")) mergeParsedDocNo(metadata, parseDocNo(tagValue)); + if (!metadata.docType) metadata.docType = normalizeDocType(firstRecordTagValue(record, "doc-type")); + if (!metadata.priority) metadata.priority = normalizePriority(firstRecordTagValue(record, "doc-priority")); + if (!metadata.year) metadata.year = normalizeYear(firstRecordTagValue(record, "doc-year")); + if (!metadata.sequence) metadata.sequence = normalizeSequence(firstRecordTagValue(record, "doc-sequence")); + + const tagDocNoFromParts = completeDocNoFromParts(metadata); + if (!metadata.docNo && tagDocNoFromParts) metadata.docNo = tagDocNoFromParts; + + mergeParsedDocNo(metadata, parseDocNo(firstBodyWindow(record))); + + const finalDocNoFromParts = completeDocNoFromParts(metadata); + if (!metadata.docNo && finalDocNoFromParts) metadata.docNo = finalDocNoFromParts; + metadata.docTypeLabel = metadata.docType ? docTypeLabels[metadata.docType] : ""; + return metadata; +} + +export function extractDocNo(record: any): string { + return extractDocMetadata(record).docNo; +} + +export function buildDocTypeTree(records: any[]): Array<{ type: DocTypeCode; label: string; nodes: any[] }> { const byType = new Map(); for (const code of docTypeOrder) byType.set(code, []); for (const record of records) { - const title = String(record?.title || ""); - let code: DocTypeCode | null = null; - const match = title.match(/^([A-Z]{2,5})[-−]?/u); - if (match) { - const candidate = match[1].toUpperCase() as DocTypeCode; - if (DOC_TYPE_CODES.includes(candidate)) code = candidate; - } - if (!code) { - const tags = Array.isArray(record?.tags) ? record.tags : []; - for (const tag of tags) { - const m = String(tag || "").match(/^doc-type:([A-Z]{2,5})/iu); - if (m) { - const candidate = m[1].toUpperCase() as DocTypeCode; - if (DOC_TYPE_CODES.includes(candidate)) { code = candidate; break; } - } - } - } - if (!code) code = "DCSN"; + const code = extractDocMetadata(record).docType || "DCSN"; byType.get(code)!.push(record); } return docTypeOrder @@ -293,8 +423,8 @@ function buildTagGroups(records: any[]): Array<{ tag: string; nodes: any[] }> { } function DocTreeNode({ record, depth, onSelect, selectedId }: AnyRecord) { - const docNo = extractDocNo(record); - const label = docNo ? `${docNo} ${record.title || ""}` : (record.title || record.id || "--"); + const metadata = extractDocMetadata(record); + const label = metadata.docNo ? `${metadata.docNo} ${record.title || ""}` : (record.title || record.id || "--"); return h("div", { className: `doc-tree-node depth-${Math.min(depth, 3)}` }, h("button", { type: "button", @@ -302,7 +432,7 @@ function DocTreeNode({ record, depth, onSelect, selectedId }: AnyRecord) { onClick: () => onSelect(record), "data-testid": `doc-tree-item-${stableTestId(record.id)}`, }, - h("span", { className: "doc-tree-meta" }, record.type), + h("span", { className: "doc-tree-meta" }, metadata.docType || record.type || "--"), h("span", { className: "doc-tree-label" }, shortText(label, 80)), ), ); @@ -358,16 +488,16 @@ function DocTypeTree({ records, onSelect, selectedId, activeGrouping }: AnyRecor } function DocDetailHeader({ record, onBack }: AnyRecord) { - const docNo = extractDocNo(record); + const metadata = extractDocMetadata(record); return h("div", { className: "doc-detail-header" }, onBack ? h("button", { type: "button", className: "ghost-btn compact", onClick: onBack, "data-testid": "doc-detail-back" }, "← 返回") : null, - docNo ? h("span", { className: "doc-detail-docno", "data-testid": "doc-detail-docno" }, docNo) : null, + metadata.docNo ? h("span", { className: "doc-detail-docno", "data-testid": "doc-detail-docno" }, metadata.docNo) : null, h("h2", { className: "doc-detail-title" }, record.title || "--"), h("div", { className: "doc-detail-meta" }, - h("span", null, record.type || "--"), - h(StatusBadge, { status: levelTone(record.level) }, record.level || "none"), + h("span", null, metadata.docType ? `${metadata.docType} ${metadata.docTypeLabel}` : record.type || "--"), + h(StatusBadge, { status: levelTone(metadata.priority || record.level) }, metadata.priority || record.level || "none"), h(StatusBadge, { status: statusTone(record.status) }, record.status || "--"), - h("span", { className: "muted" }, fmtRecordTime(record.updatedAt)), + h("span", { className: "muted" }, `更新 ${fmtRecordTime(record.updatedAt)}`), ), record.source ? h("a", { href: record.source, target: "_blank", rel: "noreferrer", className: "doc-detail-source" }, `来源: ${record.source}`) : null, Array.isArray(record.evidenceLinks) && record.evidenceLinks.length > 0 @@ -537,7 +667,9 @@ function DocWorkspace({ records, selectedRecord, onSelect, onSave, saving, saveE h("div", { className: "doc-record-list", "data-testid": "doc-record-list" }, records.length === 0 ? h(EmptyState, { title: "暂无记录", text: "通过 CLI 上传文书后会显示在这里。" }) - : records.map((record: any) => h("button", { + : records.map((record: any) => { + const metadata = extractDocMetadata(record); + return h("button", { key: record.id, type: "button", className: `doc-list-item ${selectedRecord?.id === record.id ? "selected" : ""}`, @@ -545,12 +677,13 @@ function DocWorkspace({ records, selectedRecord, onSelect, onSave, saving, saveE "data-testid": `doc-list-item-${stableTestId(record.id)}`, }, h("span", { className: "doc-list-meta" }, - h("span", null, record.type || "--"), - h(StatusBadge, { status: levelTone(record.level) }, record.level || "none"), + h("span", null, metadata.docType || record.type || "--"), + h(StatusBadge, { status: levelTone(metadata.priority || record.level) }, metadata.priority || record.level || "none"), ), - h("span", { className: "doc-list-title" }, shortText(record.title || "--", 60)), + h("span", { className: "doc-list-title" }, shortText(metadata.docNo ? `${metadata.docNo} ${record.title || ""}` : record.title || "--", 60)), h("span", { className: "doc-list-summary" }, shortText(record.summary || record.body, 100)), - )), + ); + }), ), ), h("div", { className: `doc-workspace-right ${rightOpen ? "open" : "collapsed"}` },