sync: migrate ai-operator to Gitea (2026-08-10)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
Reference in New Issue
Block a user