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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -89,53 +89,82 @@ export function ChangeReportPanel(props: {
|
|||||||
<span style={{ marginLeft: 6, opacity: 0.75 }}>({vm.analysisNote})</span>
|
<span style={{ marginLeft: 6, opacity: 0.75 }}>({vm.analysisNote})</span>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
<h4>摘要</h4>
|
|
||||||
<ul data-testid="change-report-summary">
|
<section data-testid="change-report-factual" aria-label="事实字段">
|
||||||
{vm.summary.length === 0 ? <li style={{ opacity: 0.7 }}>(无摘要)</li> : null}
|
<h4>事实字段(服务端)</h4>
|
||||||
{vm.summary.map((s) => (
|
<p style={{ margin: '0 0 8px', fontSize: 13, opacity: 0.85 }}>
|
||||||
<li key={s}>{s}</li>
|
风险等级与文档影响由服务端 diff + lineage 计算,PI 不可覆盖审批结论。
|
||||||
))}
|
</p>
|
||||||
</ul>
|
<ul data-testid="change-report-factual-notes">
|
||||||
<h4>风险条目</h4>
|
{vm.factualNotes.length === 0 ? (
|
||||||
<ul data-testid="change-report-risk-items">
|
<li style={{ opacity: 0.7 }}>风险:{vm.riskLevel || '(未评估)'}</li>
|
||||||
{vm.riskItems.length === 0 ? <li style={{ opacity: 0.7 }}>(无风险条目)</li> : null}
|
) : null}
|
||||||
{vm.riskItems.map((it, i) => (
|
{vm.factualNotes.map((n) => (
|
||||||
<li key={`${it.category}-${i}`}>
|
<li key={n}>{n}</li>
|
||||||
<strong>{it.category || '风险'}</strong>:{it.description}
|
))}
|
||||||
{it.mitigation ? <div style={{ opacity: 0.85 }}>缓解:{it.mitigation}</div> : null}
|
</ul>
|
||||||
</li>
|
<h5 style={{ marginBottom: 4 }}>文档影响</h5>
|
||||||
))}
|
<ul data-testid="change-report-doc-impacts">
|
||||||
</ul>
|
{vm.documentImpacts.length === 0 ? (
|
||||||
<h4>项目影响</h4>
|
<li style={{ opacity: 0.7 }}>无(或尚未登记下游 lineage)</li>
|
||||||
<ul data-testid="change-report-project-impact">
|
) : null}
|
||||||
{vm.projectImpactLines.length === 0 ? <li style={{ opacity: 0.7 }}>(无项目影响说明)</li> : null}
|
{vm.documentImpacts.map((d) => (
|
||||||
{vm.projectImpactLines.map((line) => (
|
<li key={`${d.docId}-${d.impactType}`}>
|
||||||
<li key={line}>{line}</li>
|
<strong>{d.docId}</strong>
|
||||||
))}
|
{d.impactType ? ` [${d.impactType}]` : ''}
|
||||||
</ul>
|
{d.reason ? `:${d.reason}` : ''}
|
||||||
<h4>文档影响</h4>
|
{d.recommendedAction ? (
|
||||||
<ul data-testid="change-report-doc-impacts">
|
<div style={{ opacity: 0.85 }}>建议:{d.recommendedAction}</div>
|
||||||
{vm.documentImpacts.length === 0 ? (
|
) : null}
|
||||||
<li style={{ opacity: 0.7 }}>无(或尚未登记下游 lineage)</li>
|
</li>
|
||||||
) : null}
|
))}
|
||||||
{vm.documentImpacts.map((d) => (
|
</ul>
|
||||||
<li key={`${d.docId}-${d.impactType}`}>
|
</section>
|
||||||
<strong>{d.docId}</strong>
|
|
||||||
{d.impactType ? ` [${d.impactType}]` : ''}
|
<section data-testid="change-report-narrative" aria-label="叙事字段">
|
||||||
{d.reason ? `:${d.reason}` : ''}
|
<h4>
|
||||||
{d.recommendedAction ? (
|
叙事字段({vm.narrativeSourceLabel})
|
||||||
<div style={{ opacity: 0.85 }}>建议:{d.recommendedAction}</div>
|
<span
|
||||||
) : null}
|
data-testid="change-report-narrative-source"
|
||||||
</li>
|
style={{ marginLeft: 8, fontSize: 12, fontWeight: 400, opacity: 0.75 }}
|
||||||
))}
|
>
|
||||||
</ul>
|
{vm.isNarrativeComplete ? 'PI' : 'fallback'}
|
||||||
<h4>开放问题</h4>
|
</span>
|
||||||
<ul data-testid="change-report-open-questions">
|
</h4>
|
||||||
{vm.openQuestions.length === 0 ? <li style={{ opacity: 0.7 }}>(无开放问题)</li> : null}
|
<h5 style={{ marginBottom: 4 }}>摘要</h5>
|
||||||
{vm.openQuestions.map((q) => (
|
<ul data-testid="change-report-summary">
|
||||||
<li key={q}>{q}</li>
|
{vm.summary.length === 0 ? <li style={{ opacity: 0.7 }}>(无摘要)</li> : null}
|
||||||
))}
|
{vm.summary.map((s) => (
|
||||||
</ul>
|
<li key={s}>{s}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<h5 style={{ marginBottom: 4 }}>风险条目(说明性)</h5>
|
||||||
|
<ul data-testid="change-report-risk-items">
|
||||||
|
{vm.riskItems.length === 0 ? <li style={{ opacity: 0.7 }}>(无风险条目)</li> : null}
|
||||||
|
{vm.riskItems.map((it, i) => (
|
||||||
|
<li key={`${it.category}-${i}`}>
|
||||||
|
<strong>{it.category || '风险'}</strong>:{it.description}
|
||||||
|
{it.mitigation ? <div style={{ opacity: 0.85 }}>缓解:{it.mitigation}</div> : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<h5 style={{ marginBottom: 4 }}>项目影响</h5>
|
||||||
|
<ul data-testid="change-report-project-impact">
|
||||||
|
{vm.projectImpactLines.length === 0 ? (
|
||||||
|
<li style={{ opacity: 0.7 }}>(无项目影响说明)</li>
|
||||||
|
) : null}
|
||||||
|
{vm.projectImpactLines.map((line) => (
|
||||||
|
<li key={line}>{line}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<h5 style={{ marginBottom: 4 }}>开放问题</h5>
|
||||||
|
<ul data-testid="change-report-open-questions">
|
||||||
|
{vm.openQuestions.length === 0 ? <li style={{ opacity: 0.7 }}>(无开放问题)</li> : null}
|
||||||
|
{vm.openQuestions.map((q) => (
|
||||||
|
<li key={q}>{q}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{props.pendingDecision ? (
|
{props.pendingDecision ? (
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ export type ChangeReportViewModel = {
|
|||||||
riskLevel: string;
|
riskLevel: string;
|
||||||
analysisStatus: string;
|
analysisStatus: string;
|
||||||
analysisNote: string | null;
|
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[];
|
summary: string[];
|
||||||
riskItems: { category: string; description: string; mitigation?: string }[];
|
riskItems: { category: string; description: string; mitigation?: string }[];
|
||||||
projectImpactLines: string[];
|
projectImpactLines: string[];
|
||||||
@@ -14,6 +18,8 @@ export type ChangeReportViewModel = {
|
|||||||
recommendedAction: string;
|
recommendedAction: string;
|
||||||
}[];
|
}[];
|
||||||
openQuestions: string[];
|
openQuestions: string[];
|
||||||
|
/** Server-bound factual keys from deterministic_facts when present. */
|
||||||
|
factualNotes: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Pure projection of ChangeReport for readable UI (testable without DOM). */
|
/** 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 =
|
const analysisNote =
|
||||||
report.analysis_status === 'fallback' || report.analysis_status === 'unassessed'
|
report.analysis_status === 'fallback' || report.analysis_status === 'unassessed'
|
||||||
? '确定性分析,非完整 PI 叙事'
|
? '确定性分析,非完整 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 {
|
return {
|
||||||
riskLevel: report.risk_level || '',
|
riskLevel: report.risk_level || '',
|
||||||
analysisStatus: report.analysis_status || '',
|
analysisStatus: report.analysis_status || '',
|
||||||
analysisNote,
|
analysisNote,
|
||||||
|
isNarrativeComplete,
|
||||||
|
narrativeSourceLabel: isNarrativeComplete ? 'PI 叙事' : '确定性 fallback',
|
||||||
summary: Array.isArray(report.summary) ? report.summary.filter(Boolean) : [],
|
summary: Array.isArray(report.summary) ? report.summary.filter(Boolean) : [],
|
||||||
riskItems: Array.isArray(report.risk_items) ? report.risk_items : [],
|
riskItems: Array.isArray(report.risk_items) ? report.risk_items : [],
|
||||||
projectImpactLines,
|
projectImpactLines,
|
||||||
@@ -58,6 +86,7 @@ export function buildChangeReportViewModel(report: ChangeReport): ChangeReportVi
|
|||||||
recommendedAction: d.recommended_action || '',
|
recommendedAction: d.recommended_action || '',
|
||||||
})),
|
})),
|
||||||
openQuestions: Array.isArray(report.open_questions) ? report.open_questions.filter(Boolean) : [],
|
openQuestions: Array.isArray(report.open_questions) ? report.open_questions.filter(Boolean) : [],
|
||||||
|
factualNotes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ describe('change report view model', () => {
|
|||||||
});
|
});
|
||||||
assert.equal(vm.riskLevel, 'high');
|
assert.equal(vm.riskLevel, 'high');
|
||||||
assert.equal(vm.analysisNote, '确定性分析,非完整 PI 叙事');
|
assert.equal(vm.analysisNote, '确定性分析,非完整 PI 叙事');
|
||||||
|
assert.equal(vm.isNarrativeComplete, false);
|
||||||
|
assert.equal(vm.narrativeSourceLabel, '确定性 fallback');
|
||||||
assert.equal(vm.riskItems.length, 1);
|
assert.equal(vm.riskItems.length, 1);
|
||||||
assert.match(vm.riskItems[0].description, /敏感删除/);
|
assert.match(vm.riskItems[0].description, /敏感删除/);
|
||||||
assert.ok(vm.projectImpactLines.some((l) => l.includes('下游受影响')));
|
assert.ok(vm.projectImpactLines.some((l) => l.includes('下游受影响')));
|
||||||
@@ -163,6 +165,27 @@ describe('change report view model', () => {
|
|||||||
assert.equal(vm.openQuestions[0], '业务叙事待 PI 补齐');
|
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', () => {
|
it('preferReportId prioritizes pending over committed latest', () => {
|
||||||
assert.equal(preferReportId(55, 20), 55);
|
assert.equal(preferReportId(55, 20), 55);
|
||||||
assert.equal(preferReportId(null, 20), 20);
|
assert.equal(preferReportId(null, 20), 20);
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export interface ChangeReport {
|
|||||||
document_impacts: { doc_id: string; impact_type: string; reason: string; recommended_action?: string }[];
|
document_impacts: { doc_id: string; impact_type: string; reason: string; recommended_action?: string }[];
|
||||||
open_questions: string[];
|
open_questions: string[];
|
||||||
analysis_status: string;
|
analysis_status: string;
|
||||||
|
/** Server-bound facts (risk floor, digests, hunk counts). Never treated as PI narrative. */
|
||||||
|
deterministic_facts?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConflictInfo {
|
export interface ConflictInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user