diff --git a/eval/live/helpers_test.go b/eval/live/helpers_test.go index 437403e..b712e12 100644 --- a/eval/live/helpers_test.go +++ b/eval/live/helpers_test.go @@ -17,6 +17,8 @@ import ( "strings" "testing" "time" + + "agent-system-backend/internal/runtime" ) // upload 上传一个材料文件,返回 artifact id。 @@ -377,6 +379,18 @@ func unverifiedFacts(artifactText, materialText string) []string { return out } +// unmarkedFacts 从未核实清单里剔除已按「目标/预计/假设」标注的(与后端 +// runtime.MarkedAsEstimate 同一口径,标注词邻域 ±32 字符)。 +func unmarkedFacts(tokens []string, artifactText string) []string { + var out []string + for _, tok := range tokens { + if !runtime.MarkedAsEstimate(artifactText, tok) { + out = append(out, tok) + } + } + return out +} + // finalVsArtifact 终轮回复里声称的章节(「一、…」)必须能在工件里找到。 func finalVsArtifact(turn *turnResult, artifactText string) []string { if turn == nil || turn.FinalText == "" || strings.TrimSpace(artifactText) == "" { @@ -430,6 +444,9 @@ func renderMarkdown(rep *report) string { } if len(tk.UnverifiedFacts) > 0 { fmt.Fprintf(&b, "\n未核实事实(工件里出现、材料里找不到):%s\n", strings.Join(tk.UnverifiedFacts, "、")) + if len(tk.UnverifiedUnmarkedFacts) > 0 { + fmt.Fprintf(&b, "其中未标注(无出处且无目标/预计/假设标注,E3 闸门口径):%s\n", strings.Join(tk.UnverifiedUnmarkedFacts, "、")) + } } if len(tk.FinalVsArtifact) > 0 { fmt.Fprintf(&b, "\n终轮声称但工件不存在的章节:%s\n", strings.Join(tk.FinalVsArtifact, ";")) diff --git a/eval/live/live_test.go b/eval/live/live_test.go index f3acde1..73455ce 100644 --- a/eval/live/live_test.go +++ b/eval/live/live_test.go @@ -252,6 +252,9 @@ type taskResult struct { Turns []turnResult `json:"turns"` Total turnMetrics `json:"total"` UnverifiedFacts []string `json:"unverifiedFacts,omitempty"` + // UnverifiedUnmarkedFacts:无出处且未按「目标/预计/假设」标注的子集 + // (E3 口径:严格口径 unverifiedFacts 保留原义以维持历史可比性)。 + UnverifiedUnmarkedFacts []string `json:"unverifiedUnmarkedFacts,omitempty"` FinalVsArtifact []string `json:"finalVsArtifact,omitempty"` } @@ -307,6 +310,7 @@ func runTask(t *testing.T, c *client, projectID string, tk task, materialsDir st artText += downloadArtifactText(t, c, projectID, name) } tr.UnverifiedFacts = unverifiedFacts(artText, materialText) + tr.UnverifiedUnmarkedFacts = unmarkedFacts(tr.UnverifiedFacts, artText) tr.FinalVsArtifact = finalVsArtifact(last, artText) } } diff --git a/internal/prompts/library/agent-sub-explore.md b/internal/prompts/library/agent-sub-explore.md index cf99b92..47352cd 100644 --- a/internal/prompts/library/agent-sub-explore.md +++ b/internal/prompts/library/agent-sub-explore.md @@ -1,7 +1,7 @@ --- id: agent.sub.explore layer: agent -version: 1 +version: 2 title: 探索子代理(只读) description: 只读检索专家——定位文件/内容/事实,不做任何修改 tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir @@ -9,6 +9,7 @@ model: lightweight --- 你是只读探索子代理,职责是高效且彻底地定位目标信息。 +- 汇报发现时给出定位(文件/页/段落);数字必须来自材料原文并注明出处,没有出处的不要写。 - 严格只读:不写入、不修改、不删除任何文件或数据;只执行检索与读取类操作。 - 检索策略:从任务给出的线索出发,多角度查询(不同关键词、不同路径);一次不中就换查询,不重复同一查询。 - 返回结论时给出定位(文件/路径/段落/链接),让派生方能直接复核。 diff --git a/internal/prompts/library/agent-sub-general.md b/internal/prompts/library/agent-sub-general.md index ad31376..1910534 100644 --- a/internal/prompts/library/agent-sub-general.md +++ b/internal/prompts/library/agent-sub-general.md @@ -1,7 +1,7 @@ --- id: agent.sub.general layer: agent -version: 1 +version: 2 title: 通用子代理 description: 独立完成子任务并返回结构化结果(默认子代理提示词) tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review @@ -9,6 +9,7 @@ model: lightweight --- 你是被派生的子代理,负责独立完成一个子任务。 +- 结论中的关键数字与事实附来源定位(材料/文件与页/段落);没有出处的数字不要写,无法回溯时说明来源缺失。 - 把任务完整做完:既不过度修饰,也不半途而废;遇到障碍先自行尝试解决,确实无法解决时在结果中说明卡点与已尝试的手段。 - 只使用与任务相关的信息,不臆测派生方未提供的上下文。 - 完成时返回结构化结果:做了什么、关键发现/结论、产出物位置(如有)、遗留问题(如有)。分点列出,关键内容不省略;摘要长度服从内容,不为简短而丢信息。 diff --git a/internal/prompts/library/agent-sub-research.md b/internal/prompts/library/agent-sub-research.md index c909d7d..9501c78 100644 --- a/internal/prompts/library/agent-sub-research.md +++ b/internal/prompts/library/agent-sub-research.md @@ -1,7 +1,7 @@ --- id: agent.sub.research layer: agent -version: 1 +version: 2 title: 调研子代理 description: 围绕问题多源调研,输出带依据的结论 tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir @@ -9,6 +9,7 @@ model: lightweight --- 你是调研子代理,围绕给定问题收集信息并给出结论。 +- 结论里的关键数字与事实必须附原文引用(材料/文件与页/段落定位,重要处引原句);没有出处的数字不要写,无法回溯时明确说明「来源缺失」而不是估一个。 - 先把问题拆成可回答的子问题,再逐一检索;优先项目内知识库与已给材料,其次按任务指定的外部来源。 - 每条结论标注依据来源;多来源冲突时不和稀泥,列出各方说法与差异点,说明你采信哪个及理由。 - 覆盖正反两面:支持与不支持该结论的证据都要呈现。 diff --git a/internal/prompts/library/base-deliverables.md b/internal/prompts/library/base-deliverables.md index cbef7fb..fc07efa 100644 --- a/internal/prompts/library/base-deliverables.md +++ b/internal/prompts/library/base-deliverables.md @@ -1,7 +1,7 @@ --- id: base.deliverables layer: base -version: 5 +version: 6 title: 交付物工作流 description: 生成文档、表格、演示稿的工作流、样式决策与技能入口(常驻基座) --- @@ -16,6 +16,8 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技 5. **登记**:`register_artifact`(path 传相对路径)。登记后对话里出现可预览的卡片。 6. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。 +数字溯源(硬性):交付物里的每一个百分比、日期和大额数字,都必须能在本轮上传材料或你自己的工具/计算输出里找到出处;找不到出处的,要么删除,要么用「目标 / 预计 / 假设」等词在文中显式标注为规划量(如「目标 85%」「预计三个月」)。终轮会对无出处且未标注的数字拦截两轮;仍无法回溯又拒绝标注的,会被系统如实记为未核实。 + 没有调用工具、没有登记工件时,禁止说「已生成 / 已保存 / 已导出」。回复里不要写工件 ID(art_…)。工具只能通过函数调用触发,不要在回复里书写 `[调用工具 …]`、`[工具结果 …]`、`[产出工件 …]` 这类文字。 交付物族(所有任务共用,不限于会议): diff --git a/internal/runtime/factcheck.go b/internal/runtime/factcheck.go index 08e0e1b..f9682b7 100644 --- a/internal/runtime/factcheck.go +++ b/internal/runtime/factcheck.go @@ -9,6 +9,10 @@ import ( "agent-system-backend/internal/quality" ) +// factCheckMaxRounds 是 E3 的事实核查修正轮上限:两轮(第二轮为「最后一次 +// 拦截」+标注出路),之后放行并记检查行。 +const factCheckMaxRounds = 2 + // P5:事实一致性核查。从交付物正文抽取可机械定位的「硬事实 token」—— // 百分比、日期、大额数字——并回溯本轮材料(附件解析文本 + 工具输出证据)。 // 找不到出处的 token 以「未核实」反馈给模型修一轮;模型可以选择删除、 @@ -44,7 +48,9 @@ func ExtractFacts(text string) []string { return out } -// UnverifiedFacts 返回材料里找不到出处的 token(白名单:豁免的通用口径词)。 +// UnverifiedFacts 返回材料里找不到出处的 token。放行三类:豁免的通用口径词、 +// 材料中能回溯的、以及 E3 标注感知——文中以「目标/预计/假设」等标注词显式 +// 标记为规划量的数字不再是凭空事实,机械溯源放行(MarkedAsEstimate)。 func UnverifiedFacts(artifactText, materialText string, exempt map[string]bool) []string { if strings.TrimSpace(artifactText) == "" || strings.TrimSpace(materialText) == "" { return nil @@ -54,13 +60,46 @@ func UnverifiedFacts(artifactText, materialText string, exempt map[string]bool) if exempt[tok] { continue } - if !strings.Contains(materialText, tok) && !strings.Contains(materialText, stripPunct(tok)) { + 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) } @@ -160,3 +199,35 @@ func factExemptions() 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 +} diff --git a/internal/runtime/factcheck_test.go b/internal/runtime/factcheck_test.go index fc10c5c..9c7fb08 100644 --- a/internal/runtime/factcheck_test.go +++ b/internal/runtime/factcheck_test.go @@ -63,3 +63,63 @@ func TestFactCheckContinuationOnce(t *testing.T) { } _ = turns2 } + +// ---- E3:标注感知豁免 ---- + +func TestMarkedAsEstimate(t *testing.T) { + cases := []struct { + name, text, tok string + want bool + }{ + {"前置标注", "目标完成率 85%,分三个阶段推进", "85%", true}, + {"后置标注", "完成率 85%(预计)", "85%", true}, + {"裸数字", "当前完成率 85%,尚有差距", "85%", false}, + {"邻域外有标注词", "目标是建成样板。完成率 85%", "85%", false}, + {"多次出现其一标注", "实际 70%。目标 85% 年内达成", "85%", true}, + {"推测标注", "用户规模约 120,000(推测)", "120,000", true}, + } + for _, c := range cases { + if got := MarkedAsEstimate(c.text, c.tok); got != c.want { + t.Errorf("%s: MarkedAsEstimate(%q) = %v, want %v", c.name, c.tok, got, c.want) + } + } +} + +func TestUnverifiedFactsMarksPass(t *testing.T) { + material := "材料:上半年营收 3,200 万元。" + // 「目标 85%」已标注 → 放行;裸 95% → 仍拦。 + artifact := "上半年营收 3,200 万元,达成率目标 85%,试点扩面后裸数字 95%。" + got := UnverifiedFacts(artifact, material, factExemptions()) + if len(got) != 1 || got[0] != "95%" { + t.Fatalf("unverified = %v(应只拦未标注的 95%%)", got) + } +} + +// ---- E3:两轮修正的轮次逻辑 ---- + +func TestFactCheckGateRounds(t *testing.T) { + st := &directRun{} + first := []string{"95%", "10月20日"} + + // 第一轮:拦截并给修正通知。 + notice, cont := st.factCheckGate(first) + if !cont || !strings.Contains(notice, "95%") || strings.Contains(notice, "最后一次拦截") { + t.Fatalf("round1: cont=%v notice=%q", cont, notice) + } + // token 完全没变(模型没理会):不再拦——重复拦截只挤占共享续写预算。 + if _, cont := st.factCheckGate(first); cont { + t.Fatal("unchanged tokens must pass through") + } + // token 有变化(改了一个、残留一个):第二轮升级为「最后一次拦截」。 + notice, cont = st.factCheckGate([]string{"10月20日"}) + if !cont || !strings.Contains(notice, "最后一次拦截") || !strings.Contains(notice, "目标") { + t.Fatalf("round2: cont=%v notice=%q", cont, notice) + } + // 轮数用尽:放行。 + if _, cont := st.factCheckGate([]string{"7%"}); cont { + t.Fatal("rounds exhausted must pass through") + } + if st.factCheckRounds != 2 { + t.Fatalf("rounds = %d", st.factCheckRounds) + } +} diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index 7384041..7634abc 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -60,7 +60,8 @@ type directRun struct { checklistTouched bool toolTurnsSince int nudgedPlanless bool - factChecked bool + factCheckRounds int // E3:事实核查修正轮计数(上限 factCheckMaxRounds) + factCheckPrev map[string]bool // E3:上一轮的未核实 token 集合(未变化则不再拦) progress *gates.ProgressReminder checks []core.CheckPart servedModel string @@ -68,10 +69,10 @@ type directRun struct { switchMu sync.Mutex // 任务快慢双速(SR-B10 实路径):started 用注入时钟记 run 起点, // nudged/replied/delegated 三个标志驱动「超时仍未反馈则提醒转双速」的一次性触发。 - started time.Time + started time.Time nudgedDualSpeedCount int - replied bool - delegatedBackground bool + replied bool + delegatedBackground bool } type toolEvidence struct { @@ -578,20 +579,23 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim if reasons := gates.CheckArtifacts(artifactRefsOf(latest), artifactLookupOf(st.rc), hardFactScorer()); len(reasons) > 0 { return st.continueOrNotice(info, strings.Join(reasons, ";")) } - // P5:事实一致性核查——交付物里的百分比/日期/大额数字回溯本轮材料; - // 找不到出处的给模型一轮修正(改值/删除/标注推测),修正过仍失败的 - // 不再拦截(记入检查行如实呈现),避免机械比对的误报变成死循环。 - if st.eff.GetBool("runtime.factCheck", true) && len(latest) > 0 && info.Continuations < gates.MaxVerifyContinuations && !st.factChecked { + // P5→E3:事实一致性核查——交付物里的百分比/日期/大额数字回溯本轮材料 + // (附件解析文本 + 工具证据)。E3 收紧为两轮:第一轮照常给修正机会 + // (改值/删除/加「目标/假设」标注——标注后经 MarkedAsEstimate 机械放行); + // 第二轮明确这是最后一次拦截并给出标注出路;两轮后不再拦截(记入检查行 + // 如实呈现),续写上限(MaxVerifyContinuations)仍兜底防死循环。 + if st.eff.GetBool("runtime.factCheck", true) && len(latest) > 0 && info.Continuations < gates.MaxVerifyContinuations { pairs := st.collectDeliverableText(latest) if len(pairs) > 0 { if tokens := UnverifiedFacts(concatArtifactText(pairs), st.materialCorpus(), factExemptions()); len(tokens) > 0 { - st.factChecked = true - // 与交付物门禁/续写上限分支对齐:检查未通过时,现场那段总结归入 - // 过程时间线——修正轮之后的收尾才是最终答复(实测 run_b520bf2e3037 - // 未降级导致好总结被前端切分折叠,用户只看到修正流水账)。 - st.demoteLastText() - st.addVerifyNote("事实核查:" + factCheckNotice(tokens)) - return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物,然后重新给出更新后的完整最终答复(先结论、再产出物与要点,可注明本次修正)。\n" + factCheckNotice(tokens))} + if notice, cont := st.factCheckGate(tokens); cont { + // 与交付物门禁/续写上限分支对齐:检查未通过时,现场那段总结归入 + // 过程时间线——修正轮之后的收尾才是最终答复(实测 run_b520bf2e3037 + // 未降级导致好总结被前端切分折叠,用户只看到修正流水账)。 + st.demoteLastText() + st.addVerifyNote("事实核查:" + notice) + return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物,然后重新给出更新后的完整最终答复(先结论、再产出物与要点,可注明本次修正)。\n" + notice)} + } } } }