sync: migrate ai-operator to Gitea (2026-08-10)

This commit is contained in:
konturai-ops
2026-08-10 15:26:52 +00:00
commit 53652b95ad
173 changed files with 16676 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
package config
import "time"
type Config struct {
App AppConfig
Asterisk AsteriskConfig
Voice VoiceConfig
Dialogue DialogueConfig
OpenAI OpenAIConfig
STT STTConfig
LLM LLMConfig
Eleven ElevenLabsConfig
Natural NaturalnessConfig
Pipeline PipelineConfig
Database DatabaseConfig
Embedding EmbeddingConfig
KB KBConfig
Handoff HandoffConfig
Fallback FallbackConfig
Audit AuditConfig
}
type AppConfig struct{ Env, LogLevel string }
type AsteriskConfig struct {
ARIURL, ARIWSURL, ARIUser, ARIPassword, ARIApp, ARIWSAuthMode string
MediaMode, MediaCodec, MediaWSBaseURL string
}
type VoiceConfig struct{ Provider, DefaultLanguage, DefaultRegionCode string }
type DialogueConfig struct {
DefaultLanguage string
DefaultRegionCode string
EnforceLanguageAndRegion bool
LanguageSelectionEnabled bool
LanguageConfidenceThreshold float64
LanguageChangeConfidenceThreshold float64
LanguageAllowChangeAfterSelection bool
LanguageDefaultOnTimeout string
RegionResolverEnabled bool
RegionConfidenceThreshold float64
RegionChangeConfidenceThreshold float64
RegionAllowChangeAfterSelection bool
RegionDefaultOnTimeout string
RegionEnableDisabledSpecial bool
}
type OpenAIConfig struct {
APIKey, RealtimeURL, RealtimeModel, RealtimeVoice, RealtimeInstructions string
RealtimeInputAudioFormat, RealtimeOutputAudioFormat string
RealtimeInputSampleRate, RealtimeOutputSampleRate int
RealtimeTurnDetection, RealtimeReasoningEffort string
RealtimeConnectTimeout, RealtimeSessionTimeout time.Duration
RealtimeMaxAudioChunkBytes int
RealtimeLiveSmokeEnabled, RealtimeInitialGreeting bool
RealtimeMaxSessionSeconds, RealtimeMaxInputAudioBytes, RealtimeMaxOutputAudioBytes int
}
type STTConfig struct {
Provider string
Mode string
Model string
LanguageAuto bool
LanguageCode string
SampleRate int
InputFormat string
PartialEnabled bool
CommittedOnlyForLLM bool
Timeout time.Duration
}
type LLMConfig struct {
Provider string
Model string
Temperature float64
MaxOutputTokens int
Timeout time.Duration
Stream bool
MaxToolLoops int
FirstChunkTimeoutMS int
StreamChunkMinChars int
StreamChunkMaxChars int
}
type ElevenLabsConfig struct {
APIKey string
VoiceIDRU string
VoiceIDKK string
STTURL string
TTSURL string
TTSMode string
TTSModelID string
TTSExpressiveModelID string
TTSOutputFormat string
TTSSampleRate int
TTSStability float64
TTSSimilarityBoost float64
TTSStyle float64
TTSUseSpeakerBoost bool
TTSOptimizeStreamingLatency int
TTSTimeout time.Duration
}
type NaturalnessConfig struct {
Enabled bool
AudioTagsEnabled bool
AudioTagsMode string
AllowNonverbalTags bool
AllowCough bool
AllowLaugh bool
MaxAudioTagsPerResponse int
RemoveAudioTagsFromTranscript bool
}
type PipelineConfig struct {
InitialGreeting bool
BargeIn bool
FlushOnUserSpeech bool
MaxTurnSeconds int
MaxResponseChars int
AllowGlobalKBWithoutRegion bool
TTFBTargetMS int
TTSStartAfterChars int
TTSStartAfterPunctuation bool
}
type DatabaseConfig struct {
URL string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
MigrationsEnabled bool
}
type EmbeddingConfig struct {
Provider string
Model string
Dimensions int
BatchSize int
MaxInputChars int
OpenAILiveEnabled bool
}
type KBConfig struct {
CrossLanguageFallback bool
DefaultLimit int
MaxLimit int
MinScore float64
QueryMaxChars int
}
type HandoffConfig struct {
Mode string
Enabled bool
TargetEndpoint string
DialplanContext string
DialplanExtension string
DialplanPriority int
QueueName string
Timeout time.Duration
MaxAttempts int
PlayMessageBeforeTransfer bool
HangupAfterStub bool
AllowInTestRouteOnly bool
MaxSummaryChars int
}
type FallbackConfig struct {
MaxLanguageFailures int
MaxRegionFailures int
MaxNoAnswer int
MaxKBUnavailable int
MaxAIErrors int
MaxMediaErrors int
MaxToolErrors int
CallTimeout time.Duration
}
type AuditConfig struct {
Enabled bool
Sink string
FailClosed bool
StoreTranscripts bool
StoreTranscriptDeltas bool
StoreRawTranscripts bool
StoreRedactedTranscripts bool
StoreToolArgs bool
StoreToolResults bool
StoreKBResults bool
StoreProviderEvents bool
StoreMediaStats bool
MaxTranscriptChars int
MaxToolArgChars int
MaxToolResultChars int
MaxEventMetadataChars int
RetentionDays int
TranscriptRetentionDays int
ToolAuditRetentionDays int
ErrorAuditRetentionDays int
ExportMaxEvents int
RedactionEnabled bool
RedactionStrict bool
LogFullPhone bool
LogFullIIN bool
LogFullCard bool
}
func (c Config) Sanitized() map[string]any {
return map[string]any{
"APP_ENV": c.App.Env, "LOG_LEVEL": c.App.LogLevel,
"ASTERISK_ARI_URL": c.Asterisk.ARIURL, "ASTERISK_ARI_WS_URL": c.Asterisk.ARIWSURL,
"ASTERISK_ARI_USER": c.Asterisk.ARIUser, "ASTERISK_ARI_PASSWORD": MaskSecret(c.Asterisk.ARIPassword),
"ASTERISK_ARI_APP": c.Asterisk.ARIApp, "ASTERISK_ARI_WS_AUTH_MODE": c.Asterisk.ARIWSAuthMode,
"ASTERISK_MEDIA_MODE": c.Asterisk.MediaMode, "ASTERISK_MEDIA_CODEC": c.Asterisk.MediaCodec, "ASTERISK_MEDIA_WS_BASE_URL": c.Asterisk.MediaWSBaseURL,
"VOICE_PROVIDER": c.Voice.Provider,
"DIALOGUE_DEFAULT_LANGUAGE": c.Dialogue.DefaultLanguage, "DIALOGUE_DEFAULT_REGION_CODE": c.Dialogue.DefaultRegionCode,
"DIALOGUE_ENFORCE_LANGUAGE_REGION": c.Dialogue.EnforceLanguageAndRegion,
"LANGUAGE_SELECTION_ENABLED": c.Dialogue.LanguageSelectionEnabled, "LANGUAGE_CONFIDENCE_THRESHOLD": c.Dialogue.LanguageConfidenceThreshold,
"LANGUAGE_CHANGE_CONFIDENCE_THRESHOLD": c.Dialogue.LanguageChangeConfidenceThreshold, "LANGUAGE_ALLOW_CHANGE_AFTER_SELECTION": c.Dialogue.LanguageAllowChangeAfterSelection,
"LANGUAGE_DEFAULT_ON_TIMEOUT": c.Dialogue.LanguageDefaultOnTimeout,
"REGION_RESOLVER_ENABLED": c.Dialogue.RegionResolverEnabled, "REGION_CONFIDENCE_THRESHOLD": c.Dialogue.RegionConfidenceThreshold,
"REGION_CHANGE_CONFIDENCE_THRESHOLD": c.Dialogue.RegionChangeConfidenceThreshold, "REGION_ALLOW_CHANGE_AFTER_SELECTION": c.Dialogue.RegionAllowChangeAfterSelection,
"REGION_DEFAULT_ON_TIMEOUT": c.Dialogue.RegionDefaultOnTimeout, "REGION_ENABLE_DISABLED_SPECIAL_REGIONS": c.Dialogue.RegionEnableDisabledSpecial,
"OPENAI_API_KEY": MaskOpenAIKey(c.OpenAI.APIKey), "OPENAI_REALTIME_URL": c.OpenAI.RealtimeURL, "OPENAI_REALTIME_MODEL": c.OpenAI.RealtimeModel,
"OPENAI_REALTIME_VOICE": c.OpenAI.RealtimeVoice, "OPENAI_REALTIME_INPUT_AUDIO_FORMAT": c.OpenAI.RealtimeInputAudioFormat,
"OPENAI_REALTIME_OUTPUT_AUDIO_FORMAT": c.OpenAI.RealtimeOutputAudioFormat,
"OPENAI_REALTIME_INITIAL_GREETING": c.OpenAI.RealtimeInitialGreeting,
"STT_PROVIDER": c.STT.Provider, "ELEVENLABS_STT_MODE": c.STT.Mode, "ELEVENLABS_STT_MODEL": c.STT.Model,
"ELEVENLABS_STT_LANGUAGE_AUTO": c.STT.LanguageAuto, "ELEVENLABS_STT_LANGUAGE_CODE": c.STT.LanguageCode,
"ELEVENLABS_STT_SAMPLE_RATE": c.STT.SampleRate, "ELEVENLABS_STT_INPUT_FORMAT": c.STT.InputFormat,
"ELEVENLABS_STT_PARTIAL_ENABLED": c.STT.PartialEnabled, "ELEVENLABS_STT_COMMITTED_ONLY_FOR_LLM": c.STT.CommittedOnlyForLLM,
"LLM_PROVIDER": c.LLM.Provider, "LLM_MODEL": c.LLM.Model, "LLM_STREAM": c.LLM.Stream,
"LLM_MAX_TOOL_LOOPS": c.LLM.MaxToolLoops, "LLM_FIRST_CHUNK_TIMEOUT_MS": c.LLM.FirstChunkTimeoutMS,
"LLM_STREAM_CHUNK_MIN_CHARS": c.LLM.StreamChunkMinChars, "LLM_STREAM_CHUNK_MAX_CHARS": c.LLM.StreamChunkMaxChars,
"ELEVENLABS_API_KEY": MaskSecret(c.Eleven.APIKey), "ELEVENLABS_VOICE_ID_RU": MaskSecret(c.Eleven.VoiceIDRU), "ELEVENLABS_VOICE_ID_KK": MaskSecret(c.Eleven.VoiceIDKK),
"ELEVENLABS_TTS_MODE": c.Eleven.TTSMode, "ELEVENLABS_TTS_MODEL_ID": c.Eleven.TTSModelID, "ELEVENLABS_TTS_EXPRESSIVE_MODEL_ID": c.Eleven.TTSExpressiveModelID,
"ELEVENLABS_TTS_OUTPUT_FORMAT": c.Eleven.TTSOutputFormat, "ELEVENLABS_TTS_SAMPLE_RATE": c.Eleven.TTSSampleRate,
"VOICE_NATURALNESS_ENABLED": c.Natural.Enabled, "VOICE_AUDIO_TAGS_ENABLED": c.Natural.AudioTagsEnabled,
"VOICE_AUDIO_TAGS_MODE": c.Natural.AudioTagsMode, "VOICE_ALLOW_NONVERBAL_TAGS": c.Natural.AllowNonverbalTags,
"VOICE_ALLOW_COUGH": c.Natural.AllowCough, "VOICE_ALLOW_LAUGH": c.Natural.AllowLaugh,
"VOICE_MAX_AUDIO_TAGS_PER_RESPONSE": c.Natural.MaxAudioTagsPerResponse, "VOICE_REMOVE_AUDIO_TAGS_FROM_TRANSCRIPT": c.Natural.RemoveAudioTagsFromTranscript,
"PIPELINE_INITIAL_GREETING": c.Pipeline.InitialGreeting, "PIPELINE_BARGE_IN": c.Pipeline.BargeIn, "PIPELINE_FLUSH_ON_USER_SPEECH": c.Pipeline.FlushOnUserSpeech,
"PIPELINE_MAX_TURN_SECONDS": c.Pipeline.MaxTurnSeconds, "PIPELINE_MAX_RESPONSE_CHARS": c.Pipeline.MaxResponseChars,
"PIPELINE_ALLOW_GLOBAL_KB_WITHOUT_REGION": c.Pipeline.AllowGlobalKBWithoutRegion,
"PIPELINE_TTFB_TARGET_MS": c.Pipeline.TTFBTargetMS, "PIPELINE_TTS_START_AFTER_CHARS": c.Pipeline.TTSStartAfterChars,
"PIPELINE_TTS_START_AFTER_PUNCTUATION": c.Pipeline.TTSStartAfterPunctuation,
"DATABASE_URL": MaskURLCredentials(c.Database.URL), "DATABASE_MAX_OPEN_CONNS": c.Database.MaxOpenConns, "DATABASE_MAX_IDLE_CONNS": c.Database.MaxIdleConns,
"DATABASE_CONN_MAX_LIFETIME": c.Database.ConnMaxLifetime.String(), "DATABASE_MIGRATIONS_ENABLED": c.Database.MigrationsEnabled,
"EMBEDDING_PROVIDER": c.Embedding.Provider, "EMBEDDING_MODEL": c.Embedding.Model, "EMBEDDING_DIMENSIONS": c.Embedding.Dimensions,
"EMBEDDING_BATCH_SIZE": c.Embedding.BatchSize, "EMBEDDING_MAX_INPUT_CHARS": c.Embedding.MaxInputChars, "EMBEDDING_OPENAI_LIVE_ENABLED": c.Embedding.OpenAILiveEnabled,
"KB_CROSS_LANGUAGE_FALLBACK": c.KB.CrossLanguageFallback, "KB_DEFAULT_LIMIT": c.KB.DefaultLimit, "KB_MAX_LIMIT": c.KB.MaxLimit, "KB_MIN_SCORE": c.KB.MinScore, "KB_QUERY_MAX_CHARS": c.KB.QueryMaxChars,
"HANDOFF_MODE": c.Handoff.Mode, "HANDOFF_ENABLED": c.Handoff.Enabled, "HANDOFF_TARGET_ENDPOINT": MaskEndpoint(c.Handoff.TargetEndpoint),
"HANDOFF_DIALPLAN_CONTEXT": c.Handoff.DialplanContext, "HANDOFF_DIALPLAN_EXTENSION": c.Handoff.DialplanExtension,
"HANDOFF_DIALPLAN_PRIORITY": c.Handoff.DialplanPriority, "HANDOFF_QUEUE_NAME": c.Handoff.QueueName,
"HANDOFF_TIMEOUT": c.Handoff.Timeout.String(), "HANDOFF_MAX_ATTEMPTS": c.Handoff.MaxAttempts,
"HANDOFF_PLAY_MESSAGE_BEFORE_TRANSFER": c.Handoff.PlayMessageBeforeTransfer, "HANDOFF_HANGUP_AFTER_STUB": c.Handoff.HangupAfterStub,
"HANDOFF_ALLOW_IN_TEST_ROUTE_ONLY": c.Handoff.AllowInTestRouteOnly, "HANDOFF_MAX_SUMMARY_CHARS": c.Handoff.MaxSummaryChars,
"FALLBACK_MAX_LANGUAGE_FAILURES": c.Fallback.MaxLanguageFailures, "FALLBACK_MAX_REGION_FAILURES": c.Fallback.MaxRegionFailures,
"FALLBACK_MAX_NO_ANSWER": c.Fallback.MaxNoAnswer, "FALLBACK_MAX_KB_UNAVAILABLE": c.Fallback.MaxKBUnavailable,
"FALLBACK_MAX_AI_ERRORS": c.Fallback.MaxAIErrors, "FALLBACK_MAX_MEDIA_ERRORS": c.Fallback.MaxMediaErrors,
"FALLBACK_MAX_TOOL_ERRORS": c.Fallback.MaxToolErrors, "FALLBACK_CALL_TIMEOUT": c.Fallback.CallTimeout.String(),
"AUDIT_ENABLED": c.Audit.Enabled, "AUDIT_SINK": c.Audit.Sink, "AUDIT_FAIL_CLOSED": c.Audit.FailClosed,
"AUDIT_STORE_TRANSCRIPTS": c.Audit.StoreTranscripts, "AUDIT_STORE_TRANSCRIPT_DELTAS": c.Audit.StoreTranscriptDeltas,
"AUDIT_STORE_RAW_TRANSCRIPTS": c.Audit.StoreRawTranscripts, "AUDIT_STORE_REDACTED_TRANSCRIPTS": c.Audit.StoreRedactedTranscripts,
"AUDIT_STORE_TOOL_ARGS": c.Audit.StoreToolArgs, "AUDIT_STORE_TOOL_RESULTS": c.Audit.StoreToolResults,
"AUDIT_STORE_KB_RESULTS": c.Audit.StoreKBResults, "AUDIT_STORE_PROVIDER_EVENTS": c.Audit.StoreProviderEvents, "AUDIT_STORE_MEDIA_STATS": c.Audit.StoreMediaStats,
"AUDIT_MAX_TRANSCRIPT_CHARS": c.Audit.MaxTranscriptChars, "AUDIT_MAX_TOOL_ARG_CHARS": c.Audit.MaxToolArgChars,
"AUDIT_MAX_TOOL_RESULT_CHARS": c.Audit.MaxToolResultChars, "AUDIT_MAX_EVENT_METADATA_CHARS": c.Audit.MaxEventMetadataChars,
"AUDIT_RETENTION_DAYS": c.Audit.RetentionDays, "TRANSCRIPT_RETENTION_DAYS": c.Audit.TranscriptRetentionDays,
"TOOL_AUDIT_RETENTION_DAYS": c.Audit.ToolAuditRetentionDays, "ERROR_AUDIT_RETENTION_DAYS": c.Audit.ErrorAuditRetentionDays,
"AUDIT_EXPORT_MAX_EVENTS": c.Audit.ExportMaxEvents, "AUDIT_REDACTION_ENABLED": c.Audit.RedactionEnabled, "AUDIT_REDACTION_STRICT": c.Audit.RedactionStrict,
"AUDIT_LOG_FULL_PHONE": c.Audit.LogFullPhone, "AUDIT_LOG_FULL_IIN": c.Audit.LogFullIIN, "AUDIT_LOG_FULL_CARD": c.Audit.LogFullCard,
}
}
+160
View File
@@ -0,0 +1,160 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfigAllowsOptionalEmptyValues(t *testing.T) {
path := filepath.Join(t.TempDir(), "app.env")
content := `APP_ENV=dev
LOG_LEVEL=debug
ASTERISK_ARI_URL=http://127.0.0.1:8088/ari
ASTERISK_ARI_WS_URL=ws://127.0.0.1:8088/ari/events
ASTERISK_ARI_USER=ai_operator
ASTERISK_ARI_PASSWORD=test-password
ASTERISK_ARI_APP=ai-operator
ASTERISK_MEDIA_MODE=chan_websocket
ASTERISK_MEDIA_CODEC=slin16
OPENAI_API_KEY=
OPENAI_REALTIME_MODEL=
DATABASE_URL=
`
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatal(err)
}
cfg, resolved, err := Load(path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if resolved != path {
t.Fatalf("resolved path = %q, want %q", resolved, path)
}
if cfg.OpenAI.APIKey != "" {
t.Fatal("OpenAI API key should be optional and empty")
}
if cfg.Database.URL != "" {
t.Fatal("Database URL should be optional and empty")
}
}
func TestLoadConfigRequiresCriticalFields(t *testing.T) {
path := filepath.Join(t.TempDir(), "bad.env")
if err := os.WriteFile(path, []byte("APP_ENV=dev\n"), 0600); err != nil {
t.Fatal(err)
}
if _, _, err := Load(path); err == nil {
t.Fatal("Load() error = nil, want validation error")
}
}
func TestMasking(t *testing.T) {
if got := MaskSecret("password"); got != "***MASKED***" {
t.Fatalf("MaskSecret() = %q", got)
}
if got := MaskOpenAIKey(""); got != "***EMPTY***" {
t.Fatalf("MaskOpenAIKey(empty) = %q", got)
}
if got := MaskOpenAIKey("sk-test"); got != "sk-***MASKED***" {
t.Fatalf("MaskOpenAIKey(non-empty) = %q", got)
}
got := MaskURLCredentials("postgres://user:pass@localhost/db")
want := "postgres://%2A%2A%2AMASKED%2A%2A%2A:%2A%2A%2AMASKED%2A%2A%2A@localhost/db"
if got != want {
t.Fatalf("MaskURLCredentials() = %q, want %q", got, want)
}
}
func TestDialogueConfigDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "dialogue.env")
content := `APP_ENV=dev
LOG_LEVEL=debug
ASTERISK_ARI_URL=http://127.0.0.1:8088/ari
ASTERISK_ARI_WS_URL=ws://127.0.0.1:8088/ari/events
ASTERISK_ARI_USER=ai_operator
ASTERISK_ARI_PASSWORD=test-password
ASTERISK_ARI_APP=ai-operator
ASTERISK_MEDIA_MODE=chan_websocket
ASTERISK_MEDIA_CODEC=slin16
`
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatal(err)
}
cfg, _, err := Load(path)
if err != nil {
t.Fatal(err)
}
if !cfg.Dialogue.EnforceLanguageAndRegion {
t.Fatal("dialogue enforcement should default true")
}
if cfg.Dialogue.DefaultLanguage != "" || cfg.Dialogue.DefaultRegionCode != "" {
t.Fatal("dialogue defaults should be empty")
}
}
func TestDialogueEnforcementCannotBeDisabledInProd(t *testing.T) {
path := filepath.Join(t.TempDir(), "prod.env")
content := `APP_ENV=prod
LOG_LEVEL=info
ASTERISK_ARI_URL=http://127.0.0.1:8088/ari
ASTERISK_ARI_WS_URL=ws://127.0.0.1:8088/ari/events
ASTERISK_ARI_USER=ai_operator
ASTERISK_ARI_PASSWORD=test-password
ASTERISK_ARI_APP=ai-operator
ASTERISK_MEDIA_MODE=chan_websocket
ASTERISK_MEDIA_CODEC=slin16
DIALOGUE_ENFORCE_LANGUAGE_REGION=false
`
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatal(err)
}
if _, _, err := Load(path); err == nil {
t.Fatal("expected prod validation error")
}
}
func TestLanguageConfigDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "language.env")
content := `APP_ENV=dev
LOG_LEVEL=debug
ASTERISK_ARI_URL=http://127.0.0.1:8088/ari
ASTERISK_ARI_WS_URL=ws://127.0.0.1:8088/ari/events
ASTERISK_ARI_USER=ai_operator
ASTERISK_ARI_PASSWORD=test-password
ASTERISK_ARI_APP=ai-operator
ASTERISK_MEDIA_MODE=chan_websocket
ASTERISK_MEDIA_CODEC=slin16
`
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatal(err)
}
cfg, _, err := Load(path)
if err != nil {
t.Fatal(err)
}
if !cfg.Dialogue.LanguageSelectionEnabled || cfg.Dialogue.LanguageConfidenceThreshold != 0.70 || cfg.Dialogue.LanguageChangeConfidenceThreshold != 0.90 || !cfg.Dialogue.LanguageAllowChangeAfterSelection || cfg.Dialogue.LanguageDefaultOnTimeout != "" {
t.Fatalf("bad language defaults: %+v", cfg.Dialogue)
}
}
func TestLanguageSelectionCannotBeDisabledInProd(t *testing.T) {
path := filepath.Join(t.TempDir(), "prod-language.env")
content := `APP_ENV=prod
LOG_LEVEL=info
ASTERISK_ARI_URL=http://127.0.0.1:8088/ari
ASTERISK_ARI_WS_URL=ws://127.0.0.1:8088/ari/events
ASTERISK_ARI_USER=ai_operator
ASTERISK_ARI_PASSWORD=test-password
ASTERISK_ARI_APP=ai-operator
ASTERISK_MEDIA_MODE=chan_websocket
ASTERISK_MEDIA_CODEC=slin16
LANGUAGE_SELECTION_ENABLED=false
`
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatal(err)
}
if _, _, err := Load(path); err == nil {
t.Fatal("expected prod language validation error")
}
}
+272
View File
@@ -0,0 +1,272 @@
package config
import (
"bufio"
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
)
const DefaultEnvPath = "/etc/ai-operator/ai-operator.env"
func ResolveEnvPath(cliPath string) string {
if cliPath != "" {
return cliPath
}
if p := os.Getenv("AI_OPERATOR_ENV_FILE"); p != "" {
return p
}
return DefaultEnvPath
}
func Load(cliPath string) (Config, string, error) {
path := ResolveEnvPath(cliPath)
v, err := readEnvFile(path)
if err != nil {
return Config{}, path, err
}
cfg := Config{
App: AppConfig{v["APP_ENV"], v["LOG_LEVEL"]},
Asterisk: AsteriskConfig{ARIURL: v["ASTERISK_ARI_URL"], ARIWSURL: v["ASTERISK_ARI_WS_URL"], ARIUser: v["ASTERISK_ARI_USER"], ARIPassword: v["ASTERISK_ARI_PASSWORD"], ARIApp: v["ASTERISK_ARI_APP"], ARIWSAuthMode: valueOrDefault(v["ASTERISK_ARI_WS_AUTH_MODE"], "basic"), MediaMode: v["ASTERISK_MEDIA_MODE"], MediaCodec: v["ASTERISK_MEDIA_CODEC"], MediaWSBaseURL: deriveMediaWSBaseURL(v["ASTERISK_MEDIA_WS_BASE_URL"], v["ASTERISK_ARI_WS_URL"])},
Voice: VoiceConfig{Provider: valueOrDefault(v["VOICE_PROVIDER"], "fake"), DefaultLanguage: valueOrDefault(v["VOICE_DEFAULT_LANGUAGE"], ""), DefaultRegionCode: valueOrDefault(v["VOICE_DEFAULT_REGION_CODE"], "")},
Dialogue: DialogueConfig{DefaultLanguage: valueOrDefault(v["DIALOGUE_DEFAULT_LANGUAGE"], ""), DefaultRegionCode: valueOrDefault(v["DIALOGUE_DEFAULT_REGION_CODE"], ""), EnforceLanguageAndRegion: boolDefault(valueOrDefault(v["DIALOGUE_ENFORCE_LANGUAGE_REGION"], "true"), true), LanguageSelectionEnabled: boolDefault(valueOrDefault(v["LANGUAGE_SELECTION_ENABLED"], "true"), true), LanguageConfidenceThreshold: floatDefault(v["LANGUAGE_CONFIDENCE_THRESHOLD"], 0.70), LanguageChangeConfidenceThreshold: floatDefault(v["LANGUAGE_CHANGE_CONFIDENCE_THRESHOLD"], 0.90), LanguageAllowChangeAfterSelection: boolDefault(valueOrDefault(v["LANGUAGE_ALLOW_CHANGE_AFTER_SELECTION"], "true"), true), LanguageDefaultOnTimeout: valueOrDefault(v["LANGUAGE_DEFAULT_ON_TIMEOUT"], ""), RegionResolverEnabled: boolDefault(valueOrDefault(v["REGION_RESOLVER_ENABLED"], "true"), true), RegionConfidenceThreshold: floatDefault(v["REGION_CONFIDENCE_THRESHOLD"], 0.70), RegionChangeConfidenceThreshold: floatDefault(v["REGION_CHANGE_CONFIDENCE_THRESHOLD"], 0.90), RegionAllowChangeAfterSelection: boolDefault(valueOrDefault(v["REGION_ALLOW_CHANGE_AFTER_SELECTION"], "true"), true), RegionDefaultOnTimeout: valueOrDefault(v["REGION_DEFAULT_ON_TIMEOUT"], ""), RegionEnableDisabledSpecial: boolDefault(valueOrDefault(v["REGION_ENABLE_DISABLED_SPECIAL_REGIONS"], "false"), false)},
OpenAI: OpenAIConfig{APIKey: v["OPENAI_API_KEY"], RealtimeURL: valueOrDefault(v["OPENAI_REALTIME_URL"], "wss://api.openai.com/v1/realtime"), RealtimeModel: valueOrDefault(v["OPENAI_REALTIME_MODEL"], "gpt-realtime-2"), RealtimeVoice: v["OPENAI_REALTIME_VOICE"], RealtimeInstructions: v["OPENAI_REALTIME_INSTRUCTIONS"], RealtimeInputAudioFormat: valueOrDefault(v["OPENAI_REALTIME_INPUT_AUDIO_FORMAT"], "pcm16"), RealtimeOutputAudioFormat: valueOrDefault(v["OPENAI_REALTIME_OUTPUT_AUDIO_FORMAT"], "pcm16"), RealtimeInputSampleRate: intDefault(v["OPENAI_REALTIME_INPUT_SAMPLE_RATE"], 24000), RealtimeOutputSampleRate: intDefault(v["OPENAI_REALTIME_OUTPUT_SAMPLE_RATE"], 24000), RealtimeTurnDetection: valueOrDefault(v["OPENAI_REALTIME_TURN_DETECTION"], "server_vad"), RealtimeReasoningEffort: valueOrDefault(v["OPENAI_REALTIME_REASONING_EFFORT"], "low"), RealtimeConnectTimeout: durationDefault(v["OPENAI_REALTIME_CONNECT_TIMEOUT"], 10*time.Second), RealtimeSessionTimeout: durationDefault(v["OPENAI_REALTIME_SESSION_TIMEOUT"], 60*time.Second), RealtimeMaxAudioChunkBytes: intDefault(v["OPENAI_REALTIME_MAX_AUDIO_CHUNK_BYTES"], 32768), RealtimeLiveSmokeEnabled: boolDefault(v["OPENAI_REALTIME_LIVE_SMOKE_ENABLED"], false), RealtimeInitialGreeting: boolDefault(v["OPENAI_REALTIME_INITIAL_GREETING"], true), RealtimeMaxSessionSeconds: intDefault(v["OPENAI_REALTIME_MAX_SESSION_SECONDS"], 60), RealtimeMaxInputAudioBytes: intDefault(v["OPENAI_REALTIME_MAX_INPUT_AUDIO_BYTES"], 10485760), RealtimeMaxOutputAudioBytes: intDefault(v["OPENAI_REALTIME_MAX_OUTPUT_AUDIO_BYTES"], 10485760)},
STT: STTConfig{Provider: valueOrDefault(v["STT_PROVIDER"], "elevenlabs"), Mode: valueOrDefault(v["ELEVENLABS_STT_MODE"], "realtime"), Model: valueOrDefault(v["ELEVENLABS_STT_MODEL"], "scribe_realtime_v2"), LanguageAuto: boolDefault(valueOrDefault(v["ELEVENLABS_STT_LANGUAGE_AUTO"], "true"), true), LanguageCode: v["ELEVENLABS_STT_LANGUAGE_CODE"], SampleRate: intDefault(v["ELEVENLABS_STT_SAMPLE_RATE"], 16000), InputFormat: valueOrDefault(v["ELEVENLABS_STT_INPUT_FORMAT"], "pcm_16000"), PartialEnabled: boolDefault(valueOrDefault(v["ELEVENLABS_STT_PARTIAL_ENABLED"], "true"), true), CommittedOnlyForLLM: boolDefault(valueOrDefault(v["ELEVENLABS_STT_COMMITTED_ONLY_FOR_LLM"], "true"), true), Timeout: durationDefault(v["ELEVENLABS_STT_TIMEOUT"], 15*time.Second)},
LLM: LLMConfig{Provider: valueOrDefault(v["LLM_PROVIDER"], "openai"), Model: valueOrDefault(v["LLM_MODEL"], "gpt-4.1-nano"), Temperature: floatDefault(v["LLM_TEMPERATURE"], 0.2), MaxOutputTokens: intDefault(v["LLM_MAX_OUTPUT_TOKENS"], 300), Timeout: durationDefault(v["LLM_TIMEOUT"], 15*time.Second), Stream: boolDefault(valueOrDefault(v["LLM_STREAM"], "true"), true), MaxToolLoops: intDefault(v["LLM_MAX_TOOL_LOOPS"], 3), FirstChunkTimeoutMS: intDefault(v["LLM_FIRST_CHUNK_TIMEOUT_MS"], 1200), StreamChunkMinChars: intDefault(v["LLM_STREAM_CHUNK_MIN_CHARS"], 30), StreamChunkMaxChars: intDefault(v["LLM_STREAM_CHUNK_MAX_CHARS"], 160)},
Eleven: ElevenLabsConfig{APIKey: v["ELEVENLABS_API_KEY"], VoiceIDRU: v["ELEVENLABS_VOICE_ID_RU"], VoiceIDKK: v["ELEVENLABS_VOICE_ID_KK"], STTURL: valueOrDefault(v["ELEVENLABS_STT_URL"], "wss://api.elevenlabs.io/v1/speech-to-text/realtime"), TTSURL: valueOrDefault(v["ELEVENLABS_TTS_URL"], "wss://api.elevenlabs.io/v1/text-to-speech"), TTSMode: valueOrDefault(v["ELEVENLABS_TTS_MODE"], "websocket"), TTSModelID: valueOrDefault(v["ELEVENLABS_TTS_MODEL_ID"], "eleven_flash_v2_5"), TTSExpressiveModelID: valueOrDefault(v["ELEVENLABS_TTS_EXPRESSIVE_MODEL_ID"], "eleven_v3"), TTSOutputFormat: valueOrDefault(v["ELEVENLABS_TTS_OUTPUT_FORMAT"], "pcm_16000"), TTSSampleRate: intDefault(v["ELEVENLABS_TTS_SAMPLE_RATE"], 16000), TTSStability: floatDefault(v["ELEVENLABS_TTS_STABILITY"], 0.42), TTSSimilarityBoost: floatDefault(v["ELEVENLABS_TTS_SIMILARITY_BOOST"], 0.8), TTSStyle: floatDefault(v["ELEVENLABS_TTS_STYLE"], 0.25), TTSUseSpeakerBoost: boolDefault(valueOrDefault(v["ELEVENLABS_TTS_USE_SPEAKER_BOOST"], "true"), true), TTSOptimizeStreamingLatency: intDefault(v["ELEVENLABS_TTS_OPTIMIZE_STREAMING_LATENCY"], 3), TTSTimeout: durationDefault(v["ELEVENLABS_TTS_TIMEOUT"], 15*time.Second)},
Natural: NaturalnessConfig{Enabled: boolDefault(valueOrDefault(v["VOICE_NATURALNESS_ENABLED"], "true"), true), AudioTagsEnabled: boolDefault(valueOrDefault(v["VOICE_AUDIO_TAGS_ENABLED"], "true"), true), AudioTagsMode: valueOrDefault(v["VOICE_AUDIO_TAGS_MODE"], "controlled"), AllowNonverbalTags: boolDefault(valueOrDefault(v["VOICE_ALLOW_NONVERBAL_TAGS"], "true"), true), AllowCough: boolDefault(valueOrDefault(v["VOICE_ALLOW_COUGH"], "false"), false), AllowLaugh: boolDefault(valueOrDefault(v["VOICE_ALLOW_LAUGH"], "true"), true), MaxAudioTagsPerResponse: intDefault(v["VOICE_MAX_AUDIO_TAGS_PER_RESPONSE"], 2), RemoveAudioTagsFromTranscript: boolDefault(valueOrDefault(v["VOICE_REMOVE_AUDIO_TAGS_FROM_TRANSCRIPT"], "true"), true)},
Pipeline: PipelineConfig{InitialGreeting: boolDefault(valueOrDefault(v["PIPELINE_INITIAL_GREETING"], "true"), true), BargeIn: boolDefault(valueOrDefault(v["PIPELINE_BARGE_IN"], "true"), true), FlushOnUserSpeech: boolDefault(valueOrDefault(v["PIPELINE_FLUSH_ON_USER_SPEECH"], "true"), true), MaxTurnSeconds: intDefault(v["PIPELINE_MAX_TURN_SECONDS"], 20), MaxResponseChars: intDefault(v["PIPELINE_MAX_RESPONSE_CHARS"], 600), AllowGlobalKBWithoutRegion: boolDefault(valueOrDefault(v["PIPELINE_ALLOW_GLOBAL_KB_WITHOUT_REGION"], "true"), true), TTFBTargetMS: intDefault(v["PIPELINE_TTFB_TARGET_MS"], 1200), TTSStartAfterChars: intDefault(v["PIPELINE_TTS_START_AFTER_CHARS"], 60), TTSStartAfterPunctuation: boolDefault(valueOrDefault(v["PIPELINE_TTS_START_AFTER_PUNCTUATION"], "true"), true)},
Database: DatabaseConfig{URL: v["DATABASE_URL"], MaxOpenConns: intDefault(v["DATABASE_MAX_OPEN_CONNS"], 10), MaxIdleConns: intDefault(v["DATABASE_MAX_IDLE_CONNS"], 5), ConnMaxLifetime: durationDefault(v["DATABASE_CONN_MAX_LIFETIME"], 30*time.Minute), MigrationsEnabled: boolDefault(valueOrDefault(v["DATABASE_MIGRATIONS_ENABLED"], "true"), true)},
Embedding: EmbeddingConfig{Provider: valueOrDefault(v["EMBEDDING_PROVIDER"], "fake"), Model: valueOrDefault(v["EMBEDDING_MODEL"], "text-embedding-3-small"), Dimensions: intDefault(v["EMBEDDING_DIMENSIONS"], 1536), BatchSize: intDefault(v["EMBEDDING_BATCH_SIZE"], 32), MaxInputChars: intDefault(v["EMBEDDING_MAX_INPUT_CHARS"], 8000), OpenAILiveEnabled: boolDefault(valueOrDefault(v["EMBEDDING_OPENAI_LIVE_ENABLED"], "false"), false)},
KB: KBConfig{CrossLanguageFallback: boolDefault(valueOrDefault(v["KB_CROSS_LANGUAGE_FALLBACK"], "true"), true), DefaultLimit: intDefault(v["KB_DEFAULT_LIMIT"], 5), MaxLimit: intDefault(v["KB_MAX_LIMIT"], 10), MinScore: floatDefault(v["KB_MIN_SCORE"], 0.20), QueryMaxChars: intDefault(v["KB_QUERY_MAX_CHARS"], 1000)},
Handoff: HandoffConfig{Mode: valueOrDefault(v["HANDOFF_MODE"], "disabled_stub"), Enabled: boolDefault(valueOrDefault(v["HANDOFF_ENABLED"], "false"), false), TargetEndpoint: v["HANDOFF_TARGET_ENDPOINT"], DialplanContext: v["HANDOFF_DIALPLAN_CONTEXT"], DialplanExtension: v["HANDOFF_DIALPLAN_EXTENSION"], DialplanPriority: intDefault(v["HANDOFF_DIALPLAN_PRIORITY"], 1), QueueName: v["HANDOFF_QUEUE_NAME"], Timeout: durationDefault(v["HANDOFF_TIMEOUT"], 30*time.Second), MaxAttempts: intDefault(v["HANDOFF_MAX_ATTEMPTS"], 1), PlayMessageBeforeTransfer: boolDefault(valueOrDefault(v["HANDOFF_PLAY_MESSAGE_BEFORE_TRANSFER"], "true"), true), HangupAfterStub: boolDefault(valueOrDefault(v["HANDOFF_HANGUP_AFTER_STUB"], "false"), false), AllowInTestRouteOnly: boolDefault(valueOrDefault(v["HANDOFF_ALLOW_IN_TEST_ROUTE_ONLY"], "true"), true), MaxSummaryChars: intDefault(v["HANDOFF_MAX_SUMMARY_CHARS"], 500)},
Fallback: FallbackConfig{MaxLanguageFailures: intDefault(v["FALLBACK_MAX_LANGUAGE_FAILURES"], 3), MaxRegionFailures: intDefault(v["FALLBACK_MAX_REGION_FAILURES"], 3), MaxNoAnswer: intDefault(v["FALLBACK_MAX_NO_ANSWER"], 2), MaxKBUnavailable: intDefault(v["FALLBACK_MAX_KB_UNAVAILABLE"], 1), MaxAIErrors: intDefault(v["FALLBACK_MAX_AI_ERRORS"], 1), MaxMediaErrors: intDefault(v["FALLBACK_MAX_MEDIA_ERRORS"], 1), MaxToolErrors: intDefault(v["FALLBACK_MAX_TOOL_ERRORS"], 2), CallTimeout: durationDefault(v["FALLBACK_CALL_TIMEOUT"], 5*time.Minute)},
Audit: AuditConfig{Enabled: boolDefault(valueOrDefault(v["AUDIT_ENABLED"], "true"), true), Sink: valueOrDefault(v["AUDIT_SINK"], "postgres"), FailClosed: boolDefault(valueOrDefault(v["AUDIT_FAIL_CLOSED"], "false"), false), StoreTranscripts: boolDefault(valueOrDefault(v["AUDIT_STORE_TRANSCRIPTS"], "true"), true), StoreTranscriptDeltas: boolDefault(valueOrDefault(v["AUDIT_STORE_TRANSCRIPT_DELTAS"], "false"), false), StoreRawTranscripts: boolDefault(valueOrDefault(v["AUDIT_STORE_RAW_TRANSCRIPTS"], "false"), false), StoreRedactedTranscripts: boolDefault(valueOrDefault(v["AUDIT_STORE_REDACTED_TRANSCRIPTS"], "true"), true), StoreToolArgs: boolDefault(valueOrDefault(v["AUDIT_STORE_TOOL_ARGS"], "true"), true), StoreToolResults: boolDefault(valueOrDefault(v["AUDIT_STORE_TOOL_RESULTS"], "true"), true), StoreKBResults: boolDefault(valueOrDefault(v["AUDIT_STORE_KB_RESULTS"], "true"), true), StoreProviderEvents: boolDefault(valueOrDefault(v["AUDIT_STORE_PROVIDER_EVENTS"], "true"), true), StoreMediaStats: boolDefault(valueOrDefault(v["AUDIT_STORE_MEDIA_STATS"], "true"), true), MaxTranscriptChars: intDefault(v["AUDIT_MAX_TRANSCRIPT_CHARS"], 4000), MaxToolArgChars: intDefault(v["AUDIT_MAX_TOOL_ARG_CHARS"], 4000), MaxToolResultChars: intDefault(v["AUDIT_MAX_TOOL_RESULT_CHARS"], 8000), MaxEventMetadataChars: intDefault(v["AUDIT_MAX_EVENT_METADATA_CHARS"], 8000), RetentionDays: intDefault(v["AUDIT_RETENTION_DAYS"], 180), TranscriptRetentionDays: intDefault(v["TRANSCRIPT_RETENTION_DAYS"], 30), ToolAuditRetentionDays: intDefault(v["TOOL_AUDIT_RETENTION_DAYS"], 180), ErrorAuditRetentionDays: intDefault(v["ERROR_AUDIT_RETENTION_DAYS"], 365), ExportMaxEvents: intDefault(v["AUDIT_EXPORT_MAX_EVENTS"], 10000), RedactionEnabled: boolDefault(valueOrDefault(v["AUDIT_REDACTION_ENABLED"], "true"), true), RedactionStrict: boolDefault(valueOrDefault(v["AUDIT_REDACTION_STRICT"], "true"), true), LogFullPhone: boolDefault(valueOrDefault(v["AUDIT_LOG_FULL_PHONE"], "false"), false), LogFullIIN: boolDefault(valueOrDefault(v["AUDIT_LOG_FULL_IIN"], "false"), false), LogFullCard: boolDefault(valueOrDefault(v["AUDIT_LOG_FULL_CARD"], "false"), false)},
}
if err := cfg.Validate(); err != nil {
return Config{}, path, err
}
return cfg, path, nil
}
func readEnvFile(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open env file: %w", err)
}
defer f.Close()
m := map[string]string{}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, val, ok := strings.Cut(line, "=")
if !ok {
return nil, fmt.Errorf("invalid env line: %s", line)
}
m[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(val), `"'`)
}
return m, sc.Err()
}
func (c Config) Validate() error {
req := map[string]string{"APP_ENV": c.App.Env, "LOG_LEVEL": c.App.LogLevel, "ASTERISK_ARI_URL": c.Asterisk.ARIURL, "ASTERISK_ARI_WS_URL": c.Asterisk.ARIWSURL, "ASTERISK_ARI_USER": c.Asterisk.ARIUser, "ASTERISK_ARI_PASSWORD": c.Asterisk.ARIPassword, "ASTERISK_ARI_APP": c.Asterisk.ARIApp, "ASTERISK_MEDIA_MODE": c.Asterisk.MediaMode, "ASTERISK_MEDIA_CODEC": c.Asterisk.MediaCodec}
for k, v := range req {
if strings.TrimSpace(v) == "" {
return fmt.Errorf("missing required env: %s", k)
}
}
if !oneOf(c.App.Env, "dev", "staging", "prod") {
return errors.New("APP_ENV must be dev, staging, or prod")
}
if !oneOf(c.App.LogLevel, "debug", "info", "warn", "error") {
return errors.New("LOG_LEVEL must be debug, info, warn, or error")
}
if !strings.HasPrefix(c.Asterisk.ARIURL, "http://127.0.0.1") && !strings.HasPrefix(c.Asterisk.ARIURL, "http://localhost") {
return errors.New("ASTERISK_ARI_URL must use localhost")
}
if !strings.HasPrefix(c.Asterisk.ARIWSURL, "ws://127.0.0.1") && !strings.HasPrefix(c.Asterisk.ARIWSURL, "ws://localhost") {
return errors.New("ASTERISK_ARI_WS_URL must use localhost")
}
if !oneOf(c.Asterisk.ARIWSAuthMode, "basic", "query_api_key", "auto") {
return errors.New("ASTERISK_ARI_WS_AUTH_MODE must be basic, query_api_key, or auto")
}
if !oneOf(c.Voice.Provider, "fake", "openai_realtime", "pipeline_elevenlabs", "pipeline_elevenlabs_streaming") {
return errors.New("VOICE_PROVIDER must be fake, openai_realtime, pipeline_elevenlabs, or pipeline_elevenlabs_streaming")
}
if c.Dialogue.DefaultLanguage != "" && !oneOf(c.Dialogue.DefaultLanguage, "ru", "kk") {
return errors.New("DIALOGUE_DEFAULT_LANGUAGE must be ru, kk, or empty")
}
if c.App.Env == "prod" && !c.Dialogue.EnforceLanguageAndRegion {
return errors.New("DIALOGUE_ENFORCE_LANGUAGE_REGION cannot be false in prod")
}
if c.App.Env == "prod" && !c.Dialogue.LanguageSelectionEnabled {
return errors.New("LANGUAGE_SELECTION_ENABLED cannot be false in prod")
}
if c.Dialogue.LanguageDefaultOnTimeout != "" {
return errors.New("LANGUAGE_DEFAULT_ON_TIMEOUT must stay empty in TZ-07")
}
if c.App.Env == "prod" && !c.Dialogue.RegionResolverEnabled {
return errors.New("REGION_RESOLVER_ENABLED cannot be false in prod")
}
if c.Dialogue.RegionDefaultOnTimeout != "" {
return errors.New("REGION_DEFAULT_ON_TIMEOUT must stay empty in TZ-08")
}
if !oneOf(c.OpenAI.RealtimeInputAudioFormat, "pcm16", "g711_ulaw", "g711_alaw") {
return errors.New("OPENAI_REALTIME_INPUT_AUDIO_FORMAT invalid")
}
if !oneOf(c.OpenAI.RealtimeOutputAudioFormat, "pcm16", "g711_ulaw", "g711_alaw") {
return errors.New("OPENAI_REALTIME_OUTPUT_AUDIO_FORMAT invalid")
}
if !oneOf(c.OpenAI.RealtimeTurnDetection, "server_vad", "semantic_vad", "disabled") {
return errors.New("OPENAI_REALTIME_TURN_DETECTION invalid")
}
if !oneOf(c.OpenAI.RealtimeReasoningEffort, "minimal", "low", "medium", "high") {
return errors.New("OPENAI_REALTIME_REASONING_EFFORT invalid")
}
if !oneOf(c.Asterisk.MediaMode, "chan_websocket", "external_media_rtp") {
return errors.New("ASTERISK_MEDIA_MODE must be chan_websocket or external_media_rtp")
}
if c.Asterisk.MediaMode != "chan_websocket" {
return errors.New("TZ expects ASTERISK_MEDIA_MODE=chan_websocket")
}
if !oneOf(c.Asterisk.MediaCodec, "slin16", "alaw", "ulaw") {
return errors.New("ASTERISK_MEDIA_CODEC invalid")
}
if c.STT.Provider != "" && !oneOf(c.STT.Provider, "elevenlabs") {
return errors.New("STT_PROVIDER must be elevenlabs")
}
if c.STT.Mode != "" && !oneOf(c.STT.Mode, "realtime") {
return errors.New("ELEVENLABS_STT_MODE must be realtime")
}
if c.STT.SampleRate <= 0 {
return errors.New("ELEVENLABS_STT_SAMPLE_RATE invalid")
}
if c.LLM.Provider != "" && !oneOf(c.LLM.Provider, "openai") {
return errors.New("LLM_PROVIDER must be openai")
}
if c.LLM.MaxToolLoops <= 0 || c.LLM.StreamChunkMinChars <= 0 || c.LLM.StreamChunkMaxChars < c.LLM.StreamChunkMinChars {
return errors.New("invalid LLM streaming settings")
}
if c.Eleven.TTSMode != "" && !oneOf(c.Eleven.TTSMode, "websocket") {
return errors.New("ELEVENLABS_TTS_MODE must be websocket")
}
if c.Eleven.TTSSampleRate <= 0 || c.Eleven.TTSOptimizeStreamingLatency < 0 {
return errors.New("invalid ElevenLabs TTS settings")
}
if c.Natural.AudioTagsMode != "" && !oneOf(c.Natural.AudioTagsMode, "controlled") {
return errors.New("VOICE_AUDIO_TAGS_MODE must be controlled")
}
if c.Natural.MaxAudioTagsPerResponse < 0 {
return errors.New("VOICE_MAX_AUDIO_TAGS_PER_RESPONSE invalid")
}
if c.Pipeline.MaxTurnSeconds <= 0 || c.Pipeline.MaxResponseChars <= 0 || c.Pipeline.TTFBTargetMS <= 0 || c.Pipeline.TTSStartAfterChars <= 0 {
return errors.New("invalid PIPELINE settings")
}
if !oneOf(c.Embedding.Provider, "fake", "openai") {
return errors.New("EMBEDDING_PROVIDER must be fake or openai")
}
if c.Embedding.Provider == "openai" && c.OpenAI.APIKey == "" {
return errors.New("OPENAI_API_KEY is required when EMBEDDING_PROVIDER=openai")
}
if c.Embedding.Dimensions != 1536 {
return errors.New("EMBEDDING_DIMENSIONS must be 1536 in TZ-09")
}
if c.KB.DefaultLimit <= 0 || c.KB.MaxLimit <= 0 || c.KB.DefaultLimit > c.KB.MaxLimit {
return errors.New("invalid KB limit settings")
}
if !oneOf(c.Handoff.Mode, "disabled_stub", "ari_redirect", "dialplan_continue", "hangup_after_message") {
return errors.New("HANDOFF_MODE invalid")
}
if c.Handoff.MaxAttempts <= 0 || c.Handoff.MaxSummaryChars <= 0 || c.Handoff.Timeout <= 0 {
return errors.New("invalid HANDOFF settings")
}
if c.Handoff.Enabled {
switch c.Handoff.Mode {
case "ari_redirect":
if strings.TrimSpace(c.Handoff.TargetEndpoint) == "" {
return errors.New("HANDOFF_TARGET_ENDPOINT is required for ari_redirect")
}
case "dialplan_continue":
if strings.TrimSpace(c.Handoff.DialplanContext) == "" || strings.TrimSpace(c.Handoff.DialplanExtension) == "" {
return errors.New("HANDOFF_DIALPLAN_CONTEXT and HANDOFF_DIALPLAN_EXTENSION are required for dialplan_continue")
}
}
}
if c.Fallback.MaxLanguageFailures <= 0 || c.Fallback.MaxRegionFailures <= 0 || c.Fallback.MaxNoAnswer <= 0 || c.Fallback.CallTimeout <= 0 {
return errors.New("invalid FALLBACK settings")
}
if c.App.Env == "prod" && !c.Audit.Enabled {
return errors.New("AUDIT_ENABLED cannot be false in prod")
}
if c.Audit.Sink != "postgres" {
return errors.New("AUDIT_SINK must be postgres")
}
if c.Audit.MaxTranscriptChars <= 0 || c.Audit.MaxToolArgChars <= 0 || c.Audit.MaxToolResultChars <= 0 || c.Audit.MaxEventMetadataChars <= 0 {
return errors.New("invalid AUDIT max char settings")
}
if c.Audit.RetentionDays <= 0 || c.Audit.TranscriptRetentionDays <= 0 || c.Audit.ToolAuditRetentionDays <= 0 || c.Audit.ErrorAuditRetentionDays <= 0 || c.Audit.ExportMaxEvents <= 0 {
return errors.New("invalid AUDIT retention settings")
}
if !c.Audit.RedactionEnabled && c.App.Env == "prod" {
return errors.New("AUDIT_REDACTION_ENABLED cannot be false in prod")
}
return nil
}
func oneOf(v string, allowed ...string) bool {
for _, a := range allowed {
if v == a {
return true
}
}
return false
}
func valueOrDefault(v, f string) string {
if strings.TrimSpace(v) == "" {
return f
}
return v
}
func floatDefault(v string, d float64) float64 {
if n, err := strconv.ParseFloat(v, 64); err == nil {
return n
}
return d
}
func intDefault(v string, d int) int {
if n, err := strconv.Atoi(v); err == nil {
return n
}
return d
}
func boolDefault(v string, d bool) bool {
if v == "" {
return d
}
b, err := strconv.ParseBool(v)
if err != nil {
return d
}
return b
}
func durationDefault(v string, d time.Duration) time.Duration {
if x, err := time.ParseDuration(v); err == nil {
return x
}
return d
}
func deriveMediaWSBaseURL(value, ariWSURL string) string {
if strings.TrimSpace(value) != "" {
return strings.TrimRight(value, "/")
}
base := strings.TrimRight(ariWSURL, "/")
if strings.HasSuffix(base, "/ari/events") {
return strings.TrimSuffix(base, "/ari/events") + "/media"
}
return "ws://127.0.0.1:8088/media"
}
+60
View File
@@ -0,0 +1,60 @@
package config
import (
"net/url"
"strings"
)
func MaskSecret(value string) string {
if strings.TrimSpace(value) == "" {
return "***EMPTY***"
}
return "***MASKED***"
}
func MaskOpenAIKey(value string) string {
if strings.TrimSpace(value) == "" {
return "***EMPTY***"
}
if strings.HasPrefix(value, "sk-") {
return "sk-***MASKED***"
}
return "***MASKED***"
}
func MaskURLCredentials(value string) string {
if strings.TrimSpace(value) == "" {
return "***EMPTY***"
}
parsed, err := url.Parse(value)
if err != nil || parsed.User == nil {
return value
}
if username := parsed.User.Username(); username != "" {
parsed.User = url.UserPassword("***MASKED***", "***MASKED***")
}
return parsed.String()
}
func MaskEndpoint(value string) string {
v := strings.TrimSpace(value)
if v == "" {
return ""
}
if strings.Contains(v, "@") {
parts := strings.Split(v, "@")
return "***MASKED***@" + parts[len(parts)-1]
}
return v
}
func MaskPhoneNumber(value string) string {
v := strings.TrimSpace(value)
if len(v) < 6 {
return "***MASKED***"
}
if len(v) <= 7 {
return v[:3] + "***"
}
return v[:4] + "***" + v[len(v)-4:]
}