From 51568ea080c324e4e6efbc4d50640c55d9a11904 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 16 Sep 2026 15:39:54 +0000 Subject: [PATCH] feat(web): distinguish change-report narrative vs factual fields Panel and view-model label server risk/lineage separately from PI narrative; add change_report_narrative_v1 helper aligned with typed-result patterns. Co-authored-by: Cursor --- .../pi-agent/change-report-narrative-v1.mjs | 69 ++++++++++ .../change-report-narrative-v1.test.mjs | 32 +++++ web/src/components/doc/ChangeReportPanel.tsx | 123 +++++++++++------- .../documentRevision/changeReportView.ts | 31 ++++- .../documentRevision/overlayMode.test.ts | 23 ++++ web/src/features/documentRevision/types.ts | 2 + 6 files changed, 232 insertions(+), 48 deletions(-) create mode 100644 deploy/pi-agent/change-report-narrative-v1.mjs create mode 100644 deploy/pi-agent/change-report-narrative-v1.test.mjs diff --git a/deploy/pi-agent/change-report-narrative-v1.mjs b/deploy/pi-agent/change-report-narrative-v1.mjs new file mode 100644 index 00000000..6775ca2b --- /dev/null +++ b/deploy/pi-agent/change-report-narrative-v1.mjs @@ -0,0 +1,69 @@ +/** + * change_report_narrative_v1 — typed schema helper aligned with Go docrevision.NarrativeSchemaKind. + * Consumed only when PI_CHANGE_REPORT_NARRATIVE is on; server never trusts risk.level / document_impacts. + */ + +export const NARRATIVE_SCHEMA_KIND = "change_report_narrative_v1"; +export const NARRATIVE_SCHEMA_VERSION = 1; + +/** + * Validate a PI narrative object. Returns { ok, reason }. + * Does not authorize risk or impacts — server recomputes those. + */ +export function validateChangeReportNarrative(payload, expected = {}) { + if (!payload || typeof payload !== "object") { + return { ok: false, reason: "empty" }; + } + if (payload.partial === true) { + return { ok: false, reason: "partial" }; + } + if (payload.error_code || String(payload.outcome || "").toLowerCase() === "error") { + return { ok: false, reason: "error_envelope" }; + } + if (payload.schema_version !== NARRATIVE_SCHEMA_VERSION) { + return { ok: false, reason: "schema_version" }; + } + if (payload.schema_kind !== NARRATIVE_SCHEMA_KIND) { + return { ok: false, reason: "schema_kind" }; + } + if (!payload.input_digest || typeof payload.input_digest !== "string") { + return { ok: false, reason: "input_digest_missing" }; + } + if (expected.input_digest && payload.input_digest !== expected.input_digest) { + return { ok: false, reason: "input_digest_mismatch" }; + } + if (expected.from_sha && payload.from_sha && payload.from_sha !== expected.from_sha) { + return { ok: false, reason: "from_sha_mismatch" }; + } + if (expected.to_sha && payload.to_sha && payload.to_sha !== expected.to_sha) { + return { ok: false, reason: "to_sha_mismatch" }; + } + const summary = Array.isArray(payload.summary) ? payload.summary.filter(Boolean) : []; + if (expected.from_sha && expected.to_sha && expected.from_sha !== expected.to_sha && summary.length === 0) { + return { ok: false, reason: "summary_required" }; + } + return { ok: true, reason: "" }; +} + +/** Build a success narrative skeleton for tests / stubs. */ +export function buildChangeReportNarrative({ + inputDigest, + fromSha, + toSha, + summary = [], + openQuestions = [], + projectImpact = {}, + riskItems = [], +} = {}) { + return { + schema_version: NARRATIVE_SCHEMA_VERSION, + schema_kind: NARRATIVE_SCHEMA_KIND, + input_digest: String(inputDigest || ""), + from_sha: String(fromSha || ""), + to_sha: String(toSha || ""), + summary, + open_questions: openQuestions, + project_impact: projectImpact, + risk_items: riskItems, + }; +} diff --git a/deploy/pi-agent/change-report-narrative-v1.test.mjs b/deploy/pi-agent/change-report-narrative-v1.test.mjs new file mode 100644 index 00000000..7d82972a --- /dev/null +++ b/deploy/pi-agent/change-report-narrative-v1.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { + NARRATIVE_SCHEMA_KIND, + buildChangeReportNarrative, + validateChangeReportNarrative, +} from "./change-report-narrative-v1.mjs"; + +const digest = "abc123"; +const good = buildChangeReportNarrative({ + inputDigest: digest, + fromSha: "f1", + toSha: "t1", + summary: ["阈值调整"], +}); +assert.equal(good.schema_kind, NARRATIVE_SCHEMA_KIND); +assert.equal(validateChangeReportNarrative(good, { input_digest: digest, from_sha: "f1", to_sha: "t1" }).ok, true); + +assert.equal(validateChangeReportNarrative({ ...good, partial: true }, { input_digest: digest }).ok, false); +assert.equal(validateChangeReportNarrative({ ...good, input_digest: "other" }, { input_digest: digest }).ok, false); +assert.equal( + validateChangeReportNarrative( + { ...good, summary: [] }, + { input_digest: digest, from_sha: "f1", to_sha: "t1" }, + ).ok, + false, +); +assert.equal( + validateChangeReportNarrative({ error_code: "TIMEOUT", schema_kind: NARRATIVE_SCHEMA_KIND }).ok, + false, +); + +console.log("change-report-narrative-v1.test.mjs: ok"); diff --git a/web/src/components/doc/ChangeReportPanel.tsx b/web/src/components/doc/ChangeReportPanel.tsx index 1d68f13d..0665067d 100644 --- a/web/src/components/doc/ChangeReportPanel.tsx +++ b/web/src/components/doc/ChangeReportPanel.tsx @@ -89,53 +89,82 @@ export function ChangeReportPanel(props: { ({vm.analysisNote}) ) : null}

-

摘要

- -

风险条目

- -

项目影响

- -

文档影响

- -

开放问题

- + +
+

事实字段(服务端)

+

+ 风险等级与文档影响由服务端 diff + lineage 计算,PI 不可覆盖审批结论。 +

+
    + {vm.factualNotes.length === 0 ? ( +
  • 风险:{vm.riskLevel || '(未评估)'}
  • + ) : null} + {vm.factualNotes.map((n) => ( +
  • {n}
  • + ))} +
+
文档影响
+
    + {vm.documentImpacts.length === 0 ? ( +
  • 无(或尚未登记下游 lineage)
  • + ) : null} + {vm.documentImpacts.map((d) => ( +
  • + {d.docId} + {d.impactType ? ` [${d.impactType}]` : ''} + {d.reason ? `:${d.reason}` : ''} + {d.recommendedAction ? ( +
    建议:{d.recommendedAction}
    + ) : null} +
  • + ))} +
+
+ +
+

+ 叙事字段({vm.narrativeSourceLabel}) + + {vm.isNarrativeComplete ? 'PI' : 'fallback'} + +

+
摘要
+
    + {vm.summary.length === 0 ?
  • (无摘要)
  • : null} + {vm.summary.map((s) => ( +
  • {s}
  • + ))} +
+
风险条目(说明性)
+
    + {vm.riskItems.length === 0 ?
  • (无风险条目)
  • : null} + {vm.riskItems.map((it, i) => ( +
  • + {it.category || '风险'}:{it.description} + {it.mitigation ?
    缓解:{it.mitigation}
    : null} +
  • + ))} +
+
项目影响
+
    + {vm.projectImpactLines.length === 0 ? ( +
  • (无项目影响说明)
  • + ) : null} + {vm.projectImpactLines.map((line) => ( +
  • {line}
  • + ))} +
+
开放问题
+
    + {vm.openQuestions.length === 0 ?
  • (无开放问题)
  • : null} + {vm.openQuestions.map((q) => ( +
  • {q}
  • + ))} +
+
) : null} {props.pendingDecision ? ( diff --git a/web/src/features/documentRevision/changeReportView.ts b/web/src/features/documentRevision/changeReportView.ts index 798f001c..01a646ca 100644 --- a/web/src/features/documentRevision/changeReportView.ts +++ b/web/src/features/documentRevision/changeReportView.ts @@ -4,6 +4,10 @@ export type ChangeReportViewModel = { riskLevel: string; analysisStatus: string; analysisNote: string | null; + /** Whether analysis_status is complete PI narrative (vs deterministic fallback). */ + isNarrativeComplete: boolean; + /** Label for the narrative section source. */ + narrativeSourceLabel: string; summary: string[]; riskItems: { category: string; description: string; mitigation?: string }[]; projectImpactLines: string[]; @@ -14,6 +18,8 @@ export type ChangeReportViewModel = { recommendedAction: string; }[]; openQuestions: string[]; + /** Server-bound factual keys from deterministic_facts when present. */ + factualNotes: string[]; }; /** Pure projection of ChangeReport for readable UI (testable without DOM). */ @@ -39,15 +45,37 @@ export function buildChangeReportViewModel(report: ChangeReport): ChangeReportVi } } + const isNarrativeComplete = report.analysis_status === 'complete'; const analysisNote = report.analysis_status === 'fallback' || report.analysis_status === 'unassessed' ? '确定性分析,非完整 PI 叙事' - : null; + : isNarrativeComplete + ? '含 PI 叙事;风险与文档影响仍以服务端为准' + : null; + + const factualNotes: string[] = []; + const facts = report.deterministic_facts; + if (facts && typeof facts === 'object') { + if (typeof facts.rule_risk === 'string' && facts.rule_risk) { + factualNotes.push(`规则风险下限:${facts.rule_risk}`); + } + if (typeof facts.hunk_count === 'number') { + factualNotes.push(`差异块数:${facts.hunk_count}`); + } + if (typeof facts.impact_count === 'number') { + factualNotes.push(`下游影响数:${facts.impact_count}`); + } + if (typeof facts.input_digest === 'string' && facts.input_digest) { + factualNotes.push(`输入摘要:${String(facts.input_digest).slice(0, 12)}…`); + } + } return { riskLevel: report.risk_level || '', analysisStatus: report.analysis_status || '', analysisNote, + isNarrativeComplete, + narrativeSourceLabel: isNarrativeComplete ? 'PI 叙事' : '确定性 fallback', summary: Array.isArray(report.summary) ? report.summary.filter(Boolean) : [], riskItems: Array.isArray(report.risk_items) ? report.risk_items : [], projectImpactLines, @@ -58,6 +86,7 @@ export function buildChangeReportViewModel(report: ChangeReport): ChangeReportVi recommendedAction: d.recommended_action || '', })), openQuestions: Array.isArray(report.open_questions) ? report.open_questions.filter(Boolean) : [], + factualNotes, }; } diff --git a/web/src/features/documentRevision/overlayMode.test.ts b/web/src/features/documentRevision/overlayMode.test.ts index aec05a16..f584dd78 100644 --- a/web/src/features/documentRevision/overlayMode.test.ts +++ b/web/src/features/documentRevision/overlayMode.test.ts @@ -155,6 +155,8 @@ describe('change report view model', () => { }); assert.equal(vm.riskLevel, 'high'); assert.equal(vm.analysisNote, '确定性分析,非完整 PI 叙事'); + assert.equal(vm.isNarrativeComplete, false); + assert.equal(vm.narrativeSourceLabel, '确定性 fallback'); assert.equal(vm.riskItems.length, 1); assert.match(vm.riskItems[0].description, /敏感删除/); assert.ok(vm.projectImpactLines.some((l) => l.includes('下游受影响'))); @@ -163,6 +165,27 @@ describe('change report view model', () => { assert.equal(vm.openQuestions[0], '业务叙事待 PI 补齐'); }); + it('marks complete analysis as PI narrative vs factual fields', () => { + const vm = buildChangeReportViewModel({ + id: 2, + doc_id: 'doc_x', + schema_version: 1, + summary: ['PI 叙事摘要'], + risk_level: 'high', + risk_items: [{ category: 'pi', description: '说明性条目' }], + project_impacts: { summary: '叙事项目影响' }, + document_impacts: [{ doc_id: 'doc_down', impact_type: 'stale_candidate', reason: '上游更新' }], + open_questions: ['确认阈值'], + analysis_status: 'complete', + deterministic_facts: { rule_risk: 'high', hunk_count: 3, impact_count: 1, input_digest: 'abcdef0123456789' }, + }); + assert.equal(vm.isNarrativeComplete, true); + assert.equal(vm.narrativeSourceLabel, 'PI 叙事'); + assert.match(vm.analysisNote || '', /服务端为准/); + assert.ok(vm.factualNotes.some((n) => n.includes('规则风险下限'))); + assert.equal(vm.summary[0], 'PI 叙事摘要'); + }); + it('preferReportId prioritizes pending over committed latest', () => { assert.equal(preferReportId(55, 20), 55); assert.equal(preferReportId(null, 20), 20); diff --git a/web/src/features/documentRevision/types.ts b/web/src/features/documentRevision/types.ts index 594a213f..118612fb 100644 --- a/web/src/features/documentRevision/types.ts +++ b/web/src/features/documentRevision/types.ts @@ -92,6 +92,8 @@ export interface ChangeReport { document_impacts: { doc_id: string; impact_type: string; reason: string; recommended_action?: string }[]; open_questions: string[]; analysis_status: string; + /** Server-bound facts (risk floor, digests, hunk counts). Never treated as PI narrative. */ + deterministic_facts?: Record; } export interface ConflictInfo {