mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-04 06:13:15 +00:00
Approved by github-maintain bot. Clean team work classification feature with comprehensive tests and i18n.
435 lines
17 KiB
Go
435 lines
17 KiB
Go
package teamworkclassify
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
)
|
|
|
|
type fakeEmbedder map[string][]float32
|
|
|
|
func (f fakeEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
|
|
out := make([][]float32, 0, len(texts))
|
|
for _, text := range texts {
|
|
var vec []float32
|
|
for key, v := range f {
|
|
if strings.Contains(text, key) {
|
|
vec = v
|
|
break
|
|
}
|
|
}
|
|
if vec == nil {
|
|
vec = []float32{0, 0}
|
|
}
|
|
out = append(out, vec)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func TestClassifySpawnModeSkipsToSelf(t *testing.T) {
|
|
result := Classify(context.Background(), Input{
|
|
Mode: ModeSpawn,
|
|
Message: "lập kế hoạch content và phân tích chiến lược",
|
|
Embedder: fakeEmbedder{
|
|
"lập kế hoạch": {1, 0},
|
|
},
|
|
})
|
|
if result.Decision != DecisionSelf {
|
|
t.Fatalf("Decision = %q, want %q", result.Decision, DecisionSelf)
|
|
}
|
|
if result.Reason == "" {
|
|
t.Fatal("Reason is empty")
|
|
}
|
|
}
|
|
|
|
func TestClassifyTeamWhenRequestMatchesTeamMemberOrToolProfile(t *testing.T) {
|
|
result := Classify(context.Background(), Input{
|
|
Mode: ModeTeam,
|
|
Message: "lập kế hoạch content và phân tích chiến lược cho chiến dịch mới",
|
|
CurrentAgent: Profile{
|
|
Kind: "agent",
|
|
Name: "Bảo An",
|
|
Text: "điều phối chung",
|
|
},
|
|
SelfTools: []Profile{
|
|
{Kind: "tool", Name: "self chat", Text: "trả lời trò chuyện nhanh"},
|
|
},
|
|
Team: Profile{
|
|
Kind: "team",
|
|
Name: "Growth Team",
|
|
Text: "team content chiến lược chiến dịch",
|
|
},
|
|
Members: []Profile{
|
|
{Kind: "member", Name: "Bảo Ly Content", Text: "content bài viết kịch bản truyền thông"},
|
|
{Kind: "member", Name: "Bảo Ly Chiến lược", Text: "chiến lược kế hoạch phân tích"},
|
|
},
|
|
CollaborationTools: []Profile{
|
|
{Kind: "tool", Name: "team_tasks", Text: "chia việc giao task cho thành viên team"},
|
|
},
|
|
Embedder: fakeEmbedder{
|
|
"lập kế hoạch": {1, 0},
|
|
"content": {1, 0},
|
|
"chiến lược": {1, 0},
|
|
"điều phối": {0, 1},
|
|
"trò chuyện": {0, 1},
|
|
},
|
|
})
|
|
if result.Decision != DecisionTeam {
|
|
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionTeam, result)
|
|
}
|
|
if result.CollaborationScore <= result.SelfScore {
|
|
t.Fatalf("CollaborationScore %.3f must be greater than SelfScore %.3f", result.CollaborationScore, result.SelfScore)
|
|
}
|
|
}
|
|
|
|
func TestClassifySelfWhenCurrentAgentToolsAreBestMatch(t *testing.T) {
|
|
result := Classify(context.Background(), Input{
|
|
Mode: ModeDelegate,
|
|
Message: "dịch nhanh câu này sang tiếng Anh",
|
|
CurrentAgent: Profile{
|
|
Kind: "agent",
|
|
Name: "Translator",
|
|
Text: "dịch thuật trả lời ngắn",
|
|
},
|
|
SelfTools: []Profile{
|
|
{Kind: "tool", Name: "translate", Text: "dịch nhanh văn bản"},
|
|
},
|
|
Delegates: []Profile{
|
|
{Kind: "delegate", Name: "Planner", Text: "lập kế hoạch dự án dài"},
|
|
},
|
|
Embedder: fakeEmbedder{
|
|
"dịch nhanh": {1, 0},
|
|
"dịch thuật": {1, 0},
|
|
"lập kế hoạch": {0, 1},
|
|
},
|
|
})
|
|
if result.Decision != DecisionSelf {
|
|
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionSelf, result)
|
|
}
|
|
}
|
|
|
|
func TestClassifyDefaultsToSelfWhenScoresAreClose(t *testing.T) {
|
|
result := Classify(context.Background(), Input{
|
|
Mode: ModeTeam,
|
|
Message: "phân tích giúp việc này nên xử lý theo cá nhân hay theo team",
|
|
CurrentAgent: Profile{Kind: "agent", Name: "Lead", Text: "xử lý yêu cầu chung"},
|
|
Team: Profile{Kind: "team", Name: "Team", Text: "xử lý yêu cầu chung theo team"},
|
|
Embedder: fakeEmbedder{
|
|
"phân tích": {1, 0},
|
|
"xử lý yêu cầu chung": {1, 0},
|
|
},
|
|
})
|
|
if result.Decision != DecisionSelf {
|
|
t.Fatalf("Decision = %q, want %q; scores %.3f %.3f", result.Decision, DecisionSelf, result.SelfScore, result.CollaborationScore)
|
|
}
|
|
}
|
|
|
|
func TestClassifySelfForCasualOrWeakCloseMessages(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
message string
|
|
}{
|
|
{name: "casual greeting", message: "chào em"},
|
|
{name: "weak close scores", message: "anh hỏi thêm chút thôi"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := Classify(context.Background(), Input{
|
|
Mode: ModeTeam,
|
|
Message: tc.message,
|
|
CurrentAgent: Profile{Kind: "agent", Name: "Lead", Text: "xử lý yêu cầu chung"},
|
|
Team: Profile{Kind: "team", Name: "Team", Text: "xử lý yêu cầu chung theo team"},
|
|
Embedder: fakeEmbedder{
|
|
"xử lý yêu cầu chung": {1, 0},
|
|
},
|
|
})
|
|
if result.Decision != DecisionSelf {
|
|
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionSelf, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClassifyWithLLMUsesLongerDefaultArbiterTimeout(t *testing.T) {
|
|
provider := &fakeArbiterProvider{content: `{"decision":"self","confidence":0.5,"mode":"team","reason":"direct enough"}`}
|
|
result := ClassifyWithLLM(context.Background(), Input{
|
|
Mode: ModeTeam,
|
|
Message: "lập kế hoạch content và chiến lược cho chiến dịch mới",
|
|
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "điều phối chung"},
|
|
Team: Profile{Kind: "team", Name: "Growth Team", Text: "content chiến lược"},
|
|
Members: []Profile{{Kind: "team_member", Name: "Bảo Ly", Text: "content chiến lược"}},
|
|
Embedder: fakeEmbedder{
|
|
"lập kế hoạch": {1, 0},
|
|
"content": {1, 0},
|
|
"điều phối": {0, 1},
|
|
},
|
|
}, provider, "arbiter-model", nil)
|
|
if result.Decision != DecisionSelf {
|
|
t.Fatalf("Decision = %q, want %q", result.Decision, DecisionSelf)
|
|
}
|
|
if provider.deadlineRemaining < 25*time.Second {
|
|
t.Fatalf("arbiter deadline remaining = %s, want at least 25s", provider.deadlineRemaining)
|
|
}
|
|
}
|
|
|
|
func TestClassifyWithLLMFallsBackToSelfOnArbiterError(t *testing.T) {
|
|
provider := &fakeArbiterProvider{err: errors.New("provider unavailable")}
|
|
result := ClassifyWithLLM(context.Background(), Input{
|
|
Mode: ModeTeam,
|
|
Message: "lập kế hoạch content và chiến lược cho chiến dịch mới",
|
|
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "điều phối chung"},
|
|
Team: Profile{Kind: "team", Name: "Growth Team", Text: "content chiến lược"},
|
|
Members: []Profile{{Kind: "team_member", Name: "Bảo Ly", Text: "content chiến lược"}},
|
|
Embedder: fakeEmbedder{
|
|
"lập kế hoạch": {1, 0},
|
|
"content": {1, 0},
|
|
"điều phối": {0, 1},
|
|
},
|
|
}, provider, "arbiter-model", nil)
|
|
if result.Decision != DecisionSelf {
|
|
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionSelf, result)
|
|
}
|
|
if !strings.Contains(result.Reason, "arbiter_failed") {
|
|
t.Fatalf("Reason = %q, want arbiter_failed marker", result.Reason)
|
|
}
|
|
}
|
|
|
|
type fakeArbiterProvider struct {
|
|
content string
|
|
err error
|
|
req providers.ChatRequest
|
|
deadlineRemaining time.Duration
|
|
}
|
|
|
|
func (p *fakeArbiterProvider) Chat(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
|
|
p.req = req
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
p.deadlineRemaining = time.Until(deadline)
|
|
}
|
|
if p.err != nil {
|
|
return nil, p.err
|
|
}
|
|
return &providers.ChatResponse{Content: p.content}, nil
|
|
}
|
|
|
|
func (p *fakeArbiterProvider) ChatStream(context.Context, providers.ChatRequest, func(providers.StreamChunk)) (*providers.ChatResponse, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (p *fakeArbiterProvider) DefaultModel() string { return "fake-model" }
|
|
func (p *fakeArbiterProvider) Name() string { return "fake-provider" }
|
|
|
|
func TestParseArbiterResultAcceptsSelfOrTeamOnly(t *testing.T) {
|
|
team, err := ParseArbiterResult(`{"decision":"team","confidence":0.91,"mode":"team","required_tool":"team_tasks","reason":"needs members"}`, ModeTeam)
|
|
if err != nil {
|
|
t.Fatalf("ParseArbiterResult(team) error = %v", err)
|
|
}
|
|
if team.Decision != DecisionTeam || team.RequiredTool != "team_tasks" || team.Mode != ModeTeam {
|
|
t.Fatalf("team result = %+v", team)
|
|
}
|
|
|
|
self, err := ParseArbiterResult(`{"decision":"ask","confidence":0.55,"mode":"team","reason":"unclear"}`, ModeTeam)
|
|
if err != nil {
|
|
t.Fatalf("ParseArbiterResult(unsupported decision fallback) error = %v", err)
|
|
}
|
|
if self.Decision != DecisionSelf {
|
|
t.Fatalf("unsupported decision must fall back to self, got %+v", self)
|
|
}
|
|
}
|
|
|
|
func TestClassifyWithLLMUsesArbiterDecisionAndEvidence(t *testing.T) {
|
|
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.86,"mode":"team","required_tool":"team_tasks","reason":"requires content and strategy members"}`}
|
|
result := ClassifyWithLLM(context.Background(), Input{
|
|
Mode: ModeTeam,
|
|
Message: "lập kế hoạch content và chiến lược cho chiến dịch mới",
|
|
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "điều phối chung"},
|
|
Team: Profile{Kind: "team", Name: "Growth Team", Text: "content chiến lược"},
|
|
Members: []Profile{
|
|
{Kind: "team_member", Name: "Bảo Ly Content", Text: "content"},
|
|
{Kind: "team_member", Name: "Bảo Ly Chiến lược", Text: "chiến lược"},
|
|
},
|
|
CollaborationTools: []Profile{{Kind: "tool", Name: "team_tasks", Text: "assign team work"}},
|
|
Embedder: fakeEmbedder{
|
|
"lập kế hoạch": {1, 0},
|
|
"content": {1, 0},
|
|
"chiến lược": {1, 0},
|
|
"điều phối": {0, 1},
|
|
},
|
|
}, provider, "arbiter-model", nil)
|
|
if result.Decision != DecisionTeam || result.RequiredTool != "team_tasks" {
|
|
t.Fatalf("ClassifyWithLLM result = %+v", result)
|
|
}
|
|
if len(provider.req.Messages) < 2 {
|
|
t.Fatalf("arbiter request messages missing: %+v", provider.req)
|
|
}
|
|
joined := provider.req.Messages[0].Content + "\n" + provider.req.Messages[1].Content
|
|
for _, want := range []string{"Return ONLY JSON", "Bảo An", "Growth Team", "team_tasks", "self_score", "collaboration_score"} {
|
|
if !strings.Contains(joined, want) {
|
|
t.Fatalf("arbiter prompt missing %q:\n%s", want, joined)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClassifyWithLLMForcesSelfWhenMemberCannotAssignOrRequest(t *testing.T) {
|
|
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.91,"mode":"team","required_tool":"team_tasks","reason":"route to teammate"}`}
|
|
result := ClassifyWithLLM(context.Background(), Input{
|
|
Mode: ModeTeam,
|
|
Message: "nhờ thành viên khác làm giúp phần này",
|
|
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "team member"},
|
|
Team: Profile{Kind: "team", Name: "Team", Text: "shared work"},
|
|
TeamRole: "member",
|
|
CanAssignTeamTasks: false,
|
|
MemberRequestsEnabled: false,
|
|
Embedder: fakeEmbedder{
|
|
"nhờ thành viên": {1, 0},
|
|
"shared work": {1, 0},
|
|
"team member": {0, 1},
|
|
},
|
|
}, provider, "arbiter-model", nil)
|
|
if result.Decision != DecisionSelf {
|
|
t.Fatalf("Decision = %q, want self for member without request permission; result=%+v", result.Decision, result)
|
|
}
|
|
if result.RequiredTool != "" {
|
|
t.Fatalf("RequiredTool = %q, want empty", result.RequiredTool)
|
|
}
|
|
if !strings.Contains(result.Reason, "member lacks team request permission") {
|
|
t.Fatalf("Reason = %q, want member permission marker", result.Reason)
|
|
}
|
|
}
|
|
|
|
func TestClassifyWithLLMForcesSelfWhenMemberRequestNeedsLeaderReview(t *testing.T) {
|
|
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.88,"mode":"team","required_tool":"team_tasks","reason":"member should request help"}`}
|
|
result := ClassifyWithLLM(context.Background(), Input{
|
|
Mode: ModeTeam,
|
|
Message: "em tạo request nhờ bạn content hỗ trợ phần này",
|
|
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "team member"},
|
|
Team: Profile{Kind: "team", Name: "Team", Text: "content team"},
|
|
TeamRole: "member",
|
|
CanAssignTeamTasks: false,
|
|
MemberRequestsEnabled: true,
|
|
MemberRequestsAutoDispatch: false,
|
|
Embedder: fakeEmbedder{
|
|
"request": {1, 0},
|
|
"content team": {1, 0},
|
|
"team member": {0, 1},
|
|
},
|
|
}, provider, "arbiter-model", nil)
|
|
if result.Decision != DecisionSelf {
|
|
t.Fatalf("Decision = %q, want self when member request does not auto-dispatch; result=%+v", result.Decision, result)
|
|
}
|
|
if !strings.Contains(result.Reason, "member lacks auto-dispatch team request permission") {
|
|
t.Fatalf("Reason = %q, want auto-dispatch permission marker", result.Reason)
|
|
}
|
|
}
|
|
|
|
func TestClassifyWithLLMAllowsAutoDispatchMemberRequest(t *testing.T) {
|
|
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.88,"mode":"team","required_tool":"team_tasks","reason":"member should request help"}`}
|
|
result := ClassifyWithLLM(context.Background(), Input{
|
|
Mode: ModeTeam,
|
|
Message: "em tạo request nhờ bạn content hỗ trợ phần này",
|
|
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "team member"},
|
|
Team: Profile{Kind: "team", Name: "Team", Text: "content team"},
|
|
TeamRole: "member",
|
|
CanAssignTeamTasks: false,
|
|
MemberRequestsEnabled: true,
|
|
MemberRequestsAutoDispatch: true,
|
|
Embedder: fakeEmbedder{
|
|
"request": {1, 0},
|
|
"content team": {1, 0},
|
|
"team member": {0, 1},
|
|
},
|
|
}, provider, "arbiter-model", nil)
|
|
if result.Decision != DecisionTeam || result.RequiredTool != "team_tasks" {
|
|
t.Fatalf("ClassifyWithLLM result = %+v, want team/team_tasks", result)
|
|
}
|
|
if !strings.Contains(result.WorkflowHint, `task_type="request"`) {
|
|
t.Fatalf("WorkflowHint = %q, want task_type=request guidance", result.WorkflowHint)
|
|
}
|
|
if !strings.Contains(result.WorkflowHint, "auto-dispatch") {
|
|
t.Fatalf("WorkflowHint = %q, want auto-dispatch guidance", result.WorkflowHint)
|
|
}
|
|
}
|
|
|
|
func TestBuildArbiterMessagesIncludesStrictTeamRoutingPolicy(t *testing.T) {
|
|
messages := BuildArbiterMessages(Input{
|
|
Mode: ModeTeam,
|
|
Message: "em đọc cả 2 file rồi diễn giải lại cho anh về phương án em chọn",
|
|
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "lead and synthesize existing team outputs"},
|
|
Team: Profile{Kind: "team", Name: "Strategy Team", Text: "team research and execution"},
|
|
CollaborationTools: []Profile{
|
|
{Kind: "tool", Name: "team_tasks", Text: "search existing tasks, create tasks, assign work"},
|
|
{Kind: "capability", Name: "shared team workspace", Text: "files from previous team work"},
|
|
},
|
|
}, Result{
|
|
Decision: DecisionSelf,
|
|
SelfScore: 0.6341,
|
|
CollaborationScore: 0.6362,
|
|
})
|
|
if len(messages) < 2 {
|
|
t.Fatalf("messages = %+v, want system and user messages", messages)
|
|
}
|
|
prompt := messages[0].Content + "\n" + messages[1].Content
|
|
for _, want := range []string{
|
|
"Choose self when the user directly asks the current agent to read, summarize, explain, compare, or interpret existing files",
|
|
"Do not choose team only because files are located in the team workspace",
|
|
"Do not choose team only because the topic mentions team, workflow, strategy, content, or prior team output",
|
|
"Choose team only when the user explicitly asks to assign, delegate, split work, ask other members, create tasks, gather opinions, or perform new multi-role work",
|
|
"When self_score and collaboration_score are close, choose self unless there is a clear team signal",
|
|
} {
|
|
if !strings.Contains(prompt, want) {
|
|
t.Fatalf("arbiter prompt missing policy %q:\n%s", want, prompt)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildArbiterMessagesIncludesTeamPermissionContext(t *testing.T) {
|
|
messages := BuildArbiterMessages(Input{
|
|
Mode: ModeTeam,
|
|
Message: "nhờ thành viên khác hỗ trợ phần này",
|
|
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "member role"},
|
|
Team: Profile{Kind: "team", Name: "Team", Text: "team work"},
|
|
TeamRole: "member",
|
|
CanAssignTeamTasks: false,
|
|
MemberRequestsEnabled: true,
|
|
MemberRequestsAutoDispatch: false,
|
|
}, Result{Decision: DecisionSelf})
|
|
prompt := messages[0].Content + "\n" + messages[1].Content
|
|
for _, want := range []string{
|
|
"Team permission context",
|
|
"current_agent_team_role: member",
|
|
"can_assign_team_tasks: false",
|
|
"member_requests_enabled: true",
|
|
"member_requests_auto_dispatch: false",
|
|
"choose self",
|
|
} {
|
|
if !strings.Contains(prompt, want) {
|
|
t.Fatalf("arbiter prompt missing %q:\n%s", want, prompt)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildProfileDocumentsIncludesTeamLinksAndTools(t *testing.T) {
|
|
input := Input{
|
|
Mode: ModeTeam,
|
|
CurrentAgent: Profile{Kind: "agent", Name: "Lead", Text: "lead"},
|
|
SelfTools: []Profile{{Kind: "tool", Name: "web_search", Text: "search"}},
|
|
Team: Profile{Kind: "team", Name: "Team A", Text: "team"},
|
|
Members: []Profile{{Kind: "member", Name: "Member A", Text: "member"}},
|
|
Delegates: []Profile{{Kind: "delegate", Name: "Delegate A", Text: "delegate"}},
|
|
CollaborationTools: []Profile{
|
|
{Kind: "tool", Name: "team_tasks", Text: "team task board"},
|
|
},
|
|
}
|
|
docs := BuildProfileDocuments(input)
|
|
joined := strings.Join(docs, "\n")
|
|
for _, want := range []string{"Lead", "web_search", "Team A", "Member A", "Delegate A", "team_tasks"} {
|
|
if !strings.Contains(joined, want) {
|
|
t.Fatalf("profile documents missing %q:\n%s", want, joined)
|
|
}
|
|
}
|
|
}
|