Files
Agent-System-Backend/internal/runtime/factcheck.go
T
weichen 5ebd1bb525
ci / vet-and-test (pull_request) Successful in 56s
Log R4 reminder injection for observable evidence
Why: Reminders are attached to the request copy and never reach the
session journal, so a transcript grep proves nothing about whether the
R4 trigger fired - the first verification attempt was invalid evidence.
The dual-speed nudge already logs for exactly this reason.

Evidence: go test ./internal/runtime green; the follow-up live probe
must show the [runtime] r4 escalation reminder injected line in the
instance log.

Impact: Two log lines, nothing else.

Refs: Dev task feature/agent-e4-escalation-ledger (third fix-up).
2026-10-11 01:22:49 +00:00

378 lines
14 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 (
"encoding/json"
"fmt"
"log"
"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 账本的证据类工具(检索面)。
// 内核工作区 read_file 不在此列:它也读模型自己的工作文件——给自身文件
// 的读结果尾注【src_N】会污染 read→edit→build 的编辑循环(实测 E4 首轮
// eval:模型视尾注为文件被外改动,反复重读重试,工具失败 10→24、调用
// 63→166)。仅当读取 inputs/ 材料时才登记(isEvidenceRead)。
var evidenceTools = map[string]bool{
"knowledge_search": true,
"web_search": true,
"tavily_search": true,
"fs_read_file": true,
}
// isEvidenceRead 判定一次工作区读是否为「材料读」:仅 inputs/(上传材料
// 落点,环境段列出)之下的 read_file 计入账本;grep 无法区分材料与自产
// 文件,一律不计。
func isEvidenceRead(name string, args json.RawMessage) bool {
if name != "read_file" {
return false
}
var obj map[string]string
if err := json.Unmarshal(args, &obj); err != nil {
return false
}
p := strings.TrimSpace(obj["path"])
p = strings.TrimPrefix(p, "./")
return strings.HasPrefix(p, "inputs/") || strings.HasPrefix(p, "/inputs/")
}
// R4 换路提醒的滑动窗口判据:最近 escalationWindow 次工具结果里出现
// escalationFailureThreshold 次失败即触发(每 run 一次)。用窗口而不是
// 「连续 N 次」——实测失败多与成功交错(20 失败散布在 108 次调用里,
// 连续计数从未达 2,首轮 eval 生产路径零触发),滑动窗口才抓得住
// 「整体在原地打转」的形态。
const (
escalationWindow = 6
escalationFailureThreshold = 3
// 检索空命中阈值独立取 2:检索单次成本高,两次落空即是换维度信号。
escalationZeroHitThreshold = 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 := countFails(st.recentOutcomes), st.failWindowNoted
zero, zeroNoted := st.zeroHitSearches, st.zeroHitNoted
if fails >= escalationFailureThreshold && !failNoted {
st.failWindowNoted = true
}
if zero >= escalationZeroHitThreshold && !zeroNoted {
st.zeroHitNoted = true
}
st.toolMu.Unlock()
if fails >= escalationFailureThreshold && !failNoted {
log.Printf("[runtime] r4 escalation reminder injected (tool failures %d in last %d outcomes)", fails, escalationWindow)
out = append(out, fmt.Sprintf("工具最近集中失败(近 %d 次调用中多次报错):停止原样重试。逐条重读错误信息,改参数、换工具或换路径再试;确认无法绕过时,如实在答复里说明卡点与已尝试的手段,不要编造结果。", fails))
}
if zero >= escalationZeroHitThreshold && !zeroNoted {
log.Printf("[runtime] r4 escalation reminder injected (zero-hit searches %d)", zero)
out = append(out, "检索已连续多次无结果:换关键词/换检索维度再试一次;仍无结果就如实写「材料中无此信息」,不要凭印象补数字。")
}
return out
}
// noteFailureOutcome 供 onEvent 在工具结束时记录成败(并行下事件乱序,
// 以 runToolCtx 已写入槽位的 status 为准)。维护最近 escalationWindow 次
// 结果的滑动窗口。
func (st *directRun) noteFailureOutcome(status string) {
st.toolMu.Lock()
defer st.toolMu.Unlock()
st.recentOutcomes = append(st.recentOutcomes, status == "error")
if len(st.recentOutcomes) > escalationWindow {
st.recentOutcomes = st.recentOutcomes[len(st.recentOutcomes)-escalationWindow:]
}
}
func countFails(outcomes []bool) int {
n := 0
for _, failed := range outcomes {
if failed {
n++
}
}
return n
}