Guard checkpoint claims against missing source context

Why: Independent review found negated short quotes, cross-project counts and unstructured batch placement could be shown as facts.

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

Impact: Require unambiguous source sentence context and matching explicit scopes, keep only cautious ordinary analysis without structured evidence, neutralize unsafe status text.

Refs: Dev task feature/fast-slow-u2-u3-plan; r0.3 review P1 findings
This commit is contained in:
2026-10-13 17:20:36 +00:00
parent 756679dace
commit 9019f9082d
4 changed files with 204 additions and 15 deletions
+91 -13
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"regexp"
"strconv"
"strings"
"unicode/utf8"
)
// These are deliberately narrow source grammars, not natural-language proofs.
@@ -16,7 +18,62 @@ var countPremisePatterns = map[string]*regexp.Regexp{
}
var directRelationPattern = regexp.MustCompile(`(?:第一期|第二期|第1期|第2期|一期|二期)[^。;;]*?(?:包含|包括|完成|交付)[0-9]+项|其中[0-9]+项依赖(?:外部)?接口|(?:会议决定|已批准|已经批准|审批通过)`)
var consequentialClaimPattern = regexp.MustCompile(`[0-90-9]|[一二三四五六七八九十]+(?:项|期|万元)|已决定|已经决定|已批准|已经批准|已获批|批复同意|已通过审批|审批通过|包含|包括|属于|剩余|第一期|第二期`)
var sourceContextRiskPattern = regexp.MustCompile(`[0-90-9]|[一二三四五六七八九十]+(?:项|期|万元)|已决定|已经决定|已批准|已经批准|已获批|批复同意|已通过审批|审批通过|包含|包括|属于|剩余|第一期|第二期`)
var consequentialClaimPattern = regexp.MustCompile(sourceContextRiskPattern.String() + `|接口|依赖|批次|批|轮|阶段|分期|归属|后续|全部|全数|全放|均|项目|范围|预算|审批|决定|安排`)
var cautiousAnalysisPattern = regexp.MustCompile(`存在差异|意见不同|不一致|需核对|需要核对|待核对|待确认|请确认|未明确|尚未给出|建议|可能`)
var safeStatusPrefix = regexp.MustCompile(`^(?:核对|继续核对|整理|继续整理|汇总|待|仍需|查看|确认|补齐|检查|阅读|梳理|形成)`)
var negatedPremisePattern = regexp.MustCompile(`并非|不是|不等于|不代表|尚未|并未|没有|未|否认|不得|不能`)
var explicitScopePattern = regexp.MustCompile(`(?:^|[。;;,,[:space:]])([\p{Han}A-Za-z0-9]{1,16})项目`)
// sourceClause restores the visible sentence around an exact short quote. A
// duplicate or oversized sentence is ambiguous and cannot prove a relation.
func sourceClause(sources materialSourceRegistry, quote core.TaskSourceQuote) (string, bool) {
src, ok := sources[quote.SourceID]
if !ok {
return "", false
}
provided := src.Provided
at := strings.Index(provided, quote.Quote)
if at < 0 || strings.Contains(provided[at+len(quote.Quote):], quote.Quote) {
return "", false
}
start := 0
for offset, r := range provided[:at] {
if strings.ContainsRune("。;;!??!\n", r) {
start = offset + utf8.RuneLen(r)
}
}
end := at + len(quote.Quote)
for end > at {
r, size := utf8.DecodeLastRuneInString(provided[at:end])
if !strings.ContainsRune("。;;!??!\n", r) {
break
}
end -= size
}
if end == at+len(quote.Quote) {
end = len(provided)
for offset, r := range provided[at+len(quote.Quote):] {
if strings.ContainsRune("。;;!??!\n", r) {
end = at + len(quote.Quote) + offset
break
}
}
}
clause := strings.TrimSpace(provided[start:end])
return clause, clause != "" && utf8.RuneCountInString(clause) <= 400
}
func explicitScope(clause string) (string, bool) {
matches := explicitScopePattern.FindAllStringSubmatch(clause, -1)
if len(matches) > 1 {
return "", false
}
if len(matches) == 1 {
return matches[0][1], true
}
return "", true
}
func parseCountPremise(quote, role string, value int) bool {
pattern := countPremisePatterns[role]
@@ -48,13 +105,18 @@ func evidencePremises(f core.WorkFindingCandidate) map[string]core.WorkEvidenceP
return byRole
}
func verifiedEvidenceCount(f core.WorkFindingCandidate, byRole map[string]core.WorkEvidencePremise, role string) (int, bool) {
func verifiedEvidenceCount(f core.WorkFindingCandidate, byRole map[string]core.WorkEvidencePremise, role string, sources materialSourceRegistry) (int, string, bool) {
premise, ok := byRole[role]
if !ok || premise.Value == nil || premise.SourceIndex < 0 || premise.SourceIndex >= len(f.Sources) {
return 0, false
return 0, "", false
}
clause, ok := sourceClause(sources, f.Sources[premise.SourceIndex])
if !ok || negatedPremisePattern.MatchString(clause) {
return 0, "", false
}
scope, scopeOK := explicitScope(clause)
value := *premise.Value
return value, parseCountPremise(f.Sources[premise.SourceIndex].Quote, role, value)
return value, scope, scopeOK && parseCountPremise(clause, role, value)
}
// normalizeMaterialEvidence only transforms a candidate. The caller remains
@@ -73,7 +135,19 @@ func normalizeMaterialEvidence(candidate core.WorkCheckpointCandidate, sources m
}
}
if f.Evidence == nil {
if f.Kind != "material_record" && consequentialClaimPattern.MatchString(f.Text+f.Impact+f.Assumptions) {
if f.Kind == "material_record" {
for _, quote := range f.Sources {
if !sourceContextRiskPattern.MatchString(quote.Quote) {
continue
}
clause, ok := sourceClause(sources, quote)
if !ok || strings.TrimRight(strings.TrimSpace(quote.Quote), "。;;!??!") != clause {
normalized.Findings[i] = pendingEvidenceFinding(f)
notes = append(notes, f.FindingKey+":短引缺少原句语境,已转待确认")
break
}
}
} else if consequentialClaimPattern.MatchString(f.Text+f.Impact+f.Assumptions) || !cautiousAnalysisPattern.MatchString(f.Text) {
normalized.Findings[i] = pendingEvidenceFinding(f)
notes = append(notes, f.FindingKey+":无可核对依据,已转待确认")
}
@@ -82,7 +156,8 @@ func normalizeMaterialEvidence(candidate core.WorkCheckpointCandidate, sources m
if f.Evidence.Mode == "direct_relation" {
premise := f.Evidence.Premises[0]
quote := f.Sources[premise.SourceIndex].Quote
if f.Kind == "material_record" && directRelationPattern.MatchString(quote) {
clause, ok := sourceClause(sources, f.Sources[premise.SourceIndex])
if f.Kind == "material_record" && ok && strings.TrimRight(strings.TrimSpace(quote), "。;;!??!") == clause && directRelationPattern.MatchString(clause) {
f.Text, f.Impact, f.Assumptions = quote, "", ""
f.Evidence = nil
normalized.Findings[i] = f
@@ -93,9 +168,9 @@ func normalizeMaterialEvidence(candidate core.WorkCheckpointCandidate, sources m
continue
}
byRole := evidencePremises(f)
total, totalOK := verifiedEvidenceCount(f, byRole, "total")
phase, phaseOK := verifiedEvidenceCount(f, byRole, "phase")
if !totalOK || !phaseOK || phase > total {
total, totalScope, totalOK := verifiedEvidenceCount(f, byRole, "total", sources)
phase, phaseScope, phaseOK := verifiedEvidenceCount(f, byRole, "phase", sources)
if !totalOK || !phaseOK || phase > total || totalScope != phaseScope {
normalized.Findings[i] = pendingEvidenceFinding(f)
notes = append(notes, f.FindingKey+":数量前提未能从短引核对,已转待确认")
continue
@@ -108,15 +183,15 @@ func normalizeMaterialEvidence(candidate core.WorkCheckpointCandidate, sources m
continue
}
f.Kind = "analysis"
f.Text = fmt.Sprintf("总量%d项减去第一期%d项,差额为%d项(%d−%d=%d,推算)", total, phase, remaining, total, phase, remaining)
f.Text = fmt.Sprintf("若两处数字指同一需求范围,总量%d项减去第一期%d项,差额为%d项(%d−%d=%d,推算)", total, phase, remaining, total, phase, remaining)
f.Impact = "差额不等于已确认的第二期清单或接口依赖归属"
f.Assumptions = "只按所引总量与第一期数量计算;具体分期归属仍待核对"
f.Evidence = nil
normalized.Findings[i] = f
continue
}
dependent, dependentOK := verifiedEvidenceCount(f, byRole, "dependent")
if !dependentOK || dependent > total {
dependent, dependentScope, dependentOK := verifiedEvidenceCount(f, byRole, "dependent", sources)
if !dependentOK || dependent > total || dependentScope != totalScope {
normalized.Findings[i] = pendingEvidenceFinding(f)
notes = append(notes, f.FindingKey+":依赖数量未能从短引核对,已转待确认")
continue
@@ -134,11 +209,14 @@ func normalizeMaterialEvidence(candidate core.WorkCheckpointCandidate, sources m
f.Text = fmt.Sprintf("%d项接口依赖需求中,属于第一期之外的数量可能为%d–%d项;材料未说明具体归属,请核对", dependent, low, high)
f.Impact, f.Assumptions = "", ""
notes = append(notes, f.FindingKey+":集合归属不唯一,已转待确认")
} else {
} else if totalScope != "" || (byRole["total"].SourceIndex == byRole["phase"].SourceIndex && byRole["phase"].SourceIndex == byRole["dependent"].SourceIndex) {
f.Kind = "analysis"
f.Text = fmt.Sprintf("按总量%d项、第一期%d项和接口依赖%d项计算,第一期之外的接口依赖为%d项(推算)", total, phase, dependent, low)
f.Impact = "该数量不代表已确认的第二期清单"
f.Assumptions = "仅在三项所引计数指向同一需求全集时成立"
} else {
f = pendingEvidenceFinding(f)
notes = append(notes, f.FindingKey+":缺少共同范围依据,已转待确认")
}
f.Evidence = nil
normalized.Findings[i] = f
+76 -1
View File
@@ -83,7 +83,7 @@ func TestMaterialEvidenceDifferenceUsesOnlyVerifiedCounts(t *testing.T) {
}
func TestMaterialEvidenceDirectRelationRequiresLiteralQuote(t *testing.T) {
docs := []core.ParsedDocument{{Meta: core.DocMeta{Filename: "决定.txt", Origin: &core.DocumentOrigin{ArtifactID: "direct", ContentHash: "hash-direct", ParserRevision: "txt.v1"}}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "第二期包含3项依赖外部接口的需求。"}}}}
docs := []core.ParsedDocument{{Meta: core.DocMeta{Filename: "决定.txt", Origin: &core.DocumentOrigin{ArtifactID: "direct", ContentHash: "hash-direct", ParserRevision: "txt.v1"}}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "第二期包含3项依赖外部接口的需求。接口负责人仍待确认。"}}}}
sources := materialSources(docs, 48000)
quote := evidenceQuote(t, sources, "direct", "第二期包含3项依赖外部接口的需求")
candidate := core.WorkCheckpointCandidate{CheckpointID: "direct", PhaseID: "crosscheck", Findings: []core.WorkFindingCandidate{{FindingKey: "relation", Text: "模型概括", Kind: "material_record", Sources: []core.TaskSourceQuote{quote}, Evidence: &core.WorkFindingEvidence{Mode: "direct_relation", Premises: []core.WorkEvidencePremise{{Role: "direct_relation", SourceIndex: 0}}}}}}
@@ -91,6 +91,11 @@ func TestMaterialEvidenceDirectRelationRequiresLiteralQuote(t *testing.T) {
if err != nil || normalized.Findings[0].Kind != "material_record" || normalized.Findings[0].Text != quote.Quote {
t.Fatalf("explicit relation was lost: %+v %v", normalized, err)
}
candidate.Findings[0].Sources[0].Quote = quote.Quote + "。"
normalized, _, err = normalizeMaterialEvidence(candidate, sources)
if err != nil || normalized.Findings[0].Kind != "material_record" {
t.Fatalf("complete sentence with punctuation was lost: %+v %v", normalized, err)
}
candidate.Findings[0].Sources[0].Quote = "第二期"
normalized, _, err = normalizeMaterialEvidence(candidate, sources)
if err != nil || normalized.Findings[0].Kind != "needs_user" {
@@ -194,3 +199,73 @@ func TestMaterialEvidenceRejectsUnprovidedOrForeignSource(t *testing.T) {
}
}
}
func TestMaterialEvidenceDoesNotDropNegationOutsideShortQuote(t *testing.T) {
docs := []core.ParsedDocument{
{Meta: core.DocMeta{Filename: "评审.txt", Origin: &core.DocumentOrigin{ArtifactID: "review", ContentHash: "review-hash", ParserRevision: "txt.v1"}}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "评审认为不等于已批准增加经费。"}}},
{Meta: core.DocMeta{Filename: "需求.txt", Origin: &core.DocumentOrigin{ArtifactID: "req", ContentHash: "req-hash", ParserRevision: "txt.v1"}}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "并非总12项需求。"}}},
{Meta: core.DocMeta{Filename: "会议.txt", Origin: &core.DocumentOrigin{ArtifactID: "meeting", ContentHash: "meeting-hash", ParserRevision: "txt.v1"}}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "第一期先完成8项核心需求。"}}},
}
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", "已批准")}}}}
normalized, _, err := normalizeMaterialEvidence(approval, sources)
if err != nil || normalized.Findings[0].Kind != "needs_user" {
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}}}
normalized, _, err = normalizeMaterialEvidence(approval, sources)
if err != nil || normalized.Findings[0].Kind != "needs_user" {
t.Fatalf("negated approval was promoted as a direct relation: %+v %v", normalized, err)
}
n := func(v int) *int { return &v }
counts := core.WorkCheckpointCandidate{CheckpointID: "count-short", PhaseID: "crosscheck", Findings: []core.WorkFindingCandidate{{FindingKey: "count", Text: "差额4项", Kind: "analysis", Assumptions: "据材料推算", Sources: []core.TaskSourceQuote{evidenceQuote(t, sources, "req", "总12项需求"), evidenceQuote(t, sources, "meeting", "第一期先完成8项核心需求")}, Evidence: &core.WorkFindingEvidence{Mode: "difference", Premises: []core.WorkEvidencePremise{{Role: "total", SourceIndex: 0, Value: n(12)}, {Role: "phase", SourceIndex: 1, Value: n(8)}}, Result: n(4)}}}}
normalized, _, err = normalizeMaterialEvidence(counts, sources)
if err != nil || normalized.Findings[0].Kind != "needs_user" {
t.Fatalf("negated total was used in arithmetic: %+v %v", normalized, err)
}
}
func TestMaterialEvidenceDifferentExplicitScopesBecomePending(t *testing.T) {
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: "B.txt", Origin: &core.DocumentOrigin{ArtifactID: "meeting", ContentHash: "meeting-hash", ParserRevision: "txt.v1"}}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "B项目第一期8项需求。"}}},
}
sources := materialSources(docs, 48000)
n := func(v int) *int { return &v }
candidate := core.WorkCheckpointCandidate{CheckpointID: "different-projects", PhaseID: "crosscheck", Findings: []core.WorkFindingCandidate{{FindingKey: "gap", Text: "差额4项", Kind: "analysis", Assumptions: "模型推断", Sources: []core.TaskSourceQuote{evidenceQuote(t, sources, "req", "总12项需求"), evidenceQuote(t, sources, "meeting", "第一期8项需求")}, Evidence: &core.WorkFindingEvidence{Mode: "difference", Premises: []core.WorkEvidencePremise{{Role: "total", SourceIndex: 0, Value: n(12)}, {Role: "phase", SourceIndex: 1, Value: n(8)}}, Result: n(4)}}}}
normalized, _, err := normalizeMaterialEvidence(candidate, sources)
if err != nil || normalized.Findings[0].Kind != "needs_user" {
t.Fatalf("different project counts were subtracted: %+v %v", normalized, err)
}
}
func TestMaterialEvidenceUnstructuredBatchPlacementBecomesPending(t *testing.T) {
sources := evidenceTestSources()
candidate := evidenceCandidate(t, sources, "difference")
candidate.Findings[0].Evidence = nil
candidate.Findings[0].Text = "接口依赖工作均安排在后续批次"
candidate.Findings[0].Impact = "应直接进入下批联调"
candidate.Findings[0].Assumptions = "模型推断"
normalized, _, err := normalizeMaterialEvidence(candidate, sources)
if err != nil || normalized.Findings[0].Kind != "needs_user" || strings.Contains(normalized.Findings[0].Text, "均安排") {
t.Fatalf("unstructured placement escaped: %+v %v", normalized, err)
}
}
func TestMaterialEvidenceOnlyKeepsCautiousOrdinaryAnalysisWithoutProof(t *testing.T) {
sources := evidenceTestSources()
candidate := evidenceCandidate(t, sources, "difference")
candidate.Findings[0].Evidence = nil
candidate.Findings[0].Assumptions = "基于所引材料"
candidate.Findings[0].Text = "两份材料存在差异,需核对"
candidate.Findings[0].Impact = ""
normalized, _, err := normalizeMaterialEvidence(candidate, sources)
if err != nil || normalized.Findings[0].Kind != "analysis" {
t.Fatalf("cautious ordinary analysis was lost: %+v %v", normalized, err)
}
candidate.Findings[0].Text = "外部依赖工作全放到下一轮"
normalized, _, err = normalizeMaterialEvidence(candidate, sources)
if err != nil || normalized.Findings[0].Kind != "needs_user" {
t.Fatalf("unproven paraphrased placement escaped: %+v %v", normalized, err)
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ func (c *runCtx) PublishWorkCheckpoint(candidate core.WorkCheckpointCandidate) (
value *string
text string
}{{&normalized.Focus, "核对材料中可证实的要点"}, {&normalized.Remaining, "数字或归属仍待核对"}, {&normalized.NextStep, "核对原文后继续整理"}} {
if consequentialClaimPattern.MatchString(*field.value) {
if consequentialClaimPattern.MatchString(*field.value) || (*field.value != "" && !safeStatusPrefix.MatchString(*field.value)) {
*field.value = field.text
statusNormalized = true
}
@@ -147,3 +147,39 @@ func TestWorkCheckpointEvidenceSourceMustBelongToTaskInputRevision(t *testing.T)
})
}
}
func TestWorkCheckpointEvidenceStatusCannotAssertUnstructuredPlacement(t *testing.T) {
c := checkpointEnvironment(t)
cp := validCheckpoint(c, "unsafe-status")
cp.Focus = "接口依赖工作均安排在后续批次"
cp.Remaining = "该批次已经确定"
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)
}
for _, value := range []string{card.Task.Focus, card.Task.Remaining, card.Task.NextStep} {
if strings.Contains(value, "均安排") || strings.Contains(value, "已经确定") || strings.Contains(value, "全部转入") {
t.Fatalf("unsupported placement escaped into status: %q", value)
}
}
}
func TestWorkCheckpointEvidenceUnknownStatusAssertionIsNeutralized(t *testing.T) {
c := checkpointEnvironment(t)
cp := validCheckpoint(c, "unknown-status")
cp.Focus = "结果全数转到下一轮"
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 strings.Contains(card.Task.Focus, "全数转到下一轮") {
t.Fatal("unknown status assertion escaped", card.Task.Focus)
}
}