Narrow the blocking gate to hard facts and add fact-consistency checks (quality P5) #123
+15
-11
@@ -176,18 +176,22 @@ func Run(opt Options) error {
|
||||
rag.SecretLookup = func(service, fallback string) string {
|
||||
return runtime.LookupSecret(store.OwnerDefault, service, fallback)
|
||||
}
|
||||
// 网络搜索(Tavily):配置密钥池才注册(对齐 agent-native 语义:未配置不静默降级)。
|
||||
eff := setSvc.Effective(store.OwnerDefault, "")
|
||||
tavilyRaw := eff.GetString("tools.tavily.apiKeys", "")
|
||||
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
|
||||
tavilyRaw = active
|
||||
}
|
||||
if keys := splitKeyPool(tavilyRaw); len(keys) > 0 {
|
||||
provider := agenttools.NewTavilyProvider(eff.GetString("tools.tavily.endpoint", ""), keys)
|
||||
if err := tools.RegisterSearchTools(reg, provider); err != nil {
|
||||
return fmt.Errorf("search tools: %w", err)
|
||||
// 网络搜索(Tavily):始终注册,密钥按调用时读取(设置页/svc 播种改了
|
||||
// 即刻生效——此前启动时快照密钥,播种后必须重启工具才注册)。
|
||||
// 未配置时调用返回明确的「未配置」提示,不静默降级为空结果。
|
||||
if err := tools.RegisterLazySearchTools(reg, func() agenttools.SearchProvider {
|
||||
effNow := setSvc.Effective(store.OwnerDefault, "")
|
||||
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
|
||||
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
|
||||
tavilyNow = active
|
||||
}
|
||||
log.Printf("tools: tavily_search/web_search registered (%d key(s))", len(keys))
|
||||
keys := splitKeyPool(tavilyNow)
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
return agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("search tools: %w", err)
|
||||
}
|
||||
promptLib, err := prompts.DefaultLibrary()
|
||||
if err != nil {
|
||||
|
||||
@@ -27,14 +27,14 @@ var (
|
||||
const cardAppendWindow = 250 * time.Millisecond
|
||||
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
// MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量
|
||||
// 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。
|
||||
MirrorConvCards bool
|
||||
now func() time.Time
|
||||
emitMu sync.Mutex
|
||||
lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡)
|
||||
now func() time.Time
|
||||
emitMu sync.Mutex
|
||||
lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡)
|
||||
}
|
||||
|
||||
func New(st *store.Store, emit Emitter) *Service {
|
||||
|
||||
@@ -2,8 +2,8 @@ package plugins
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// P5/裁定 4:拦截路径只认硬事实(lint 实际运行且零 error);
|
||||
// 关键词维度(栏目式标题/可执行性等)不得再把合格交付物拦下来。
|
||||
func TestHardFactsIgnoresKeywordDimensions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 评分整体会因「结论先行 20/可读性 25」判负的文本——硬事实口径必须放行
|
||||
//(无 lint 或 lint 通过时)。
|
||||
path := filepath.Join(dir, "报告.md")
|
||||
if err := os.WriteFile(path, []byte("# 概述\n\n正文内容。\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
full := ScoreFile(path)
|
||||
if full.Passed {
|
||||
t.Fatalf("keyword dimensions should still fail the full score (fixture sanity): %+v", full)
|
||||
}
|
||||
hard := HardFacts("报告.md", []byte("# 概述\n\n正文内容。\n"))
|
||||
if !hard.Passed {
|
||||
t.Fatalf("hard facts must ignore keyword dimensions: %+v", hard.Violations)
|
||||
}
|
||||
if len(hard.Lint) > 0 {
|
||||
for _, issue := range hard.Lint {
|
||||
if issue.Severity == "error" {
|
||||
t.Fatalf("lint errors must still block: %+v", issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lint 不可运行(技能根已配置)仍是硬事实失败——fail-visible 保持。
|
||||
func TestHardFactsKeepsLintUnavailableBlocking(t *testing.T) {
|
||||
SetLintEnvRoot(t.TempDir())
|
||||
t.Cleanup(func() { SetLintEnvRoot("") })
|
||||
hard := HardFacts("报告.md", []byte("# 概述\n正文。\n"))
|
||||
if hard.Passed {
|
||||
t.Fatalf("lint-not-run must stay blocking: %+v", hard)
|
||||
}
|
||||
found := false
|
||||
for _, v := range hard.Violations {
|
||||
if strings.Contains(v, "自检未运行") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected 自检未运行, got %v", hard.Violations)
|
||||
}
|
||||
}
|
||||
@@ -451,3 +451,43 @@ func firstLine(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// HardFacts 只评可机械判定的硬事实:lint 实际运行且零 error。
|
||||
// 关键词维度(ScoreText 六维)从拦截路径移出(裁定 4/P5):它们描述的是
|
||||
// 写作质量偏好,实测会把正常中文章节标题判成栏目名、把没有"未指明"字样的
|
||||
// 行动表判成不可执行,造成终轮死循环;评分整体仍由 ScoreBytes 供评测侧使用。
|
||||
func HardFacts(name string, content []byte) Report {
|
||||
report := ScoreBytes(name, content)
|
||||
report.Passed = true
|
||||
report.Violations = nil
|
||||
for _, issue := range report.Lint {
|
||||
if issue.Severity == "error" {
|
||||
report.Passed = false
|
||||
report.Violations = append(report.Violations, "版式 "+issue.Rule+":"+issue.Message)
|
||||
}
|
||||
}
|
||||
if !report.LintRan && lintEnvRoot != "" {
|
||||
report.Passed = false
|
||||
report.Violations = append(report.Violations, "自检未运行(housestyle.lint 不可用)")
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
// DeliverableText 供事实核查抽取工件文本(docx 与 ScoreBytes 同源口径;
|
||||
// 二进制返回空)。导出给 runtime 包使用。
|
||||
func DeliverableText(name string, content []byte) string {
|
||||
dir, err := os.MkdirTemp("", "factcheck")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||
return ""
|
||||
}
|
||||
text, err := readDeliverable(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestLintUnavailableFailsVisibleWhenSkillsRootSet(t *testing.T) {
|
||||
}
|
||||
|
||||
// docx 标题样式要被标成 # 前缀;表格单元格/正文不参与「栏目式标题」判定
|
||||
//(实测误报:单元格「项目概述撰写」被当成标题,检查连续判负)。
|
||||
// (实测误报:单元格「项目概述撰写」被当成标题,检查连续判负)。
|
||||
func TestDocxHeadingMarkedAndTableCellNotFlagged(t *testing.T) {
|
||||
docx := buildDocx(t, []paraSpec{
|
||||
{style: "Heading1", text: "概述"},
|
||||
|
||||
@@ -3,7 +3,7 @@ package route
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
core "agent-system-core"
|
||||
|
||||
"agent-system-backend/internal/quality"
|
||||
)
|
||||
|
||||
// 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(白名单:豁免的通用口径词)。
|
||||
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)) {
|
||||
out = append(out, tok)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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("。逐条二选一:改成材料里能找到的数值,或删除/明确标注为推测或目标值(如“目标 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% 确定)与零值
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 事实核查:交付物里材料找不到出处的百分比被抽出并组成反馈;找到的不报。
|
||||
func TestUnverifiedFactsAndNotice(t *testing.T) {
|
||||
material := "目标是准确率 85%以上。截止 10月12日。团队共 12 人。"
|
||||
artifact := "准确率达到 95%,截止 10月20日,覆盖 3,500 用户,目标 85%。"
|
||||
got := UnverifiedFacts(artifact, material, factExemptions())
|
||||
joined := strings.Join(got, ",")
|
||||
for _, want := range []string{"95%", "10月20日", "3,500"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("missing %q in %v", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(joined, "85%") {
|
||||
t.Fatalf("sourced figure must not be reported: %v", got)
|
||||
}
|
||||
notice := factCheckNotice(got)
|
||||
if !strings.Contains(notice, "95%") || !strings.Contains(notice, "改成材料里") {
|
||||
t.Fatalf("notice malformed: %q", notice)
|
||||
}
|
||||
if UnverifiedFacts("", material, nil) != nil || UnverifiedFacts(artifact, "", nil) != nil {
|
||||
t.Fatal("empty sides short-circuit")
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:终轮对带未核实数字的交付物给一轮修正;修正后(重新登记干净版本)放行。
|
||||
func TestFactCheckContinuationOnce(t *testing.T) {
|
||||
rc := &fakeRunCtx{
|
||||
ctx: context.Background(),
|
||||
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
|
||||
verify: true,
|
||||
execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "报告.md"},
|
||||
execArtifactContent: []byte("# 报告\n\n准确率 95%。"),
|
||||
}
|
||||
// 第一轮:登记带编造数字的工件 -> 事实核查续跑一次;第二轮:干净版本通过。
|
||||
turns1 := padTurns(
|
||||
textDeltaTurn("", toolCall("make_report", `{"content":"准确率 95%"}`)),
|
||||
textDeltaTurn("写好了。"),
|
||||
)
|
||||
turns2 := padTurns(
|
||||
textDeltaTurn("材料里是 85%。"),
|
||||
)
|
||||
r := &LLMRunner{Stream: scriptStream(padTurns(turns1...), nil), VerifyStream: passedStream()}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
sawFact := false
|
||||
for _, p := range rc.assistant {
|
||||
if p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, "事实核查") {
|
||||
sawFact = true
|
||||
}
|
||||
}
|
||||
if !sawFact {
|
||||
t.Fatalf("fact check continuation note missing: %+v", rc.assistant)
|
||||
}
|
||||
_ = turns2
|
||||
}
|
||||
@@ -582,10 +582,13 @@ func artifactLookupOf(rc RunCtx) gates.ArtifactLookup {
|
||||
}
|
||||
}
|
||||
|
||||
// qualityScorer 把 quality.ScoreBytes 投影为库的评分端口。
|
||||
func qualityScorer() gates.QualityScorer {
|
||||
// hardFactScorer 把 quality.ScoreBytes 投影为「硬事实」检查端口(裁定 4/P5):
|
||||
// 只拦可机械判定的硬事实——lint error(含 lint 未运行);关键词评分维度
|
||||
// (结论先行/可读性/可执行性等)只作观测记录,不再进入拦截路径——
|
||||
// 实测这些维度把正常章节标题判成栏目名导致终轮死循环。
|
||||
func hardFactScorer() gates.QualityScorer {
|
||||
return func(name string, content []byte) (bool, string) {
|
||||
report := quality.ScoreBytes(name, content)
|
||||
report := quality.HardFacts(name, content)
|
||||
if report.Passed {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ type directRun struct {
|
||||
checklistTouched bool
|
||||
toolTurnsSince int
|
||||
nudgedPlanless bool
|
||||
factChecked bool
|
||||
progress *gates.ProgressReminder
|
||||
checks []core.CheckPart
|
||||
servedModel string
|
||||
@@ -523,10 +524,24 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim
|
||||
// 终轮检查只评每个文件的最新版本:修订产生新工件后,被替代的旧版本
|
||||
// 已不是交付物,继续按旧版评分会让检查永远无法通过(实测死循环根因)。
|
||||
latest := st.latestDeliverables()
|
||||
// N1 WP5:工件存在/非空/质量检查走下沉的 CheckArtifacts(端口在 llm.go 投影)。
|
||||
if reasons := gates.CheckArtifacts(artifactRefsOf(latest), artifactLookupOf(st.rc), qualityScorer()); len(reasons) > 0 {
|
||||
// N1 WP5:工件存在/非空/硬事实检查走下沉的 CheckArtifacts(端口在 llm.go 投影;
|
||||
// P5 后 scorer 只拦 lint error 与 lint 未运行)。
|
||||
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 {
|
||||
pairs := st.collectDeliverableText(latest)
|
||||
if len(pairs) > 0 {
|
||||
if tokens := UnverifiedFacts(concatArtifactText(pairs), st.materialCorpus(), factExemptions()); len(tokens) > 0 {
|
||||
st.factChecked = true
|
||||
st.addVerifyNote("事实核查:" + factCheckNotice(tokens))
|
||||
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物。\n" + factCheckNotice(tokens))}
|
||||
}
|
||||
}
|
||||
}
|
||||
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, latest)
|
||||
if err != nil {
|
||||
st.addVerifyNote(err.Error())
|
||||
|
||||
@@ -219,11 +219,11 @@ func TestVerifierGarbageFailsClosed(t *testing.T) {
|
||||
if p.Text == "答案是 42。" && !p.Process {
|
||||
kept = true
|
||||
}
|
||||
if !p.Process && strings.Contains(p.Text, "验证器不可用") {
|
||||
if !p.Process && strings.Contains(p.Text, "验证器未独立配置") {
|
||||
notice = true
|
||||
}
|
||||
}
|
||||
if !kept || !notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") {
|
||||
if !kept || !notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") && !hasProcessNote(rc.assistant, "未独立配置") {
|
||||
t.Fatalf("parts = %+v", texts)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
const verifyCapNotice = "本轮回复未通过终轮检查。"
|
||||
|
||||
const verifierUnavailableNotice = "本轮回复未通过独立验证(验证器不可用)"
|
||||
const verifierUnavailableNotice = "本轮未做同源模型验证(验证器未独立配置);硬事实与事实核查已执行"
|
||||
|
||||
const verifyFallbackPrompt = `你是独立验证器,不调用任何工具。根据用户要求、终轮回复、工具证据和工件清单判断这一轮是否已经完成。
|
||||
同一对象的后一次检查覆盖前一次,以最后一次为准。内置质量检查已通过时,不要用更早的工具输出推翻它。
|
||||
@@ -29,6 +29,15 @@ func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence,
|
||||
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
|
||||
stream := l.VerifyStream
|
||||
if stream == nil {
|
||||
// P5:异模型验证——轻量档回落到与主模型同名时,验证器看到的输入与
|
||||
// 生成者同源(自证倾向、同样的解析毛病)。此时跳过模型验证,让
|
||||
// 硬事实/事实核查承担终轮检查,比假独立更诚实。
|
||||
chat := ResolveChatModel(rc.Profile(), rc.Settings())
|
||||
sameModel := strings.EqualFold(strings.TrimSpace(route.Model), strings.TrimSpace(chat.Model)) &&
|
||||
strings.EqualFold(strings.TrimSpace(route.Base), strings.TrimSpace(chat.Base))
|
||||
if !rc.Settings().GetBool("runtime.verifierSameModel", false) && sameModel {
|
||||
return gates.Verdict{}, fmt.Errorf("验证器未配置独立轻量模型(当前与主模型相同),跳过同源验证")
|
||||
}
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ import (
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/runmgr"
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/settings"
|
||||
skillseed "agent-system-backend/internal/skills"
|
||||
"agent-system-backend/internal/squadadapter"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/tools"
|
||||
core "agent-system-core"
|
||||
@@ -929,7 +929,7 @@ func TestSquadOrchestration(t *testing.T) {
|
||||
py, _ := osexec.LookPath("python3")
|
||||
return nativeadp.Config{DefaultModel: "stub", SkillsRoots: []string{skillsRoot},
|
||||
Exec: &nativetools.ExecConfig{Bash: "/bin/bash", Python: py}, Stream: stubNativeStream(
|
||||
"# 交付\n\n这是内核桩后端产出的步骤交付物正文,长度超过五十个字符以满足最小质检要求。")}, true
|
||||
"# 交付\n\n这是内核桩后端产出的步骤交付物正文,长度超过五十个字符以满足最小质检要求。")}, true
|
||||
}
|
||||
defer func() { squadadapter.AgentNativeOptions = savedNative }()
|
||||
h := srv.Handler()
|
||||
|
||||
@@ -96,6 +96,8 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "runtime.verify", Group: "runtime", Label: "终轮结果检查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "终轮确定性检查与独立验证"},
|
||||
{Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"},
|
||||
{Key: "runtime.codeOrchestration", Group: "runtime", Label: "代码编排", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许模型用 run_tool_script 写脚本批量调用工具。默认关闭"},
|
||||
{Key: "runtime.factCheck", Group: "runtime", Label: "终轮事实核查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "交付物里的百分比/日期/大额数字回溯本轮材料,找不到出处的给模型一轮修正(修正过仍失败不拦截,只如实记录)"},
|
||||
{Key: "runtime.verifierSameModel", Group: "runtime", Label: "允许同源验证器", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "轻量档未单独配置时,默认跳过与主模型同源的独立验证(硬事实与事实核查仍执行);显式开启才用主模型做验证"},
|
||||
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型重写一段最终回复。默认关闭(裁定 4:转录权威后工作模型已看到完整证据,重写只见过 160 字摘要、实测会虚构章节与承诺未做之事);异常收尾时另有兜底"},
|
||||
{Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"},
|
||||
{Key: "runtime.intentGenerator", Group: "runtime", Label: "意图命令生成(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则与语义都未命中时用轻量模型生成意图命令(幻觉 fail-closed 回退)。默认关闭(行为与 I1 一致)"},
|
||||
|
||||
@@ -167,7 +167,6 @@ func containsStr(list []string, want string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// Q3 WP2:agent-system 退役后,未注入 AgentNativeOptions 时解析器为空——
|
||||
// 步骤后端解析 fail-closed,不再静默走 SpawnSubagent 压缩路径。
|
||||
func TestBuildBackendsWithoutNativeOptionsIsEmpty(t *testing.T) {
|
||||
|
||||
@@ -54,7 +54,6 @@ func TestOrchestrateRunsOnKernelStub(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func stubStream(text string) agentllm.StreamFunc {
|
||||
return func(_ context.Context, _ string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
ch := make(chan agentllm.Event, 8)
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestSandboxArgvShape(t *testing.T) {
|
||||
argv := sandboxArgv(ExecSpec{
|
||||
Bin: "/usr/bin/python3", Args: []string{"/data/s/x.py"},
|
||||
Dir: "/data/s", SkillsDir: "/data/skills",
|
||||
Env: []string{"PATH=/usr/bin:/bin", "HOME=/tmp/h"},
|
||||
Env: []string{"PATH=/usr/bin:/bin", "HOME=/tmp/h"},
|
||||
})
|
||||
joined := strings.Join(argv, "\x00")
|
||||
musts := []string{
|
||||
|
||||
@@ -20,9 +20,9 @@ func (dummyCtx) PeerConversations() ([]core.Conversation, error) { return nil, n
|
||||
func (dummyCtx) PeerConversationMessages(id string, limit int) ([]core.Message, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (dummyCtx) OwnerID() string { return "o" }
|
||||
func (dummyCtx) WorkspaceRoot() string { return "" }
|
||||
func (dummyCtx) SettingBool(string, bool) bool { return false }
|
||||
func (dummyCtx) OwnerID() string { return "o" }
|
||||
func (dummyCtx) WorkspaceRoot() string { return "" }
|
||||
func (dummyCtx) SettingBool(string, bool) bool { return false }
|
||||
|
||||
// TestHTTPExecutorTemplateAndPath:模板变量注入、结果路径裁剪、错误状态。
|
||||
func TestHTTPExecutorTemplateAndPath(t *testing.T) {
|
||||
|
||||
@@ -22,7 +22,23 @@ type TavilySettings struct {
|
||||
// RegisterSearchTools 注册 tavily_search(完整参数面)与 web_search(别名)。
|
||||
// 工具面与 agent-native runtime/tools/search.go 逐字段对齐(名称/参数/输出/失败降级文案)。
|
||||
func RegisterSearchTools(r *Registry, provider agenttools.SearchProvider) error {
|
||||
return registerSearch(r, func(ToolExecCtx) agenttools.SearchProvider { return provider })
|
||||
}
|
||||
|
||||
// RegisterLazySearchTools 注册按调用时取密钥的搜索工具:provider 在每次调用时
|
||||
// 重建(设置页/svc 播种改了 tavily 密钥即刻生效,不再需要重启进程——
|
||||
// 实测播种后 tavily 工具要重启才注册)。configure 返回 nil 时不静默降级,
|
||||
// 返回明确的未配置提示。
|
||||
func RegisterLazySearchTools(r *Registry, configure func() agenttools.SearchProvider) error {
|
||||
return registerSearch(r, func(ToolExecCtx) agenttools.SearchProvider { return configure() })
|
||||
}
|
||||
|
||||
func registerSearch(r *Registry, providerFor func(ToolExecCtx) agenttools.SearchProvider) error {
|
||||
tavily := func(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
||||
provider := providerFor(rc)
|
||||
if provider == nil {
|
||||
return "", fmt.Errorf("搜索未配置:settings.tools.tavily.apiKeys(或对应 secret)为空;配置后立即生效,无需重启")
|
||||
}
|
||||
return execSearch(provider, args)
|
||||
}
|
||||
if err := r.Register(core.ToolDesc{
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
"agent-system-backend/eval"
|
||||
"agent-system-backend/internal/workflow"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// ab_demo_test.go — Q3 WP5 验收:同批任务(含 demo.brief 场景)经两个入口
|
||||
|
||||
@@ -35,20 +35,20 @@ import (
|
||||
|
||||
// 宿主 intent 步的意图 ID(目录由本包合成,处理函数在 host.go)。
|
||||
const (
|
||||
IntentHitl = "wf.hitl"
|
||||
IntentTool = "wf.tool"
|
||||
IntentHitl = "wf.hitl"
|
||||
IntentTool = "wf.tool"
|
||||
IntentArtifact = "wf.artifact"
|
||||
IntentCard = "wf.card"
|
||||
IntentScript = "wf.script"
|
||||
IntentCritic = "wf.critic"
|
||||
IntentCard = "wf.card"
|
||||
IntentScript = "wf.script"
|
||||
IntentCritic = "wf.critic"
|
||||
)
|
||||
|
||||
// CriticPair 记录一个「原步 ↔ 评审步」映射(收口后校验最终判定用)。
|
||||
type CriticPair struct {
|
||||
StepID string // 被评审的原步 ID
|
||||
StepTitle string
|
||||
EvalID string // 评审步 ID(<StepID>_critic)
|
||||
VerdictField string // 黑板判定字段名
|
||||
StepID string // 被评审的原步 ID
|
||||
StepTitle string
|
||||
EvalID string // 评审步 ID(<StepID>_critic)
|
||||
VerdictField string // 黑板判定字段名
|
||||
}
|
||||
|
||||
// Compiled 是一次编译产物。
|
||||
@@ -86,7 +86,7 @@ func Compile(def core.WorkflowDef, vars workflow.Vars, opts CompileOptions, know
|
||||
var steps []squad.StepSpec
|
||||
var agents []squad.RoleSpec
|
||||
agentSeen := map[string]bool{}
|
||||
prevDef := "" // 上一个 def 步 ID(artifact 空内容的 prev 回退)
|
||||
prevDef := "" // 上一个 def 步 ID(artifact 空内容的 prev 回退)
|
||||
prevChain := "" // 图链上的前驱(含评审步)
|
||||
|
||||
for i, raw := range def.Steps {
|
||||
@@ -376,8 +376,8 @@ func buildCatalog() *squad.IntentCatalog {
|
||||
Revision: 1,
|
||||
Markdown: "# 工作流宿主能力\n\n- wf.hitl 人工问答\n- wf.tool 宿主工具\n- wf.artifact 工件落库\n- wf.card 收件箱卡\n- wf.script 脚本宿主\n- wf.critic 步骤验收\n",
|
||||
Specs: map[string]squad.IntentSpecView{
|
||||
IntentHitl: spec(IntentHitl, "人工问答步", req("question"), opt("step_id"), opt("kind"), opt("choices"), opt("allow_free_input"), opt("free_input_hint")),
|
||||
IntentTool: spec(IntentTool, "宿主工具步", req("tool"), opt("step_id"), opt("args")),
|
||||
IntentHitl: spec(IntentHitl, "人工问答步", req("question"), opt("step_id"), opt("kind"), opt("choices"), opt("allow_free_input"), opt("free_input_hint")),
|
||||
IntentTool: spec(IntentTool, "宿主工具步", req("tool"), opt("step_id"), opt("args")),
|
||||
IntentArtifact: spec(IntentArtifact, "工件步", req("name"), opt("step_id"), opt("content"), opt("content_ref")),
|
||||
IntentCard: spec(IntentCard, "收件箱卡步", req("template"), opt("step_id")),
|
||||
IntentScript: spec(IntentScript, "脚本步", req("path"), opt("step_id"), opt("model")),
|
||||
|
||||
Reference in New Issue
Block a user