287 lines
11 KiB
Go
287 lines
11 KiB
Go
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
|
||
}
|