feat: add configurable AgentRouter AI provider

This commit is contained in:
2026-08-22 21:47:41 +08:00
parent 4f91726edb
commit cf2f114dc8
20 changed files with 1204 additions and 79 deletions

View 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)
}
}