Fall back to system-row Tavily secret and regulations KB in knowledge_search #216

Merged
weichen merged 3 commits from fix/tavily-secret-kb-fallback into main 2026-10-19 10:19:40 +00:00
8 changed files with 228 additions and 14 deletions
+2 -10
View File
@@ -299,11 +299,7 @@ func Run(opt Options) error {
// 未配置时调用返回明确的「未配置」提示,不静默降级为空结果。
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
}
keys := splitKeyPool(tavilyNow)
keys := tavilyKeyPool(st, effNow.GetString("tools.tavily.apiKeys", ""))
if len(keys) == 0 {
return nil
}
@@ -369,11 +365,7 @@ func Run(opt Options) error {
},
}
cfg.Stream = runtime.GuardProjectStream(rc, "", cfg.Client.Stream)
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
tavilyNow = active
}
if keys := splitKeyPool(tavilyNow); len(keys) > 0 {
if keys := tavilyKeyPool(st, effNow.GetString("tools.tavily.apiKeys", "")); len(keys) > 0 {
cfg.Search = agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys)
}
if effNow.GetBool("runtime.exec", true) {
+51
View File
@@ -25,9 +25,60 @@ const (
toolRegulationsSearch = "regulations_search"
toolRegulationEvidence = "regulation_evidence"
toolKnowledgeSearch = "knowledge_search"
knowledgeRegulationsFallbackPrefix = "项目资料无命中;以下为法规知识库命中(可用 regulation_evidence 取原文):\n"
)
func registerRegulationsTools(reg *tools.Registry, caps hostcaps) error {
if err := registerRegulationsSearchTools(reg, caps); err != nil {
return err
}
return wrapKnowledgeSearchWithRegulations(reg, caps)
}
// wrapKnowledgeSearchWithRegulations 让 knowledge_search 在项目资料无命中时回退
// 法规知识库;未注册 knowledge_search 时不做任何事。
func wrapKnowledgeSearchWithRegulations(reg *tools.Registry, caps hostcaps) error {
desc, inner, ok := reg.Get(toolKnowledgeSearch)
if !ok {
return nil
}
return reg.Register(desc, func(rc tools.ToolExecCtx, args json.RawMessage) (string, error) {
out, err := inner(rc, args)
if err != nil || out != tools.KnowledgeSearchNoHits {
return out, err
}
if fallback, ok := knowledgeRegulationsFallback(caps, rc, args); ok {
return knowledgeRegulationsFallbackPrefix + fallback, nil
}
return out, nil
})
}
func knowledgeRegulationsFallback(caps hostcaps, rc tools.ToolExecCtx, args json.RawMessage) (string, bool) {
var a struct {
Query string `json:"query"`
}
if json.Unmarshal(args, &a) != nil {
return "", false
}
q, err := json.Marshal(map[string]string{"query": a.Query})
if err != nil {
return "", false
}
res, err := regulationsSearchTool(caps, rc, q)
if err != nil {
return "", false
}
var parsed regulationsToolSearchOutput
if json.Unmarshal([]byte(res), &parsed) != nil || parsed.State == extension.RegulationsStateUnavailable || len(parsed.Hits) == 0 {
return "", false
}
return res, true
}
func registerRegulationsSearchTools(reg *tools.Registry, caps hostcaps) error {
if err := reg.Register(core.ToolDesc{
Name: toolRegulationsSearch,
Description: "检索已发布的医疗器械法规、标准(GB/YY/YY/T 等)、指导原则、通告等共享法规知识库;用户提到标准号、法规名称、条款或监管要求时优先调用本工具,再考虑网络搜索;命中后可用 regulation_evidence 取条款原文以精确引用",
+92
View File
@@ -224,3 +224,95 @@ func TestRegulationsToolsReportUnavailable(t *testing.T) {
t.Fatalf("provider failure reported as success: %+v", res)
}
}
type knowledgeHitsCtx struct {
regulationsToolCtx
hits []core.RetrievalHit
}
func (c knowledgeHitsCtx) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) {
return c.hits, nil
}
func newKnowledgeFallbackRegistry(t *testing.T, h inprocHost) *tools.Registry {
t.Helper()
reg := tools.NewRegistry()
if err := reg.RegisterBuiltin(); err != nil {
t.Fatal(err)
}
before, _, _ := reg.Get(toolKnowledgeSearch)
if err := registerRegulationsTools(reg, h.caps); err != nil {
t.Fatal(err)
}
if after, _, _ := reg.Get(toolKnowledgeSearch); !reflect.DeepEqual(after, before) {
t.Fatalf("knowledge_search desc changed: %+v", after)
}
return reg
}
func callKnowledgeSearch(reg *tools.Registry, hits []core.RetrievalHit) core.ToolCallResult {
rc := knowledgeHitsCtx{regulationsToolCtx: regulationsToolCtx{owner: "alice"}, hits: hits}
return reg.Execute(rc, core.ToolCallRequest{CallID: "c1", Name: toolKnowledgeSearch, Arguments: json.RawMessage(`{"query":"电气安全"}`)})
}
func TestKnowledgeSearchFallsBackToRegulationsHits(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerRecordingRegulations(t, h)
p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-9", State: extension.RegulationsStateOK,
Hits: []extension.RegulationHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", DocNo: "GB 9706.1-2020",
Excerpt: "法规条款片段", Disclosure: extension.RegulationDisclosureFull}}}
reg := newKnowledgeFallbackRegistry(t, h)
res := callKnowledgeSearch(reg, nil)
if res.Status != "ok" || !strings.HasPrefix(res.Output, knowledgeRegulationsFallbackPrefix) ||
!strings.Contains(res.Output, "法规条款片段") || !strings.Contains(res.Output, "GB 9706.1-2020") {
t.Fatalf("fallback result = %+v", res)
}
if len(p.searches) != 1 || p.searches[0].Query != "电气安全" {
t.Fatalf("regulations request = %+v", p.searches)
}
}
func TestKnowledgeSearchKeepsNoHitsWhenRegulationsEmpty(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerRecordingRegulations(t, h)
p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-9", State: extension.RegulationsStateOK}
reg := newKnowledgeFallbackRegistry(t, h)
if res := callKnowledgeSearch(reg, nil); res.Status != "ok" || res.Output != tools.KnowledgeSearchNoHits {
t.Fatalf("empty regulations result = %+v", res)
}
if len(p.searches) != 1 {
t.Fatalf("regulations not consulted: %+v", p.searches)
}
}
func TestKnowledgeSearchKeepsNoHitsWhenRegulationsUnavailable(t *testing.T) {
h, _ := newRegulationsTestHost(t)
reg := newKnowledgeFallbackRegistry(t, h)
if res := callKnowledgeSearch(reg, nil); res.Status != "ok" || res.Output != tools.KnowledgeSearchNoHits {
t.Fatalf("no provider result = %+v", res)
}
p := registerRecordingRegulations(t, h)
for _, err := range []error{extension.ErrRegulationsUnavailable, errors.New("provider failure")} {
p.err = err
if res := callKnowledgeSearch(reg, nil); res.Status != "ok" || res.Output != tools.KnowledgeSearchNoHits {
t.Fatalf("provider %v result = %+v", err, res)
}
}
}
func TestKnowledgeSearchProjectHitSkipsRegulations(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerRecordingRegulations(t, h)
p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-9", State: extension.RegulationsStateOK,
Hits: []extension.RegulationHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", Excerpt: "法规条款片段"}}}
reg := newKnowledgeFallbackRegistry(t, h)
hits := []core.RetrievalHit{{Doc: core.KnowledgeDoc{Title: "项目说明书", Source: "upload", Chunk: "项目片段"}, Score: 0.8}}
res := callKnowledgeSearch(reg, hits)
if res.Status != "ok" || !strings.Contains(res.Output, "项目片段") ||
strings.Contains(res.Output, knowledgeRegulationsFallbackPrefix) || strings.Contains(res.Output, "法规条款片段") {
t.Fatalf("project hit result = %+v", res)
}
if len(p.searches) != 0 {
t.Fatalf("regulations consulted on project hit: %+v", p.searches)
}
}
+23
View File
@@ -0,0 +1,23 @@
package app
import (
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
)
const tavilySecretService = "tools.tavily"
type activeSecretSource interface {
ActiveSecret(owner, service string) (string, bool)
}
// tavilyKeyPool 解析 Tavily 密钥池:默认属主受管密钥 → 系统行受管密钥
// (owner=settings.OwnerSystemRow)→ 设置明文 tools.tavily.apiKeys。
func tavilyKeyPool(secrets activeSecretSource, plaintext string) []string {
for _, owner := range []string{store.OwnerDefault, settings.OwnerSystemRow} {
if active, ok := secrets.ActiveSecret(owner, tavilySecretService); ok {
return splitKeyPool(active)
}
}
return splitKeyPool(plaintext)
}
+53
View File
@@ -0,0 +1,53 @@
package app
import (
"path/filepath"
"reflect"
"testing"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
)
func newTavilyKeysStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "tavily.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestTavilyKeyPoolFallsBackToSystemRowSecret(t *testing.T) {
st := newTavilyKeysStore(t)
if _, err := st.AddSecret(settings.OwnerSystemRow, tavilySecretService, "system", "fake-sys-a, fake-sys-b"); err != nil {
t.Fatal(err)
}
if got, want := tavilyKeyPool(st, "fake-plain"), []string{"fake-sys-a", "fake-sys-b"}; !reflect.DeepEqual(got, want) {
t.Fatalf("system-row key pool = %v, want %v", got, want)
}
}
func TestTavilyKeyPoolPrefersOwnerRowSecret(t *testing.T) {
st := newTavilyKeysStore(t)
if _, err := st.AddSecret(settings.OwnerSystemRow, tavilySecretService, "system", "fake-sys"); err != nil {
t.Fatal(err)
}
if _, err := st.AddSecret(store.OwnerDefault, tavilySecretService, "owner", "fake-owner"); err != nil {
t.Fatal(err)
}
if got, want := tavilyKeyPool(st, "fake-plain"), []string{"fake-owner"}; !reflect.DeepEqual(got, want) {
t.Fatalf("owner-row key pool = %v, want %v", got, want)
}
}
func TestTavilyKeyPoolFallsBackToPlaintextSetting(t *testing.T) {
st := newTavilyKeysStore(t)
if got, want := tavilyKeyPool(st, " fake-plain-a ,, fake-plain-b"), []string{"fake-plain-a", "fake-plain-b"}; !reflect.DeepEqual(got, want) {
t.Fatalf("plaintext key pool = %v, want %v", got, want)
}
if got := tavilyKeyPool(st, ""); len(got) != 0 {
t.Fatalf("empty key pool = %v", got)
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ description: 何时用工具、如何并行调用、如何处理工具结果
---
使用工具时遵循:
- 优先专用工具而非通用手段:读文件用 read_file(行号分页)、找内容用 grep、找文件用 glob、改文件用 edit_file/multi_edit(须先读过、old_string 唯一);会话工作区内的文件操作一律用这些工具,不用 run_shell 的 cat/sed 代替。检索知识用检索工具、查时间用时间工具;能用结构化工具完成的,不绕道自由文本。
- 优先专用工具而非通用手段:读文件用 read_file(行号分页)、找内容用 grep、找文件用 glob、改文件用 edit_file/multi_edit(须先读过、old_string 唯一);会话工作区内的文件操作一律用这些工具,不用 run_shell 的 cat/sed 代替。检索项目资料用 knowledge_search;法规、标准、标准号(GB、YY、YY/T、IEC 等)、指导原则类问题优先用 regulations_search,需条款原文再用 regulation_evidence;查时间用时间工具;能用结构化工具完成的,不绕道自由文本。
- 相互独立的调用并行发起;有依赖的调用按依赖顺序串行。
- 调用前确认参数完整、类型正确;不确定参数含义时先查阅工具描述,不猜测字段。
- 工具结果是事实来源:基于结果作答并如实转述;结果为空或报错时说明情况,不假装拿到了数据。
@@ -5,4 +5,4 @@ version: 1
title: knowledge_search 工具描述
description: 知识检索工具的模型侧说明文案
---
在项目知识与插件知识库中做全文检索,返回命中片段。用法:一次一个明确的检索意图;换角度检索时改关键词再查,不重复同一查询。命中为空说明库中没有该表述,可换同义词再试一到两次;仍为空就承认未命中,不虚构结果。回答"项目里有没有/是什么"类问题优先用本工具。
在项目知识与插件知识库中做全文检索,返回命中片段。用法:一次一个明确的检索意图;换角度检索时改关键词再查,不重复同一查询。命中为空说明库中没有该表述,可换同义词再试一到两次;仍为空就承认未命中,不虚构结果。回答"项目里有没有/是什么"类项目资料问题优先用本工具;法规、标准、标准号(GB、YY、YY/T、IEC 等)或指导原则类问题优先用 regulations_search,再用 regulation_evidence 取条款原文。
+5 -2
View File
@@ -13,6 +13,9 @@ import (
core "agent-system-core"
)
// KnowledgeSearchNoHits 是 knowledge_search 项目资料无命中时的输出。
const KnowledgeSearchNoHits = "(无命中)"
// Executor 是无状态执行器:入参已校验,返回输出文本或错误。
type Executor func(rc ToolExecCtx, args json.RawMessage) (string, error)
@@ -121,7 +124,7 @@ func (r *Registry) RegisterBuiltin() error {
return err
}
if err := r.Register(core.ToolDesc{
Name: "knowledge_search", Description: "在项目知识与插件知识库中做全文检索,返回命中片段", Source: core.SourceBuiltin, ReadOnly: true,
Name: "knowledge_search", Description: "在项目资料(项目知识与插件知识库)中做全文检索,返回命中片段;法规、标准、标准号(GB/YY/YY/T/IEC 等)或指导原则类问题优先用 regulations_search", Source: core.SourceBuiltin, ReadOnly: true,
Parameters: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string","description":"检索词"}},"required":["query"]}`),
}, func(rc ToolExecCtx, args json.RawMessage) (string, error) {
var a struct {
@@ -141,7 +144,7 @@ func (r *Registry) RegisterBuiltin() error {
return "", err
}
if len(hits) == 0 {
return "(无命中)", nil
return KnowledgeSearchNoHits, nil
}
var sb strings.Builder
for i, h := range hits {