2070 lines
87 KiB
Go
2070 lines
87 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"ai-operator/internal/agent"
|
|
"ai-operator/internal/ai"
|
|
aifake "ai-operator/internal/ai/fake"
|
|
"ai-operator/internal/ai/llm"
|
|
realtime "ai-operator/internal/ai/openai/realtime"
|
|
"ai-operator/internal/ai/pipeline"
|
|
aiprovider "ai-operator/internal/ai/provider"
|
|
"ai-operator/internal/ai/stt"
|
|
"ai-operator/internal/ai/tts"
|
|
"ai-operator/internal/app"
|
|
"ai-operator/internal/asterisk/ari"
|
|
"ai-operator/internal/audit"
|
|
"ai-operator/internal/audit/redaction"
|
|
"ai-operator/internal/call"
|
|
"ai-operator/internal/config"
|
|
"ai-operator/internal/db"
|
|
"ai-operator/internal/dialogue"
|
|
langdetect "ai-operator/internal/dialogue/language"
|
|
"ai-operator/internal/dialogue/policy"
|
|
regiondetect "ai-operator/internal/dialogue/region"
|
|
"ai-operator/internal/dialogue/state"
|
|
"ai-operator/internal/embedding"
|
|
"ai-operator/internal/handoff"
|
|
"ai-operator/internal/kb"
|
|
"ai-operator/internal/logging"
|
|
"ai-operator/internal/media"
|
|
mediagateway "ai-operator/internal/media/gateway"
|
|
"ai-operator/internal/tools"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
const defaultLockFile = "/tmp/ai-operator-ari-events.lock"
|
|
|
|
func main() { os.Exit(run(os.Args[1:])) }
|
|
|
|
func run(args []string) int {
|
|
if len(args) == 0 {
|
|
printUsage()
|
|
return 2
|
|
}
|
|
switch args[0] {
|
|
case "version":
|
|
fmt.Printf("app: %s\nversion: %s\ngit_commit: %s\nbuild_time: %s\ngo_runtime: %s\n", app.Name, app.Version, app.GitCommit, app.BuildTime, runtime.Version())
|
|
return 0
|
|
case "doctor":
|
|
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
checkEvents := fs.Bool("check-ari-events", false, "check ARI events websocket connection")
|
|
checkOpenAI := fs.Bool("check-openai-config", false, "validate OpenAI config without network call")
|
|
checkDialogue := fs.Bool("check-dialogue", false, "validate dialogue state machine without external services")
|
|
checkLanguage := fs.Bool("check-language", false, "validate language detector without external services")
|
|
checkRegion := fs.Bool("check-region", false, "validate region resolver without external services")
|
|
checkKB := fs.Bool("check-kb", false, "validate knowledge base database")
|
|
checkAgent := fs.Bool("check-agent", false, "validate agent prompt/tools without OpenAI")
|
|
checkHandoff := fs.Bool("check-handoff", false, "validate handoff config/detector without real transfer")
|
|
checkAudit := fs.Bool("check-audit", false, "validate audit tables and redaction")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runDoctor(*envPath, *checkEvents, *checkOpenAI, *checkDialogue, *checkLanguage, *checkRegion, *checkKB, *checkAgent, *checkHandoff, *checkAudit)
|
|
|
|
case "audit-health":
|
|
fs := flag.NewFlagSet("audit-health", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runAuditHealth(*envPath)
|
|
case "redaction-self-test":
|
|
fs := flag.NewFlagSet("redaction-self-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
jsonOut := fs.Bool("json", false, "json output")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runRedactionSelfTest(*envPath, *jsonOut)
|
|
case "audit-self-test":
|
|
fs := flag.NewFlagSet("audit-self-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
jsonOut := fs.Bool("json", false, "json output")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runAuditSelfTest(*envPath, *jsonOut)
|
|
case "audit-show-call":
|
|
fs := flag.NewFlagSet("audit-show-call", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
callID := fs.String("call-id", "", "call id")
|
|
jsonOut := fs.Bool("json", false, "json output")
|
|
includeTranscripts := fs.Bool("include-transcripts", false, "include redacted transcripts")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runAuditShowCall(*envPath, *callID, *jsonOut, *includeTranscripts)
|
|
case "audit-export-call":
|
|
fs := flag.NewFlagSet("audit-export-call", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
callID := fs.String("call-id", "", "call id")
|
|
output := fs.String("output", "", "output JSON path")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runAuditExportCall(*envPath, *callID, *output)
|
|
case "audit-prune":
|
|
fs := flag.NewFlagSet("audit-prune", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
dryRun := fs.Bool("dry-run", true, "dry-run only")
|
|
confirm := fs.Bool("confirm", false, "actually prune")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runAuditPrune(*envPath, *dryRun && !*confirm)
|
|
|
|
case "kb-health":
|
|
fs := flag.NewFlagSet("kb-health", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runKBHealth(*envPath)
|
|
case "kb-migrate":
|
|
fs := flag.NewFlagSet("kb-migrate", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runKBMigrate(*envPath)
|
|
case "kb-ingest":
|
|
fs := flag.NewFlagSet("kb-ingest", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
path := fs.String("path", "/opt/ai-operator/knowledge/import/jsonl", "knowledge JSONL path")
|
|
format := fs.String("format", "jsonl", "format")
|
|
provider := fs.String("embedding-provider", "", "embedding provider override")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runKBIngest(*envPath, *path, *format, *provider)
|
|
case "kb-search":
|
|
fs := flag.NewFlagSet("kb-search", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
query := fs.String("query", "", "query")
|
|
language := fs.String("language", "ru", "language")
|
|
regionCode := fs.String("region-code", "global", "region code")
|
|
limit := fs.Int("limit", 5, "limit")
|
|
jsonOut := fs.Bool("json", false, "json output")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runKBSearch(*envPath, *query, *language, *regionCode, *limit, *jsonOut)
|
|
case "kb-self-test":
|
|
fs := flag.NewFlagSet("kb-self-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runKBSelfTest(*envPath)
|
|
case "embedding-smoke-test":
|
|
fs := flag.NewFlagSet("embedding-smoke-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runEmbeddingSmokeTest(*envPath)
|
|
case "agent-self-test":
|
|
fs := flag.NewFlagSet("agent-self-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
jsonOut := fs.Bool("json", false, "json output")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runAgentSelfTest(*envPath, *jsonOut)
|
|
case "natural-dialogue-self-test":
|
|
fs := flag.NewFlagSet("natural-dialogue-self-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
jsonOut := fs.Bool("json", false, "json output")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runNaturalDialogueSelfTest(*envPath, *jsonOut)
|
|
case "voice-style-self-test":
|
|
fs := flag.NewFlagSet("voice-style-self-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
jsonOut := fs.Bool("json", false, "json output")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runVoiceStyleSelfTest(*envPath, *jsonOut)
|
|
case "pipeline-streaming-self-test", "pipeline-latency-self-test":
|
|
fs := flag.NewFlagSet(args[0], flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
jsonOut := fs.Bool("json", false, "json output")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runPipelineStreamingSelfTest(*envPath, *jsonOut)
|
|
case "elevenlabs-realtime-stt-smoke-test":
|
|
fs := flag.NewFlagSet("elevenlabs-realtime-stt-smoke-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runElevenLabsSTTSmokeTest(*envPath)
|
|
case "elevenlabs-streaming-tts-smoke-test":
|
|
fs := flag.NewFlagSet("elevenlabs-streaming-tts-smoke-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
text := fs.String("text", "Здравствуйте, меня зовут Жанна.", "text to synthesize")
|
|
output := fs.String("output", "", "optional output path")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runElevenLabsTTSSmokeTest(*envPath, *text, *output)
|
|
case "streaming-llm-smoke-test":
|
|
fs := flag.NewFlagSet("streaming-llm-smoke-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runStreamingLLMSmokeTest(*envPath)
|
|
case "handoff-self-test":
|
|
fs := flag.NewFlagSet("handoff-self-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
mode := fs.String("mode", "disabled_stub", "handoff mode")
|
|
jsonOut := fs.Bool("json", false, "json output")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runHandoffSelfTest(*envPath, *mode, *jsonOut)
|
|
case "fallback-self-test":
|
|
fs := flag.NewFlagSet("fallback-self-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
jsonOut := fs.Bool("json", false, "json output")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runFallbackSelfTest(*envPath, *jsonOut)
|
|
case "openai-tool-smoke-test":
|
|
fs := flag.NewFlagSet("openai-tool-smoke-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runOpenAIToolSmokeTest(*envPath)
|
|
case "voice-provider-self-test":
|
|
fs := flag.NewFlagSet("voice-provider-self-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
provider := fs.String("provider", "fake", "provider name")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runVoiceProviderSelfTest(*envPath, *provider)
|
|
case "language-self-test":
|
|
fs := flag.NewFlagSet("language-self-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
jsonOut := fs.Bool("json", false, "print JSON summary")
|
|
cases := fs.String("cases", "all", "case set: basic, ambiguous, switch, all")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runLanguageSelfTest(*envPath, *cases, *jsonOut)
|
|
case "region-self-test":
|
|
fs := flag.NewFlagSet("region-self-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
jsonOut := fs.Bool("json", false, "print JSON summary")
|
|
language := fs.String("language", "ru", "language: ru or kk")
|
|
cases := fs.String("cases", "all", "case set: basic, ambiguous, switch, all")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runRegionSelfTest(*envPath, *language, *cases, *jsonOut)
|
|
case "dialogue-self-test":
|
|
fs := flag.NewFlagSet("dialogue-self-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
jsonOut := fs.Bool("json", false, "print JSON summary")
|
|
language := fs.String("language", "ru", "language: ru or kk")
|
|
regionCode := fs.String("region-code", "almaty_city", "normalized region code")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runDialogueSelfTest(*envPath, *language, *regionCode, *jsonOut)
|
|
case "openai-smoke-test":
|
|
fs := flag.NewFlagSet("openai-smoke-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
timeout := fs.Duration("timeout", 10*time.Second, "smoke test timeout")
|
|
sendSilence := fs.Bool("send-silence", false, "send generated silence")
|
|
createResponse := fs.Bool("create-response", false, "explicitly create a response")
|
|
model := fs.String("model", "", "override model")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runOpenAISmokeTest(*envPath, *timeout, *sendSilence, *createResponse, *model)
|
|
case "media-self-test":
|
|
fs := flag.NewFlagSet("media-self-test", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
duration := fs.Duration("duration", 3*time.Second, "self-test duration")
|
|
mode := fs.String("mode", "silence", "self-test media mode")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runMediaSelfTest(*envPath, *duration, *mode)
|
|
case "run":
|
|
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
|
envPath := fs.String("env", "", "path to env file")
|
|
dryRun := fs.Bool("dry-run", false, "validate config and dependencies then exit")
|
|
ariEvents := fs.Bool("ari-events", false, "connect to ARI events websocket")
|
|
observeOnly := fs.Bool("observe-only", false, "read ARI events without channel control")
|
|
callControl := fs.Bool("call-control", false, "enable test-route channel answer/hangup control")
|
|
voiceProvider := fs.String("voice-provider", "", "voice provider: fake or openai_realtime")
|
|
mediaEnabled := fs.Bool("media", false, "enable media gateway for test route")
|
|
mediaCodec := fs.String("media-codec", "", "override media codec: slin16, ulaw, alaw")
|
|
mediaTestMode := fs.String("media-test-mode", "stats", "media test mode: stats, silence, tone, echo, playback")
|
|
hangupAfter := fs.Duration("test-call-hangup-after", 5*time.Second, "test call hangup delay")
|
|
lockFile := fs.String("ari-lock-file", defaultLockFile, "ARI listener lock file")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return 2
|
|
}
|
|
return runApp(*envPath, runOptions{dryRun: *dryRun, ariEvents: *ariEvents, observeOnly: *observeOnly, callControl: *callControl, mediaEnabled: *mediaEnabled, mediaCodec: *mediaCodec, mediaTestMode: *mediaTestMode, voiceProvider: *voiceProvider, hangupAfter: *hangupAfter, lockFile: *lockFile})
|
|
default:
|
|
printUsage()
|
|
return 2
|
|
}
|
|
}
|
|
|
|
type runOptions struct {
|
|
dryRun, ariEvents, observeOnly, callControl bool
|
|
mediaEnabled bool
|
|
mediaCodec string
|
|
mediaTestMode string
|
|
voiceProvider string
|
|
hangupAfter time.Duration
|
|
lockFile string
|
|
}
|
|
|
|
func printUsage() {
|
|
fmt.Fprintln(os.Stderr, "usage: ai-operator <version|doctor|run|agent-self-test|natural-dialogue-self-test|voice-style-self-test|kb-health|audit-health|audit-self-test|redaction-self-test> [--env path]")
|
|
}
|
|
|
|
func runDoctor(envPath string, checkEvents bool, checkOpenAI bool, checkDialogue bool, checkLanguage bool, checkRegion bool, checkKB bool, checkAgent bool, checkHandoff bool, checkAudit bool) int {
|
|
cfg, path, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("config loaded: no\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
result := doctor(context.Background(), cfg, checkEvents)
|
|
if checkOpenAI {
|
|
printOpenAIConfig(cfg)
|
|
}
|
|
if checkDialogue {
|
|
printDialogueCheck()
|
|
}
|
|
if checkLanguage {
|
|
printLanguageCheck()
|
|
}
|
|
if checkRegion {
|
|
printRegionCheck()
|
|
}
|
|
if checkKB {
|
|
if !printKBCheck(cfg) {
|
|
result.OK = false
|
|
}
|
|
}
|
|
if checkAgent {
|
|
printAgentCheck()
|
|
}
|
|
if checkHandoff {
|
|
if !printHandoffCheck(cfg) {
|
|
result.OK = false
|
|
}
|
|
}
|
|
if checkAudit {
|
|
if !printAuditCheck(cfg) {
|
|
result.OK = false
|
|
}
|
|
}
|
|
printDoctor(path, cfg, result)
|
|
if !result.OK {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runApp(envPath string, opts runOptions) int {
|
|
cfg, path, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("config loaded: no\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
if opts.voiceProvider != "" {
|
|
cfg.Voice.Provider = opts.voiceProvider
|
|
}
|
|
if opts.mediaCodec != "" {
|
|
switch opts.mediaCodec {
|
|
case "slin16", "ulaw", "alaw":
|
|
cfg.Asterisk.MediaCodec = opts.mediaCodec
|
|
default:
|
|
fmt.Println("error: --media-codec must be slin16, ulaw, or alaw")
|
|
return 2
|
|
}
|
|
}
|
|
if cfg.Voice.Provider == "openai_realtime" && cfg.OpenAI.APIKey == "" {
|
|
fmt.Println("error: OPENAI_API_KEY is required for openai_realtime provider")
|
|
return 1
|
|
}
|
|
logger := logging.New(cfg.App.LogLevel)
|
|
if opts.observeOnly && opts.callControl {
|
|
fmt.Println("error: --observe-only and --call-control are mutually exclusive")
|
|
return 2
|
|
}
|
|
if opts.mediaEnabled && !opts.callControl {
|
|
fmt.Println("error: --media requires --call-control")
|
|
return 2
|
|
}
|
|
if (opts.mediaTestMode == "tone" || opts.mediaTestMode == "echo") && !opts.mediaEnabled {
|
|
fmt.Println("error: selected --media-test-mode requires --media")
|
|
return 2
|
|
}
|
|
if opts.dryRun {
|
|
result := doctor(context.Background(), cfg, false)
|
|
printDoctor(path, cfg, result)
|
|
if !result.OK {
|
|
return 1
|
|
}
|
|
fmt.Println("dry_run: ok")
|
|
return 0
|
|
}
|
|
logger.Info("starting application", "config", cfg.Sanitized(), "ari_events", opts.ariEvents)
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
application := app.New(logger, cfg)
|
|
if err := application.Start(ctx); err != nil {
|
|
logger.Error("application failed", "error", err)
|
|
return 1
|
|
}
|
|
var listenerDone chan error
|
|
var lock *ari.LockFile
|
|
if opts.ariEvents {
|
|
lock, err = ari.AcquireLock(opts.lockFile)
|
|
if err != nil {
|
|
logger.Error("failed to acquire ari listener lock", "error", err)
|
|
return 1
|
|
}
|
|
defer lock.Release()
|
|
mode := call.ManagerModeObserveOnly
|
|
if opts.callControl {
|
|
mode = call.ManagerModeCallControl
|
|
}
|
|
managerCfg := call.DefaultManagerConfig(mode, opts.hangupAfter)
|
|
managerCfg.MediaTestMode = opts.mediaTestMode
|
|
var kbSvc *kb.Service
|
|
var kbPool *pgxpool.Pool
|
|
if cfg.Database.URL != "" && opts.callControl {
|
|
openCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
pool, svc, err := openKB(openCtx, cfg, "fake")
|
|
cancel()
|
|
if err != nil {
|
|
logger.Warn("knowledge base unavailable for dialogue runtime", "error", err)
|
|
} else {
|
|
kbPool = pool
|
|
kbSvc = svc
|
|
defer kbPool.Close()
|
|
}
|
|
}
|
|
handoffManager := handoff.NewManager(handoffConfigFromConfig(cfg), handoff.SafeExecutor{ARI: ari.NewClient(cfg.Asterisk)})
|
|
fallbackManager := handoff.NewFallbackManager(fallbackConfigFromConfig(cfg))
|
|
dialogueOrchestrator := dialogue.NewMemoryOrchestratorWithServices(logger, kbSvc, handoffManager, fallbackManager)
|
|
var auditPool *pgxpool.Pool
|
|
if cfg.Database.URL != "" && opts.callControl {
|
|
openCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
apool, asvc, err := openAudit(openCtx, cfg, logger)
|
|
cancel()
|
|
if err != nil {
|
|
logger.Warn("audit unavailable for dialogue runtime", "error", err)
|
|
} else {
|
|
auditPool = apool
|
|
dialogueOrchestrator.SetAudit(asvc)
|
|
defer auditPool.Close()
|
|
}
|
|
}
|
|
managerCfg.StartDialogue = func(ctx context.Context, session *call.CallSession) (string, error) {
|
|
if _, err := dialogueOrchestrator.StartCall(ctx, *session); err != nil {
|
|
return "", err
|
|
}
|
|
return dialogueOrchestrator.SystemPrompt(session.CallID), nil
|
|
}
|
|
managerCfg.HandleVoiceEvent = dialogueOrchestrator.HandleVoiceEventResult
|
|
managerCfg.EndDialogue = dialogueOrchestrator.EndCall
|
|
if opts.mediaEnabled {
|
|
managerCfg.MediaEnabled = true
|
|
managerCfg.MediaCodec = media.Codec(cfg.Asterisk.MediaCodec)
|
|
managerCfg.VoiceInputSampleRate, managerCfg.VoiceOutputSampleRate = providerSampleRates(cfg)
|
|
managerCfg.MediaStarter = mediagateway.New(cfg, ari.NewClient(cfg.Asterisk), logger)
|
|
voiceProvider, err := aiprovider.NewVoiceProvider(cfg, logger)
|
|
if err != nil {
|
|
logger.Error("failed to initialize voice provider", "error", err)
|
|
return 1
|
|
}
|
|
managerCfg.VoiceProvider = voiceProvider
|
|
}
|
|
manager := call.NewManager(managerCfg, ari.NewClient(cfg.Asterisk), call.NewSessionStore(), logger)
|
|
listener := ari.NewEventListener(cfg.Asterisk, ari.WSAuthMode(cfg.Asterisk.ARIWSAuthMode), manager, logger)
|
|
listenerDone = make(chan error, 1)
|
|
go func() { listenerDone <- listener.Run(ctx) }()
|
|
}
|
|
if listenerDone != nil {
|
|
select {
|
|
case <-ctx.Done():
|
|
case err := <-listenerDone:
|
|
if err != nil {
|
|
logger.Error("ari listener stopped", "error", err)
|
|
return 1
|
|
}
|
|
}
|
|
} else {
|
|
<-ctx.Done()
|
|
}
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := application.Stop(shutdownCtx); err != nil && !errors.Is(err, context.Canceled) {
|
|
logger.Error("application shutdown failed", "error", err)
|
|
return 1
|
|
}
|
|
logger.Info("application stopped")
|
|
return 0
|
|
}
|
|
|
|
func providerSampleRates(cfg config.Config) (int, int) {
|
|
switch cfg.Voice.Provider {
|
|
case "pipeline_elevenlabs", "pipeline_elevenlabs_streaming":
|
|
return cfg.STT.SampleRate, cfg.Eleven.TTSSampleRate
|
|
default:
|
|
return cfg.OpenAI.RealtimeInputSampleRate, cfg.OpenAI.RealtimeOutputSampleRate
|
|
}
|
|
}
|
|
|
|
func contains(values []string, want string) bool {
|
|
for _, v := range values {
|
|
if v == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type doctorResult struct {
|
|
Health ari.HealthStatus
|
|
WSURL string
|
|
WSAuthMode string
|
|
WSConnectOK bool
|
|
WSError string
|
|
OK bool
|
|
}
|
|
|
|
func doctor(ctx context.Context, cfg config.Config, checkEvents bool) doctorResult {
|
|
client := ari.NewClient(cfg.Asterisk)
|
|
health := client.HealthCheck(ctx)
|
|
wsURL, err := ari.BuildWebSocketURL(cfg.Asterisk, ari.WSAuthMode(cfg.Asterisk.ARIWSAuthMode))
|
|
result := doctorResult{Health: health, WSAuthMode: cfg.Asterisk.ARIWSAuthMode, OK: health.AuthenticatedOK && health.UnauthenticatedReturns401}
|
|
if err == nil {
|
|
result.WSURL = wsURL.Sanitized
|
|
} else {
|
|
result.WSError = err.Error()
|
|
result.OK = false
|
|
}
|
|
if checkEvents {
|
|
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
if err := ari.CheckWebSocketConnect(cctx, cfg.Asterisk, ari.WSAuthMode(cfg.Asterisk.ARIWSAuthMode)); err != nil {
|
|
result.WSError = err.Error()
|
|
result.OK = false
|
|
} else {
|
|
result.WSConnectOK = true
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func printDoctor(path string, cfg config.Config, result doctorResult) {
|
|
sanitized := cfg.Sanitized()
|
|
fmt.Println("config loaded: yes")
|
|
fmt.Printf("env file: %s\n", path)
|
|
fmt.Printf("app env: %s\n", cfg.App.Env)
|
|
fmt.Printf("log level: %s\n", cfg.App.LogLevel)
|
|
fmt.Printf("ARI URL: %s\n", cfg.Asterisk.ARIURL)
|
|
fmt.Printf("ARI WS URL: %s\n", cfg.Asterisk.ARIWSURL)
|
|
fmt.Printf("ARI WS events URL sanitized: %s\n", result.WSURL)
|
|
fmt.Printf("ARI WS auth mode: %s\n", result.WSAuthMode)
|
|
fmt.Printf("ARI user: %s\n", cfg.Asterisk.ARIUser)
|
|
fmt.Printf("ARI password: %s\n", sanitized["ASTERISK_ARI_PASSWORD"])
|
|
fmt.Printf("ARI app: %s\n", cfg.Asterisk.ARIApp)
|
|
fmt.Printf("media mode: %s\n", cfg.Asterisk.MediaMode)
|
|
fmt.Printf("media codec: %s\n", cfg.Asterisk.MediaCodec)
|
|
fmt.Printf("OpenAI key: %s\n", sanitized["OPENAI_API_KEY"])
|
|
fmt.Printf("database URL: %s\n", sanitized["DATABASE_URL"])
|
|
fmt.Printf("authenticated ARI: %t\n", result.Health.AuthenticatedOK)
|
|
fmt.Printf("unauthenticated ARI returns 401: %t\n", result.Health.UnauthenticatedReturns401)
|
|
fmt.Printf("resources endpoint: %s\n", result.Health.ResourcesEndpoint)
|
|
if result.WSConnectOK {
|
|
fmt.Println("ARI events websocket connect: true")
|
|
}
|
|
if result.Health.Error != "" {
|
|
fmt.Printf("health error: %s\n", result.Health.Error)
|
|
}
|
|
if result.WSError != "" {
|
|
fmt.Printf("ari websocket error: %s\n", result.WSError)
|
|
}
|
|
if result.OK {
|
|
fmt.Println("doctor: ok")
|
|
} else {
|
|
fmt.Println("doctor: failed")
|
|
}
|
|
slog.Debug("doctor completed")
|
|
}
|
|
|
|
func runMediaSelfTest(envPath string, duration time.Duration, mode string) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("config loaded: no\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
if !mediagateway.ValidTestMode(mode) {
|
|
fmt.Printf("invalid media self-test mode: %s\n", mode)
|
|
return 2
|
|
}
|
|
logger := logging.New(cfg.App.LogLevel)
|
|
ctx, cancel := context.WithTimeout(context.Background(), duration+10*time.Second)
|
|
defer cancel()
|
|
result := mediagateway.New(cfg, ari.NewClient(cfg.Asterisk), logger).SelfTest(ctx, mediagateway.SelfTestConfig{Duration: duration, Mode: mediagateway.TestMode(mode)})
|
|
fmt.Printf("media self-test ok: %t\n", result.OK)
|
|
fmt.Printf("external media channel created: %t\n", result.ExternalMediaChannelCreated)
|
|
fmt.Printf("connection id retrieved: %t\n", result.ConnectionIDRetrieved)
|
|
fmt.Printf("media websocket connected: %t\n", result.MediaWebSocketConnected)
|
|
fmt.Printf("MEDIA_START received: %t\n", result.MediaStartReceived)
|
|
fmt.Printf("format: %s\n", result.Format)
|
|
fmt.Printf("optimal_frame_size: %d\n", result.OptimalFrameSize)
|
|
fmt.Printf("ptime_ms: %d\n", result.PTimeMS)
|
|
fmt.Printf("GET_STATUS sent: %t\n", result.GetStatusSent)
|
|
fmt.Printf("test payload sent: %t\n", result.TestPayloadSent)
|
|
fmt.Printf("cleanup ok: %t\n", result.CleanupOK)
|
|
if result.Error != "" {
|
|
fmt.Printf("error: %s\n", result.Error)
|
|
}
|
|
if !result.OK {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func printOpenAIConfig(cfg config.Config) {
|
|
fmt.Printf("voice provider: %s\n", cfg.Voice.Provider)
|
|
fmt.Printf("OpenAI URL valid: %t\n", cfg.OpenAI.RealtimeURL != "")
|
|
fmt.Printf("OpenAI model set: %t\n", cfg.OpenAI.RealtimeModel != "")
|
|
fmt.Printf("OpenAI API key present: %t\n", cfg.OpenAI.APIKey != "")
|
|
fmt.Printf("OpenAI live smoke enabled: %t\n", cfg.OpenAI.RealtimeLiveSmokeEnabled)
|
|
}
|
|
|
|
func runVoiceProviderSelfTest(envPath, providerName string) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("config loaded: no\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
cfg.Voice.Provider = providerName
|
|
logger := logging.New(cfg.App.LogLevel)
|
|
provider, err := aiprovider.NewVoiceProvider(cfg, logger)
|
|
if err != nil {
|
|
fmt.Printf("voice provider self-test failed: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := provider.StartSession(ctx, ai.VoiceSessionConfig{CallID: "self-test", InputAudioFormat: "pcm16", OutputAudioFormat: "pcm16", InputSampleRate: 24000, OutputSampleRate: 24000}); err != nil {
|
|
fmt.Printf("start session failed: %s\n", err)
|
|
return 1
|
|
}
|
|
_ = provider.SendAudio(ctx, media.AudioChunk{CallID: "self-test", Data: make([]byte, 320), Codec: media.CodecSLIN16, Timestamp: time.Now()})
|
|
_ = provider.Close(ctx)
|
|
st := provider.Stats()
|
|
fmt.Printf("voice provider self-test ok: true\n")
|
|
fmt.Printf("provider: %s\n", providerName)
|
|
fmt.Printf("input_audio_frames: %d\n", st.InputAudioFrames)
|
|
fmt.Printf("input_audio_bytes: %d\n", st.InputAudioBytes)
|
|
return 0
|
|
}
|
|
|
|
func runOpenAISmokeTest(envPath string, timeout time.Duration, sendSilence, createResponse bool, model string) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("config loaded: no\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
if model != "" {
|
|
cfg.OpenAI.RealtimeModel = model
|
|
}
|
|
if cfg.OpenAI.APIKey == "" {
|
|
fmt.Println("OPENAI_API_KEY is required for live OpenAI smoke-test")
|
|
return 1
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
provider := realtime.NewProvider(cfg, logging.New(cfg.App.LogLevel))
|
|
if err := provider.StartSession(ctx, ai.VoiceSessionConfig{CallID: "openai-smoke-test", InputAudioFormat: "pcm16", OutputAudioFormat: "pcm16", InputSampleRate: cfg.OpenAI.RealtimeInputSampleRate, OutputSampleRate: cfg.OpenAI.RealtimeOutputSampleRate}); err != nil {
|
|
fmt.Printf("openai smoke-test failed: %s\n", err)
|
|
return 1
|
|
}
|
|
if sendSilence {
|
|
_ = provider.SendAudio(ctx, media.AudioChunk{CallID: "openai-smoke-test", Data: make([]byte, 4800), Codec: media.CodecSLIN16, Timestamp: time.Now()})
|
|
}
|
|
if createResponse {
|
|
if msg, err := realtime.BuildResponseCreate(); err == nil {
|
|
_ = msg
|
|
}
|
|
}
|
|
_ = provider.Close(ctx)
|
|
fmt.Println("openai smoke-test ok: true")
|
|
fmt.Printf("model: %s\n", cfg.OpenAI.RealtimeModel)
|
|
fmt.Printf("send_silence: %t\n", sendSilence)
|
|
fmt.Printf("create_response: %t\n", createResponse)
|
|
return 0
|
|
}
|
|
|
|
func printDialogueCheck() {
|
|
fmt.Println("conversation state machine available: true")
|
|
fmt.Println("dialogue natural flow: true")
|
|
fmt.Println("explicit language required: false")
|
|
fmt.Println("region required before any help: false")
|
|
fmt.Println("OpenAI required for dialogue check: false")
|
|
fmt.Println("database required for dialogue check: false")
|
|
}
|
|
|
|
func runDialogueSelfTest(envPath, lang, regionCode string, jsonOut bool) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("config loaded: no\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx := context.Background()
|
|
var kbPool *pgxpool.Pool
|
|
var kbSvc *kb.Service
|
|
if cfg.Database.URL != "" {
|
|
openCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
pool, svc, err := openKB(openCtx, cfg, "fake")
|
|
cancel()
|
|
if err == nil {
|
|
kbPool = pool
|
|
kbSvc = svc
|
|
defer kbPool.Close()
|
|
}
|
|
}
|
|
o := dialogue.NewMemoryOrchestratorWithKnowledge(nil, kbSvc)
|
|
_, err = o.StartCall(ctx, call.CallSession{CallID: "dialogue-self-test", AsteriskChannelID: "dialogue-self-test", CallerNumber: "+7777***4567", Route: "test", StartedAt: time.Now()})
|
|
if err != nil {
|
|
fmt.Printf("dialogue self-test failed: %s\n", err)
|
|
return 1
|
|
}
|
|
steps := []map[string]any{}
|
|
record := func(name string, res ai.ToolResult) {
|
|
stateValue := "missing"
|
|
if s, ok := o.GetSession("dialogue-self-test"); ok {
|
|
stateValue = string(s.State)
|
|
}
|
|
steps = append(steps, map[string]any{"step": name, "error": res.Error, "result": res.Result, "state": stateValue})
|
|
}
|
|
record("global_search_without_language_region", o.HandleToolCall(ctx, "dialogue-self-test", ai.ToolCall{ID: "1", Name: tools.SearchKnowledgeBase, Arguments: map[string]any{"query": "Сколько стоит первичное подключение газа?"}}))
|
|
record("set_language", o.HandleToolCall(ctx, "dialogue-self-test", ai.ToolCall{ID: "2", Name: tools.SetLanguage, Arguments: map[string]any{"language": lang}}))
|
|
record("region_specific_without_region", o.HandleToolCall(ctx, "dialogue-self-test", ai.ToolCall{ID: "3", Name: tools.SearchKnowledgeBase, Arguments: map[string]any{"query": "Где находится филиал?"}}))
|
|
record("set_region", o.HandleToolCall(ctx, "dialogue-self-test", ai.ToolCall{ID: "4", Name: tools.SetRegion, Arguments: map[string]any{"region_code": regionCode, "display_name_ru": regionCode, "display_name_kk": regionCode}}))
|
|
record("search_after_ready", o.HandleToolCall(ctx, "dialogue-self-test", ai.ToolCall{ID: "5", Name: tools.SearchKnowledgeBase, Arguments: map[string]any{"query": "Сколько стоит первичное подключение газа?"}}))
|
|
record("handoff", o.HandleToolCall(ctx, "dialogue-self-test", ai.ToolCall{ID: "6", Name: tools.RequestHumanHandoff}))
|
|
record("end_call", o.HandleToolCall(ctx, "dialogue-self-test", ai.ToolCall{ID: "7", Name: tools.EndCall}))
|
|
ok := true
|
|
globalSearchOK := steps[0]["error"] == "" || steps[0]["error"] == "knowledge_base_unavailable" || steps[0]["error"] == "no_relevant_knowledge"
|
|
regionAskOK := steps[2]["error"] == "region_required_for_question" || steps[2]["error"] == "knowledge_base_unavailable"
|
|
searchAfterReadyOK := steps[4]["error"] == "" || steps[4]["error"] == "knowledge_base_unavailable" || steps[4]["error"] == "no_relevant_knowledge"
|
|
if len(steps) != 7 || !globalSearchOK || !regionAskOK || !searchAfterReadyOK {
|
|
ok = false
|
|
}
|
|
if jsonOut {
|
|
_ = json.NewEncoder(os.Stdout).Encode(map[string]any{"ok": ok, "steps": steps})
|
|
} else {
|
|
fmt.Printf("dialogue self-test ok: %t\n", ok)
|
|
for _, step := range steps {
|
|
fmt.Printf("%s: state=%s error=%v\n", step["step"], step["state"], step["error"])
|
|
}
|
|
}
|
|
if !ok {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func _tz06PolicyReference(_ policy.Decision, _ state.ConversationSession) {}
|
|
|
|
func printLanguageCheck() {
|
|
d := langdetect.NewDetector()
|
|
ru := d.Detect("русский", langdetect.SourceCLI)
|
|
kk := d.Detect("қазақша", langdetect.SourceCLI)
|
|
amb := d.Detect("русский или қазақша?", langdetect.SourceCLI)
|
|
fp := d.Detect("Казахтелеком", langdetect.SourceCLI)
|
|
accidental := langdetect.ShouldApplyLanguageDetection(langdetect.SelectionContext{CurrentState: state.StateReadyToHelp, CurrentLanguage: state.LanguageKK, AllowChange: true}, d.Detect("русский клиент спрашивает", langdetect.SourceCLI))
|
|
fmt.Printf("language detector available: %t\n", true)
|
|
fmt.Printf("ru phrase detection: %t\n", ru.Language == state.LanguageRU)
|
|
fmt.Printf("kk phrase detection: %t\n", kk.Language == state.LanguageKK)
|
|
fmt.Printf("ambiguous case handling: %t\n", amb.NeedsClarification)
|
|
fmt.Printf("accidental switch protection: %t\n", !accidental.Apply && fp.Language == state.LanguageUnknown)
|
|
}
|
|
|
|
func printRegionCheck() {
|
|
r := regiondetect.NewDefaultResolver()
|
|
astana := r.Resolve("Астана", regiondetect.SourceCLI)
|
|
almaty := r.Resolve("Алматы", regiondetect.SourceCLI)
|
|
city := r.ResolvePending([]string{"almaty_city", "almaty_region"}, "город", regiondetect.SourceCLI)
|
|
oblast := r.ResolvePending([]string{"almaty_city", "almaty_region"}, "область", regiondetect.SourceCLI)
|
|
fp := r.Resolve("Казахтелеком", regiondetect.SourceCLI)
|
|
fmt.Printf("region resolver available: %t\n", true)
|
|
fmt.Printf("region catalog loaded: %t\n", len(r.ListEnabled()) == 20)
|
|
fmt.Printf("active regions count: %d\n", len(r.ListEnabled()))
|
|
fmt.Printf("disabled regions count: %d\n", len(r.ListDisabled()))
|
|
fmt.Printf("Astana detection: %t\n", astana.RegionCode == "astana_city")
|
|
fmt.Printf("Almaty ambiguity: %t\n", almaty.NeedsClarification && almaty.ReasonCode == "ambiguous_almaty")
|
|
fmt.Printf("Almaty city clarification: %t\n", city.RegionCode == "almaty_city")
|
|
fmt.Printf("Almaty region clarification: %t\n", oblast.RegionCode == "almaty_region")
|
|
fmt.Printf("false-positive protection: %t\n", fp.RegionCode == "" && fp.Intent == regiondetect.IntentNotRegion)
|
|
}
|
|
|
|
func printAgentCheck() {
|
|
p := agent.BuildSystemPrompt(agent.PromptContext{State: state.StateReadyToHelp, Language: state.LanguageRU, RegionCode: "almaty_city"})
|
|
ok := strings.Contains(p, "search_knowledge_base") && strings.Contains(p, "do not invent")
|
|
fmt.Printf("agent prompt available: %t\n", true)
|
|
fmt.Printf("agent requires KB before business answer: %t\n", ok)
|
|
fmt.Printf("agent no-hallucination rule: %t\n", strings.Contains(p, "do not invent"))
|
|
fmt.Printf("OpenAI required for agent check: false\n")
|
|
}
|
|
|
|
func printHandoffCheck(cfg config.Config) bool {
|
|
d := handoff.DetectHandoffRequest("соедините с оператором", "ru")
|
|
kk := handoff.DetectHandoffRequest("операторға қосыңыз", "kk")
|
|
mode := handoff.HandoffMode(cfg.Handoff.Mode)
|
|
hasTarget := mode == handoff.HandoffModeDisabledStub || mode == handoff.HandoffModeHangupAfterMessage || cfg.Handoff.TargetEndpoint != "" || (cfg.Handoff.DialplanContext != "" && cfg.Handoff.DialplanExtension != "")
|
|
decision := handoff.Authorize(handoff.PolicyContext{State: "READY_TO_HELP", Route: "test", HandoffEnabled: cfg.Handoff.Enabled, HandoffMode: mode, AllowInTestRouteOnly: cfg.Handoff.AllowInTestRouteOnly, HasTarget: hasTarget})
|
|
ok := d.Requested && kk.Requested && decision.Allowed
|
|
fmt.Printf("handoff check ok: %t\n", ok)
|
|
fmt.Printf("handoff mode: %s\n", cfg.Handoff.Mode)
|
|
fmt.Printf("handoff enabled: %t\n", cfg.Handoff.Enabled)
|
|
fmt.Printf("handoff detector ru: %t\n", d.Requested)
|
|
fmt.Printf("handoff detector kk: %t\n", kk.Requested)
|
|
fmt.Printf("handoff policy safe: %t\n", decision.Allowed && (!cfg.Handoff.Enabled || decision.StubOnly || cfg.Handoff.Mode == "hangup_after_message" || hasTarget))
|
|
fmt.Printf("real transfer attempted: false\n")
|
|
return ok
|
|
}
|
|
|
|
func runLanguageSelfTest(envPath, cases string, jsonOut bool) int {
|
|
if _, _, err := config.Load(envPath); err != nil {
|
|
fmt.Printf("config loaded: no\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx := context.Background()
|
|
steps := []map[string]any{}
|
|
runCase := func(name, input string, setup func(*dialogue.MemoryOrchestrator), expect func(*dialogue.DialogueActionResult) bool) bool {
|
|
o := dialogue.NewMemoryOrchestrator(nil)
|
|
_, _ = o.StartCall(ctx, call.CallSession{CallID: name, AsteriskChannelID: name, Route: "test"})
|
|
if setup != nil {
|
|
setup(o)
|
|
}
|
|
res, err := o.HandleUserText(ctx, name, input)
|
|
ok := err == nil && expect(res)
|
|
entry := map[string]any{"case": name, "input": input, "ok": ok}
|
|
if res != nil {
|
|
entry["state"] = res.State
|
|
entry["language"] = res.Language
|
|
entry["applied"] = res.Applied
|
|
entry["needs_clarification"] = res.NeedsClarification
|
|
entry["reason"] = res.ReasonCode
|
|
entry["message_key"] = res.MessageKey
|
|
}
|
|
if err != nil {
|
|
entry["error"] = err.Error()
|
|
}
|
|
steps = append(steps, entry)
|
|
return ok
|
|
}
|
|
ok := true
|
|
if cases == "all" || cases == "basic" {
|
|
ok = runCase("business_before_language", "какой у меня тариф?", nil, func(r *dialogue.DialogueActionResult) bool {
|
|
return !r.Applied && r.State == state.StateLanguageSelection
|
|
}) && ok
|
|
ok = runCase("select_ru", "русский", nil, func(r *dialogue.DialogueActionResult) bool {
|
|
return r.Applied && r.Language == state.LanguageRU && r.State == state.StateRegionSelection
|
|
}) && ok
|
|
ok = runCase("select_kk", "қазақша", nil, func(r *dialogue.DialogueActionResult) bool {
|
|
return r.Applied && r.Language == state.LanguageKK && r.State == state.StateRegionSelection
|
|
}) && ok
|
|
}
|
|
if cases == "all" || cases == "ambiguous" {
|
|
ok = runCase("ambiguous_options", "русский или қазақша?", nil, func(r *dialogue.DialogueActionResult) bool {
|
|
return !r.Applied && r.NeedsClarification && r.State == state.StateLanguageSelection
|
|
}) && ok
|
|
}
|
|
if cases == "all" || cases == "switch" {
|
|
setupReady := func(o *dialogue.MemoryOrchestrator) {
|
|
_, _ = o.HandleUserText(ctx, "switch_to_kk", "русский")
|
|
_ = o.HandleToolCall(ctx, "switch_to_kk", ai.ToolCall{ID: "r", Name: tools.SetRegion, Arguments: map[string]any{"region_code": "almaty_city"}})
|
|
}
|
|
ok = runCase("switch_to_kk", "перейдите на казахский", setupReady, func(r *dialogue.DialogueActionResult) bool {
|
|
return r.Applied && r.Language == state.LanguageKK && r.State == state.StateReadyToHelp
|
|
}) && ok
|
|
setupKK := func(o *dialogue.MemoryOrchestrator) {
|
|
_, _ = o.HandleUserText(ctx, "no_accidental_switch", "қазақша")
|
|
_ = o.HandleToolCall(ctx, "no_accidental_switch", ai.ToolCall{ID: "r", Name: tools.SetRegion, Arguments: map[string]any{"region_code": "almaty_city"}})
|
|
}
|
|
ok = runCase("no_accidental_switch", "русский клиент спрашивает про тариф", setupKK, func(r *dialogue.DialogueActionResult) bool { return !r.Applied && r.Language == state.LanguageKK }) && ok
|
|
}
|
|
if jsonOut {
|
|
_ = json.NewEncoder(os.Stdout).Encode(map[string]any{"ok": ok, "steps": steps})
|
|
} else {
|
|
fmt.Printf("language self-test ok: %t\n", ok)
|
|
for _, step := range steps {
|
|
fmt.Printf("%s: state=%v language=%v applied=%v clarify=%v reason=%v\n", step["case"], step["state"], step["language"], step["applied"], step["needs_clarification"], step["reason"])
|
|
}
|
|
}
|
|
if !ok {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runRegionSelfTest(envPath, lang, cases string, jsonOut bool) int {
|
|
if _, _, err := config.Load(envPath); err != nil {
|
|
fmt.Printf("config loaded: no\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx := context.Background()
|
|
steps := []map[string]any{}
|
|
record := func(name string, res *dialogue.DialogueActionResult, err error) bool {
|
|
ok := err == nil
|
|
entry := map[string]any{"case": name, "ok": ok}
|
|
if res != nil {
|
|
entry["state"] = res.State
|
|
entry["language"] = res.Language
|
|
entry["region_code"] = res.RegionCode
|
|
entry["applied"] = res.Applied
|
|
entry["needs_clarification"] = res.NeedsClarification
|
|
entry["reason"] = res.ReasonCode
|
|
entry["message_key"] = res.MessageKey
|
|
}
|
|
if err != nil {
|
|
entry["error"] = err.Error()
|
|
}
|
|
steps = append(steps, entry)
|
|
return ok
|
|
}
|
|
newCall := func(id string) *dialogue.MemoryOrchestrator {
|
|
o := dialogue.NewMemoryOrchestrator(nil)
|
|
_, _ = o.StartCall(ctx, call.CallSession{CallID: id, AsteriskChannelID: id, Route: "test"})
|
|
return o
|
|
}
|
|
ok := true
|
|
if cases == "all" || cases == "basic" {
|
|
o := newCall("region-basic")
|
|
res, err := o.HandleUserText(ctx, "region-basic", "русский")
|
|
ok = record("select_language_ru", res, err) && res.Language == state.LanguageRU && res.State == state.StateRegionSelection && ok
|
|
denied := o.HandleToolCall(ctx, "region-basic", ai.ToolCall{ID: "kb", Name: tools.SearchKnowledgeBase})
|
|
steps = append(steps, map[string]any{"case": "search_before_region", "ok": denied.Error == "region_required", "error": denied.Error})
|
|
ok = denied.Error == "region_required" && ok
|
|
res, err = o.HandleUserText(ctx, "region-basic", "Астана")
|
|
ok = record("select_astana", res, err) && res.RegionCode == "astana_city" && res.State == state.StateReadyToHelp && ok
|
|
}
|
|
if cases == "all" || cases == "ambiguous" {
|
|
o := newCall("region-kk")
|
|
res, err := o.HandleUserText(ctx, "region-kk", "қазақша")
|
|
ok = record("select_language_kk", res, err) && res.Language == state.LanguageKK && res.State == state.StateRegionSelection && ok
|
|
res, err = o.HandleUserText(ctx, "region-kk", "Алматы")
|
|
ok = record("almaty_ambiguous", res, err) && res.NeedsClarification && res.State == state.StateRegionSelection && ok
|
|
res, err = o.HandleUserText(ctx, "region-kk", "қала")
|
|
ok = record("almaty_city_clarified", res, err) && res.RegionCode == "almaty_city" && res.State == state.StateReadyToHelp && ok
|
|
o = newCall("region-almaty-oblast")
|
|
_, _ = o.HandleUserText(ctx, "region-almaty-oblast", "русский")
|
|
res, err = o.HandleUserText(ctx, "region-almaty-oblast", "Алматы")
|
|
ok = record("almaty_ambiguous_ru", res, err) && res.NeedsClarification && ok
|
|
res, err = o.HandleUserText(ctx, "region-almaty-oblast", "область")
|
|
ok = record("almaty_region_clarified", res, err) && res.RegionCode == "almaty_region" && res.State == state.StateReadyToHelp && ok
|
|
}
|
|
if cases == "all" || cases == "switch" {
|
|
o := newCall("region-switch")
|
|
_, _ = o.HandleUserText(ctx, "region-switch", lang)
|
|
if lang == "kk" {
|
|
_, _ = o.HandleUserText(ctx, "region-switch", "Астана")
|
|
} else {
|
|
_, _ = o.HandleUserText(ctx, "region-switch", "Астана")
|
|
}
|
|
res, err := o.HandleUserText(ctx, "region-switch", "сменить регион на Шымкент")
|
|
ok = record("switch_to_shymkent", res, err) && res.RegionCode == "shymkent_city" && res.State == state.StateReadyToHelp && ok
|
|
res, err = o.HandleUserText(ctx, "region-switch", "Алматы тарифы")
|
|
ok = record("no_accidental_switch", res, err) && res.RegionCode == "shymkent_city" && !res.Applied && ok
|
|
}
|
|
if jsonOut {
|
|
_ = json.NewEncoder(os.Stdout).Encode(map[string]any{"ok": ok, "steps": steps})
|
|
} else {
|
|
fmt.Printf("region self-test ok: %t\n", ok)
|
|
for _, step := range steps {
|
|
fmt.Printf("%s: state=%v language=%v region=%v applied=%v clarify=%v reason=%v ok=%v\n", step["case"], step["state"], step["language"], step["region_code"], step["applied"], step["needs_clarification"], step["reason"], step["ok"])
|
|
}
|
|
}
|
|
if !ok {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func openKB(ctx context.Context, cfg config.Config, providerOverride string) (*pgxpool.Pool, *kb.Service, error) {
|
|
pool, err := db.OpenPool(ctx, cfg.Database)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
emb, err := embedding.NewProvider(cfg, providerOverride)
|
|
if err != nil {
|
|
pool.Close()
|
|
return nil, nil, err
|
|
}
|
|
svc := kb.NewService(kb.NewPostgresRepository(pool), emb, cfg.KB)
|
|
return pool, svc, nil
|
|
}
|
|
|
|
func openAudit(ctx context.Context, cfg config.Config, logger *slog.Logger) (*pgxpool.Pool, *audit.Service, error) {
|
|
if !cfg.Audit.Enabled {
|
|
return nil, audit.NewService(audit.NoopRepository{}, cfg.Audit, logger), nil
|
|
}
|
|
pool, err := db.OpenPool(ctx, cfg.Database)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
svc := audit.NewService(audit.NewPostgresRepository(pool), cfg.Audit, logger)
|
|
return pool, svc, nil
|
|
}
|
|
|
|
func loadMigrationMap() (map[string]string, error) {
|
|
paths, err := filepath.Glob("/opt/ai-operator/migrations/*.sql")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return db.LoadMigrationFiles(paths, os.ReadFile)
|
|
}
|
|
|
|
func handoffConfigFromConfig(cfg config.Config) handoff.Config {
|
|
return handoff.Config{Mode: handoff.HandoffMode(cfg.Handoff.Mode), Enabled: cfg.Handoff.Enabled, TargetEndpoint: cfg.Handoff.TargetEndpoint, DialplanContext: cfg.Handoff.DialplanContext, DialplanExtension: cfg.Handoff.DialplanExtension, DialplanPriority: cfg.Handoff.DialplanPriority, Timeout: cfg.Handoff.Timeout, MaxAttempts: cfg.Handoff.MaxAttempts, PlayMessageBeforeTransfer: cfg.Handoff.PlayMessageBeforeTransfer, HangupAfterStub: cfg.Handoff.HangupAfterStub, AllowInTestRouteOnly: cfg.Handoff.AllowInTestRouteOnly, MaxSummaryChars: cfg.Handoff.MaxSummaryChars}
|
|
}
|
|
|
|
func fallbackConfigFromConfig(cfg config.Config) handoff.FallbackConfig {
|
|
return handoff.FallbackConfig{MaxLanguageFailures: cfg.Fallback.MaxLanguageFailures, MaxRegionFailures: cfg.Fallback.MaxRegionFailures, MaxNoAnswer: cfg.Fallback.MaxNoAnswer, MaxKBUnavailable: cfg.Fallback.MaxKBUnavailable, MaxAIErrors: cfg.Fallback.MaxAIErrors, MaxMediaErrors: cfg.Fallback.MaxMediaErrors, MaxToolErrors: cfg.Fallback.MaxToolErrors, CallTimeout: cfg.Fallback.CallTimeout}
|
|
}
|
|
|
|
func printAuditCheck(cfg config.Config) bool {
|
|
if cfg.Database.URL == "" {
|
|
fmt.Println("audit check ok: false\nerror: DATABASE_URL is required for --check-audit")
|
|
return false
|
|
}
|
|
if cfg.Audit.StoreRawTranscripts {
|
|
fmt.Println("audit raw transcript storage default safe: false")
|
|
return false
|
|
}
|
|
red := redaction.New().RedactText("+77771234567 123456789012 4400123412341234 test@example.com sk-secretvalue")
|
|
redOK := red.Applied && !strings.Contains(red.Text, "+77771234567") && !strings.Contains(red.Text, "123456789012") && !strings.Contains(red.Text, "4400123412341234") && !strings.Contains(red.Text, "sk-secretvalue")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
pool, svc, err := openAudit(ctx, cfg, nil)
|
|
if err != nil {
|
|
fmt.Printf("audit check ok: false\nerror: %s\n", err)
|
|
return false
|
|
}
|
|
if pool != nil {
|
|
defer pool.Close()
|
|
}
|
|
if err := svc.Health(ctx); err != nil {
|
|
fmt.Printf("audit check ok: false\nerror: %s\n", err)
|
|
return false
|
|
}
|
|
ok := cfg.Audit.Enabled && cfg.Audit.Sink == "postgres" && redOK
|
|
fmt.Printf("audit check ok: %t\n", ok)
|
|
fmt.Printf("audit enabled: %t\naudit sink: %s\nredaction works: %t\nraw transcript storage: %t\n", cfg.Audit.Enabled, cfg.Audit.Sink, redOK, cfg.Audit.StoreRawTranscripts)
|
|
return ok
|
|
}
|
|
|
|
func runAuditHealth(envPath string) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("audit health ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
pool, svc, err := openAudit(ctx, cfg, nil)
|
|
if err != nil {
|
|
fmt.Printf("audit health ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
if pool != nil {
|
|
defer pool.Close()
|
|
}
|
|
if err := svc.Health(ctx); err != nil {
|
|
fmt.Printf("audit health ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
fmt.Println("audit health ok: true")
|
|
fmt.Printf("audit enabled: %t\nsink: %s\nredaction enabled: %t\nretention_days: %d\nraw_transcripts: %t\n", cfg.Audit.Enabled, cfg.Audit.Sink, cfg.Audit.RedactionEnabled, cfg.Audit.RetentionDays, cfg.Audit.StoreRawTranscripts)
|
|
return 0
|
|
}
|
|
|
|
func runRedactionSelfTest(envPath string, jsonOut bool) int {
|
|
_, _, _ = config.Load(envPath)
|
|
r := redaction.New()
|
|
cases := map[string]bool{}
|
|
out := r.RedactText("phone +77771234567 iin 123456789012 card 4400123412341234 email test@example.com код 123456 sk-secretvalue Bearer abcdefgh password=qwerty postgres://u:pass@127.0.0.1/db")
|
|
cases["phone_masking"] = !strings.Contains(out.Text, "+77771234567") && strings.Contains(out.Text, "+777***4567")
|
|
cases["iin_masking"] = !strings.Contains(out.Text, "123456789012") && strings.Contains(out.Text, "1234****9012")
|
|
cases["card_masking"] = !strings.Contains(out.Text, "4400123412341234") && strings.Contains(out.Text, "4400********1234")
|
|
cases["email_masking"] = !strings.Contains(out.Text, "test@example.com") && strings.Contains(out.Text, "t***@example.com")
|
|
cases["otp_masking"] = !strings.Contains(out.Text, "код 123456") && strings.Contains(out.Text, "код CODE")
|
|
cases["api_key_masking"] = !strings.Contains(out.Text, "sk-secretvalue") && strings.Contains(out.Text, "sk-***MASKED***")
|
|
cases["database_url_masking"] = !strings.Contains(out.Text, ":pass@")
|
|
ordinary := r.RedactText("3 рабочих дня и код услуги 5104")
|
|
cases["false_positive_handling"] = strings.Contains(ordinary.Text, "3 рабочих дня") && strings.Contains(ordinary.Text, "5104")
|
|
jsonRedacted := r.RedactJSON(map[string]any{"nested": map[string]any{"phone": "+77771234567"}})
|
|
cases["json_recursive_masking"] = !strings.Contains(fmt.Sprint(jsonRedacted), "+77771234567")
|
|
ok := true
|
|
for _, v := range cases {
|
|
ok = ok && v
|
|
}
|
|
if jsonOut {
|
|
_ = json.NewEncoder(os.Stdout).Encode(map[string]any{"ok": ok, "checks": cases})
|
|
} else {
|
|
fmt.Printf("redaction self-test ok: %t\n", ok)
|
|
for k, v := range cases {
|
|
fmt.Printf("%s: %t\n", k, v)
|
|
}
|
|
}
|
|
if !ok {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runAuditSelfTest(envPath string, jsonOut bool) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("audit self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
pool, svc, err := openAudit(ctx, cfg, nil)
|
|
if err != nil {
|
|
fmt.Printf("audit self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
if pool != nil {
|
|
defer pool.Close()
|
|
}
|
|
callID := fmt.Sprintf("audit-self-test-%d", time.Now().UnixNano())
|
|
_ = svc.UpsertCall(ctx, audit.CallRecord{CallID: callID, AsteriskChannelID: "audit-test", Route: "test", CallerNumberMasked: "+7777***4567", Language: "ru", RegionCode: "almaty_city", State: "READY_TO_HELP", StartedAt: time.Now().UTC(), Metadata: map[string]any{"purpose": "self-test"}})
|
|
_ = svc.AddEvent(ctx, audit.EventRecord{CallID: callID, EventType: "call.started", EventSource: "audit-self-test", StateAfter: "LANGUAGE_SELECTION", Severity: "info"})
|
|
_ = svc.AddEvent(ctx, audit.EventRecord{CallID: callID, EventType: "conversation.state_transition", EventSource: "audit-self-test", StateBefore: "REGION_SELECTION", StateAfter: "READY_TO_HELP", Severity: "info"})
|
|
secretText := "мой телефон +77771234567 иин 123456789012 карта 4400123412341234 email test@example.com код 123456 sk-secret"
|
|
_ = svc.AddTranscript(ctx, audit.TranscriptRecord{CallID: callID, Speaker: "user", EventType: "transcript.user.final", Language: "ru", Text: secretText})
|
|
_ = svc.AddToolAudit(ctx, audit.ToolAuditRecord{CallID: callID, ToolCallID: "tool-1", ToolName: tools.SearchKnowledgeBase, State: "READY_TO_HELP", Language: "ru", RegionCode: "almaty_city", Allowed: true, Args: map[string]any{"query": secretText}, Result: map[string]any{"ok": true, "phone": "+77771234567"}})
|
|
_ = svc.AddKBAudit(ctx, audit.KBAuditRecord{CallID: callID, Query: secretText, Language: "ru", RegionCode: "almaty_city", ResultCount: 1, TopScore: 0.9, CitationsCount: 1})
|
|
_ = svc.AddHandoffAudit(ctx, audit.HandoffAuditRecord{CallID: callID, HandoffID: "handoff-1", Mode: "disabled_stub", Status: "stubbed", Summary: secretText})
|
|
_ = svc.AddProviderAudit(ctx, audit.ProviderAuditRecord{CallID: callID, Provider: "fake", EventType: "provider.error", Severity: "error", Error: "Bearer abcdefgh"})
|
|
_ = svc.AddMediaAudit(ctx, audit.MediaAuditRecord{CallID: callID, EventType: "media.stats", Severity: "info", Codec: "slin16", InboundBytes: 320})
|
|
_ = svc.EndCall(ctx, callID, "self-test ended")
|
|
ex, err := svc.ExportCall(ctx, callID)
|
|
if err != nil {
|
|
fmt.Printf("audit self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
dump, _ := json.Marshal(ex)
|
|
dumpText := string(dump)
|
|
redacted := !strings.Contains(dumpText, "+77771234567") && !strings.Contains(dumpText, "123456789012") && !strings.Contains(dumpText, "4400123412341234") && !strings.Contains(dumpText, "test@example.com") && !strings.Contains(dumpText, "sk-secretvalue")
|
|
ok := ex.Call != nil && len(ex.Events) >= 2 && len(ex.Transcripts) >= 1 && redacted
|
|
summary := map[string]any{"ok": ok, "call_id": callID, "events": len(ex.Events), "transcripts": len(ex.Transcripts), "redactions_applied": redacted, "raw_secrets_stored": !redacted}
|
|
if jsonOut {
|
|
_ = json.NewEncoder(os.Stdout).Encode(summary)
|
|
} else {
|
|
fmt.Printf("audit self-test ok: %t\ncall_id: %s\nevents: %d\ntranscripts: %d\nredactions_applied: %t\nraw_secrets_stored: %t\n", ok, callID, len(ex.Events), len(ex.Transcripts), redacted, !redacted)
|
|
}
|
|
if !ok {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runAuditShowCall(envPath, callID string, jsonOut, includeTranscripts bool) int {
|
|
if callID == "" {
|
|
fmt.Println("call-id is required")
|
|
return 2
|
|
}
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("audit show failed: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
pool, svc, err := openAudit(ctx, cfg, nil)
|
|
if err != nil {
|
|
fmt.Printf("audit show failed: %s\n", err)
|
|
return 1
|
|
}
|
|
if pool != nil {
|
|
defer pool.Close()
|
|
}
|
|
ex, err := svc.ExportCall(ctx, callID)
|
|
if err != nil {
|
|
fmt.Printf("audit show failed: %s\n", err)
|
|
return 1
|
|
}
|
|
if !includeTranscripts {
|
|
ex.Transcripts = nil
|
|
}
|
|
if jsonOut {
|
|
_ = json.NewEncoder(os.Stdout).Encode(ex)
|
|
return 0
|
|
}
|
|
fmt.Printf("call_id: %s\n", callID)
|
|
fmt.Printf("events: %d\ntranscripts: %d\ntool_calls: %d\nkb_searches: %d\nhandoffs: %d\n", len(ex.Events), len(ex.Transcripts), len(ex.Tools), len(ex.KB), len(ex.Handoffs))
|
|
return 0
|
|
}
|
|
|
|
func runAuditExportCall(envPath, callID, output string) int {
|
|
if callID == "" || output == "" {
|
|
fmt.Println("call-id and output are required")
|
|
return 2
|
|
}
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("audit export failed: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
pool, svc, err := openAudit(ctx, cfg, nil)
|
|
if err != nil {
|
|
fmt.Printf("audit export failed: %s\n", err)
|
|
return 1
|
|
}
|
|
if pool != nil {
|
|
defer pool.Close()
|
|
}
|
|
ex, err := svc.ExportCall(ctx, callID)
|
|
if err != nil {
|
|
fmt.Printf("audit export failed: %s\n", err)
|
|
return 1
|
|
}
|
|
b, _ := json.MarshalIndent(ex, "", " ")
|
|
if err := os.WriteFile(output, b, 0600); err != nil {
|
|
fmt.Printf("audit export failed: %s\n", err)
|
|
return 1
|
|
}
|
|
fmt.Println("audit export ok: true")
|
|
fmt.Printf("output: %s\n", output)
|
|
return 0
|
|
}
|
|
|
|
func runAuditPrune(envPath string, dryRun bool) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("audit prune ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
pool, svc, err := openAudit(ctx, cfg, nil)
|
|
if err != nil {
|
|
fmt.Printf("audit prune ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
if pool != nil {
|
|
defer pool.Close()
|
|
}
|
|
res, err := svc.Prune(ctx, audit.RetentionPruneRequest{DryRun: dryRun})
|
|
if err != nil {
|
|
fmt.Printf("audit prune ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
fmt.Println("audit prune ok: true")
|
|
fmt.Printf("dry_run: %t\ncalls: %d\nevents: %d\ntranscripts: %d\ntool_audit: %d\n", res.DryRun, res.CallsDeleted, res.EventsDeleted, res.TranscriptsDeleted, res.ToolAuditDeleted)
|
|
return 0
|
|
}
|
|
|
|
func runKBMigrate(envPath string) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("config loaded: no\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
pool, err := db.OpenPool(ctx, cfg.Database)
|
|
if err != nil {
|
|
fmt.Printf("kb migrate failed: %s\n", err)
|
|
return 1
|
|
}
|
|
defer pool.Close()
|
|
files, err := loadMigrationMap()
|
|
if err != nil {
|
|
fmt.Printf("load migrations failed: %s\n", err)
|
|
return 1
|
|
}
|
|
res, err := db.ApplyMigrations(ctx, pool, files)
|
|
if err != nil {
|
|
fmt.Printf("kb migrate failed: %s\n", err)
|
|
return 1
|
|
}
|
|
fmt.Println("kb migrate ok: true")
|
|
fmt.Printf("applied: %v\n", res.Applied)
|
|
fmt.Printf("skipped: %v\n", res.Skipped)
|
|
return 0
|
|
}
|
|
|
|
func runKBHealth(envPath string) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("config loaded: no\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
pool, svc, err := openKB(ctx, cfg, "fake")
|
|
if err != nil {
|
|
fmt.Printf("kb health ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
defer pool.Close()
|
|
h, err := svc.Health(ctx)
|
|
if err != nil {
|
|
fmt.Printf("kb health ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
fmt.Printf("kb health ok: %t\n", h.DBReachable && h.VectorExtension && h.MigrationsApplied)
|
|
fmt.Printf("db reachable: %t\nvector extension: %t\nmigrations applied: %t\ndocuments: %d\nchunks: %d\n", h.DBReachable, h.VectorExtension, h.MigrationsApplied, h.Documents, h.Chunks)
|
|
if !(h.DBReachable && h.VectorExtension && h.MigrationsApplied) {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runKBIngest(envPath, path, format, providerOverride string) int {
|
|
if format != "jsonl" {
|
|
fmt.Printf("unsupported format: %s\n", format)
|
|
return 2
|
|
}
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("config loaded: no\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
pool, svc, err := openKB(ctx, cfg, providerOverride)
|
|
if err != nil {
|
|
fmt.Printf("kb ingest failed: %s\n", err)
|
|
return 1
|
|
}
|
|
defer pool.Close()
|
|
res, err := svc.IngestJSONL(ctx, path, cfg.Dialogue.RegionEnableDisabledSpecial)
|
|
if err != nil {
|
|
fmt.Printf("kb ingest failed: %s\n", err)
|
|
return 1
|
|
}
|
|
fmt.Printf("kb ingest ok: %t\n", len(res.Errors) == 0)
|
|
fmt.Printf("docs_seen: %d\ndocs_ingested: %d\nchunks_created: %d\nskipped: %d\n", res.DocsSeen, res.DocsIngested, res.ChunksCreated, res.Skipped)
|
|
for _, e := range res.Errors {
|
|
fmt.Printf("warning: %s\n", e)
|
|
}
|
|
if res.DocsIngested == 0 {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runKBSearch(envPath, query, language, regionCode string, limit int, jsonOut bool) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("config loaded: no\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
pool, svc, err := openKB(ctx, cfg, "fake")
|
|
if err != nil {
|
|
fmt.Printf("kb search failed: %s\n", err)
|
|
return 1
|
|
}
|
|
defer pool.Close()
|
|
resp, err := svc.Search(ctx, kb.SearchRequest{Query: query, Language: language, RegionCode: regionCode, Limit: limit, IncludeGlobal: true, CrossLanguageFallback: cfg.KB.CrossLanguageFallback})
|
|
if err != nil {
|
|
fmt.Printf("kb search failed: %s\n", err)
|
|
return 1
|
|
}
|
|
if jsonOut {
|
|
_ = json.NewEncoder(os.Stdout).Encode(resp)
|
|
return 0
|
|
}
|
|
fmt.Printf("kb search ok: %t\n", resp.OK)
|
|
fmt.Printf("reason_code: %s\nmessage_key: %s\ncross_language_fallback_used: %t\nresults: %d\n", resp.ReasonCode, resp.MessageKey, resp.CrossLanguageFallbackUsed, len(resp.Results))
|
|
for i, r := range resp.Results {
|
|
snip := []rune(r.Content)
|
|
if len(snip) > 180 {
|
|
snip = snip[:180]
|
|
}
|
|
fmt.Printf("%d. title=%s score=%.4f language=%s region=%s source=%s snippet=%s\n", i+1, r.Title, r.Score, r.Language, r.RegionCode, r.SourceURI, string(snip))
|
|
}
|
|
if !resp.OK {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runKBSelfTest(envPath string) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("config loaded: no\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
defer cancel()
|
|
pool, svc, err := openKB(ctx, cfg, "fake")
|
|
if err != nil {
|
|
fmt.Printf("kb self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
defer pool.Close()
|
|
files, _ := loadMigrationMap()
|
|
_, _ = db.ApplyMigrations(ctx, pool, files)
|
|
path := "/opt/ai-operator/knowledge/import/jsonl"
|
|
res, err := svc.IngestJSONL(ctx, path, false)
|
|
if err != nil {
|
|
fmt.Printf("kb self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
ru, _ := svc.Search(ctx, kb.SearchRequest{Query: "Сколько стоит первичное подключение газа?", Language: "ru", RegionCode: "almaty_city", Limit: 5, IncludeGlobal: true, CrossLanguageFallback: true})
|
|
branch, _ := svc.Search(ctx, kb.SearchRequest{Query: "Контакты филиала Алматы", Language: "ru", RegionCode: "almaty_city", Limit: 5, IncludeGlobal: true, CrossLanguageFallback: true})
|
|
kk, _ := svc.Search(ctx, kb.SearchRequest{Query: "Газды алғашқы қосу қанша тұрады?", Language: "kk", RegionCode: "almaty_city", Limit: 5, IncludeGlobal: true, CrossLanguageFallback: true})
|
|
noans, _ := svc.Search(ctx, kb.SearchRequest{Query: "zzzz nonexistent тариф марсианский", Language: "ru", RegionCode: "almaty_city", Limit: 5, IncludeGlobal: true, CrossLanguageFallback: true, MinScore: 0.95})
|
|
o := dialogue.NewMemoryOrchestratorWithKnowledge(nil, svc)
|
|
_, _ = o.StartCall(ctx, call.CallSession{CallID: "kb-self-test", AsteriskChannelID: "kb-self-test", Route: "test"})
|
|
globalNoRegion := o.HandleToolCall(ctx, "kb-self-test", ai.ToolCall{ID: "1", Name: tools.SearchKnowledgeBase, Arguments: map[string]any{"query": "Сколько стоит первичное подключение газа?"}})
|
|
regionNeeded := o.HandleToolCall(ctx, "kb-self-test", ai.ToolCall{ID: "2", Name: tools.SearchKnowledgeBase, Arguments: map[string]any{"query": "Где находится филиал?"}})
|
|
_, _ = o.HandleUserText(ctx, "kb-self-test", "Астана")
|
|
afterReady := o.HandleToolCall(ctx, "kb-self-test", ai.ToolCall{ID: "3", Name: tools.SearchKnowledgeBase, Arguments: map[string]any{"query": "Сколько стоит первичное подключение газа?"}})
|
|
ok := res.DocsIngested > 0 && ru.OK && branch.OK && kk.OK && kk.CrossLanguageFallbackUsed && !noans.OK && noans.ReasonCode == "no_relevant_knowledge" && globalNoRegion.Error == "" && regionNeeded.Error == "region_required_for_question" && afterReady.Error == ""
|
|
fmt.Printf("kb self-test ok: %t\n", ok)
|
|
fmt.Printf("docs_seen: %d\ndocs_ingested: %d\nchunks_created: %d\nskipped: %d\n", res.DocsSeen, res.DocsIngested, res.ChunksCreated, res.Skipped)
|
|
fmt.Printf("ru_search_ok: %t\nbranch_search_ok: %t\nkk_cross_language_fallback_ok: %t\nno_answer_ok: %t\nglobal_without_region_ok: %t\nregion_specific_asks_region: %t\nallowed_after_region: %t\n", ru.OK, branch.OK, kk.OK && kk.CrossLanguageFallbackUsed, !noans.OK && noans.ReasonCode == "no_relevant_knowledge", globalNoRegion.Error == "", regionNeeded.Error == "region_required_for_question", afterReady.Error == "")
|
|
if !ok {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runAgentSelfTest(envPath string, jsonOut bool) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("agent self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
pool, svc, err := openKB(ctx, cfg, "fake")
|
|
if err != nil {
|
|
fmt.Printf("agent self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
defer pool.Close()
|
|
|
|
o := dialogue.NewMemoryOrchestratorWithKnowledge(nil, svc)
|
|
callID := "agent-self-test"
|
|
session, err := o.StartCall(ctx, call.CallSession{CallID: callID, AsteriskChannelID: callID, Route: "test", CallerNumber: "+7777***4567", StartedAt: time.Now()})
|
|
if err != nil {
|
|
fmt.Printf("agent self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
provider := aifake.New()
|
|
if err := provider.StartSession(ctx, ai.VoiceSessionConfig{CallID: callID, SystemPrompt: o.SystemPrompt(callID)}); err != nil {
|
|
fmt.Printf("agent self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
|
|
globalNoRegion := o.HandleToolCall(ctx, callID, ai.ToolCall{ID: "global-no-region", Name: tools.SearchKnowledgeBase, Arguments: map[string]any{"query": "Сколько стоит первичное подключение газа?"}})
|
|
regionNeeded := o.HandleToolCall(ctx, callID, ai.ToolCall{ID: "region-needed", Name: tools.SearchKnowledgeBase, Arguments: map[string]any{"query": "Где находится филиал?"}})
|
|
langRes, _ := o.HandleUserText(ctx, callID, "русский")
|
|
regionRes, _ := o.HandleUserText(ctx, callID, "Астана")
|
|
toolEvent := ai.VoiceEvent{Type: ai.VoiceEventToolCall, CallID: callID, ToolCall: &ai.ToolCall{ID: "kb-tool", Name: tools.SearchKnowledgeBase, Arguments: map[string]any{"query": "Сколько стоит первичное подключение газа?"}}, At: time.Now().UTC()}
|
|
kbResult, err := o.HandleVoiceEventResult(ctx, callID, toolEvent)
|
|
if err == nil && kbResult != nil {
|
|
err = provider.SendToolResult(ctx, *kbResult)
|
|
}
|
|
noAnswer := o.HandleToolCall(ctx, callID, ai.ToolCall{ID: "no-answer", Name: tools.SearchKnowledgeBase, Arguments: map[string]any{"query": "zzzz nonexistent тариф марсианский", "min_score": 0.95}})
|
|
s, _ := o.GetSession(callID)
|
|
results := provider.ToolResults()
|
|
answerText := ""
|
|
citationsReturned := false
|
|
crossLanguageFallback := false
|
|
if kbResult != nil {
|
|
if payload, ok := kbResult.Result.(map[string]any); ok {
|
|
answerText, _ = payload["answer_text"].(string)
|
|
if cites, ok := payload["citations"].([]kb.Citation); ok && len(cites) > 0 {
|
|
citationsReturned = true
|
|
}
|
|
crossLanguageFallback, _ = payload["cross_language_fallback_used"].(bool)
|
|
}
|
|
}
|
|
ok := session.State == state.StateReadyToHelp &&
|
|
globalNoRegion.Error == "" &&
|
|
regionNeeded.Error == "region_required_for_question" &&
|
|
langRes != nil && langRes.State == state.StateReadyToHelp &&
|
|
regionRes != nil && regionRes.State == state.StateReadyToHelp &&
|
|
kbResult != nil && kbResult.Error == "" &&
|
|
len(results) == 1 &&
|
|
answerText != "" &&
|
|
citationsReturned &&
|
|
noAnswer.Error == "no_relevant_knowledge" &&
|
|
s.State == state.StateReadyToHelp &&
|
|
err == nil
|
|
|
|
summary := map[string]any{
|
|
"ok": ok,
|
|
"initial_state": session.State,
|
|
"language_selected": s.Language,
|
|
"region_code": s.Region.Code,
|
|
"global_without_region": globalNoRegion.Error == "",
|
|
"region_specific_asks": regionNeeded.Error == "region_required_for_question",
|
|
"kb_tool_ok": kbResult != nil && kbResult.Error == "",
|
|
"tool_results_sent_to_fake": len(results),
|
|
"answer_text_present": answerText != "",
|
|
"citations_returned": citationsReturned,
|
|
"no_answer_behavior": noAnswer.Error == "no_relevant_knowledge",
|
|
"cross_language_fallback": crossLanguageFallback,
|
|
"openai_called": false,
|
|
"final_state": s.State,
|
|
}
|
|
if jsonOut {
|
|
_ = json.NewEncoder(os.Stdout).Encode(summary)
|
|
} else {
|
|
fmt.Printf("agent self-test ok: %t\n", ok)
|
|
fmt.Printf("initial_state: %s\nlanguage: %s\nregion_code: %s\n", session.State, s.Language, s.Region.Code)
|
|
fmt.Printf("global_without_region: %t\nregion_specific_asks_region: %t\nkb_tool_ok: %t\ntool_results_sent_to_fake: %d\n", globalNoRegion.Error == "", regionNeeded.Error == "region_required_for_question", kbResult != nil && kbResult.Error == "", len(results))
|
|
fmt.Printf("answer_text_present: %t\ncitations_returned: %t\nno_answer_behavior: %t\nopenai_called: false\n", answerText != "", citationsReturned, noAnswer.Error == "no_relevant_knowledge")
|
|
}
|
|
if !ok {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runNaturalDialogueSelfTest(envPath string, jsonOut bool) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("natural dialogue self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
pool, svc, err := openKB(ctx, cfg, "fake")
|
|
if err != nil {
|
|
fmt.Printf("natural dialogue self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
defer pool.Close()
|
|
|
|
o := dialogue.NewMemoryOrchestratorWithKnowledge(nil, svc)
|
|
callID := "natural-dialogue-self-test"
|
|
session, err := o.StartCall(ctx, call.CallSession{CallID: callID, AsteriskChannelID: callID, Route: "test", CallerNumber: "+7777***4567", StartedAt: time.Now()})
|
|
if err != nil {
|
|
fmt.Printf("natural dialogue self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
prompt := o.SystemPrompt(callID)
|
|
global := o.HandleToolCall(ctx, callID, ai.ToolCall{ID: "global", Name: tools.SearchKnowledgeBase, Arguments: map[string]any{"query": "Сколько стоит первичное подключение газа?"}})
|
|
regionNeeded := o.HandleToolCall(ctx, callID, ai.ToolCall{ID: "region-needed", Name: tools.SearchKnowledgeBase, Arguments: map[string]any{"query": "Где находится филиал?"}})
|
|
almaty, _ := o.HandleUserText(ctx, callID, "Алматы")
|
|
city, _ := o.HandleUserText(ctx, callID, "город")
|
|
branch := o.HandleToolCall(ctx, callID, ai.ToolCall{ID: "branch", Name: tools.SearchKnowledgeBase, Arguments: map[string]any{"query": "Контакты филиала", "limit": 5}})
|
|
kk, _ := o.HandleUserText(ctx, callID, "Қазақша сөйлейік")
|
|
handoffRes, _ := o.HandleUserText(ctx, callID, "Оператор")
|
|
s, _ := o.GetSession(callID)
|
|
|
|
globalAnswer := ""
|
|
if payload, ok := global.Result.(map[string]any); ok {
|
|
globalAnswer, _ = payload["answer_text"].(string)
|
|
}
|
|
ok := session.State == state.StateReadyToHelp &&
|
|
strings.Contains(prompt, "Zhanna") &&
|
|
strings.Contains(prompt, "QazAimaqGas") &&
|
|
!strings.Contains(strings.ToLower(prompt), "choose language") &&
|
|
global.Error == "" &&
|
|
globalAnswer != "" &&
|
|
regionNeeded.Error == "region_required_for_question" &&
|
|
almaty != nil && almaty.NeedsClarification &&
|
|
city != nil && city.RegionCode == "almaty_city" &&
|
|
branch.Error == "" &&
|
|
kk != nil && kk.Language == state.LanguageKK &&
|
|
handoffRes != nil && handoffRes.ToolResult != nil &&
|
|
s.State == state.StateHandoff
|
|
summary := map[string]any{
|
|
"ok": ok,
|
|
"initial_state": session.State,
|
|
"prompt_has_zhanna": strings.Contains(prompt, "Zhanna"),
|
|
"prompt_has_qazaimaqgas": strings.Contains(prompt, "QazAimaqGas"),
|
|
"old_ivr_removed": !strings.Contains(strings.ToLower(prompt), "choose language"),
|
|
"global_question_without_region": global.Error == "",
|
|
"global_answer_present": globalAnswer != "",
|
|
"region_specific_question_asks_region": regionNeeded.Error == "region_required_for_question",
|
|
"almaty_ambiguity": almaty != nil && almaty.NeedsClarification,
|
|
"city_clarification_selected_almaty_city": city != nil && city.RegionCode == "almaty_city",
|
|
"branch_search_after_region": branch.Error == "",
|
|
"kk_switch": kk != nil && kk.Language == state.LanguageKK,
|
|
"handoff_stub": handoffRes != nil && handoffRes.ToolResult != nil,
|
|
"final_state": s.State,
|
|
}
|
|
if jsonOut {
|
|
_ = json.NewEncoder(os.Stdout).Encode(summary)
|
|
} else {
|
|
fmt.Printf("natural dialogue self-test ok: %t\n", ok)
|
|
fmt.Printf("initial_state: %s\n", session.State)
|
|
fmt.Printf("global_without_region: %t\nregion_specific_asks_region: %t\nalmaty_ambiguity: %t\nbranch_search_after_region: %t\nkk_switch: %t\nhandoff_stub: %t\n", global.Error == "", regionNeeded.Error == "region_required_for_question", almaty != nil && almaty.NeedsClarification, branch.Error == "", kk != nil && kk.Language == state.LanguageKK, handoffRes != nil && handoffRes.ToolResult != nil)
|
|
}
|
|
if !ok {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runVoiceStyleSelfTest(envPath string, jsonOut bool) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("voice style self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
|
|
prompt := agent.BuildSystemPrompt(agent.PromptContext{State: state.StateReadyToHelp, Language: state.LanguageRU})
|
|
sessionUpdate, err := realtime.BuildSessionUpdate(cfg.OpenAI, prompt)
|
|
if err != nil {
|
|
fmt.Printf("voice style self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
var sessionEnvelope map[string]any
|
|
if err := json.Unmarshal(sessionUpdate, &sessionEnvelope); err != nil {
|
|
fmt.Printf("voice style self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
session, _ := sessionEnvelope["session"].(map[string]any)
|
|
audioCfg, _ := session["audio"].(map[string]any)
|
|
outputCfg, _ := audioCfg["output"].(map[string]any)
|
|
sessionVoice, _ := outputCfg["voice"].(string)
|
|
|
|
promptHasZhanna := strings.Contains(prompt, "Жанна")
|
|
promptHasCompany := strings.Contains(prompt, "QazAimaqGas")
|
|
promptShortVoice := strings.Contains(prompt, "1-3 sentences") && strings.Contains(prompt, "voice answers short")
|
|
promptNoRobot := strings.Contains(prompt, "Do not sound robotic")
|
|
promptConversational := strings.Contains(prompt, "conversational Russian") && strings.Contains(prompt, "simple Kazakh")
|
|
voiceReadFromEnv := cfg.OpenAI.RealtimeVoice != ""
|
|
sessionUpdateHasVoice := cfg.OpenAI.RealtimeVoice != "" && sessionVoice == cfg.OpenAI.RealtimeVoice
|
|
noAPIKeyLeak := !strings.Contains(string(sessionUpdate), "sk-") && !strings.Contains(prompt, "OPENAI_API_KEY")
|
|
noAudioPayload := !strings.Contains(string(sessionUpdate), "input_audio_buffer.append") && !strings.Contains(string(sessionUpdate), "base64")
|
|
|
|
ok := promptHasZhanna &&
|
|
promptHasCompany &&
|
|
promptShortVoice &&
|
|
promptNoRobot &&
|
|
promptConversational &&
|
|
voiceReadFromEnv &&
|
|
sessionUpdateHasVoice &&
|
|
noAPIKeyLeak &&
|
|
noAudioPayload
|
|
|
|
summary := map[string]any{
|
|
"ok": ok,
|
|
"prompt_has_zhanna": promptHasZhanna,
|
|
"prompt_has_qazaimaqgas": promptHasCompany,
|
|
"prompt_short_voice": promptShortVoice,
|
|
"prompt_not_robotic": promptNoRobot,
|
|
"prompt_conversational": promptConversational,
|
|
"env_voice": cfg.OpenAI.RealtimeVoice,
|
|
"voice_read_from_env": voiceReadFromEnv,
|
|
"session_update_has_voice": sessionUpdateHasVoice,
|
|
"session_update_voice": sessionVoice,
|
|
"openai_called": false,
|
|
"api_key_leaked": !noAPIKeyLeak,
|
|
"raw_audio_logged": false,
|
|
"base64_logged": false,
|
|
}
|
|
if jsonOut {
|
|
_ = json.NewEncoder(os.Stdout).Encode(summary)
|
|
} else {
|
|
fmt.Printf("voice style self-test ok: %t\n", ok)
|
|
fmt.Printf("prompt_has_zhanna: %t\nprompt_has_qazaimaqgas: %t\nprompt_short_voice: %t\nprompt_not_robotic: %t\n", promptHasZhanna, promptHasCompany, promptShortVoice, promptNoRobot)
|
|
fmt.Printf("env_voice: %s\nvoice_read_from_env: %t\nsession_update_has_voice: %t\nsession_update_voice: %s\n", cfg.OpenAI.RealtimeVoice, voiceReadFromEnv, sessionUpdateHasVoice, sessionVoice)
|
|
fmt.Printf("openai_called: false\napi_key_leaked: %t\nraw_audio_logged: false\nbase64_logged: false\n", !noAPIKeyLeak)
|
|
}
|
|
if !ok {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runPipelineStreamingSelfTest(envPath string, jsonOut bool) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("pipeline streaming self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
cfg.Pipeline.InitialGreeting = false
|
|
fstt := pipeline.NewFakeSTT()
|
|
fttsFactory := &pipeline.FakeTTSFactory{}
|
|
provider := pipeline.NewStreamingProviderWithDeps(cfg, slog.Default(), fstt, pipeline.FakeLLM{}, fttsFactory.New)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := provider.StartSession(ctx, ai.VoiceSessionConfig{CallID: "pipeline-self-test", SystemPrompt: agent.BuildSystemPrompt(agent.PromptContext{State: state.StateReadyToHelp}), InputSampleRate: 16000, OutputSampleRate: 16000}); err != nil {
|
|
fmt.Printf("pipeline streaming self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
defer provider.Close(context.Background())
|
|
if err := provider.SendAudio(ctx, media.AudioChunk{CallID: "pipeline-self-test", Codec: media.CodecSLIN16, Data: []byte{0, 0, 1, 0}}); err != nil {
|
|
fmt.Printf("pipeline streaming self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
order := []string{}
|
|
audioOut := false
|
|
toolResultSent := false
|
|
deadline := time.After(3 * time.Second)
|
|
for !audioOut {
|
|
select {
|
|
case ev := <-provider.Events():
|
|
order = append(order, string(ev.Type))
|
|
if ev.Type == ai.VoiceEventToolCall && ev.ToolCall != nil {
|
|
toolResultSent = true
|
|
_ = provider.SendToolResult(ctx, ai.ToolResult{CallID: ev.CallID, ToolCallID: ev.ToolCall.ID, Result: map[string]any{"ok": true, "answer_text": "Первичное подключение газа к газовому оборудованию осуществляется бесплатно."}})
|
|
}
|
|
if ev.Type == ai.VoiceEventAssistantAudioDelta && len(ev.Audio) > 0 {
|
|
audioOut = true
|
|
}
|
|
case <-deadline:
|
|
fmt.Printf("pipeline streaming self-test ok: false\nerror: timeout\n")
|
|
return 1
|
|
}
|
|
}
|
|
stats := provider.Stats()
|
|
textSent := len(fttsFactory.Texts) > 0
|
|
ok := toolResultSent && audioOut && textSent && stats.InputAudioFrames > 0 && stats.OutputAudioFrames > 0
|
|
out := map[string]any{
|
|
"ok": ok,
|
|
"audio_in": stats.InputAudioFrames > 0,
|
|
"stt_partial": contains(order, string(ai.VoiceEventUserTranscriptDelta)),
|
|
"stt_committed": contains(order, string(ai.VoiceEventUserTranscriptDone)),
|
|
"tool_call_emitted": toolResultSent,
|
|
"llm_text_delta_to_tts": textSent,
|
|
"tts_audio_delta": audioOut,
|
|
"media_audio_out_event": audioOut,
|
|
"tts_starts_before_full_answer": textSent,
|
|
"openai_called": false,
|
|
"elevenlabs_called": false,
|
|
"raw_audio_logged": false,
|
|
"base64_logged": false,
|
|
}
|
|
if jsonOut {
|
|
_ = json.NewEncoder(os.Stdout).Encode(out)
|
|
} else {
|
|
fmt.Printf("pipeline streaming self-test ok: %t\n", ok)
|
|
fmt.Printf("audio_in: %t\nstt_partial: %t\nstt_committed: %t\ntool_call_emitted: %t\nllm_text_delta_to_tts: %t\ntts_audio_delta: %t\n", out["audio_in"], out["stt_partial"], out["stt_committed"], toolResultSent, textSent, audioOut)
|
|
fmt.Printf("openai_called: false\nelevenlabs_called: false\nraw_audio_logged: false\nbase64_logged: false\n")
|
|
}
|
|
if !ok {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runElevenLabsSTTSmokeTest(envPath string) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("elevenlabs realtime stt smoke-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
if cfg.Eleven.APIKey == "" {
|
|
fmt.Println("elevenlabs realtime stt smoke-test ok: false")
|
|
fmt.Println("error: ELEVENLABS_API_KEY is empty; not opening network connection")
|
|
return 1
|
|
}
|
|
provider := stt.NewElevenLabsRealtime(cfg)
|
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.STT.Timeout)
|
|
defer cancel()
|
|
if err := provider.Start(ctx, stt.StreamRequest{CallID: "stt-smoke", Model: cfg.STT.Model, LanguageAuto: cfg.STT.LanguageAuto, LanguageCode: cfg.STT.LanguageCode, SampleRate: cfg.STT.SampleRate, InputFormat: cfg.STT.InputFormat}); err != nil {
|
|
fmt.Printf("elevenlabs realtime stt smoke-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
defer provider.Close(context.Background())
|
|
if err := provider.SendAudio(ctx, media.AudioChunk{CallID: "stt-smoke", Codec: media.CodecSLIN16, Data: make([]byte, 3200)}); err != nil {
|
|
fmt.Printf("elevenlabs realtime stt smoke-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
select {
|
|
case ev, ok := <-provider.Events():
|
|
if !ok {
|
|
fmt.Println("elevenlabs realtime stt smoke-test ok: false")
|
|
fmt.Println("error: websocket closed without event")
|
|
return 1
|
|
}
|
|
fmt.Printf("elevenlabs realtime stt smoke-test ok: %t\n", ev.Type != stt.EventError && ev.Type != stt.EventClosed)
|
|
fmt.Printf("event_type: %s\n", ev.Type)
|
|
if ev.Error != "" {
|
|
fmt.Printf("close_or_error: %s\n", ev.Error)
|
|
}
|
|
if ev.Type == stt.EventError || ev.Type == stt.EventClosed {
|
|
return 1
|
|
}
|
|
return 0
|
|
case <-time.After(3 * time.Second):
|
|
fmt.Println("elevenlabs realtime stt smoke-test ok: true")
|
|
fmt.Println("event_type: none_after_silence_frame")
|
|
return 0
|
|
case <-ctx.Done():
|
|
fmt.Printf("elevenlabs realtime stt smoke-test ok: false\nerror: %s\n", ctx.Err())
|
|
return 1
|
|
}
|
|
}
|
|
|
|
func runElevenLabsTTSSmokeTest(envPath, text, output string) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("elevenlabs streaming tts smoke-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
if cfg.Eleven.APIKey == "" || cfg.Eleven.VoiceIDRU == "" {
|
|
fmt.Println("elevenlabs streaming tts smoke-test ok: false")
|
|
fmt.Println("error: ELEVENLABS_API_KEY or ELEVENLABS_VOICE_ID_RU is empty; not opening network connection")
|
|
return 1
|
|
}
|
|
stream := tts.NewElevenLabsWS(cfg)
|
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.Eleven.TTSTimeout)
|
|
defer cancel()
|
|
if err := stream.Start(ctx, tts.TTSStreamRequest{CallID: "tts-smoke", Language: "ru", VoiceID: cfg.Eleven.VoiceIDRU, ModelID: cfg.Eleven.TTSModelID, OutputFormat: cfg.Eleven.TTSOutputFormat, SampleRate: cfg.Eleven.TTSSampleRate}); err != nil {
|
|
fmt.Printf("elevenlabs streaming tts smoke-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
defer stream.Close(context.Background())
|
|
if err := stream.SendText(ctx, text, true); err != nil {
|
|
fmt.Printf("elevenlabs streaming tts smoke-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
select {
|
|
case audio := <-stream.Audio():
|
|
if output != "" {
|
|
if err := os.WriteFile(output, audio.Data, 0600); err != nil {
|
|
fmt.Printf("elevenlabs streaming tts smoke-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
}
|
|
fmt.Println("elevenlabs streaming tts smoke-test ok: true")
|
|
fmt.Printf("audio_bytes: %d\n", len(audio.Data))
|
|
return 0
|
|
case <-ctx.Done():
|
|
fmt.Printf("elevenlabs streaming tts smoke-test ok: false\nerror: %s\n", ctx.Err())
|
|
return 1
|
|
}
|
|
}
|
|
|
|
func runStreamingLLMSmokeTest(envPath string) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("streaming llm smoke-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
if cfg.OpenAI.APIKey == "" {
|
|
fmt.Println("streaming llm smoke-test ok: false")
|
|
fmt.Println("error: OPENAI_API_KEY is empty; not opening network connection")
|
|
return 1
|
|
}
|
|
streamer := llm.NewOpenAIStreaming(cfg)
|
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.LLM.Timeout)
|
|
defer cancel()
|
|
events, err := streamer.StreamGenerate(ctx, llm.GenerateRequest{CallID: "llm-smoke", SystemPrompt: "Answer with one short sentence.", Messages: []llm.Message{{Role: "user", Content: "Say hello as Zhanna."}}})
|
|
if err != nil {
|
|
fmt.Printf("streaming llm smoke-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
got := false
|
|
for ev := range events {
|
|
if ev.Type == llm.EventTextDelta && ev.Text != "" {
|
|
got = true
|
|
break
|
|
}
|
|
if ev.Type == llm.EventError {
|
|
fmt.Printf("streaming llm smoke-test ok: false\nerror: %s\n", ev.Error)
|
|
return 1
|
|
}
|
|
}
|
|
fmt.Printf("streaming llm smoke-test ok: %t\n", got)
|
|
if !got {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runHandoffSelfTest(envPath, mode string, jsonOut bool) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("handoff self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
hcfg := handoffConfigFromConfig(cfg)
|
|
hcfg.Mode = handoff.HandoffMode(mode)
|
|
hcfg.Enabled = false
|
|
if hcfg.MaxSummaryChars == 0 {
|
|
hcfg.MaxSummaryChars = 500
|
|
}
|
|
o := dialogue.NewMemoryOrchestratorWithServices(nil, nil, handoff.NewManager(hcfg, nil), handoff.NewFallbackManager(fallbackConfigFromConfig(cfg)))
|
|
ctx := context.Background()
|
|
callID := "handoff-self-test"
|
|
_, _ = o.StartCall(ctx, call.CallSession{CallID: callID, AsteriskChannelID: callID, Route: "test", CallerNumber: "+7777***4567"})
|
|
res, err := o.HandleUserText(ctx, callID, "оператор")
|
|
s, _ := o.GetSession(callID)
|
|
ok := err == nil && res != nil && res.ToolResult != nil && s.State == state.StateHandoff && res.ToolResult.Error == ""
|
|
status := ""
|
|
transferAttempted := false
|
|
if res != nil && res.ToolResult != nil {
|
|
if payload, okp := res.ToolResult.Result.(map[string]any); okp {
|
|
if data, okd := payload["data"].(map[string]any); okd {
|
|
status, _ = data["status"].(string)
|
|
transferAttempted, _ = data["transfer_attempted"].(bool)
|
|
}
|
|
}
|
|
}
|
|
ok = ok && status == "stubbed" && !transferAttempted && handoff.Message("handoff.stub", "ru") != "" && handoff.Message("handoff.stub", "kk") != ""
|
|
out := map[string]any{"ok": ok, "state": s.State, "status": status, "mode": mode, "transfer_attempted": transferAttempted, "ru_message": handoff.Message("handoff.stub", "ru") != "", "kk_message": handoff.Message("handoff.stub", "kk") != "", "real_ari_action_executed": false}
|
|
if jsonOut {
|
|
_ = json.NewEncoder(os.Stdout).Encode(out)
|
|
} else {
|
|
fmt.Printf("handoff self-test ok: %t\n", ok)
|
|
fmt.Printf("state: %s\nstatus: %s\nmode: %s\ntransfer_attempted: %t\nreal_ari_action_executed: false\n", s.State, status, mode, transferAttempted)
|
|
}
|
|
if !ok {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runFallbackSelfTest(envPath string, jsonOut bool) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("fallback self-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
fm := handoff.NewFallbackManager(fallbackConfigFromConfig(cfg))
|
|
now := time.Now()
|
|
checks := map[string]bool{
|
|
"language_failures": fm.Evaluate(handoff.FallbackCounters{LanguageFailures: cfg.Fallback.MaxLanguageFailures}, "ru", now).ShouldOfferHandoff,
|
|
"region_failures": fm.Evaluate(handoff.FallbackCounters{RegionFailures: cfg.Fallback.MaxRegionFailures}, "ru", now).ShouldOfferHandoff,
|
|
"kb_unavailable": fm.Evaluate(handoff.FallbackCounters{KBUnavailable: cfg.Fallback.MaxKBUnavailable}, "ru", now).ShouldOfferHandoff,
|
|
"no_answer": fm.Evaluate(handoff.FallbackCounters{NoAnswerCount: cfg.Fallback.MaxNoAnswer}, "ru", now).ShouldOfferHandoff,
|
|
"ai_provider_error": fm.Evaluate(handoff.FallbackCounters{AIProviderErrors: cfg.Fallback.MaxAIErrors}, "ru", now).ShouldOfferHandoff,
|
|
"media_error": fm.Evaluate(handoff.FallbackCounters{MediaErrors: cfg.Fallback.MaxMediaErrors}, "ru", now).ShouldOfferHandoff,
|
|
"tool_error": fm.Evaluate(handoff.FallbackCounters{ToolErrors: cfg.Fallback.MaxToolErrors}, "ru", now).ShouldOfferHandoff,
|
|
"timeout": fm.Evaluate(handoff.FallbackCounters{StartedAt: now.Add(-cfg.Fallback.CallTimeout - time.Second)}, "ru", now).ShouldClose,
|
|
}
|
|
ok := true
|
|
for _, v := range checks {
|
|
ok = ok && v
|
|
}
|
|
if jsonOut {
|
|
_ = json.NewEncoder(os.Stdout).Encode(map[string]any{"ok": ok, "checks": checks, "real_transfer_attempted": false})
|
|
} else {
|
|
fmt.Printf("fallback self-test ok: %t\n", ok)
|
|
for k, v := range checks {
|
|
fmt.Printf("%s: %t\n", k, v)
|
|
}
|
|
fmt.Println("real_transfer_attempted: false")
|
|
}
|
|
if !ok {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runOpenAIToolSmokeTest(envPath string) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("openai tool smoke-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
if cfg.OpenAI.APIKey == "" {
|
|
fmt.Println("OPENAI_API_KEY is required for OpenAI tool smoke-test")
|
|
return 1
|
|
}
|
|
msg, err := realtime.BuildToolResult(ai.ToolResult{CallID: "openai-tool-smoke-test", ToolCallID: "tool-call-test", Result: map[string]any{"ok": true, "message": "tool result serialization test"}})
|
|
if err != nil {
|
|
fmt.Printf("openai tool smoke-test ok: false\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
fmt.Println("openai tool smoke-test ok: true")
|
|
fmt.Printf("tool_result_payload_bytes: %d\n", len(msg))
|
|
fmt.Println("network_call_performed: false")
|
|
return 0
|
|
}
|
|
|
|
func runEmbeddingSmokeTest(envPath string) int {
|
|
cfg, _, err := config.Load(envPath)
|
|
if err != nil {
|
|
fmt.Printf("config loaded: no\nerror: %s\n", err)
|
|
return 1
|
|
}
|
|
if cfg.OpenAI.APIKey == "" {
|
|
fmt.Println("OPENAI_API_KEY is required for live embedding smoke-test")
|
|
return 1
|
|
}
|
|
prov, err := embedding.NewProvider(cfg, "openai")
|
|
if err != nil {
|
|
fmt.Printf("embedding smoke-test failed: %s\n", err)
|
|
return 1
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
vecs, err := prov.Embed(ctx, []string{"test"})
|
|
if err != nil {
|
|
fmt.Printf("embedding smoke-test failed: %s\n", err)
|
|
return 1
|
|
}
|
|
fmt.Println("embedding smoke-test ok: true")
|
|
fmt.Printf("model: %s\ndimensions: %d\n", prov.Model(), len(vecs[0]))
|
|
return 0
|
|
}
|
|
|
|
func printKBCheck(cfg config.Config) bool {
|
|
if cfg.Database.URL == "" {
|
|
fmt.Println("kb check ok: false\nerror: DATABASE_URL is required for --check-kb")
|
|
return false
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
pool, svc, err := openKB(ctx, cfg, "fake")
|
|
if err != nil {
|
|
fmt.Printf("kb check ok: false\nerror: %s\n", err)
|
|
return false
|
|
}
|
|
defer pool.Close()
|
|
h, err := svc.Health(ctx)
|
|
if err != nil {
|
|
fmt.Printf("kb check ok: false\nerror: %s\n", err)
|
|
return false
|
|
}
|
|
ok := h.DBReachable && h.VectorExtension && h.MigrationsApplied
|
|
fmt.Printf("kb check ok: %t\n", ok)
|
|
fmt.Printf("kb documents: %d\nkb chunks: %d\n", h.Documents, h.Chunks)
|
|
return ok
|
|
}
|