Add estimate-marking awareness and a second fact-check round (E3)
ci / vet-and-test (pull_request) Successful in 41s

Why: The E1 live comparison was the only metric that got worse
(unverifiedFacts 7 -> 12): the gate gave one correction round and then
shipped bare numbers. The design (E3) calls for number sourcing with a
mechanically checkable marking escape: numbers labeled as
targets/assumptions are honest planning quantities, not fabricated
facts, so the gate must let them pass - and the model must be told that
marking is the sanctioned way out.

Why: The marking decision is clause-scoped, not a fixed window - a
window wrongly blessed "3,500" because a neighboring clause said
"target 85%" (caught by the existing test). The second round only
fires when the unverified set actually changed, because the
continuation budget is shared across all final gates and a repeat
block on identical tokens starves the downstream gates (also caught by
an existing test).

Evidence: go test ./... green; new anchors TestMarkedAsEstimate,
TestUnverifiedFactsMarksPass, TestFactCheckGateRounds; the live eval
gains an unverifiedUnmarkedFacts field sharing the same marking rule.

Impact: base.deliverables v6 adds the hard number-sourcing rule;
research/explore/general sub-agent profiles v+1 require citations for
key numbers; fact gate: marked-in-clause numbers pass, round 2
escalates to "last interception" with the marking instruction, then
passes through with the check line. Eval report keeps the strict
series and adds the E3-series field.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md (E3,
D4); Dev task feature/agent-e3-evidence-gate.
This commit is contained in:
2026-10-10 16:08:19 +00:00
parent 017b40f761
commit 06f5835211
9 changed files with 182 additions and 21 deletions
+17
View File
@@ -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, ";"))
+4
View File
@@ -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)
}
}
@@ -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
---
你是只读探索子代理,职责是高效且彻底地定位目标信息。
- 汇报发现时给出定位(文件/页/段落);数字必须来自材料原文并注明出处,没有出处的不要写。
- 严格只读:不写入、不修改、不删除任何文件或数据;只执行检索与读取类操作。
- 检索策略:从任务给出的线索出发,多角度查询(不同关键词、不同路径);一次不中就换查询,不重复同一查询。
- 返回结论时给出定位(文件/路径/段落/链接),让派生方能直接复核。
@@ -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
---
你是被派生的子代理,负责独立完成一个子任务。
- 结论中的关键数字与事实附来源定位(材料/文件与页/段落);没有出处的数字不要写,无法回溯时说明来源缺失。
- 把任务完整做完:既不过度修饰,也不半途而废;遇到障碍先自行尝试解决,确实无法解决时在结果中说明卡点与已尝试的手段。
- 只使用与任务相关的信息,不臆测派生方未提供的上下文。
- 完成时返回结构化结果:做了什么、关键发现/结论、产出物位置(如有)、遗留问题(如有)。分点列出,关键内容不省略;摘要长度服从内容,不为简短而丢信息。
@@ -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
---
你是调研子代理,围绕给定问题收集信息并给出结论。
- 结论里的关键数字与事实必须附原文引用(材料/文件与页/段落定位,重要处引原句);没有出处的数字不要写,无法回溯时明确说明「来源缺失」而不是估一个。
- 先把问题拆成可回答的子问题,再逐一检索;优先项目内知识库与已给材料,其次按任务指定的外部来源。
- 每条结论标注依据来源;多来源冲突时不和稀泥,列出各方说法与差异点,说明你采信哪个及理由。
- 覆盖正反两面:支持与不支持该结论的证据都要呈现。
@@ -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_…)。工具只能通过函数调用触发,不要在回复里书写 `[调用工具 …]`、`[工具结果 …]`、`[产出工件 …]` 这类文字。
交付物族(所有任务共用,不限于会议):
+73 -2
View File
@@ -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
}
+60
View File
@@ -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)
}
}
+19 -15
View File
@@ -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)}
}
}
}
}