Canonicalize material quotes and own checkpoint status

Why: Independent re-review found single-word decision quotes, project-prefix scope mixing, revoked totals and approval status bypasses.

Evidence: Each new counterexample failed before repair; Backend go test ./... -count=1 and selected live-tag fake-stream tests passed after.

Impact: Display complete visible source sentences, parse only bounded positive count clauses, and set material progress text from the host phase.

Refs: Dev task feature/fast-slow-u2-u3-plan; r0.3 second review Important findings
This commit is contained in:
2026-10-13 17:32:01 +00:00
parent 9019f9082d
commit 6d50e487ca
4 changed files with 140 additions and 31 deletions
+33 -18
View File
@@ -12,18 +12,18 @@ import (
// These are deliberately narrow source grammars, not natural-language proofs. // These are deliberately narrow source grammars, not natural-language proofs.
// A missed expression produces a pending question instead of a model claim. // A missed expression produces a pending question instead of a model claim.
var countPremisePatterns = map[string]*regexp.Regexp{ var countPremisePatterns = map[string]*regexp.Regexp{
"total": regexp.MustCompile(`(?:交付包含|总共|共计|共有|总计|总)([0-9]+)项(?:核心需求|需求|功能|任务|[,。、;;[:space:]]|$)`), "total": regexp.MustCompile(`^(?:交付包含|总共|共计|共有|总计|总)([0-9]+)项(?:核心需求|需求|功能|任务)?$`),
"phase": regexp.MustCompile(`(?:第一期|第1期|一期)(?:先)?(?:完成|交付|安排)?([0-9]+)项(?:核心需求|需求|功能|任务|[,。、;;[:space:]]|$)`), "phase": regexp.MustCompile(`^(?:第一期|第1期|一期)(?:先)?(?:完成|交付|安排)?([0-9]+)项(?:核心需求|需求|功能|任务)?$`),
"dependent": regexp.MustCompile(`其中([0-9]+)项依赖(?:外部)?接口`), "dependent": regexp.MustCompile(`^其中([0-9]+)项依赖(?:外部)?接口(?:的需求)?$`),
} }
var directRelationPattern = regexp.MustCompile(`(?:第一期|第二期|第1期|第2期|一期|二期)[^。;;]*?(?:包含|包括|完成|交付)[0-9]+项|其中[0-9]+项依赖(?:外部)?接口|(?:会议决定|已批准|已经批准|审批通过)`) var directRelationPattern = regexp.MustCompile(`(?:第一期|第二期|第1期|第2期|一期|二期)[^。;;]*?(?:包含|包括|完成|交付)[0-9]+项|其中[0-9]+项依赖(?:外部)?接口|(?:会议决定|已批准|已经批准|审批通过)`)
var sourceContextRiskPattern = regexp.MustCompile(`[0-90-9]|[一二三四五六七八九十]+(?:项|期|万元)|已决定|已经决定|已批准|已经批准|已获批|批复同意|已通过审批|审批通过|包含|包括|属于|剩余|第一期|第二期`) var sourceContextRiskPattern = regexp.MustCompile(`[0-90-9]|[一二三四五六七八九十]+(?:项|期|万元)|已决定|已经决定|已批准|已经批准|已获批|批复同意|已通过审批|审批通过|包含|包括|属于|剩余|第一期|第二期`)
var consequentialClaimPattern = regexp.MustCompile(sourceContextRiskPattern.String() + `|接口|依赖|批次|批|轮|阶段|分期|归属|后续|全部|全数|全放|均|项目|范围|预算|审批|决定|安排`) var consequentialClaimPattern = regexp.MustCompile(sourceContextRiskPattern.String() + `|接口|依赖|批次|批|轮|阶段|分期|归属|后续|全部|全数|全放|均|项目|范围|预算|审批|决定|安排`)
var cautiousAnalysisPattern = regexp.MustCompile(`存在差异|意见不同|不一致|需核对|需要核对|待核对|待确认|请确认|未明确|尚未给出|建议|可能`) var cautiousAnalysisPattern = regexp.MustCompile(`存在差异|意见不同|不一致|需核对|需要核对|待核对|待确认|请确认|未明确|尚未给出|建议|可能`)
var safeStatusPrefix = regexp.MustCompile(`^(?:核对|继续核对|整理|继续整理|汇总|待|仍需|查看|确认|补齐|检查|阅读|梳理|形成)`) var negatedPremisePattern = regexp.MustCompile(`并非|不是|不等于|不代表|尚未|并未|没有|未|否认|否定|不得|不能|已取消|取消|废止|撤销|作废|失效|不再`)
var negatedPremisePattern = regexp.MustCompile(`并非|不是|不等于|不代表|尚未|并未|没有|未|否认|不得|不能`) var explicitScopePattern = regexp.MustCompile(`(?:^|[。;;,,[:space:]])(?:([A-Za-z0-9甲乙丙丁]{1,8})项目|项目([A-Za-z0-9甲乙丙丁]{1,8}))`)
var explicitScopePattern = regexp.MustCompile(`(?:^|[。;;,,[:space:]])([\p{Han}A-Za-z0-9]{1,16})项目`) var scopePrefixPattern = regexp.MustCompile(`^(?:[A-Za-z0-9甲乙丙丁]{1,8}项目|项目[A-Za-z0-9甲乙丙丁]{1,8})`)
// sourceClause restores the visible sentence around an exact short quote. A // sourceClause restores the visible sentence around an exact short quote. A
// duplicate or oversized sentence is ambiguous and cannot prove a relation. // duplicate or oversized sentence is ambiguous and cannot prove a relation.
@@ -70,7 +70,13 @@ func explicitScope(clause string) (string, bool) {
return "", false return "", false
} }
if len(matches) == 1 { if len(matches) == 1 {
return matches[0][1], true if matches[0][1] != "" {
return matches[0][1], true
}
return matches[0][2], true
}
if strings.Contains(clause, "项目") {
return "", false
} }
return "", true return "", true
} }
@@ -80,12 +86,21 @@ func parseCountPremise(quote, role string, value int) bool {
if pattern == nil || value < 0 { if pattern == nil || value < 0 {
return false return false
} }
matches := pattern.FindAllStringSubmatch(quote, -1) matched := -1
if len(matches) != 1 { for _, segment := range strings.FieldsFunc(quote, func(r rune) bool { return strings.ContainsRune(",,。;;!??!\n", r) }) {
return false segment = strings.TrimSpace(segment)
segment = scopePrefixPattern.ReplaceAllString(segment, "")
parts := pattern.FindStringSubmatch(segment)
if len(parts) != 2 {
continue
}
count, err := strconv.Atoi(parts[1])
if err != nil || matched >= 0 {
return false
}
matched = count
} }
count, err := strconv.Atoi(matches[0][1]) return matched == value
return err == nil && count == value
} }
func pendingEvidenceFinding(f core.WorkFindingCandidate) core.WorkFindingCandidate { func pendingEvidenceFinding(f core.WorkFindingCandidate) core.WorkFindingCandidate {
@@ -136,16 +151,16 @@ func normalizeMaterialEvidence(candidate core.WorkCheckpointCandidate, sources m
} }
if f.Evidence == nil { if f.Evidence == nil {
if f.Kind == "material_record" { if f.Kind == "material_record" {
for _, quote := range f.Sources { for j, quote := range f.Sources {
if !sourceContextRiskPattern.MatchString(quote.Quote) {
continue
}
clause, ok := sourceClause(sources, quote) clause, ok := sourceClause(sources, quote)
if !ok || strings.TrimRight(strings.TrimSpace(quote.Quote), "。;;!??!") != clause { if !ok {
normalized.Findings[i] = pendingEvidenceFinding(f) normalized.Findings[i] = pendingEvidenceFinding(f)
notes = append(notes, f.FindingKey+":短引缺少原句语境,已转待确认") notes = append(notes, f.FindingKey+":原句语境不唯一,已转待确认")
break break
} }
f.Sources = append([]core.TaskSourceQuote(nil), f.Sources...)
f.Sources[j].Quote = clause
normalized.Findings[i] = f
} }
} else if consequentialClaimPattern.MatchString(f.Text+f.Impact+f.Assumptions) || !cautiousAnalysisPattern.MatchString(f.Text) { } else if consequentialClaimPattern.MatchString(f.Text+f.Impact+f.Assumptions) || !cautiousAnalysisPattern.MatchString(f.Text) {
normalized.Findings[i] = pendingEvidenceFinding(f) normalized.Findings[i] = pendingEvidenceFinding(f)
+44 -1
View File
@@ -209,7 +209,7 @@ func TestMaterialEvidenceDoesNotDropNegationOutsideShortQuote(t *testing.T) {
sources := materialSources(docs, 48000) sources := materialSources(docs, 48000)
approval := core.WorkCheckpointCandidate{CheckpointID: "approval-short", PhaseID: "crosscheck", Findings: []core.WorkFindingCandidate{{FindingKey: "approval", Text: "已批准", Kind: "material_record", Sources: []core.TaskSourceQuote{evidenceQuote(t, sources, "review", "已批准")}}}} approval := core.WorkCheckpointCandidate{CheckpointID: "approval-short", PhaseID: "crosscheck", Findings: []core.WorkFindingCandidate{{FindingKey: "approval", Text: "已批准", Kind: "material_record", Sources: []core.TaskSourceQuote{evidenceQuote(t, sources, "review", "已批准")}}}}
normalized, _, err := normalizeMaterialEvidence(approval, sources) normalized, _, err := normalizeMaterialEvidence(approval, sources)
if err != nil || normalized.Findings[0].Kind != "needs_user" { if err != nil || normalized.Findings[0].Kind != "material_record" || !strings.Contains(normalized.Findings[0].Sources[0].Quote, "不等于已批准") {
t.Fatalf("negation was removed from a source record: %+v %v", normalized, err) t.Fatalf("negation was removed from a source record: %+v %v", normalized, err)
} }
approval.Findings[0].Evidence = &core.WorkFindingEvidence{Mode: "direct_relation", Premises: []core.WorkEvidencePremise{{Role: "direct_relation", SourceIndex: 0}}} approval.Findings[0].Evidence = &core.WorkFindingEvidence{Mode: "direct_relation", Premises: []core.WorkEvidencePremise{{Role: "direct_relation", SourceIndex: 0}}}
@@ -225,6 +225,49 @@ func TestMaterialEvidenceDoesNotDropNegationOutsideShortQuote(t *testing.T) {
} }
} }
func TestMaterialEvidenceRestoresNegationForUnlistedDecisionWord(t *testing.T) {
docs := []core.ParsedDocument{{Meta: core.DocMeta{Filename: "方案.txt", Origin: &core.DocumentOrigin{ArtifactID: "decision", ContentHash: "decision-hash", ParserRevision: "txt.v1"}}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "方案尚未通过。"}}}}
sources := materialSources(docs, 48000)
candidate := core.WorkCheckpointCandidate{CheckpointID: "decision-short", PhaseID: "crosscheck", Findings: []core.WorkFindingCandidate{{FindingKey: "decision", Text: "通过", Kind: "material_record", Sources: []core.TaskSourceQuote{evidenceQuote(t, sources, "decision", "通过")}}}}
normalized, _, err := normalizeMaterialEvidence(candidate, sources)
if err != nil || normalized.Findings[0].Kind != "material_record" || normalized.Findings[0].Sources[0].Quote != "方案尚未通过" {
t.Fatalf("short decision quote lost negation: %+v %v", normalized, err)
}
}
func TestMaterialEvidenceRejectsProjectPrefixScopesAndCancelledCounts(t *testing.T) {
n := func(v int) *int { return &v }
for _, tc := range []struct {
name, total, phase, dependent string
}{
{"two scopes in one source", "项目A总12项需求,项目B第一期8项需求,其中12项依赖接口", "项目A总12项需求,项目B第一期8项需求,其中12项依赖接口", "项目A总12项需求,项目B第一期8项需求,其中12项依赖接口"},
{"cancelled total", "已取消总12项需求", "第一期先完成8项核心需求", "其中3项依赖外部接口"},
} {
t.Run(tc.name, func(t *testing.T) {
docs := []core.ParsedDocument{
{Meta: core.DocMeta{Filename: "范围.txt", Origin: &core.DocumentOrigin{ArtifactID: "total", ContentHash: "h-total", ParserRevision: "txt.v1"}}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: tc.total}}},
{Meta: core.DocMeta{Filename: "阶段.txt", Origin: &core.DocumentOrigin{ArtifactID: "phase", ContentHash: "h-phase", ParserRevision: "txt.v1"}}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: tc.phase}}},
{Meta: core.DocMeta{Filename: "依赖.txt", Origin: &core.DocumentOrigin{ArtifactID: "dependent", ContentHash: "h-dependent", ParserRevision: "txt.v1"}}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: tc.dependent}}},
}
sources := materialSources(docs, 48000)
candidate := core.WorkCheckpointCandidate{CheckpointID: "unsafe-scope", PhaseID: "crosscheck", Findings: []core.WorkFindingCandidate{{FindingKey: "scope", Text: "差额与归属确定", Kind: "analysis", Assumptions: "模型推断", Sources: []core.TaskSourceQuote{evidenceQuote(t, sources, "total", tc.total), evidenceQuote(t, sources, "phase", tc.phase), evidenceQuote(t, sources, "dependent", tc.dependent)}, Evidence: &core.WorkFindingEvidence{Mode: "unresolved_relation", Premises: []core.WorkEvidencePremise{{Role: "total", SourceIndex: 0, Value: n(12)}, {Role: "phase", SourceIndex: 1, Value: n(8)}, {Role: "dependent", SourceIndex: 2, Value: n(12)}}}}}}
if tc.name == "two scopes in one source" {
candidate.Findings[0].Sources = candidate.Findings[0].Sources[:1]
candidate.Findings[0].Evidence.Premises[1].SourceIndex = 0
candidate.Findings[0].Evidence.Premises[2].SourceIndex = 0
} else {
candidate.Findings[0].Evidence.Mode = "difference"
candidate.Findings[0].Evidence.Premises = candidate.Findings[0].Evidence.Premises[:2]
candidate.Findings[0].Evidence.Result = n(4)
}
normalized, _, err := normalizeMaterialEvidence(candidate, sources)
if err != nil || normalized.Findings[0].Kind != "needs_user" {
t.Fatalf("unsafe scope/count was treated as proven: %+v %v", normalized, err)
}
})
}
}
func TestMaterialEvidenceDifferentExplicitScopesBecomePending(t *testing.T) { func TestMaterialEvidenceDifferentExplicitScopesBecomePending(t *testing.T) {
docs := []core.ParsedDocument{ docs := []core.ParsedDocument{
{Meta: core.DocMeta{Filename: "A.txt", Origin: &core.DocumentOrigin{ArtifactID: "req", ContentHash: "req-hash", ParserRevision: "txt.v1"}}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "A项目总12项需求。"}}}, {Meta: core.DocMeta{Filename: "A.txt", Origin: &core.DocumentOrigin{ArtifactID: "req", ContentHash: "req-hash", ParserRevision: "txt.v1"}}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "A项目总12项需求。"}}},
+20 -11
View File
@@ -69,22 +69,31 @@ func (c *runCtx) PublishWorkCheckpoint(candidate core.WorkCheckpointCandidate) (
if err != nil { if err != nil {
return "", err return "", err
} }
statusNormalized := false if normalized.Focus != "" || normalized.Remaining != "" || normalized.NextStep != "" {
for _, field := range []struct { notes = append(notes, "任务状态按工作阶段显示中性说明")
value *string
text string
}{{&normalized.Focus, "核对材料中可证实的要点"}, {&normalized.Remaining, "数字或归属仍待核对"}, {&normalized.NextStep, "核对原文后继续整理"}} {
if consequentialClaimPattern.MatchString(*field.value) || (*field.value != "" && !safeStatusPrefix.MatchString(*field.value)) {
*field.value = field.text
statusNormalized = true
}
} }
if statusNormalized { switch candidate.PhaseID {
notes = append(notes, "任务状态中的未证实判断已改为中性说明") case "reading":
normalized.Focus, normalized.Remaining, normalized.NextStep = "整理材料中的事实", "仍需核对差异和缺项", "继续核对材料"
case "crosscheck":
normalized.Focus, normalized.Remaining, normalized.NextStep = "核对材料中的差异", "仍需整理结论", "结合依据形成结果"
case "summarizing":
normalized.Focus, normalized.Remaining, normalized.NextStep = "整理已核对的要点", "仍需形成完整答复", "完成整理结果"
case "draft-ready":
normalized.Focus, normalized.Remaining, normalized.NextStep = "形成整理结果", "等待完整答复", "输出最终结果"
} }
if err := validateMaterialApprovalClaims(normalized); err != nil { if err := validateMaterialApprovalClaims(normalized); err != nil {
return "", err return "", err
} }
for i, f := range normalized.Findings {
for j, q := range f.Sources {
src, err := c.sources.validate(q)
if err != nil || src.ID != verifiedSources[i][j].ID || src.ArtifactID != verifiedSources[i][j].ArtifactID {
return "", fmt.Errorf("checkpoint.source: normalized quote unavailable")
}
verifiedSources[i][j] = src
}
}
findings := make([]core.TaskFinding, 0, len(normalized.Findings)) findings := make([]core.TaskFinding, 0, len(normalized.Findings))
critical := false critical := false
for i, f := range normalized.Findings { for i, f := range normalized.Findings {
@@ -19,7 +19,7 @@ func checkpointEvidenceEnvironment(t *testing.T) *runCtx {
conv := recoveryConversation(t, st) conv := recoveryConversation(t, st)
parent := recoveryRun(t, st, conv, core.RunRunning) parent := recoveryRun(t, st, conv, core.RunRunning)
inputs := []struct{ name, text string }{ inputs := []struct{ name, text string }{
{"需求.txt", "交付包含12项需求,其中3项依赖外部接口。"}, {"需求.txt", "交付包含12项需求,其中3项依赖外部接口。方案尚未通过。"},
{"会议.txt", "会议决定分两期交付,第一期先完成8项核心需求。"}, {"会议.txt", "会议决定分两期交付,第一期先完成8项核心需求。"},
} }
docs := make([]core.ParsedDocument, 0, len(inputs)) docs := make([]core.ParsedDocument, 0, len(inputs))
@@ -183,3 +183,45 @@ func TestWorkCheckpointEvidenceUnknownStatusAssertionIsNeutralized(t *testing.T)
t.Fatal("unknown status assertion escaped", card.Task.Focus) t.Fatal("unknown status assertion escaped", card.Task.Focus)
} }
} }
func TestWorkCheckpointEvidenceStatusDoesNotInferApprovalFromConfirmVerb(t *testing.T) {
c := checkpointEnvironment(t)
cp := validCheckpoint(c, "approval-status")
cp.Focus = "确认方案获准"
cp.NextStep = "确认方案已生效"
if _, err := c.PublishWorkCheckpoint(cp); err != nil {
t.Fatal(err)
}
card, err := c.mgr.st.GetCard(c.run.Owner, c.task.cardID)
if err != nil {
t.Fatal(err)
}
if card.Task.Focus == cp.Focus || card.Task.NextStep == cp.NextStep {
t.Fatalf("model approval status escaped: %q / %q", card.Task.Focus, card.Task.NextStep)
}
}
func TestWorkCheckpointEvidencePersistsFullNegativeSourceSentence(t *testing.T) {
c := checkpointEvidenceEnvironment(t)
var quote core.TaskSourceQuote
for id, source := range c.sources {
if strings.Contains(source.Ref.Label, "需求.txt") {
quote = core.TaskSourceQuote{SourceID: id, BlockIndex: source.Ref.BlockIndex, Quote: "通过"}
break
}
}
if quote.SourceID == "" {
t.Fatal("requirements source missing")
}
cp := core.WorkCheckpointCandidate{CheckpointID: "negative-record", PhaseID: "crosscheck", Findings: []core.WorkFindingCandidate{{FindingKey: "decision", Text: "方案通过", Kind: "material_record", Sources: []core.TaskSourceQuote{quote}}}}
if _, err := c.PublishWorkCheckpoint(cp); err != nil {
t.Fatal(err)
}
card, err := c.mgr.st.GetCard(c.run.Owner, c.task.cardID)
if err != nil {
t.Fatal(err)
}
if len(card.Task.Findings) != 1 || !strings.Contains(card.Task.Findings[0].Text, "方案尚未通过") || strings.Contains(card.Task.Findings[0].Text, ":「通过」") {
t.Fatalf("short affirmative quote reached the card: %+v", card.Task.Findings)
}
}