Files
Agent-System-Backend/internal/runtime/factcheck.go
T
weichen 21b8461525
ci / vet-and-test (pull_request) Successful in 43s
Add R4 escalation reminders and the structured source-id evidence ledger (E4)
Why: The plan's E4 remainder under the MiniMax-only constraint: the
harness must detect the model going in circles and inject a
change-of-strategy instruction (R4 - the model cannot be trusted to
notice its own repeat-fail loop), and number sourcing needs addressable
evidence: tool results carry [src_N|tool] tags so "found in materials"
becomes "found in source src_N" for both the model and the fact-check
notice.

Why: Failure counting lives in one place (onEvent end, keyed by the
slot status) because tool execution is parallel; the ledger and tag
append are serialized by the same mutex, and escalation reminders fire
once per condition streak - following the dual-speed nudge finding that
MiniMax ignores vague reminders but follows concrete instructions.

Evidence: go test ./... green; new anchors TestEscalationOnConsecutive
ToolFailures, TestEscalationOnZeroHitSearches, TestEvidenceLedger
TagsSource, TestEvidenceLedgerSkipsNonEvidenceTools. Live before/
after comparison runs on the Dev side per ruling 6.

Impact: knowledge/web/tavily searches, fs_read_file and the workspace
read_file/grep results now end with a source tag and enter the run
ledger (2k-char head); two consecutive tool failures or two empty
search results inject a concrete change-of-strategy reminder;
base.deliverables v7 and agent-sub-research v3 explain the tags;
fact-check notice points at src results. Non-evidence tools and
failures are unchanged.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md (E4,
R4); Dev task feature/agent-e4-escalation-ledger.
2026-10-10 17:41:38 +00:00

338 lines
12 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 runtime
import (
"fmt"
"regexp"
"strings"
core "agent-system-core"
"agent-system-backend/internal/quality"
)
// factCheckMaxRounds 是 E3 的事实核查修正轮上限:两轮(第二轮为「最后一次
// 拦截」+标注出路),之后放行并记检查行。
const factCheckMaxRounds = 2
// P5:事实一致性核查。从交付物正文抽取可机械定位的「硬事实 token」——
// 百分比、日期、大额数字——并回溯本轮材料(附件解析文本 + 工具输出证据)。
// 找不到出处的 token 以「未核实」反馈给模型修一轮;模型可以选择删除、
// 改成材料里的数值、或明确标注为推测/目标值。人名/称谓不做机械比对
// (材料里多为"说话人N",机械比对必然误报)。
var (
factPctRe = regexp.MustCompile(`\d+(?:\.\d+)?%`)
factDateRe = regexp.MustCompile(`\d{1,2}月\d{1,2}日|\d{4}年|\d{4}-\d{1,2}-\d{1,2}`)
factBigNumRe = regexp.MustCompile(`\d[\d,]{4,}`)
)
// ExtractFacts 抽取交付物文本里的硬事实 token(去重保序)。
func ExtractFacts(text string) []string {
var out []string
seen := map[string]bool{}
add := func(tok string) {
tok = strings.TrimSpace(tok)
if tok == "" || seen[tok] {
return
}
seen[tok] = true
out = append(out, tok)
}
for _, m := range factPctRe.FindAllString(text, -1) {
add(m)
}
for _, m := range factDateRe.FindAllString(text, -1) {
add(m)
}
for _, m := range factBigNumRe.FindAllString(text, -1) {
add(m)
}
return out
}
// UnverifiedFacts 返回材料里找不到出处的 token。放行三类:豁免的通用口径词、
// 材料中能回溯的、以及 E3 标注感知——文中以「目标/预计/假设」等标注词显式
// 标记为规划量的数字不再是凭空事实,机械溯源放行(MarkedAsEstimate)。
func UnverifiedFacts(artifactText, materialText string, exempt map[string]bool) []string {
if strings.TrimSpace(artifactText) == "" || strings.TrimSpace(materialText) == "" {
return nil
}
var out []string
for _, tok := range ExtractFacts(artifactText) {
if exempt[tok] {
continue
}
if !strings.Contains(materialText, tok) && !strings.Contains(materialText, stripPunct(tok)) && !MarkedAsEstimate(artifactText, tok) {
out = append(out, tok)
}
}
return out
}
// factMarkingRe:数字邻域出现这些词即视为「已标注的规划量」。选取保守集合,
// 避免把「会议目标」之类的普通动词误当标注(需要与数字同现于邻域内)。
var factMarkingRe = regexp.MustCompile(`目标|假设|预计|预估|推测|估算|规划|拟定|建议值|上限|下限|阈值|基准值|参考值|暂定`)
// factClauseCut 是分句分隔符:标注判定以「句」为单位而不是固定窗口——
// 固定窗口会把相邻句的标注词错标给前一个数字(实测:「覆盖 3,500 用户,
// 目标 85%」里 3,500 被误放行)。
const factClauseCut = "。;!?\n,、;"
// MarkedAsEstimate 报告 token 在 text 中的任一出现是否与标注词同句
// (同句=两侧最近分隔符之间,括号不算分隔,覆盖「目标完成率 85%」「85%
// (预计)」)。供终轮核查与评测端共用同一口径。
func MarkedAsEstimate(text, tok string) bool {
from := 0
for {
i := strings.Index(text[from:], tok)
if i < 0 {
return false
}
i += from
lo := strings.LastIndexAny(text[max(0, i-64):i], factClauseCut) + max(0, i-64) + 1
if end := strings.IndexAny(text[i:], factClauseCut); end >= 0 {
clause := text[lo : i+end]
if factMarkingRe.MatchString(clause) {
return true
}
} else if factMarkingRe.MatchString(text[lo:]) {
return true
}
from = i + len(tok)
}
}
func stripPunct(s string) string {
return strings.NewReplacer(",", "", ",", "", " ", "").Replace(s)
}
// factCheckNotice 把未核实 token 组成终轮反馈(给模型的一轮修正机会)。
func factCheckNotice(tokens []string) string {
if len(tokens) == 0 {
return ""
}
var b strings.Builder
b.WriteString("交付物里以下数字/日期在本次材料中找不到出处:")
for i, tok := range tokens {
if i >= 12 {
b.WriteString(" 等")
break
}
if i > 0 {
b.WriteString("、")
}
b.WriteString(tok)
}
b.WriteString("。逐条二选一:改成材料或来源结果(检索/读取输出带【src_N|…】标记)里能找到的数值,或删除/明确标注为推测或目标值(如“目标 85%”)。不要保留无法回溯的裸数字。")
return b.String()
}
// materialCorpus 汇总本轮可见材料:附件解析文本 + 工具证据文本。
// 证据用完整 verifyText(尾部 600 字)而非 160 字摘要——足够定位数字。
func (st *directRun) materialCorpus() string {
var b strings.Builder
for _, doc := range st.rc.Attachments() {
b.WriteString(doc.PlainText())
b.WriteString("\n")
}
for _, ev := range st.evidence {
b.WriteString(ev.VerifyText)
b.WriteString("\n")
}
return b.String()
}
// deliverableText 拼接本轮交付工件的文本内容(UTF-8 文本直接读;docx
// 由 quality 的解析口径承担——这里只收集,不重复解析)。
func (st *directRun) deliverableText(refs []*artifactTextPair) string {
var b strings.Builder
for _, pair := range refs {
b.WriteString(pair.text)
b.WriteString("\n")
}
return b.String()
}
type artifactTextPair struct {
id string
name string
text string
}
// collectDeliverableText 经工件端口读取文本类交付物内容
// (docx 用 quality 同源的抽取口径;二进制工件跳过)。
func (st *directRun) collectDeliverableText(refs []*core.ArtifactRef) []artifactTextPair {
lookup := artifactLookupOf(st.rc)
var out []artifactTextPair
for _, ref := range refs {
if ref == nil || ref.ArtifactID == "" {
continue
}
name, content, ok := lookup(string(ref.ArtifactID))
if !ok || len(content) == 0 {
continue
}
text := deliverablePlainText(name, content)
if text != "" {
out = append(out, artifactTextPair{id: string(ref.ArtifactID), name: name, text: text})
}
}
return out
}
// deliverablePlainText 用 quality 的同源口径抽取工件文本。
func deliverablePlainText(name string, content []byte) string {
return quality.DeliverableText(name, content)
}
// concatArtifactText 拼接工件文本对。
func concatArtifactText(pairs []artifactTextPair) string {
var b strings.Builder
for _, p := range pairs {
b.WriteString(p.text)
b.WriteString("\n")
}
return b.String()
}
// factExemptions 豁免通用口径词(非事实性数字)。
func factExemptions() map[string]bool {
return map[string]bool{
"100%": true, "0%": true, // 常见修辞(100% 确定)与零值
}
}
// factCheckGate 决定本轮未核实 token 的处置(E3):
// - 第一轮:给修正通知并续跑;
// - 第二轮(仅当 token 集合相对上一轮有变化——模型确实改过但仍有残留):
// 「最后一次拦截」+ 标注出路;
// - token 集合与上一轮完全相同(模型没有理会)或轮数用尽:放行,
// 由调用方记入检查行如实呈现。续写预算是各终轮门禁共享的,
// 无变化的重复拦截只会挤占预算(实测会挤掉后续门禁的修正机会)。
func (st *directRun) factCheckGate(tokens []string) (notice string, cont bool) {
same := st.factCheckPrev != nil && len(tokens) == len(st.factCheckPrev)
if same {
for _, tok := range tokens {
if !st.factCheckPrev[tok] {
same = false
break
}
}
}
if st.factCheckRounds >= factCheckMaxRounds || same {
return "", false
}
st.factCheckRounds++
st.factCheckPrev = map[string]bool{}
for _, tok := range tokens {
st.factCheckPrev[tok] = true
}
notice = factCheckNotice(tokens)
if st.factCheckRounds > 1 {
notice = "最后一次拦截:" + notice + " 若该数字确属规划量而非事实,必须在文中用「目标 / 预计 / 假设」等词显式标注(如「目标 85%」);既无出处又不标注的数字不得保留。"
}
return notice, true
}
// ---- E4:证据账本与 R4 升级提醒 ----
// evidenceEntry 是账本的一条来源记录(正文截头保存;全文在转录与工具证据里)。
type evidenceEntry struct {
ID string
Tool string
Text string
}
// evidenceTools 登记进 source_id 账本的证据类工具(读检索面)。
var evidenceTools = map[string]bool{
"knowledge_search": true,
"web_search": true,
"tavily_search": true,
"fs_read_file": true,
"read_file": true, // 内核工作区读(直连路径实际使用的名字)
"grep": true,
}
// escalationFailureThreshold 是连续工具失败的换路提醒阈值(R4)。
const escalationFailureThreshold = 2
// tagSource 把一次成功的证据工具输出登记进账本,返回给模型看的来源标记
// (如 src_3|knowledge_search)。并行执行下由 toolMu 串行化计数与追加。
func (st *directRun) tagSource(tool, text string) (string, bool) {
st.toolMu.Lock()
defer st.toolMu.Unlock()
st.ledgerCount++
id := fmt.Sprintf("src_%d", st.ledgerCount)
head := []rune(text)
if len(head) > 2000 { // 账本只存索引性头部,全文在转录/证据里
head = head[:2000]
}
st.ledger = append(st.ledger, evidenceEntry{ID: id, Tool: tool, Text: string(head)})
return id + "|" + tool, true
}
// noteEvidenceCall 维护 R4 的检索空命中计数(成功但无实质内容的证据类
// 工具调用)。工具成败计数不在这里——并行下以 onEvent 终点的槽位状态为
// 单一事实源(noteFailureOutcome),避免两处重复计数。
func (st *directRun) noteEvidenceCall(name, status, output string) {
if status != "ok" {
return
}
st.toolMu.Lock()
defer st.toolMu.Unlock()
trimmed := strings.TrimSpace(output)
if evidenceTools[name] && (trimmed == "" || containsAny(trimmed, zeroHitMarkers)) {
st.zeroHitSearches++
} else if evidenceTools[name] {
st.zeroHitSearches = 0
}
}
// zeroHitMarkers 是检索结果「无命中」的常见表述(按工具输出原文判别)。
var zeroHitMarkers = []string{"未找到", "没有找到", "无结果", "无相关", "暂无匹配", "No results"}
func containsAny(s string, subs []string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
// escalationReminders 返回待注入的换路提醒并清标记(每类条件一次,
// 连续失败再次触发时会重新提醒——失败计数在成功后重置,streak 标记随重置)。
// 文案直接给动作:MiniMax 对泛泛提醒不敏感(实测),对具体指令照办。
func (st *directRun) escalationReminders() []string {
var out []string
st.toolMu.Lock()
fails, failNoted := st.consecToolFailures, st.failStreakNoted
zero, zeroNoted := st.zeroHitSearches, st.zeroHitNoted
if fails >= escalationFailureThreshold && !failNoted {
st.failStreakNoted = true
}
if zero >= escalationFailureThreshold && !zeroNoted {
st.zeroHitNoted = true
}
st.toolMu.Unlock()
if fails >= escalationFailureThreshold && !failNoted {
out = append(out, fmt.Sprintf("工具已连续失败 %d 次:停止原样重试。逐条重读最近一次错误信息,改参数、换工具或换路径再试;确认无法绕过时,如实在答复里说明卡点与已尝试的手段,不要编造结果。", fails))
}
if zero >= escalationFailureThreshold && !zeroNoted {
out = append(out, "检索已连续多次无结果:换关键词/换检索维度再试一次;仍无结果就如实写「材料中无此信息」,不要凭印象补数字。")
}
return out
}
// noteFailureOutcome 供 onEvent 在工具结束时同步成败计数(并行下事件乱序,
// 以 runToolCtx 已写入槽位的 status 为准)。
func (st *directRun) noteFailureOutcome(status string) {
st.toolMu.Lock()
defer st.toolMu.Unlock()
if status == "error" {
st.consecToolFailures++
} else {
st.consecToolFailures = 0
st.failStreakNoted = false // 新失败链可再触发提醒
}
}