diff --git a/internal/runmgr/material_evidence.go b/internal/runmgr/material_evidence.go index 10024c6..2f70c9a 100644 --- a/internal/runmgr/material_evidence.go +++ b/internal/runmgr/material_evidence.go @@ -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 diff --git a/internal/runmgr/material_evidence_test.go b/internal/runmgr/material_evidence_test.go index 633c4fe..c239dad 100644 --- a/internal/runmgr/material_evidence_test.go +++ b/internal/runmgr/material_evidence_test.go @@ -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) + } +} diff --git a/internal/runmgr/task_checkpoint.go b/internal/runmgr/task_checkpoint.go index 9a4dc7e..2fb41c6 100644 --- a/internal/runmgr/task_checkpoint.go +++ b/internal/runmgr/task_checkpoint.go @@ -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 } diff --git a/internal/runmgr/task_checkpoint_evidence_test.go b/internal/runmgr/task_checkpoint_evidence_test.go index a06f05d..1d10c8f 100644 --- a/internal/runmgr/task_checkpoint_evidence_test.go +++ b/internal/runmgr/task_checkpoint_evidence_test.go @@ -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) + } +}