Files
Agent-System-Backend/internal/runmgr/material_evidence.go
T

287 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package runmgr
import (
core "agent-system-core"
"fmt"
"regexp"
"strconv"
"strings"
"unicode/utf8"
)
// These are deliberately narrow source grammars, not natural-language proofs.
// A missed expression produces a pending question instead of a model claim.
var countPremisePatterns = map[string]*regexp.Regexp{
"total": regexp.MustCompile(`^(?:交付包含|总共|共计|共有|总计|总)([0-9]+)项(?:核心需求|需求|功能|任务)?$`),
"phase": regexp.MustCompile(`^(?:第一期|第1期|一期)(?:先)?(?:完成|交付|安排)?([0-9]+)项(?:核心需求|需求|功能|任务)?$`),
"dependent": regexp.MustCompile(`^其中([0-9]+)项依赖(?:外部)?接口(?:的需求)?$`),
}
var directRelationPattern = regexp.MustCompile(`(?:第一期|第二期|第1期|第2期|一期|二期)[^。;;]*?(?:包含|包括|完成|交付)[0-9]+项|其中[0-9]+项依赖(?:外部)?接口|(?:会议决定|已批准|已经批准|审批通过)`)
var cautiousOrdinaryPatterns = []*regexp.Regexp{
regexp.MustCompile(`^(?:两份)?(?:材料|来源|记录|文档|意见)(?:之间|口径)?(?:存在差异|意见不同|不一致)(?:,(?:需核对|需要核对|待核对))?$`),
regexp.MustCompile(`^(?:验收条件|性能阈值|责任人|接口负责人)(?:尚未明确|未明确|待确认|待核对)$`),
regexp.MustCompile(`^(?:建议|请)(?:进一步)?核对(?:材料|来源|验收条件|日期意见|预算口径)$`),
}
var negatedPremisePattern = regexp.MustCompile(`并非|不是|不等于|不代表|尚未|并未|没有|未|否认|否定|不得|不能|已取消|取消|废止|撤销|作废|失效|不再|调整|改为|改成|变成|变为|现为|现已|更新为|修订|更正|最新|旧版|原先|增加|新增|减少`)
var numericTokenPattern = regexp.MustCompile(`[0-90-9一二三四五六七八九十百千万两]`)
var allowedNumericContextPattern = regexp.MustCompile(`^(?:需求规定总预算为[0-9]+万元|会议决定分两期交付)$`)
var explicitScopePattern = regexp.MustCompile(`(?:^|[。;;,,[:space:]])(?:([A-Za-z0-9甲乙丙丁]{1,8})项目|项目([A-Za-z0-9甲乙丙丁]{1,8}))`)
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
// 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)
closed := false
for end > at {
r, size := utf8.DecodeLastRuneInString(provided[at:end])
if !strings.ContainsRune("。;;!??!\n", r) {
break
}
end -= size
closed = true
}
if !closed {
end = len(provided)
for offset, r := range provided[at+len(quote.Quote):] {
if strings.ContainsRune("。;;!??!\n", r) {
end = at + len(quote.Quote) + offset
closed = true
break
}
}
}
if !closed && strings.TrimSpace(src.Provided) != strings.TrimSpace(src.Text) {
return "", false
}
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) > 0 {
scope := ""
for _, match := range matches {
name := match[1]
if name == "" {
name = match[2]
}
if scope != "" && scope != name {
return "", false
}
scope = name
}
return scope, true
}
if strings.Contains(clause, "项目") {
return "", false
}
return "", true
}
func parseCountPremise(quote, role string, value int) bool {
pattern := countPremisePatterns[role]
if pattern == nil || value < 0 {
return false
}
matched := -1
for _, segment := range strings.FieldsFunc(quote, func(r rune) bool { return strings.ContainsRune(",,。;;!??!\n", r) }) {
segment = strings.TrimSpace(segment)
segment = scopePrefixPattern.ReplaceAllString(segment, "")
if numericTokenPattern.MatchString(segment) {
recognized := false
for _, supported := range countPremisePatterns {
if supported.MatchString(segment) {
recognized = true
break
}
}
if !recognized && !allowedNumericContextPattern.MatchString(segment) {
return false
}
}
parts := pattern.FindStringSubmatch(segment)
if len(parts) != 2 {
continue
}
count, err := strconv.Atoi(parts[1])
if err != nil || matched >= 0 {
return false
}
matched = count
}
return matched == value
}
func pendingEvidenceFinding(f core.WorkFindingCandidate) core.WorkFindingCandidate {
f.Kind = "needs_user"
f.Text = "所引材料未能证明这项数字或归属判断,请核对原文"
f.Impact = ""
f.Assumptions = ""
f.Evidence = nil
return f
}
func cautiousOrdinaryText(text string) bool {
text = strings.TrimSpace(text)
if utf8.RuneCountInString(text) > 80 {
return false
}
for _, pattern := range cautiousOrdinaryPatterns {
if pattern.MatchString(text) {
return true
}
}
return false
}
func evidencePremises(f core.WorkFindingCandidate) map[string]core.WorkEvidencePremise {
byRole := make(map[string]core.WorkEvidencePremise, len(f.Evidence.Premises))
for _, premise := range f.Evidence.Premises {
byRole[premise.Role] = premise
}
return byRole
}
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
}
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, scope, scopeOK && parseCountPremise(clause, role, value)
}
// normalizeMaterialEvidence only transforms a candidate. The caller remains
// responsible for checking actor, artifact version, visibility and CAS.
func normalizeMaterialEvidence(candidate core.WorkCheckpointCandidate, sources materialSourceRegistry) (core.WorkCheckpointCandidate, []string, error) {
if err := core.ValidateWorkCheckpoint(candidate); err != nil {
return core.WorkCheckpointCandidate{}, nil, err
}
normalized := candidate
normalized.Findings = append([]core.WorkFindingCandidate(nil), candidate.Findings...)
var notes []string
for i, f := range candidate.Findings {
for _, quote := range f.Sources {
if _, err := sources.validate(quote); err != nil {
return core.WorkCheckpointCandidate{}, nil, err
}
}
if f.Evidence == nil {
if f.Kind == "material_record" {
for j, quote := range f.Sources {
clause, ok := sourceClause(sources, quote)
if !ok {
normalized.Findings[i] = pendingEvidenceFinding(f)
notes = append(notes, f.FindingKey+":原句语境不唯一,已转待确认")
break
}
f.Sources = append([]core.TaskSourceQuote(nil), f.Sources...)
f.Sources[j].Quote = clause
normalized.Findings[i] = f
}
} else if f.Kind == "needs_user" || !cautiousOrdinaryText(f.Text) {
normalized.Findings[i] = pendingEvidenceFinding(f)
notes = append(notes, f.FindingKey+":无可核对依据,已转待确认")
} else {
f.Impact = ""
f.Assumptions = "此项仅为待核对线索,需对照所引原文"
normalized.Findings[i] = f
}
continue
}
if f.Evidence.Mode == "direct_relation" {
premise := f.Evidence.Premises[0]
quote := f.Sources[premise.SourceIndex].Quote
clause, ok := sourceClause(sources, f.Sources[premise.SourceIndex])
if f.Kind == "material_record" && len(f.Sources) == 1 && premise.SourceIndex == 0 && ok && strings.TrimRight(strings.TrimSpace(quote), "。;;!??!") == clause && directRelationPattern.MatchString(clause) {
f.Text, f.Impact, f.Assumptions = quote, "", ""
f.Evidence = nil
normalized.Findings[i] = f
} else {
normalized.Findings[i] = pendingEvidenceFinding(f)
notes = append(notes, f.FindingKey+":原句未明确关系,已转待确认")
}
continue
}
byRole := evidencePremises(f)
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
}
remaining := total - phase
if f.Evidence.Mode == "difference" {
if f.Evidence.Result == nil || *f.Evidence.Result != remaining {
normalized.Findings[i] = pendingEvidenceFinding(f)
notes = append(notes, f.FindingKey+":算式不符,已转待确认")
continue
}
f.Kind = "analysis"
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, dependentScope, dependentOK := verifiedEvidenceCount(f, byRole, "dependent", sources)
if !dependentOK || dependent > total || dependentScope != totalScope {
normalized.Findings[i] = pendingEvidenceFinding(f)
notes = append(notes, f.FindingKey+":依赖数量未能从短引核对,已转待确认")
continue
}
low := dependent - phase
if low < 0 {
low = 0
}
high := dependent
if remaining < high {
high = remaining
}
if low != high {
f.Kind = "needs_user"
f.Text = fmt.Sprintf("%d项接口依赖需求中,属于第一期之外的数量可能为%d–%d项;材料未说明具体归属,请核对", dependent, low, high)
f.Impact, f.Assumptions = "", ""
notes = append(notes, f.FindingKey+":集合归属不唯一,已转待确认")
} else if totalScope != "" {
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
}
// The wire candidate was bounded above. Host-generated text is internal and
// must not cause a second 16KiB tool-argument rejection after normalization.
return normalized, notes, nil
}