Files
Agent-System-Backend/internal/app/regulations_tools_test.go
T
agent-exec-transport 2182a51aa1
governance / governance (pull_request) Successful in 18s
ci / vet-and-test (pull_request) Successful in 1m15s
agent-exec transport ed457bf5f823c43a1d72b50b1f3cfeef/8e408ea19f4b
2026-10-19 09:24:43 +00:00

319 lines
14 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"agent-system-backend/internal/tools"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
type regulationsToolCtx struct{ owner string }
func (c regulationsToolCtx) ProjectID() string { return "" }
func (c regulationsToolCtx) ConversationID() string { return "" }
func (c regulationsToolCtx) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) {
return nil, nil
}
func (c regulationsToolCtx) WriteArtifact(string, string, string, []byte) (core.Artifact, error) {
return core.Artifact{}, errors.New("read-only")
}
func (c regulationsToolCtx) PeerConversations() ([]core.Conversation, error) { return nil, nil }
func (c regulationsToolCtx) PeerConversationMessages(string, int) ([]core.Message, error) {
return nil, nil
}
func (c regulationsToolCtx) OwnerID() string { return c.owner }
func (c regulationsToolCtx) WorkspaceRoot() string { return "" }
func (c regulationsToolCtx) SettingBool(_ string, d bool) bool { return d }
type recordingRegulationsProvider struct {
*fakeRegulationsProvider
searches []extension.RegulationsSearchRequest
evidences []extension.RegulationEvidenceRequest
}
func (p *recordingRegulationsProvider) SearchRegulations(ctx context.Context, c extension.RegulationsCaller, req extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) {
p.searches = append(p.searches, req)
return p.fakeRegulationsProvider.SearchRegulations(ctx, c, req)
}
func (p *recordingRegulationsProvider) ResolveRegulationEvidence(ctx context.Context, c extension.RegulationsCaller, req extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) {
p.evidences = append(p.evidences, req)
return p.fakeRegulationsProvider.ResolveRegulationEvidence(ctx, c, req)
}
func newRegulationsToolsRegistry(t *testing.T, h inprocHost) *tools.Registry {
t.Helper()
reg := tools.NewRegistry()
if err := registerRegulationsTools(reg, h.caps); err != nil {
t.Fatal(err)
}
return reg
}
func registerRecordingRegulations(t *testing.T, h inprocHost) *recordingRegulationsProvider {
t.Helper()
p := &recordingRegulationsProvider{fakeRegulationsProvider: &fakeRegulationsProvider{}}
if err := (pluginRegistry{pluginID: "kbreg", caps: h.caps}).RegisterRegulationsProvider(p); err != nil {
t.Fatal(err)
}
return p
}
func callRegulationsTool(reg *tools.Registry, name, args string) core.ToolCallResult {
return reg.Execute(regulationsToolCtx{owner: "alice"}, core.ToolCallRequest{CallID: "c1", Name: name, Arguments: json.RawMessage(args)})
}
func TestRegulationsToolsRegisteredAsReadOnlyBuiltins(t *testing.T) {
h, _ := newRegulationsTestHost(t)
reg := newRegulationsToolsRegistry(t, h)
for _, name := range []string{toolRegulationsSearch, toolRegulationEvidence} {
desc, _, ok := reg.Get(name)
if !ok || desc.Source != core.SourceBuiltin || !desc.ReadOnly || desc.Edits || desc.Hidden {
t.Fatalf("%s desc = %+v ok=%v", name, desc, ok)
}
var schema struct {
Type string `json:"type"`
Required []string `json:"required"`
Props map[string]any `json:"properties"`
}
if err := json.Unmarshal(desc.Parameters, &schema); err != nil || schema.Type != "object" || len(schema.Required) == 0 {
t.Fatalf("%s schema = %s %v", name, desc.Parameters, err)
}
for _, req := range schema.Required {
if _, ok := schema.Props[req]; !ok {
t.Fatalf("%s required %q not declared", name, req)
}
}
}
}
func TestRegulationsSearchToolMapsHitsAndClampsLimit(t *testing.T) {
h, st := newRegulationsTestHost(t)
p := registerRecordingRegulations(t, h)
p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-3", State: extension.RegulationsStateDegraded,
DegradedReasons: []string{"vector_index_unavailable"},
Hits: []extension.RegulationHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", DocNo: "GB 9706.1-2020",
Title: "医用电气设备", EditionLabel: "2020", LegalStatus: "current", ClauseNo: "7.2.1",
HeadingPath: []string{"7", "7.2"}, Excerpt: "条款片段", ExcerptStart: 3, NodeSHA256: "abc",
Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights", PageStart: 12, PageEnd: 13,
IsSummary: true, MatchKinds: []string{"docNo", "text"}, Score: 0.9}}}
reg := newRegulationsToolsRegistry(t, h)
res := callRegulationsTool(reg, toolRegulationsSearch,
`{"query":" 电气安全 ","docNo":"GB 9706.1-2020","clauseNo":"7.2.1","kinds":["standard"],"currentOnly":true,"limit":50}`)
if res.Status != "ok" {
t.Fatalf("search result = %+v", res)
}
want := regulationsToolSearchOutput{CorpusRevision: "rev-3", State: extension.RegulationsStateDegraded,
DegradedReasons: []string{"vector_index_unavailable"},
Hits: []regulationsToolHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", DocNo: "GB 9706.1-2020",
Title: "医用电气设备", EditionLabel: "2020", LegalStatus: "current", ClauseNo: "7.2.1",
HeadingPath: []string{"7", "7.2"}, Excerpt: "条款片段",
Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights", PageStart: 12, PageEnd: 13,
IsSummary: true, MatchKinds: []string{"docNo", "text"}}}}
var got regulationsToolSearchOutput
if err := json.Unmarshal([]byte(res.Output), &got); err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("search output = %s %v", res.Output, err)
}
if strings.Contains(res.Output, "score") || strings.Contains(res.Output, "nodeSha256") {
t.Fatalf("search output leaks unlisted fields: %s", res.Output)
}
wantReq := extension.RegulationsSearchRequest{SchemaVersion: regV, Query: "电气安全", Kinds: []string{"standard"},
CurrentOnly: true, DocNo: "GB 9706.1-2020", ClauseNo: "7.2.1", Limit: regulationsSearchMaxLimit}
if len(p.searches) != 1 || !reflect.DeepEqual(p.searches[0], wantReq) {
t.Fatalf("provider request = %+v", p.searches)
}
wantCaller := extension.RegulationsCaller{PluginID: regulationsToolCallerID, Actor: "alice", Owner: "alice"}
if len(p.callers) != 1 || p.callers[0] != wantCaller {
t.Fatalf("caller = %+v", p.callers)
}
callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q"}`)
callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q","limit":-3}`)
callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q","limit":7}`)
for i, want := range []int{regulationsSearchDefaultLimit, regulationsSearchDefaultLimit, 7} {
if got := p.searches[i+1].Limit; got != want {
t.Fatalf("limit case %d = %d, want %d", i, got, want)
}
}
evs, err := st.ListAuditEvents("alice", 10)
if err != nil || len(evs) != 4 {
t.Fatalf("audit events = %+v %v", evs, err)
}
for _, ev := range evs {
if ev.Action != auditRegulationsSearch || ev.Actor != "plugin:"+regulationsToolCallerID || strings.Contains(ev.Evidence, "电气安全") {
t.Fatalf("search audit = %+v", ev)
}
}
}
func TestRegulationEvidenceToolMapsFields(t *testing.T) {
h, st := newRegulationsTestHost(t)
p := registerRecordingRegulations(t, h)
p.evidence = extension.RegulationEvidence{CorpusRevision: "rev-3", RegulationID: "r1", EditionID: "e1", NodeID: "n1",
ClauseNo: "7.2.1", Excerpt: "原文", Start: 4, End: 10, NodeSHA256: "abc", SourceAssetSHA256: "def",
PageStart: 12, PageEnd: 12, Disclosure: extension.RegulationDisclosureFull, HashVerified: true}
reg := newRegulationsToolsRegistry(t, h)
res := callRegulationsTool(reg, toolRegulationEvidence, `{"corpusRevision":"rev-3","nodeId":"n1","start":4,"end":10}`)
if res.Status != "ok" {
t.Fatalf("evidence result = %+v", res)
}
var got extension.RegulationEvidence
if err := json.Unmarshal([]byte(res.Output), &got); err != nil || got != p.evidence {
t.Fatalf("evidence output = %s %v", res.Output, err)
}
wantReq := extension.RegulationEvidenceRequest{SchemaVersion: regV, CorpusRevision: "rev-3", NodeID: "n1", Start: 4, End: 10}
if len(p.evidences) != 1 || p.evidences[0] != wantReq {
t.Fatalf("provider request = %+v", p.evidences)
}
if evs, err := st.ListAuditEvents("alice", 10); err != nil || len(evs) != 1 || evs[0].Action != auditRegulationsEvidence {
t.Fatalf("evidence audit = %+v %v", evs, err)
}
}
func TestRegulationsToolsRejectMissingArgs(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerRecordingRegulations(t, h)
reg := newRegulationsToolsRegistry(t, h)
for _, c := range []struct{ name, args string }{
{toolRegulationsSearch, `{}`},
{toolRegulationsSearch, `{"query":" "}`},
{toolRegulationEvidence, `{"corpusRevision":"rev-3"}`},
{toolRegulationEvidence, `{"nodeId":"n1"}`},
{toolRegulationEvidence, `{"corpusRevision":"rev-3","nodeId":" "}`},
} {
if res := callRegulationsTool(reg, c.name, c.args); res.Status != "error" {
t.Fatalf("%s %s accepted: %+v", c.name, c.args, res)
}
}
if len(p.callers) != 0 {
t.Fatal("provider reached with missing arguments")
}
}
func TestRegulationsToolsReportUnavailable(t *testing.T) {
h, st := newRegulationsTestHost(t)
reg := newRegulationsToolsRegistry(t, h)
assertUnavailable := func(label string) {
t.Helper()
for _, c := range []struct{ name, args string }{
{toolRegulationsSearch, `{"query":"q"}`},
{toolRegulationEvidence, `{"corpusRevision":"rev-3","nodeId":"n1"}`},
} {
res := callRegulationsTool(reg, c.name, c.args)
var out regulationsToolUnavailable
if res.Status != "ok" || json.Unmarshal([]byte(res.Output), &out) != nil ||
out.State != extension.RegulationsStateUnavailable || out.Reason == "" {
t.Fatalf("%s %s = %+v", label, c.name, res)
}
}
}
assertUnavailable("no provider")
if evs, err := st.ListAuditEvents("alice", 10); err != nil || len(evs) != 0 {
t.Fatalf("unavailable calls audited: %+v %v", evs, err)
}
p := registerRecordingRegulations(t, h)
p.err = extension.ErrRegulationsUnavailable
assertUnavailable("provider unavailable")
p.err = errors.New("provider failure")
if res := callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q"}`); res.Status != "error" {
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)
}
}