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}
- 摘要
-
- {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.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.openQuestions.length === 0 ? - (无开放问题)
: null}
- {vm.openQuestions.map((q) => (
- - {q}
- ))}
-
+
+
+ 事实字段(服务端)
+
+ 风险等级与文档影响由服务端 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 {