merge: origin/feature/pu7-pi-narrative
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: "PU7 PI change-report 叙事 — 获批后任务契约"
|
||||
status: planned
|
||||
status: done
|
||||
created: 2026-09-16
|
||||
updated: 2026-09-16
|
||||
owner: TBD
|
||||
owner: Cursor-Auto
|
||||
branch: feature/pu7-pi-narrative
|
||||
mutex: docgov
|
||||
go_no_go: go
|
||||
@@ -11,22 +11,26 @@ go_no_go: go
|
||||
|
||||
# PU7 PI change-report 叙事 — 获批后任务契约
|
||||
|
||||
**go/no-go**:no-go。未获批禁止实现。
|
||||
**go/no-go**:GO(用户覆盖授权,见 Wave3 research)。已在 `feature/pu7-pi-narrative` 实现;默认 flag off。
|
||||
|
||||
## 目标(获批后)
|
||||
## 目标
|
||||
|
||||
只读消费 immutable diff+lineage+risk floor 的 PI 叙事;任意 schema/超时/注入 → 全量 fallback。
|
||||
|
||||
## 预注册门
|
||||
|
||||
- flag:`PI_CHANGE_REPORT_NARRATIVE`
|
||||
- flag:`PI_CHANGE_REPORT_NARRATIVE`(默认 off)
|
||||
- 禁止覆盖服务端风险/审批结论
|
||||
- typed schema + 文档版本 + input digest 绑定
|
||||
- typed schema + input digest 绑定(`change_report_narrative_v1`)
|
||||
- UI 明确区分 narrative vs 事实字段
|
||||
|
||||
## 路径提示
|
||||
## 路径
|
||||
|
||||
`internal/docrevision/`、`deploy/pi-agent/` typed result、`ChangeReportPanel.tsx`
|
||||
- `internal/docrevision/flags.go` / `narrative.go` / `service.go`
|
||||
- `deploy/pi-agent/change-report-narrative-v1.mjs`
|
||||
- `web/.../ChangeReportPanel.tsx` / `changeReportView.ts`
|
||||
- 报告:[../../test-reports/2026-09-16-pu7-pi-narrative-test-report.md](../../test-reports/2026-09-16-pu7-pi-narrative-test-report.md)
|
||||
- short-term:[../short-term/2026-09-16-pu7-pi-narrative.md](../short-term/2026-09-16-pu7-pi-narrative.md)
|
||||
|
||||
## 回滚
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: "PU7 PI change-report 叙事"
|
||||
status: done
|
||||
created: 2026-09-16
|
||||
updated: 2026-09-16
|
||||
owner: Cursor-Auto
|
||||
branch: feature/pu7-pi-narrative
|
||||
worktree: .worktrees/feature-pu7-pi-narrative
|
||||
mutex: docgov
|
||||
---
|
||||
|
||||
# PU7 PI change-report 叙事
|
||||
|
||||
## 目标
|
||||
|
||||
`PI_CHANGE_REPORT_NARRATIVE` 默认 off;开启后只读消费 immutable diff+lineage 的 typed 叙事,失败全量 deterministic fallback。
|
||||
|
||||
## 范围
|
||||
|
||||
- `internal/docrevision/` flag + ResolveChangeReport
|
||||
- `deploy/pi-agent/change-report-narrative-v1.mjs`
|
||||
- `ChangeReportPanel` / changeReportView 区分事实 vs 叙事
|
||||
|
||||
## 非目标
|
||||
|
||||
- 默认开启 flag
|
||||
- 覆盖服务端 risk / approval
|
||||
- 合入 `dev`(本任务仅 feature 分支)
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] flag 默认 off
|
||||
2. [x] typed narrative + input digest;失败全量 fallback
|
||||
3. [x] UI 区分 narrative / 事实字段
|
||||
4. [x] 单测 flag off + fallback
|
||||
5. [x] test-report + push feature 分支
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] 默认 off 行为 = deterministic fallback
|
||||
- [x] 畸形/超时/digest 不匹配 → 无混合字段
|
||||
- [x] PI 声称低风险不影响服务端 risk floor
|
||||
- [x] 报告归档
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Contract: [../long-term/2026-09-16-pu7-pi-narrative-contract.md](../long-term/2026-09-16-pu7-pi-narrative-contract.md)
|
||||
- Test report: [../../test-reports/2026-09-16-pu7-pi-narrative-test-report.md](../../test-reports/2026-09-16-pu7-pi-narrative-test-report.md)
|
||||
@@ -1,22 +1,120 @@
|
||||
# PU7 PI change-report 叙事测试报告
|
||||
# PU7 PI change-report 叙事 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
覆盖 no-go 全量 backlog 实施。
|
||||
|
||||
文档版本 baseline 已交付确定性 diff + lineage + fallback change report。覆盖授权后需落地可选 PI 叙事增强:默认 flag off,开启时只读消费 immutable 输入,任意失败全量降级,且不得覆盖服务端风险/审批。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
见计划对应章节。
|
||||
|
||||
- `PI_CHANGE_REPORT_NARRATIVE` 默认 off
|
||||
- Flag on 合法叙事:保留服务端 `rule_risk` 与 lineage,仅叠加叙事字段
|
||||
- Flag on 畸形/超时/digest 不匹配/partial:全量 deterministic fallback,无混合字段
|
||||
- UI view-model 区分事实字段与叙事来源
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
Go unit / flag default off。
|
||||
|
||||
- OS:Linux;Go:模块内 `go test`
|
||||
- 层级:`internal/docrevision` 单元/服务路径;`deploy/pi-agent` schema 单测;前端 `overlayMode.test.ts` view-model
|
||||
- 本报告为 feature 分支验收(**未**合入 `dev`,**未**开启生产 flag)。真实容器 PI 叙事联调留待 flag-on 晋级任务
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
默认 off;开启路径 fail-closed 或 shadow。
|
||||
|
||||
| 用例 | 预期 |
|
||||
|------|------|
|
||||
| Flag 默认 | `PIChangeReportNarrativeEnabled()==false` |
|
||||
| Flag off + 叙事 payload | 忽略叙事 → `analysis_status=fallback` |
|
||||
| Flag on + 合法 narrative | `complete`;`risk_level==rule_risk`;lineage 不被 PI 伪造覆盖 |
|
||||
| Flag on + TIMEOUT/malformed | `fallback`;`err==nil`;无 PI 摘要泄漏 |
|
||||
| UI complete | `isNarrativeComplete=true`;事实 notes 含 rule_risk |
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
- Verdict: PASS(单元)
|
||||
|
||||
### 5.1 Flag 默认 off
|
||||
|
||||
- **Input**:进程未设置 `PI_CHANGE_REPORT_NARRATIVE`
|
||||
- **Actual Output**:
|
||||
|
||||
```text
|
||||
=== RUN TestPIChangeReportNarrativeFlagDefaultOff
|
||||
--- PASS: TestPIChangeReportNarrativeFlagDefaultOff (0.00s)
|
||||
=== RUN TestResolveChangeReport_FlagOffUsesDeterministic
|
||||
--- PASS: TestResolveChangeReport_FlagOffUsesDeterministic (0.00s)
|
||||
=== RUN TestCreateAgentCandidate_FlagOffIgnoresNarrativeEnrichment
|
||||
--- PASS: TestCreateAgentCandidate_FlagOffIgnoresNarrativeEnrichment (0.00s)
|
||||
```
|
||||
|
||||
- **Verdict**:PASS
|
||||
- **Duration**:<10ms
|
||||
|
||||
### 5.2 Flag on — 合法叙事不覆盖服务端风险
|
||||
|
||||
- **Input**:`schema_kind=change_report_narrative_v1` + 匹配 `input_digest`;PI `risk.level=low`;伪造 `document_impacts`
|
||||
- **Actual Output**:
|
||||
|
||||
```text
|
||||
=== RUN TestResolveChangeReport_FlagOnValidNarrativeKeepsServerRisk
|
||||
--- PASS: TestResolveChangeReport_FlagOnValidNarrativeKeepsServerRisk (0.00s)
|
||||
```
|
||||
|
||||
- **Verdict**:PASS(服务端 high/critical floor 保留;lineage=`doc_down`)
|
||||
|
||||
### 5.3 Flag on — 全量 fallback(无混合字段)
|
||||
|
||||
- **Input**:bad json / schema / digest / TIMEOUT envelope / partial / empty summary
|
||||
- **Actual Output**:
|
||||
|
||||
```text
|
||||
=== RUN TestResolveChangeReport_FlagOnMalformedFullFallback
|
||||
=== RUN TestResolveChangeReport_FlagOnMalformedFullFallback/bad_json
|
||||
=== RUN TestResolveChangeReport_FlagOnMalformedFullFallback/bad_schema_version
|
||||
=== RUN TestResolveChangeReport_FlagOnMalformedFullFallback/digest_mismatch
|
||||
=== RUN TestResolveChangeReport_FlagOnMalformedFullFallback/timeout_envelope
|
||||
=== RUN TestResolveChangeReport_FlagOnMalformedFullFallback/partial_flag
|
||||
=== RUN TestResolveChangeReport_FlagOnMalformedFullFallback/empty_summary
|
||||
--- PASS: TestResolveChangeReport_FlagOnMalformedFullFallback (0.00s)
|
||||
=== RUN TestCreateAgentCandidate_FlagOnTimeoutFallsBackWithoutError
|
||||
--- PASS: TestCreateAgentCandidate_FlagOnTimeoutFallsBackWithoutError (0.00s)
|
||||
```
|
||||
|
||||
- **Verdict**:PASS
|
||||
|
||||
### 5.4 前端叙事 vs 事实
|
||||
|
||||
- **Input**:`analysis_status=complete` + `deterministic_facts`
|
||||
- **Actual Output**:`marks complete analysis as PI narrative vs factual fields` PASS(14/14 overlayMode 相关套件)
|
||||
- **Verdict**:PASS
|
||||
|
||||
### 5.5 pi-agent typed schema helper
|
||||
|
||||
```text
|
||||
change-report-narrative-v1.test.mjs: ok
|
||||
```
|
||||
|
||||
- **Verdict**:PASS
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
见各 worktree `go test` 输出。
|
||||
|
||||
```text
|
||||
$ go test -v -count=1 ./internal/docrevision/ -run 'Narrative|FlagOff|FlagOn|DeterministicChangeReport'
|
||||
PASS
|
||||
ok chat-agent-system/internal/docrevision 0.004s
|
||||
|
||||
$ go test -count=1 -short ./internal/docrevision/
|
||||
PASS
|
||||
ok chat-agent-system/internal/docrevision 0.007s
|
||||
|
||||
$ node deploy/pi-agent/change-report-narrative-v1.test.mjs
|
||||
change-report-narrative-v1.test.mjs: ok
|
||||
|
||||
$ tsx --test src/features/documentRevision/overlayMode.test.ts
|
||||
ℹ tests 14
|
||||
ℹ pass 14
|
||||
ℹ fail 0
|
||||
```
|
||||
|
||||
通过率:上述套件 **100%**。
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
默认 flag off;不升 MULTI_INTENT_PLAN_TIER。
|
||||
|
||||
- **结论**:PU7 在 `feature/pu7-pi-narrative` 达到契约要求;默认 off,回滚=关 flag;可合入 `dev` 前占集成槽位冒烟。
|
||||
- **后续**:flag-on 真实 PI 容器联调与 browser 验收另开任务;勿默认开启 `PI_CHANGE_REPORT_NARRATIVE`。
|
||||
|
||||
@@ -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");
|
||||
@@ -0,0 +1,45 @@
|
||||
package docrevision
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// EnvPIChangeReportNarrative gates optional PI change-report narrative enrichment.
|
||||
// Default off: deterministic fallback only.
|
||||
const EnvPIChangeReportNarrative = "PI_CHANGE_REPORT_NARRATIVE"
|
||||
|
||||
var (
|
||||
flagsMu sync.RWMutex
|
||||
narrativeOn = envBool(EnvPIChangeReportNarrative, false)
|
||||
)
|
||||
|
||||
func envBool(key string, def bool) bool {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
|
||||
}
|
||||
|
||||
// PIChangeReportNarrativeEnabled reports whether PI narrative enrichment is on.
|
||||
func PIChangeReportNarrativeEnabled() bool {
|
||||
flagsMu.RLock()
|
||||
defer flagsMu.RUnlock()
|
||||
return narrativeOn
|
||||
}
|
||||
|
||||
// SetPIChangeReportNarrativeForTest overrides the flag in tests.
|
||||
func SetPIChangeReportNarrativeForTest(on bool) {
|
||||
flagsMu.Lock()
|
||||
narrativeOn = on
|
||||
flagsMu.Unlock()
|
||||
}
|
||||
|
||||
// ResetPIChangeReportNarrativeForTest restores the flag from the process environment.
|
||||
func ResetPIChangeReportNarrativeForTest() {
|
||||
flagsMu.Lock()
|
||||
narrativeOn = envBool(EnvPIChangeReportNarrative, false)
|
||||
flagsMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package docrevision
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// NarrativeSchemaKind identifies the typed PI change-report narrative payload.
|
||||
NarrativeSchemaKind = "change_report_narrative_v1"
|
||||
// NarrativeSchemaVersion is the only accepted schema_version for narrative payloads.
|
||||
NarrativeSchemaVersion = 1
|
||||
)
|
||||
|
||||
// NarrativeInputDigest binds the PI narrative to immutable server facts (diff + lineage + risk floor).
|
||||
// Callers must recompute this digest and reject any payload whose input_digest differs.
|
||||
func NarrativeInputDigest(fromSHA, toSHA, ruleRisk string, impacts []DocImpact, hunks []DiffHunk) string {
|
||||
type hunkRef struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Section string `json:"section,omitempty"`
|
||||
}
|
||||
type impactRef struct {
|
||||
DocID string `json:"doc_id"`
|
||||
ImpactType string `json:"impact_type"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
hrefs := make([]hunkRef, 0, len(hunks))
|
||||
for _, h := range hunks {
|
||||
hrefs = append(hrefs, hunkRef{ID: h.ID, Kind: h.Kind, Section: h.Section})
|
||||
}
|
||||
irefs := make([]impactRef, 0, len(impacts))
|
||||
for _, imp := range impacts {
|
||||
irefs = append(irefs, impactRef{DocID: imp.DocID, ImpactType: imp.ImpactType, Reason: imp.Reason})
|
||||
}
|
||||
sort.Slice(irefs, func(i, j int) bool {
|
||||
if irefs[i].DocID != irefs[j].DocID {
|
||||
return irefs[i].DocID < irefs[j].DocID
|
||||
}
|
||||
return irefs[i].ImpactType < irefs[j].ImpactType
|
||||
})
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"from_sha": fromSHA,
|
||||
"to_sha": toSHA,
|
||||
"rule_risk": ruleRisk,
|
||||
"impacts": irefs,
|
||||
"hunks": hrefs,
|
||||
})
|
||||
sum := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ResolveChangeReport selects deterministic fallback and optionally overlays a typed PI narrative.
|
||||
//
|
||||
// Flag OFF (default): empty ReportJSON or narrative-shaped payload → deterministic fallback;
|
||||
// legacy caller-supplied change_report.json still validates (may return ErrReportInvalid).
|
||||
//
|
||||
// Flag ON: empty / malformed / timeout / digest mismatch / partial → full deterministic fallback
|
||||
// only (no mixed PI fields, no ErrReportInvalid). Valid narrative overlays summary/open_questions
|
||||
// etc. but never overrides server risk level, document impacts, or confirmation policy.
|
||||
func ResolveChangeReport(raw string, fromSHA, toSHA, ruleRisk string, impacts []DocImpact, diff DiffResult) (*ChangeReport, error) {
|
||||
det := mustDeterministicReport(fromSHA, toSHA, ruleRisk, impacts, diff)
|
||||
|
||||
if !PIChangeReportNarrativeEnabled() {
|
||||
if strings.TrimSpace(raw) == "" || looksLikeNarrativePayload(raw) {
|
||||
return det, nil
|
||||
}
|
||||
rep, err := ValidateChangeReportJSON(raw, fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
|
||||
return rep, err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return det, nil
|
||||
}
|
||||
expected := NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
|
||||
narr, ok := parseNarrativePayload(raw, expected, fromSHA, toSHA)
|
||||
if !ok {
|
||||
return det, nil
|
||||
}
|
||||
return mergeNarrativeOntoDeterministic(det, narr, ruleRisk, impacts, fromSHA, toSHA, diff), nil
|
||||
}
|
||||
|
||||
func mustDeterministicReport(fromSHA, toSHA, ruleRisk string, impacts []DocImpact, diff DiffResult) *ChangeReport {
|
||||
raw := BuildDeterministicChangeReportJSON(fromSHA, toSHA, ruleRisk, impacts, diff)
|
||||
rep, err := ValidateChangeReportJSON(raw, fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
|
||||
if err != nil || rep == nil {
|
||||
fb := fallbackReport(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks, "deterministic build")
|
||||
fb.AnalysisStatus = AnalysisFallback
|
||||
return fb
|
||||
}
|
||||
rep.AnalysisStatus = AnalysisFallback
|
||||
if rep.DeterministicFacts == nil {
|
||||
rep.DeterministicFacts = map[string]any{}
|
||||
}
|
||||
rep.DeterministicFacts["input_digest"] = NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
|
||||
rep.DeterministicFacts["narrative_flag"] = PIChangeReportNarrativeEnabled()
|
||||
return rep
|
||||
}
|
||||
|
||||
type narrativePayload struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
SchemaKind string `json:"schema_kind"`
|
||||
InputDigest string `json:"input_digest"`
|
||||
FromSHA string `json:"from_sha"`
|
||||
ToSHA string `json:"to_sha"`
|
||||
Summary []string `json:"summary"`
|
||||
RiskItems []RiskItem `json:"risk_items"`
|
||||
ProjectImpact map[string]any `json:"project_impact"`
|
||||
OpenQuestions []string `json:"open_questions"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
Outcome string `json:"outcome"`
|
||||
Partial bool `json:"partial"`
|
||||
DocumentImpacts []DocImpact `json:"document_impacts"` // ignored for authority
|
||||
Risk *struct {
|
||||
Level string `json:"level"`
|
||||
Items []RiskItem `json:"items"`
|
||||
} `json:"risk"` // level ignored; items may feed narrative annotations
|
||||
}
|
||||
|
||||
func looksLikeNarrativePayload(raw string) bool {
|
||||
var probe struct {
|
||||
SchemaKind string `json:"schema_kind"`
|
||||
Narrative json.RawMessage `json:"narrative"`
|
||||
CRN json.RawMessage `json:"change_report_narrative"`
|
||||
ResultV1 json.RawMessage `json:"result_v1"`
|
||||
}
|
||||
if json.Unmarshal([]byte(raw), &probe) != nil {
|
||||
return false
|
||||
}
|
||||
if probe.SchemaKind == NarrativeSchemaKind {
|
||||
return true
|
||||
}
|
||||
if len(probe.Narrative) > 0 || len(probe.CRN) > 0 {
|
||||
return true
|
||||
}
|
||||
if len(probe.ResultV1) > 0 {
|
||||
var meta struct {
|
||||
ErrorCode string `json:"error_code"`
|
||||
Outcome string `json:"outcome"`
|
||||
}
|
||||
if json.Unmarshal(probe.ResultV1, &meta) == nil && (meta.ErrorCode != "" || strings.EqualFold(meta.Outcome, "error")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseNarrativePayload(raw, expectedDigest, fromSHA, toSHA string) (*narrativePayload, bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil, false
|
||||
}
|
||||
var envelope map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(trimmed), &envelope); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if rv, ok := envelope["result_v1"]; ok {
|
||||
var meta struct {
|
||||
Outcome string `json:"outcome"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
}
|
||||
if json.Unmarshal(rv, &meta) == nil {
|
||||
if strings.EqualFold(meta.Outcome, "error") || strings.TrimSpace(meta.ErrorCode) != "" {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
}
|
||||
body := []byte(trimmed)
|
||||
if nraw, ok := envelope["narrative"]; ok {
|
||||
body = nraw
|
||||
} else if nraw, ok := envelope["change_report_narrative"]; ok {
|
||||
body = nraw
|
||||
}
|
||||
|
||||
var p narrativePayload
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if p.Partial {
|
||||
return nil, false
|
||||
}
|
||||
if code := strings.TrimSpace(p.ErrorCode); code != "" {
|
||||
return nil, false
|
||||
}
|
||||
if outcome := strings.ToLower(strings.TrimSpace(p.Outcome)); outcome == "error" || outcome == "timeout" || outcome == "cancelled" {
|
||||
return nil, false
|
||||
}
|
||||
if p.SchemaVersion != NarrativeSchemaVersion {
|
||||
return nil, false
|
||||
}
|
||||
if p.SchemaKind != NarrativeSchemaKind {
|
||||
return nil, false
|
||||
}
|
||||
if strings.TrimSpace(p.InputDigest) == "" || !strings.EqualFold(p.InputDigest, expectedDigest) {
|
||||
return nil, false
|
||||
}
|
||||
if strings.TrimSpace(p.FromSHA) != "" && !strings.EqualFold(p.FromSHA, fromSHA) {
|
||||
return nil, false
|
||||
}
|
||||
if strings.TrimSpace(p.ToSHA) != "" && !strings.EqualFold(p.ToSHA, toSHA) {
|
||||
return nil, false
|
||||
}
|
||||
if fromSHA != toSHA {
|
||||
if len(filterNonEmpty(p.Summary)) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
if strings.EqualFold(strings.Join(p.Summary, " "), "未修改") {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return &p, true
|
||||
}
|
||||
|
||||
func mergeNarrativeOntoDeterministic(det *ChangeReport, narr *narrativePayload, ruleRisk string, impacts []DocImpact, fromSHA, toSHA string, diff DiffResult) *ChangeReport {
|
||||
if det == nil {
|
||||
det = fallbackReport(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks, "missing deterministic base")
|
||||
}
|
||||
riskItems := narr.RiskItems
|
||||
if len(riskItems) == 0 && narr.Risk != nil {
|
||||
riskItems = narr.Risk.Items
|
||||
}
|
||||
if len(riskItems) == 0 && (ruleRisk == RiskHigh || ruleRisk == RiskCritical) {
|
||||
riskItems = det.RiskItems
|
||||
}
|
||||
|
||||
facts := map[string]any{}
|
||||
for k, v := range det.DeterministicFacts {
|
||||
facts[k] = v
|
||||
}
|
||||
facts["from_sha"] = fromSHA
|
||||
facts["to_sha"] = toSHA
|
||||
facts["rule_risk"] = ruleRisk
|
||||
facts["hunk_count"] = len(diff.Hunks)
|
||||
facts["impact_count"] = len(impacts)
|
||||
facts["input_digest"] = NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
|
||||
facts["narrative_applied"] = true
|
||||
if narr.Risk != nil && strings.TrimSpace(narr.Risk.Level) != "" {
|
||||
facts["pi_risk_claimed"] = narr.Risk.Level
|
||||
}
|
||||
|
||||
proj := narr.ProjectImpact
|
||||
if proj == nil {
|
||||
proj = det.ProjectImpacts
|
||||
}
|
||||
|
||||
return &ChangeReport{
|
||||
SchemaVersion: NarrativeSchemaVersion,
|
||||
Summary: filterNonEmpty(narr.Summary),
|
||||
RiskLevel: ruleRisk,
|
||||
RiskItems: riskItems,
|
||||
ProjectImpacts: proj,
|
||||
DocumentImpacts: impacts,
|
||||
OpenQuestions: filterNonEmpty(narr.OpenQuestions),
|
||||
AnalysisStatus: AnalysisComplete,
|
||||
DeterministicFacts: facts,
|
||||
RawJSON: "",
|
||||
}
|
||||
}
|
||||
|
||||
func filterNonEmpty(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildNarrativePromptInput is a read-only bundle for PI (diff + lineage + risk floor).
|
||||
func BuildNarrativePromptInput(fromSHA, toSHA, ruleRisk string, impacts []DocImpact, diff DiffResult) map[string]any {
|
||||
digest := NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
|
||||
return map[string]any{
|
||||
"schema_kind": NarrativeSchemaKind,
|
||||
"schema_version": NarrativeSchemaVersion,
|
||||
"input_digest": digest,
|
||||
"from_sha": fromSHA,
|
||||
"to_sha": toSHA,
|
||||
"rule_risk": ruleRisk,
|
||||
"diff_summary": diff.Summary,
|
||||
"hunks": diff.Hunks,
|
||||
"document_impacts": impacts,
|
||||
"instructions": fmt.Sprintf(
|
||||
"Return JSON schema_kind=%s schema_version=%d with matching input_digest; do not set risk.level below rule_risk=%s; document_impacts are server-authoritative.",
|
||||
NarrativeSchemaKind, NarrativeSchemaVersion, ruleRisk,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package docrevision_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/docrevision"
|
||||
)
|
||||
|
||||
func sampleDiff(t *testing.T) (fromSHA, toSHA, ruleRisk string, impacts []docrevision.DocImpact, diff docrevision.DiffResult) {
|
||||
t.Helper()
|
||||
before := docrevision.CanonicalizeMarkdown("# A\n\n旧文\n")
|
||||
after := docrevision.CanonicalizeMarkdown("# A\n\n新文 REQ-1 验收标准\n")
|
||||
fb := docrevision.BuildBlockMap(before, nil)
|
||||
ab := docrevision.BuildBlockMap(after, fb)
|
||||
diff = docrevision.ComputeDiff(before, after, fb, ab)
|
||||
fromSHA = docrevision.SHA256Hex(before)
|
||||
toSHA = docrevision.SHA256Hex(after)
|
||||
impacts = []docrevision.DocImpact{{
|
||||
DocID: "doc_down", ImpactType: "stale_candidate", Reason: "上游更新",
|
||||
RecommendedAction: "复核",
|
||||
}}
|
||||
ruleRisk = docrevision.RuleRiskFloor(diff.Hunks, impacts)
|
||||
return fromSHA, toSHA, ruleRisk, impacts, diff
|
||||
}
|
||||
|
||||
func TestPIChangeReportNarrativeFlagDefaultOff(t *testing.T) {
|
||||
docrevision.ResetPIChangeReportNarrativeForTest()
|
||||
t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
|
||||
if docrevision.PIChangeReportNarrativeEnabled() {
|
||||
t.Fatal("PI_CHANGE_REPORT_NARRATIVE must default off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChangeReport_FlagOffUsesDeterministic(t *testing.T) {
|
||||
docrevision.SetPIChangeReportNarrativeForTest(false)
|
||||
t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
|
||||
|
||||
fromSHA, toSHA, ruleRisk, impacts, diff := sampleDiff(t)
|
||||
digest := docrevision.NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
|
||||
piRaw, _ := json.Marshal(map[string]any{
|
||||
"schema_version": 1,
|
||||
"schema_kind": docrevision.NarrativeSchemaKind,
|
||||
"input_digest": digest,
|
||||
"from_sha": fromSHA,
|
||||
"to_sha": toSHA,
|
||||
"summary": []string{"PI 不应在 flag off 时生效"},
|
||||
"open_questions": []string{"q"},
|
||||
"risk": map[string]any{"level": "low", "items": []any{}},
|
||||
})
|
||||
|
||||
rep, err := docrevision.ResolveChangeReport(string(piRaw), fromSHA, toSHA, ruleRisk, impacts, diff)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.AnalysisStatus != docrevision.AnalysisFallback {
|
||||
t.Fatalf("flag off must ignore narrative payload, want fallback got %s", rep.AnalysisStatus)
|
||||
}
|
||||
for _, s := range rep.Summary {
|
||||
if s == "PI 不应在 flag off 时生效" {
|
||||
t.Fatal("PI narrative must not apply when flag off")
|
||||
}
|
||||
}
|
||||
|
||||
rep2, err := docrevision.ResolveChangeReport("", fromSHA, toSHA, ruleRisk, impacts, diff)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep2.AnalysisStatus != docrevision.AnalysisFallback {
|
||||
t.Fatalf("flag off empty raw want fallback got %s", rep2.AnalysisStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChangeReport_FlagOnValidNarrativeKeepsServerRisk(t *testing.T) {
|
||||
docrevision.SetPIChangeReportNarrativeForTest(true)
|
||||
t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
|
||||
|
||||
fromSHA, toSHA, ruleRisk, impacts, diff := sampleDiff(t)
|
||||
if ruleRisk != docrevision.RiskHigh && ruleRisk != docrevision.RiskCritical {
|
||||
t.Fatalf("fixture should elevate rule risk, got %s", ruleRisk)
|
||||
}
|
||||
digest := docrevision.NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
|
||||
piRaw, _ := json.Marshal(map[string]any{
|
||||
"schema_version": 1,
|
||||
"schema_kind": docrevision.NarrativeSchemaKind,
|
||||
"input_digest": digest,
|
||||
"from_sha": fromSHA,
|
||||
"to_sha": toSHA,
|
||||
"summary": []string{"补充验收阈值说明"},
|
||||
"open_questions": []string{"硬件是否已确认"},
|
||||
"project_impact": map[string]any{"summary": "叙事:验证范围扩大"},
|
||||
"risk": map[string]any{"level": "low", "items": []map[string]any{{"category": "pi", "description": "试图降风险"}}},
|
||||
"document_impacts": []map[string]any{{
|
||||
"doc_id": "forged_doc", "impact_type": "none", "reason": "PI 伪造影响",
|
||||
}},
|
||||
})
|
||||
|
||||
rep, err := docrevision.ResolveChangeReport(string(piRaw), fromSHA, toSHA, ruleRisk, impacts, diff)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.AnalysisStatus != docrevision.AnalysisComplete {
|
||||
t.Fatalf("want complete got %s", rep.AnalysisStatus)
|
||||
}
|
||||
if rep.RiskLevel != ruleRisk {
|
||||
t.Fatalf("PI must not override server risk: want %s got %s", ruleRisk, rep.RiskLevel)
|
||||
}
|
||||
if len(rep.Summary) != 1 || rep.Summary[0] != "补充验收阈值说明" {
|
||||
t.Fatalf("narrative summary: %+v", rep.Summary)
|
||||
}
|
||||
if len(rep.DocumentImpacts) != 1 || rep.DocumentImpacts[0].DocID != "doc_down" {
|
||||
t.Fatalf("server lineage must win, got %+v", rep.DocumentImpacts)
|
||||
}
|
||||
if rep.DeterministicFacts["narrative_applied"] != true {
|
||||
t.Fatalf("facts: %+v", rep.DeterministicFacts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChangeReport_FlagOnMalformedFullFallback(t *testing.T) {
|
||||
docrevision.SetPIChangeReportNarrativeForTest(true)
|
||||
t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
|
||||
|
||||
fromSHA, toSHA, ruleRisk, impacts, diff := sampleDiff(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{"bad json", `{not-json`},
|
||||
{"bad schema version", `{"schema_version":99,"schema_kind":"change_report_narrative_v1","input_digest":"x","summary":["a"]}`},
|
||||
{"digest mismatch", mustNarrativeJSON(t, fromSHA, toSHA, "deadbeef", []string{"ok"})},
|
||||
{"timeout envelope", `{"result_v1":{"outcome":"error","error_code":"TIMEOUT"},"narrative":{"schema_version":1,"schema_kind":"change_report_narrative_v1"}}`},
|
||||
{"partial flag", `{"schema_version":1,"schema_kind":"change_report_narrative_v1","input_digest":"x","partial":true,"summary":["a"]}`},
|
||||
{"empty summary", mustNarrativeJSON(t, fromSHA, toSHA, docrevision.NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks), nil)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rep, err := docrevision.ResolveChangeReport(tc.raw, fromSHA, toSHA, ruleRisk, impacts, diff)
|
||||
if err != nil {
|
||||
t.Fatalf("flag-on fallback must not error: %v", err)
|
||||
}
|
||||
if rep.AnalysisStatus != docrevision.AnalysisFallback {
|
||||
t.Fatalf("want full fallback got %s", rep.AnalysisStatus)
|
||||
}
|
||||
if rep.RiskLevel != ruleRisk {
|
||||
t.Fatalf("fallback risk want %s got %s", ruleRisk, rep.RiskLevel)
|
||||
}
|
||||
if len(rep.DocumentImpacts) != len(impacts) {
|
||||
t.Fatalf("fallback must keep server impacts")
|
||||
}
|
||||
for _, s := range rep.Summary {
|
||||
if s == "补充验收阈值说明" || s == "PI only" {
|
||||
t.Fatalf("mixed narrative field leaked: %q", s)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustNarrativeJSON(t *testing.T, fromSHA, toSHA, digest string, summary []string) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"schema_version": 1,
|
||||
"schema_kind": docrevision.NarrativeSchemaKind,
|
||||
"input_digest": digest,
|
||||
"from_sha": fromSHA,
|
||||
"to_sha": toSHA,
|
||||
"summary": summary,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestCreateAgentCandidate_FlagOffIgnoresNarrativeEnrichment(t *testing.T) {
|
||||
docrevision.SetPIChangeReportNarrativeForTest(false)
|
||||
t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
|
||||
|
||||
svc := docrevision.NewService(nil)
|
||||
ctx := context.Background()
|
||||
st, err := svc.OpenTemplateVersion(ctx, "doc_flag_off", "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cand, rep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
|
||||
DocID: "doc_flag_off", RunID: "r1", InputSnapshotID: st.HeadSnapshotID,
|
||||
OutputMarkdown: "# base\n\nbody changed\n",
|
||||
ReportJSON: "",
|
||||
ActorKind: docrevision.ActorAgent,
|
||||
})
|
||||
if err != nil && cand == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.AnalysisStatus != docrevision.AnalysisFallback {
|
||||
t.Fatalf("flag off empty report want fallback got %s", rep.AnalysisStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAgentCandidate_FlagOnTimeoutFallsBackWithoutError(t *testing.T) {
|
||||
docrevision.SetPIChangeReportNarrativeForTest(true)
|
||||
t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
|
||||
|
||||
svc := docrevision.NewService(nil)
|
||||
ctx := context.Background()
|
||||
st, err := svc.OpenTemplateVersion(ctx, "doc_flag_on_to", "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cand, rep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
|
||||
DocID: "doc_flag_on_to", RunID: "r-timeout", InputSnapshotID: st.HeadSnapshotID,
|
||||
OutputMarkdown: "# base\n\nbody changed\n",
|
||||
ReportJSON: `{"result_v1":{"outcome":"error","error_code":"TIMEOUT"}}`,
|
||||
ActorKind: docrevision.ActorAgent,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("timeout under flag on must full-fallback without error, got %v", err)
|
||||
}
|
||||
if cand == nil || rep == nil {
|
||||
t.Fatal("expected candidate+report")
|
||||
}
|
||||
if rep.AnalysisStatus != docrevision.AnalysisFallback {
|
||||
t.Fatalf("want fallback got %s", rep.AnalysisStatus)
|
||||
}
|
||||
}
|
||||
@@ -492,25 +492,16 @@ func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRe
|
||||
diff := ComputeDiff(inMD, canon, inSnap.BlockMap, outSnap.BlockMap)
|
||||
impacts := s.impactLocked(ctx, req.DocID)
|
||||
ruleRisk := RuleRiskFloor(diff.Hunks, impacts)
|
||||
deterministicJSON := BuildDeterministicChangeReportJSON(inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff)
|
||||
reportJSON := strings.TrimSpace(req.ReportJSON)
|
||||
if reportJSON == "" {
|
||||
reportJSON = deterministicJSON
|
||||
} else if PIChangeReportNarrativeEnabled() {
|
||||
reportJSON = SelectChangeReportJSON(reportJSON, deterministicJSON, inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff.Hunks)
|
||||
}
|
||||
rep, err := ValidateChangeReportJSON(reportJSON, inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff.Hunks)
|
||||
rep, err := ResolveChangeReport(req.ReportJSON, inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff)
|
||||
status := CandidateReady
|
||||
if err != nil {
|
||||
// Invalid reports stay visible as candidates but must not look "ready" for auto-commit.
|
||||
// (Flag-off legacy path only; flag-on narrative failures already full-fallback with err=nil.)
|
||||
status = CandidateNeedsConfirmation
|
||||
if rep != nil {
|
||||
rep.AnalysisStatus = AnalysisUnassessed
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(req.ReportJSON) == "" && rep != nil && rep.AnalysisStatus == AnalysisComplete {
|
||||
rep.AnalysisStatus = AnalysisFallback
|
||||
}
|
||||
if NeedsConfirmation(rep.RiskLevel) {
|
||||
status = CandidateNeedsConfirmation
|
||||
}
|
||||
|
||||
@@ -90,53 +90,82 @@ export function ChangeReportPanel(props: {
|
||||
<span style={{ marginLeft: 6, opacity: 0.75 }}>({vm.analysisNote})</span>
|
||||
) : null}
|
||||
</p>
|
||||
<h4>摘要</h4>
|
||||
<ul data-testid="change-report-summary">
|
||||
{vm.summary.length === 0 ? <li style={{ opacity: 0.7 }}>(无摘要)</li> : null}
|
||||
{vm.summary.map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
<h4>风险条目</h4>
|
||||
<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>
|
||||
<h4>项目影响</h4>
|
||||
<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>
|
||||
<h4>文档影响</h4>
|
||||
<ul data-testid="change-report-doc-impacts">
|
||||
{vm.documentImpacts.length === 0 ? (
|
||||
<li style={{ opacity: 0.7 }}>无(或尚未登记下游 lineage)</li>
|
||||
) : null}
|
||||
{vm.documentImpacts.map((d) => (
|
||||
<li key={`${d.docId}-${d.impactType}`}>
|
||||
<strong>{d.docId}</strong>
|
||||
{d.impactType ? ` [${d.impactType}]` : ''}
|
||||
{d.reason ? `:${d.reason}` : ''}
|
||||
{d.recommendedAction ? (
|
||||
<div style={{ opacity: 0.85 }}>建议:{d.recommendedAction}</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h4>开放问题</h4>
|
||||
<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 data-testid="change-report-factual" aria-label="事实字段">
|
||||
<h4>事实字段(服务端)</h4>
|
||||
<p style={{ margin: '0 0 8px', fontSize: 13, opacity: 0.85 }}>
|
||||
风险等级与文档影响由服务端 diff + lineage 计算,PI 不可覆盖审批结论。
|
||||
</p>
|
||||
<ul data-testid="change-report-factual-notes">
|
||||
{vm.factualNotes.length === 0 ? (
|
||||
<li style={{ opacity: 0.7 }}>风险:{vm.riskLevel || '(未评估)'}</li>
|
||||
) : null}
|
||||
{vm.factualNotes.map((n) => (
|
||||
<li key={n}>{n}</li>
|
||||
))}
|
||||
</ul>
|
||||
<h5 style={{ marginBottom: 4 }}>文档影响</h5>
|
||||
<ul data-testid="change-report-doc-impacts">
|
||||
{vm.documentImpacts.length === 0 ? (
|
||||
<li style={{ opacity: 0.7 }}>无(或尚未登记下游 lineage)</li>
|
||||
) : null}
|
||||
{vm.documentImpacts.map((d) => (
|
||||
<li key={`${d.docId}-${d.impactType}`}>
|
||||
<strong>{d.docId}</strong>
|
||||
{d.impactType ? ` [${d.impactType}]` : ''}
|
||||
{d.reason ? `:${d.reason}` : ''}
|
||||
{d.recommendedAction ? (
|
||||
<div style={{ opacity: 0.85 }}>建议:{d.recommendedAction}</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section data-testid="change-report-narrative" aria-label="叙事字段">
|
||||
<h4>
|
||||
叙事字段({vm.narrativeSourceLabel})
|
||||
<span
|
||||
data-testid="change-report-narrative-source"
|
||||
style={{ marginLeft: 8, fontSize: 12, fontWeight: 400, opacity: 0.75 }}
|
||||
>
|
||||
{vm.isNarrativeComplete ? 'PI' : 'fallback'}
|
||||
</span>
|
||||
</h4>
|
||||
<h5 style={{ marginBottom: 4 }}>摘要</h5>
|
||||
<ul data-testid="change-report-summary">
|
||||
{vm.summary.length === 0 ? <li style={{ opacity: 0.7 }}>(无摘要)</li> : null}
|
||||
{vm.summary.map((s) => (
|
||||
<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}
|
||||
{props.pendingDecision ? (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ConflictInfo {
|
||||
|
||||
Reference in New Issue
Block a user