feat: add configurable AgentRouter AI provider
This commit is contained in:
295
internal/growthreport/agentrouter_test.go
Normal file
295
internal/growthreport/agentrouter_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package growthreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAgentRouterClientExecutesToolsThenReturnsStructuredCopy(t *testing.T) {
|
||||
turn := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
turn++
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer key" || r.Header.Get("User-Agent") != agentRouterUserAgent {
|
||||
t.Fatalf("provider headers are missing")
|
||||
}
|
||||
var request map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request["reasoning_effort"] != "medium" || request["parallel_tool_calls"] != false {
|
||||
t.Fatalf("request controls = %#v", request)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if turn == 1 {
|
||||
_, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"finish_reason":"tool_calls","message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_event_evidence","arguments":"{\"evidenceId\":\"event_teacher\"}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":1}}`))
|
||||
return
|
||||
}
|
||||
messages := request["messages"].([]any)
|
||||
toolMessage := messages[len(messages)-1].(map[string]any)
|
||||
if toolMessage["role"] != "tool" || toolMessage["tool_call_id"] != "call_1" {
|
||||
t.Fatalf("tool result message = %#v", toolMessage)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":"chat-2","choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"{\"eventRecommendations\":[{\"evidenceId\":\"event_teacher\",\"priorityPercent\":40,\"stance\":\"PROMOTE\",\"relevanceReason\":\"این مناسبت با الگوی فروش کسبوکار همخوان است.\",\"insightBasis\":\"BUSINESS_PROFILE\"},{\"evidenceId\":\"event_yalda\",\"priorityPercent\":35,\"stance\":\"PREPARE\",\"relevanceReason\":\"این رویداد با کانال فروش شما مرتبط است.\",\"insightBasis\":\"BUSINESS_PROFILE\"},{\"evidenceId\":\"event_nowruz\",\"priorityPercent\":25,\"stance\":\"CAUTION\",\"relevanceReason\":\"این بازه برای برنامهریزی عرضه مناسب است.\",\"insightBasis\":\"BUSINESS_PROFILE\"}]}"}}],"usage":{"prompt_tokens":3,"completion_tokens":4}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
executor := &testTools{}
|
||||
client := NewAgentRouterClient(AgentRouterConfig{BaseURL: server.URL, APIKey: "key", Model: "gpt-test", Timeout: time.Second, EnableTools: true}, server.Client())
|
||||
var generator CopyGenerator = client
|
||||
copy, usage, err := generator.GenerateWithTools(context.Background(), json.RawMessage(`{"events":[]}`), profileKnownEvidence(), executor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(copy.EventRecommendations) != 3 || executor.calls != 1 || executor.evidenceID != "event_teacher" || turn != 2 || usage.InputTokens != 5 || usage.OutputTokens != 5 || usage.ProviderRequests != 2 {
|
||||
t.Fatalf("copy=%+v calls=%d evidenceID=%q turns=%d usage=%+v", copy, executor.calls, executor.evidenceID, turn, usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRouterClientRetriesRejectedSemanticOutput(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests++
|
||||
copy := validRankedCopy()
|
||||
if requests == 1 {
|
||||
copy.EventRecommendations[0].RelevanceReason = "رشد ۲۰ درصدی پیشبینی میشود."
|
||||
}
|
||||
content, err := json.Marshal(copy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "chat", "choices": []any{map[string]any{
|
||||
"finish_reason": "stop", "message": map[string]any{"role": "assistant", "content": string(content)},
|
||||
}}, "usage": map[string]any{"prompt_tokens": 2, "completion_tokens": 1},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewAgentRouterClient(AgentRouterConfig{BaseURL: server.URL, APIKey: "key", Model: "gpt-test", Timeout: time.Second}, server.Client())
|
||||
_, usage, err := client.GenerateWithTools(context.Background(), json.RawMessage(`{"events":[]}`), profileKnownEvidence(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requests != 2 || usage.ProviderRequests != 2 {
|
||||
t.Fatalf("requests=%d usage=%+v", requests, usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRouterClientPreservesSemanticErrorWhenRetryFails(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests++
|
||||
if requests == 1 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"invalid","choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"{\"eventRecommendations\":[]}"}}]}`))
|
||||
return
|
||||
}
|
||||
http.Error(w, "provider unavailable", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewAgentRouterClient(AgentRouterConfig{BaseURL: server.URL, APIKey: "key", Model: "gpt-test", Timeout: time.Second}, server.Client())
|
||||
_, _, err := client.GenerateWithTools(context.Background(), json.RawMessage(`{"events":[]}`), profileKnownEvidence(), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid event recommendation count") || !strings.Contains(err.Error(), "http 500") {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRouterClientForcesFinalResponseAtToolLimit(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
var request map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if requests <= maxAvalAIToolCalls {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "chat", "choices": []any{map[string]any{
|
||||
"finish_reason": "tool_calls", "message": map[string]any{
|
||||
"role": "assistant", "tool_calls": []any{map[string]any{
|
||||
"id": "call", "type": "function", "function": map[string]any{"name": "get_growth_context", "arguments": "{}"},
|
||||
}},
|
||||
},
|
||||
}}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1},
|
||||
})
|
||||
return
|
||||
}
|
||||
if _, hasTools := request["tools"]; hasTools {
|
||||
t.Error("tools remain available on the forced final turn")
|
||||
}
|
||||
content, err := json.Marshal(validRankedCopy())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "final", "choices": []any{map[string]any{
|
||||
"finish_reason": "stop", "message": map[string]any{"role": "assistant", "content": string(content)},
|
||||
}}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
executor := &testTools{}
|
||||
client := NewAgentRouterClient(AgentRouterConfig{BaseURL: server.URL, APIKey: "key", Model: "gpt-test", Timeout: time.Second, EnableTools: true}, server.Client())
|
||||
_, usage, err := client.GenerateWithTools(context.Background(), json.RawMessage(`{"events":[]}`), profileKnownEvidence(), executor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requests != maxAvalAIProviderTurns || executor.calls != maxAvalAIToolCalls || usage.ProviderRequests != int64(maxAvalAIProviderTurns) {
|
||||
t.Fatalf("requests=%d calls=%d usage=%+v", requests, executor.calls, usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRouterClientDefaultsToDirectStructuredGeneration(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, hasTools := request["tools"]; hasTools {
|
||||
t.Error("default AgentRouter request unexpectedly advertises tools")
|
||||
}
|
||||
content, err := json.Marshal(validRankedCopy())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "direct", "choices": []any{map[string]any{
|
||||
"finish_reason": "stop", "message": map[string]any{"role": "assistant", "content": string(content)},
|
||||
}}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewAgentRouterClient(AgentRouterConfig{BaseURL: server.URL, APIKey: "key", Model: "gpt-test", Timeout: time.Second}, server.Client())
|
||||
_, usage, err := client.GenerateWithTools(context.Background(), json.RawMessage(`{"events":[]}`), profileKnownEvidence(), &testTools{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if usage.ProviderRequests != 1 {
|
||||
t.Fatalf("usage=%+v", usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgentRouterCopyAdaptsBareRecommendationArray(t *testing.T) {
|
||||
known := KnownEvidence{
|
||||
"event:education-enrollment-autumn-1405": {InsightBasis: InsightBasisBusinessProfile, Promotable: true},
|
||||
"event:cyber-monday-2026": {InsightBasis: InsightBasisBusinessProfile, Promotable: true},
|
||||
"event:black-friday-2026": {InsightBasis: InsightBasisBusinessProfile, Promotable: true},
|
||||
}
|
||||
message := agentRouterChatMessage{Content: `[
|
||||
{"eventId":"education-enrollment-autumn-1405","priorityShare":50,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","reason":"همخوانی مستقیم با آموزش آنلاین؛ مرجع شواهد: event:education-enrollment-autumn-1405"},
|
||||
{"eventId":"cyber-monday-2026","priorityShare":30,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","reason":"تناسب موضوعی با خدمات شبکه و اینترنت؛ مرجع شواهد: event:cyber-monday-2026"},
|
||||
{"eventId":"black-friday-2026","priorityShare":20,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","reason":"فرصتی تجاری برای تقویت فروش آنلاین؛ مرجع شواهد: event:black-friday-2026"}
|
||||
]`}
|
||||
|
||||
copy, err := parseAgentRouterCopy(message, known)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(copy.EventRecommendations) != 3 || copy.EventRecommendations[0].EvidenceID != "event:education-enrollment-autumn-1405" || copy.EventRecommendations[0].PriorityPercent != 50 {
|
||||
t.Fatalf("copy=%+v", copy)
|
||||
}
|
||||
if copy.EventRecommendations[0].RelevanceReason != "همخوانی مستقیم با آموزش آنلاین" {
|
||||
t.Fatalf("reason=%q", copy.EventRecommendations[0].RelevanceReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgentRouterCopyEnforcesDeterministicStancePolicy(t *testing.T) {
|
||||
known := KnownEvidence{
|
||||
"event:decline": {InsightBasis: InsightBasisBusinessProfile, MaterialDecline: true, Promotable: true},
|
||||
"event:prepare": {InsightBasis: InsightBasisBusinessProfile, Promotable: false},
|
||||
"event:promote": {InsightBasis: InsightBasisBusinessProfile, Promotable: true},
|
||||
}
|
||||
message := agentRouterChatMessage{Content: `[
|
||||
{"eventId":"decline","priorityShare":50,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","reason":"افت معنادار پیشین نیازمند رویکرد محتاطانه است"},
|
||||
{"eventId":"prepare","priorityShare":30,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","reason":"این فرصت برای آماده سازی عملیات کسب و کار مناسب است"},
|
||||
{"eventId":"promote","priorityShare":20,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","reason":"این فرصت با فروش آنلاین کسب و کار هم خوانی دارد"}
|
||||
]`}
|
||||
|
||||
copy, err := parseAgentRouterCopy(message, known)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if copy.EventRecommendations[0].Stance != StanceCaution {
|
||||
t.Fatalf("decline stance=%q", copy.EventRecommendations[0].Stance)
|
||||
}
|
||||
if copy.EventRecommendations[1].Stance != StancePrepare {
|
||||
t.Fatalf("non-promotable stance=%q", copy.EventRecommendations[1].Stance)
|
||||
}
|
||||
if copy.EventRecommendations[2].Stance != StancePromote {
|
||||
t.Fatalf("promotable stance=%q", copy.EventRecommendations[2].Stance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgentRouterCopyRemovesRedundantEvidenceSuffixFromObject(t *testing.T) {
|
||||
known := KnownEvidence{
|
||||
"event:education-enrollment-autumn-1405": {InsightBasis: InsightBasisBusinessProfile, Promotable: true},
|
||||
"event:cyber-monday-2026": {InsightBasis: InsightBasisBusinessProfile, Promotable: true},
|
||||
"event:black-friday-2026": {InsightBasis: InsightBasisBusinessProfile, Promotable: true},
|
||||
}
|
||||
message := agentRouterChatMessage{Content: `{"eventRecommendations":[
|
||||
{"evidenceId":"event:education-enrollment-autumn-1405","priorityPercent":45,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","relevanceReason":"با آموزش آنلاین هم خوانی مستقیم دارد؛ شواهد event:education-enrollment-autumn-1405 ارتباط مستقیم را نشان می دهد."},
|
||||
{"evidenceId":"event:cyber-monday-2026","priorityPercent":35,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","relevanceReason":"با خدمات شبکه و اینترنت تناسب دارد؛ مستند به event:cyber-monday-2026."},
|
||||
{"evidenceId":"event:black-friday-2026","priorityPercent":20,"stance":"CAUTION","insightBasis":"BUSINESS_PROFILE","relevanceReason":"برای فروش آنلاین قابل استفاده است؛ مستند به event:black-friday-2026."}
|
||||
]}`}
|
||||
|
||||
copy, err := parseAgentRouterCopy(message, known)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if copy.EventRecommendations[0].RelevanceReason != "با آموزش آنلاین هم خوانی مستقیم دارد" {
|
||||
t.Fatalf("reason=%q", copy.EventRecommendations[0].RelevanceReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgentRouterCopySelectsTopThreeFromOverCompleteObject(t *testing.T) {
|
||||
known := KnownEvidence{
|
||||
"event:education": {InsightBasis: InsightBasisBusinessProfile, Promotable: true},
|
||||
"event:black": {InsightBasis: InsightBasisBusinessProfile, Promotable: true},
|
||||
"event:cyber": {InsightBasis: InsightBasisBusinessProfile, Promotable: true},
|
||||
"event:yalda": {InsightBasis: InsightBasisBusinessProfile, Promotable: true},
|
||||
"event:nowruz": {InsightBasis: InsightBasisBusinessProfile, Promotable: true},
|
||||
}
|
||||
message := agentRouterChatMessage{Content: `{"eventRecommendations":[
|
||||
{"evidenceId":"event:yalda","priorityPercent":15,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","relevanceReason":"با خرید مناسبتی و فروش حضوری هم خوانی دارد."},
|
||||
{"evidenceId":"event:black","priorityPercent":25,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","relevanceReason":"برای تقویت فروش آنلاین و تبلیغات مناسب است."},
|
||||
{"evidenceId":"event:education","priorityPercent":30,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","relevanceReason":"با کانال های فعال کسب و کار هم راستا است."},
|
||||
{"evidenceId":"event:nowruz","priorityPercent":10,"stance":"PREPARE","insightBasis":"BUSINESS_PROFILE","relevanceReason":"برای آماده سازی پیشنهادهای فروش مناسب است."},
|
||||
{"evidenceId":"event:cyber","priorityPercent":20,"stance":"CAUTION","insightBasis":"BUSINESS_PROFILE","relevanceReason":"با فروش از مسیر وب سایت تناسب دارد."}
|
||||
]}`}
|
||||
|
||||
copy, err := parseAgentRouterCopy(message, known)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(copy.EventRecommendations) != 3 {
|
||||
t.Fatalf("recommendations=%d", len(copy.EventRecommendations))
|
||||
}
|
||||
wantIDs := []string{"event:education", "event:black", "event:cyber"}
|
||||
total := 0
|
||||
for index, recommendation := range copy.EventRecommendations {
|
||||
if recommendation.EvidenceID != wantIDs[index] {
|
||||
t.Fatalf("recommendation[%d].evidenceId=%q", index, recommendation.EvidenceID)
|
||||
}
|
||||
if index > 0 && recommendation.PriorityPercent > copy.EventRecommendations[index-1].PriorityPercent {
|
||||
t.Fatalf("priorities are not descending: %+v", copy.EventRecommendations)
|
||||
}
|
||||
total += recommendation.PriorityPercent
|
||||
}
|
||||
if total != 100 {
|
||||
t.Fatalf("priority total=%d", total)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user