package runtime import ( "encoding/json" "fmt" "log" "regexp" "strings" core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/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 }