319 lines
14 KiB
Go
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)
|
|
}
|
|
}
|