309 lines
13 KiB
Go
309 lines
13 KiB
Go
package io
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||
)
|
||
|
||
// stubRetriever is a fake KnowledgeRetriever for testing knowledge responses.
|
||
// 它同时记下每次检索收到的角色,使测试能断言「角色作用域到底是谁」。
|
||
type stubRetriever struct {
|
||
results []memory.Result
|
||
err error
|
||
personas []string
|
||
}
|
||
|
||
func (s *stubRetriever) Retrieve(ctx context.Context, personaID, query string, topK int) ([]memory.Result, error) {
|
||
_ = ctx
|
||
s.personas = append(s.personas, personaID)
|
||
return s.results, s.err
|
||
}
|
||
|
||
// knowledgeToolForTest builds a tools set with opts and returns the knowledge
|
||
// tool.
|
||
func knowledgeToolForTest(t *testing.T, opts ...Option) *knowledgeTool {
|
||
t.Helper()
|
||
return findTool(t, buildTools(t, &config.Config{}, opts...), "knowledge").(*knowledgeTool)
|
||
}
|
||
|
||
// TestKnowledge_EmptyPersonaWallsInTheStore 是切片 KB1 根因①的证据型测试:
|
||
// 把**空角色**透给记忆检索器时,存储会以 ErrMissingPersona 硬失败(不是「查不到
|
||
// 命中」)。修复前 knowledge 工具在模型省略可选参数 persona 时正是这么做的 ——
|
||
// 于是模型拿到一条它无从纠正的错误(它不知道该填哪个角色 id),只能对着同一堵墙
|
||
// 反复重试(安卓侧实测:连续两次「工具调用失败 · knowledge」)。
|
||
func TestKnowledge_EmptyPersonaWallsInTheStore(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
conn, err := db.Open(ctx, ":memory:", silentLogger())
|
||
if err != nil {
|
||
t.Fatalf("db.Open: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = conn.Close() })
|
||
store, err := memory.NewStore(ctx, conn, silentLogger())
|
||
if err != nil {
|
||
t.Fatalf("memory.NewStore: %v", err)
|
||
}
|
||
retriever := memory.NewRetriever(store)
|
||
|
||
if _, err := retriever.Retrieve(ctx, "", "W15-key-test", 3); !errors.Is(err, memory.ErrMissingPersona) {
|
||
t.Fatalf("空角色检索的错误 = %v, want memory.ErrMissingPersona", err)
|
||
}
|
||
if _, err := retriever.Retrieve(ctx, "cyrene", "W15-key-test", 3); err != nil {
|
||
t.Fatalf("有角色时不应失败: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestKnowledge_MissingPersonaIsDigestibleRefusal 锁定根因①的修复:角色既不在参数
|
||
// 里、也不在本回合 ctx 上时,工具返回一条**可消化的拒绝** —— 写明不可用并要求改道,
|
||
// 而不是把底层的 missing persona 透出去(那正是「连撞两次同墙」的成因)。
|
||
func TestKnowledge_MissingPersonaIsDigestibleRefusal(t *testing.T) {
|
||
t.Parallel()
|
||
sr := &stubRetriever{}
|
||
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr))
|
||
|
||
_, err := kt.Execute(context.Background(), map[string]any{"query": "W15-key-test"})
|
||
if err == nil {
|
||
t.Fatal("没有角色时应当明确拒绝,而不是静默检索")
|
||
}
|
||
if !errors.Is(err, ErrKnowledgeUnavailable) {
|
||
t.Fatalf("err = %v, want ErrKnowledgeUnavailable(调用方要能以 errors.Is 分类)", err)
|
||
}
|
||
msg := err.Error()
|
||
if !strings.Contains(msg, "不要重复调用本工具") {
|
||
t.Fatalf("拒绝文案没有要求改道,模型会继续撞墙: %q", msg)
|
||
}
|
||
if strings.Contains(msg, "missing persona") {
|
||
t.Fatalf("拒绝文案把底层不可纠正的错误直接透给了模型: %q", msg)
|
||
}
|
||
if len(sr.personas) != 0 {
|
||
t.Fatalf("拒绝的调用不该触达检索器, got %v", sr.personas)
|
||
}
|
||
}
|
||
|
||
// TestKnowledge_UsesTurnPersonaFromContext 锁定运行时路径:模型省略 persona 参数时,
|
||
// 工具取循环发布在 ctx 上的**本回合角色**(tool.WithTurnPersona,见
|
||
// internal/runtime/tool/turncontext.go + loop 的发布点)。
|
||
func TestKnowledge_UsesTurnPersonaFromContext(t *testing.T) {
|
||
t.Parallel()
|
||
sr := &stubRetriever{}
|
||
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr))
|
||
|
||
ctx := tool.WithTurnPersona(context.Background(), "persona-turn")
|
||
if _, err := kt.Execute(ctx, map[string]any{"query": "回忆"}); err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
if len(sr.personas) != 1 || sr.personas[0] != "persona-turn" {
|
||
t.Fatalf("检索角色 = %v, want [persona-turn]", sr.personas)
|
||
}
|
||
}
|
||
|
||
// TestKnowledge_ExplicitPersonaWins 断言显式参数优先于本回合角色:模型点名角色时
|
||
// 尊重模型的选择(既有语义不变)。
|
||
func TestKnowledge_ExplicitPersonaWins(t *testing.T) {
|
||
t.Parallel()
|
||
sr := &stubRetriever{}
|
||
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr))
|
||
|
||
ctx := tool.WithTurnPersona(context.Background(), "persona-turn")
|
||
if _, err := kt.Execute(ctx, map[string]any{"query": "回忆", "persona": "persona-arg"}); err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
if len(sr.personas) != 1 || sr.personas[0] != "persona-arg" {
|
||
t.Fatalf("检索角色 = %v, want [persona-arg]", sr.personas)
|
||
}
|
||
}
|
||
|
||
func TestKnowledge_NoKnowledgeBase(t *testing.T) {
|
||
t.Parallel()
|
||
kt := knowledgeToolForTest(t) // no retriever wired
|
||
_, err := kt.Execute(context.Background(), map[string]any{"query": "cyrene"})
|
||
if !errors.Is(err, ErrKnowledgeUnavailable) {
|
||
t.Fatalf("expected ErrKnowledgeUnavailable, got %v", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "没有配置知识库") {
|
||
t.Fatalf("拒绝文案没有说明原因: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestKnowledge_ReturnsResults(t *testing.T) {
|
||
t.Parallel()
|
||
sr := &stubRetriever{results: []memory.Result{
|
||
{Item: memory.MemoryItem{ID: "1", Content: "Cyrene 是主人", Kind: memory.KindWorldEvent, Scope: memory.ScopeProject}, Score: 0.9},
|
||
}}
|
||
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr))
|
||
|
||
got, err := kt.Execute(context.Background(), map[string]any{"query": "cyrene", "persona": "p1", "top_k": 5})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
m := got.(map[string]any)
|
||
if m["count"].(int) != 1 {
|
||
t.Fatalf("count = %v", m["count"])
|
||
}
|
||
results := m["results"].([]map[string]any)
|
||
if results[0]["content"] != "Cyrene 是主人" {
|
||
t.Fatalf("content = %v", results[0]["content"])
|
||
}
|
||
if results[0]["kind"] != "world_event" {
|
||
t.Fatalf("kind = %v", results[0]["kind"])
|
||
}
|
||
}
|
||
|
||
func TestKnowledge_EmptyResults(t *testing.T) {
|
||
t.Parallel()
|
||
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{}))
|
||
ctx := tool.WithTurnPersona(context.Background(), "p1")
|
||
got, err := kt.Execute(ctx, map[string]any{"query": "nothing"})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
m := got.(map[string]any)
|
||
if m["count"].(int) != 0 {
|
||
t.Fatalf("count = %v", m["count"])
|
||
}
|
||
if m["note"] != "no relevant knowledge found" {
|
||
t.Fatalf("note = %v", m["note"])
|
||
}
|
||
if _, ok := m["results"].([]map[string]any); !ok {
|
||
t.Fatalf("results absent or wrong type: %T", m["results"])
|
||
}
|
||
}
|
||
|
||
func TestKnowledge_RetrieverError(t *testing.T) {
|
||
t.Parallel()
|
||
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{err: errors.New("boom")}))
|
||
ctx := tool.WithTurnPersona(context.Background(), "p1")
|
||
_, err := kt.Execute(ctx, map[string]any{"query": "x"})
|
||
if err == nil || !strings.Contains(err.Error(), "retrieve") {
|
||
t.Fatalf("expected retrieve error, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestKnowledge_MissingQuery(t *testing.T) {
|
||
t.Parallel()
|
||
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{}))
|
||
_, err := kt.Execute(context.Background(), map[string]any{})
|
||
if err == nil || !strings.Contains(err.Error(), "query is required") {
|
||
t.Fatalf("expected query-required error, got %v", err)
|
||
}
|
||
}
|
||
|
||
// restrictedKnowledgeResults 是群聊/陌生人场景的固定命中集:共享域非敏感项
|
||
// (用户习惯 user + 项目 project)与两类敏感项(角色日常 persona + L4 意图记录)。
|
||
func restrictedKnowledgeResults() []memory.Result {
|
||
return []memory.Result{
|
||
{Item: memory.MemoryItem{ID: "u", Content: "用户习惯:喜欢冰美式", Kind: memory.KindRelationship, Scope: memory.ScopeUser}, Score: 0.9},
|
||
{Item: memory.MemoryItem{ID: "p", Content: "项目记忆:Cyrene 用 Go 编写", Kind: memory.KindWorldEvent, Scope: memory.ScopeProject}, Score: 0.8},
|
||
{Item: memory.MemoryItem{ID: "s", Content: "角色日常:周末喜欢爬山", Kind: memory.KindRelationship, Scope: memory.ScopePersona}, Score: 0.7},
|
||
{Item: memory.MemoryItem{ID: "i", Content: "intent:isTask=true;taskKind=task;confidence=0.9;text=帮我订一杯冰美式", Kind: memory.KindRelationship, Scope: memory.ScopeUser}, Score: 0.6},
|
||
}
|
||
}
|
||
|
||
// knowledgeContents 把工具返回的 results 抽成 content 集合,便于断言泄露/保留。
|
||
func knowledgeContents(t *testing.T, got any) map[string]bool {
|
||
t.Helper()
|
||
results := got.(map[string]any)["results"].([]map[string]any)
|
||
out := make(map[string]bool, len(results))
|
||
for _, r := range results {
|
||
out[r["content"].(string)] = true
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestKnowledge_RestrictedEnv_OnlySharedVisible 是审计 2026-10-05 高风险项的证据型
|
||
// 测试:群聊/陌生人(受限环境)调用 knowledge 时,只返回非敏感共享域记忆;角色日常
|
||
// (ScopePersona,主人私有)与 L4 意图记录(主人逐句原话)绝不返回 —— 复用循环注入
|
||
// 路径的同一隔离判据(loop.FilterSharedVisible),不另造第二套。
|
||
func TestKnowledge_RestrictedEnv_OnlySharedVisible(t *testing.T) {
|
||
t.Parallel()
|
||
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{results: restrictedKnowledgeResults()}))
|
||
ctx := loop.WithSessionEnv(
|
||
tool.WithTurnPersona(context.Background(), "persona-a"),
|
||
loop.SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false},
|
||
)
|
||
|
||
got, err := kt.Execute(ctx, map[string]any{"query": "回忆"})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
if n := got.(map[string]any)["count"].(int); n != 2 {
|
||
t.Fatalf("受限环境 count = %d, want 2(只留共享域非敏感项)", n)
|
||
}
|
||
contents := knowledgeContents(t, got)
|
||
for _, want := range []string{"用户习惯:喜欢冰美式", "项目记忆:Cyrene 用 Go 编写"} {
|
||
if !contents[want] {
|
||
t.Fatalf("受限环境漏了可查的共享域记忆 %q: %v", want, contents)
|
||
}
|
||
}
|
||
for _, leaked := range []string{"角色日常:周末喜欢爬山", "intent:isTask=true;taskKind=task;confidence=0.9;text=帮我订一杯冰美式"} {
|
||
if contents[leaked] {
|
||
t.Fatalf("受限环境泄露了敏感记忆 %q: %v", leaked, contents)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestKnowledge_SurfacesLinkedProject 钉住审计 2026-10-05 §4:knowledge 工具此前
|
||
// 丢掉了 Result.Project,模型因此够不着「项目索引 → 项目描述」层级。修复后项目
|
||
// 作用域命中必须带出链接的项目条目(id/title/desc),非项目命中则不带该键。
|
||
func TestKnowledge_SurfacesLinkedProject(t *testing.T) {
|
||
t.Parallel()
|
||
sr := &stubRetriever{results: []memory.Result{
|
||
{
|
||
Item: memory.MemoryItem{ID: "m1", Content: "项目记忆:Cyrene 用 Go 编写", Kind: memory.KindWorldEvent, Scope: memory.ScopeProject},
|
||
Score: 0.7,
|
||
Project: &projectindex.Project{ID: "proj.1", Title: "Cyrene", Desc: "陪伴型 AI 助手"},
|
||
},
|
||
{
|
||
Item: memory.MemoryItem{ID: "m2", Content: "用户习惯:喜欢冰美式", Kind: memory.KindRelationship, Scope: memory.ScopeUser},
|
||
Score: 0.4,
|
||
},
|
||
}}
|
||
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr))
|
||
|
||
ctx := loop.WithSessionEnv(tool.WithTurnPersona(context.Background(), "persona-a"), loop.OwnerEnv())
|
||
got, err := kt.Execute(ctx, map[string]any{"query": "Cyrene"})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
results := got.(map[string]any)["results"].([]map[string]any)
|
||
proj, ok := results[0]["project"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("项目作用域命中未带出链接项目:%+v", results[0])
|
||
}
|
||
if proj["id"] != "proj.1" || proj["title"] != "Cyrene" || proj["desc"] != "陪伴型 AI 助手" {
|
||
t.Fatalf("project = %+v, want proj.1/Cyrene/陪伴型 AI 助手", proj)
|
||
}
|
||
if _, present := results[1]["project"]; present {
|
||
t.Fatalf("非项目命中不该带 project 键:%+v", results[1])
|
||
}
|
||
}
|
||
|
||
// TestKnowledge_OwnerEnv_AllScopes 钉住反向边界:owner(全权限)环境不做隔离过滤,
|
||
// 四类命中都照常返回 —— 修复只收窄群聊/陌生人,绝不误伤主人自己的完整读取。
|
||
func TestKnowledge_OwnerEnv_AllScopes(t *testing.T) {
|
||
t.Parallel()
|
||
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{results: restrictedKnowledgeResults()}))
|
||
ctx := loop.WithSessionEnv(
|
||
tool.WithTurnPersona(context.Background(), "persona-a"),
|
||
loop.OwnerEnv(),
|
||
)
|
||
|
||
got, err := kt.Execute(ctx, map[string]any{"query": "回忆"})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
if n := got.(map[string]any)["count"].(int); n != 4 {
|
||
t.Fatalf("owner 环境 count = %d, want 4(完整召回)", n)
|
||
}
|
||
}
|