176 lines
4.6 KiB
Go
176 lines
4.6 KiB
Go
package support
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
)
|
||
|
|
|
||
|
|
type mockCompleter struct {
|
||
|
|
text string
|
||
|
|
err error
|
||
|
|
calls int
|
||
|
|
lastSystem string
|
||
|
|
lastUser string
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *mockCompleter) Complete(ctx context.Context, system, user string) (processing.Completion, error) {
|
||
|
|
m.calls++
|
||
|
|
m.lastSystem = system
|
||
|
|
m.lastUser = user
|
||
|
|
if m.err != nil {
|
||
|
|
return processing.Completion{}, m.err
|
||
|
|
}
|
||
|
|
return processing.Completion{Text: m.text, Model: "mock"}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestParseAIAssistResult(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
got, err := parseAIAssistResult(map[string]any{
|
||
|
|
"body": "Hello",
|
||
|
|
"confidence": 0.9,
|
||
|
|
"handoff": false,
|
||
|
|
"citations": []any{"kb:pay"},
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if got.Body != "Hello" || got.Confidence != 0.9 || got.Handoff || len(got.Citations) != 1 {
|
||
|
|
t.Fatalf("%+v", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCompleterSupportAI_draftOnly(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
mc := &mockCompleter{text: `{"body":"Reset via Settings → Security.","confidence":0.91,"handoff":false,"citations":["kb:password"]}`}
|
||
|
|
sys, user := BuildAutoReplyMessages(AutoPromptInput{
|
||
|
|
Subject: "password reset",
|
||
|
|
Body: "I forgot my password",
|
||
|
|
Category: "account",
|
||
|
|
CompanyID: uuid.New(),
|
||
|
|
KBSnippets: []KBSnippet{{Slug: "password", Title: "Reset", BodyMD: "Use Settings."}},
|
||
|
|
})
|
||
|
|
comp, obj, cerr := processing.CompleteJSON(context.Background(), mc, sys, user, processing.CompleteOptions{MaxTokens: 100})
|
||
|
|
if cerr != nil {
|
||
|
|
t.Fatal(cerr)
|
||
|
|
}
|
||
|
|
if comp.Text == "" || obj["body"] == nil {
|
||
|
|
t.Fatalf("comp=%+v obj=%v", comp, obj)
|
||
|
|
}
|
||
|
|
res, err := parseAIAssistResult(obj)
|
||
|
|
if err != nil || res.Confidence < 0.9 {
|
||
|
|
t.Fatalf("res=%+v err=%v", res, err)
|
||
|
|
}
|
||
|
|
if mc.calls < 1 {
|
||
|
|
t.Fatal("expected completer call")
|
||
|
|
}
|
||
|
|
labeled := labelAIBody(res.Body)
|
||
|
|
if !strings.Contains(labeled, "AI-assisted") {
|
||
|
|
t.Fatalf("%q", labeled)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCompleterSupportAI_handoffOnLowConfidence(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
mc := &mockCompleter{text: `{"body":"Not sure","confidence":0.2,"handoff":false}`}
|
||
|
|
res, err := parseAIAssistResult(mustJSON(mc.text))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
cfg := AutoConfig{AIConfidenceThreshold: 0.65}
|
||
|
|
if !(res.Handoff || res.Confidence < cfg.AIConfidenceThreshold) {
|
||
|
|
t.Fatal("expected handoff branch")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCompleterSupportAI_handoffFlag(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
res, err := parseAIAssistResult(map[string]any{"body": "x", "confidence": 0.99, "handoff": true})
|
||
|
|
if err != nil || !res.Handoff {
|
||
|
|
t.Fatalf("%+v %v", res, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLabelAIBody(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
got := labelAIBody("Thanks for writing.")
|
||
|
|
if !strings.Contains(got, "AI-assisted") {
|
||
|
|
t.Fatalf("%q", got)
|
||
|
|
}
|
||
|
|
again := labelAIBody(got)
|
||
|
|
if strings.Count(again, "AI-assisted reply") != 1 {
|
||
|
|
t.Fatalf("double footer: %q", again)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTryAutoReplyLLM_usesRunner(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
stub := &stubAIRunner{}
|
||
|
|
s := &Service{SupportAI: stub}
|
||
|
|
// nil pool → disabled before runner
|
||
|
|
err := s.TryAutoReplyLLM(context.Background(), uuid.New())
|
||
|
|
if !errors.Is(err, ErrAIAutoReplyDisabled) {
|
||
|
|
t.Fatalf("got %v", err)
|
||
|
|
}
|
||
|
|
if stub.calls != 0 {
|
||
|
|
t.Fatal("runner should not run without pool")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func mustJSON(s string) map[string]any {
|
||
|
|
obj, err := processing.ParseJSONObject(s)
|
||
|
|
if err != nil {
|
||
|
|
panic(err)
|
||
|
|
}
|
||
|
|
return obj
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestAIDeliveryConstants(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
if AIDeliveryDraft != "draft" || AIDeliveryAutoSend != "auto_send" {
|
||
|
|
t.Fatal("delivery constants")
|
||
|
|
}
|
||
|
|
if AutoSourceAI != "ai" {
|
||
|
|
t.Fatal("auto source")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEnqueueAIFallback_nilSafe(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
s := &Service{}
|
||
|
|
if err := s.EnqueueAIFallback(context.Background(), Ticket{ID: uuid.New()}); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMaybeAutoReplyOnCreate_aiEnqueuePathDocumented(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
// Without pool, GetAutoConfig would panic — document that orchestrator requires Pool.
|
||
|
|
cfg := AutoConfig{Enabled: true, FAQEnabled: true, AIEnabled: true, MatchConfidenceThreshold: 0.78}
|
||
|
|
match := MatchAutoReplyResult{Matched: false, Confidence: 0.1, Kind: MatchKindNone}
|
||
|
|
if match.Matched && match.Confidence >= cfg.MatchConfidenceThreshold {
|
||
|
|
t.Fatal("should miss")
|
||
|
|
}
|
||
|
|
if !cfg.AIEnabled {
|
||
|
|
t.Fatal("AI should enqueue")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestRedactedJSONMetaNoPII(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
meta, _ := json.Marshal(map[string]any{
|
||
|
|
"source": AutoSourceAI,
|
||
|
|
"reason": "llm_error",
|
||
|
|
"err": RedactForAutoLog("fail sk-abcdefghijklmnopqrstuvwxyz user@example.com"),
|
||
|
|
})
|
||
|
|
s := string(meta)
|
||
|
|
if strings.Contains(s, "sk-abcdefghijklmnopqrstuvwxyz") || strings.Contains(s, "user@example.com") {
|
||
|
|
t.Fatalf("PII in meta: %s", s)
|
||
|
|
}
|
||
|
|
}
|