diff --git a/internal/app/app.go b/internal/app/app.go index df553d4..175ae5c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) { diff --git a/internal/app/regulations_tools.go b/internal/app/regulations_tools.go index 731bcd2..89d2766 100644 --- a/internal/app/regulations_tools.go +++ b/internal/app/regulations_tools.go @@ -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 取条款原文以精确引用", diff --git a/internal/app/regulations_tools_test.go b/internal/app/regulations_tools_test.go index 3f2db92..2c1471d 100644 --- a/internal/app/regulations_tools_test.go +++ b/internal/app/regulations_tools_test.go @@ -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) + } +} diff --git a/internal/app/tavily_keys.go b/internal/app/tavily_keys.go new file mode 100644 index 0000000..8a76067 --- /dev/null +++ b/internal/app/tavily_keys.go @@ -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) +} diff --git a/internal/app/tavily_keys_test.go b/internal/app/tavily_keys_test.go new file mode 100644 index 0000000..0618c99 --- /dev/null +++ b/internal/app/tavily_keys_test.go @@ -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) + } +} diff --git a/internal/prompts/library/base-tools.md b/internal/prompts/library/base-tools.md index 724f889..5a03fea 100644 --- a/internal/prompts/library/base-tools.md +++ b/internal/prompts/library/base-tools.md @@ -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;查时间用时间工具;能用结构化工具完成的,不绕道自由文本。 - 相互独立的调用并行发起;有依赖的调用按依赖顺序串行。 - 调用前确认参数完整、类型正确;不确定参数含义时先查阅工具描述,不猜测字段。 - 工具结果是事实来源:基于结果作答并如实转述;结果为空或报错时说明情况,不假装拿到了数据。 diff --git a/internal/prompts/library/tool-knowledge-search.md b/internal/prompts/library/tool-knowledge-search.md index 61e1c34..4e859cc 100644 --- a/internal/prompts/library/tool-knowledge-search.md +++ b/internal/prompts/library/tool-knowledge-search.md @@ -5,4 +5,4 @@ version: 1 title: knowledge_search 工具描述 description: 知识检索工具的模型侧说明文案 --- -在项目知识与插件知识库中做全文检索,返回命中片段。用法:一次一个明确的检索意图;换角度检索时改关键词再查,不重复同一查询。命中为空说明库中没有该表述,可换同义词再试一到两次;仍为空就承认未命中,不虚构结果。回答"项目里有没有/是什么"类问题优先用本工具。 +在项目知识与插件知识库中做全文检索,返回命中片段。用法:一次一个明确的检索意图;换角度检索时改关键词再查,不重复同一查询。命中为空说明库中没有该表述,可换同义词再试一到两次;仍为空就承认未命中,不虚构结果。回答"项目里有没有/是什么"类项目资料问题优先用本工具;法规、标准、标准号(GB、YY、YY/T、IEC 等)或指导原则类问题优先用 regulations_search,再用 regulation_evidence 取条款原文。 diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 7d9b408..847dd3e 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -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 {