Initial import — snapshot from admin host /srv/gosec/gsc-ops-api
This repo had no version control prior to this commit. The import is a
straight snapshot of the working tree at 2026-05-03; the deployed
binary on fihelvop01 was being rebuilt from this source via `make
build` + scp into place, with no upstream review path.
The snapshot already includes one in-flight fix made on 2026-05-03 to
internal/service/persona.go:GetSelfModel — the handler queried
`source` and `strength` columns plus an `is_active = true` filter on
persona.persona_commitments, none of which exist on that table (its
shape is session-bound commitments with `status`, `commitment_meta`,
etc.). The query returned a 500 every time SynapseHub bootstrapped a
persona's self-model, dropping the IdentityConstraints / Commitments /
ConscienceStandards layer from the assembled prompt. The patched
query reads existing columns only (commitment_text, commitment_type),
filters on `status='active'`, and synthesises Source="learned" /
Strength=1.0 to keep the SelfModel response shape stable for callers.
Verified live: `GET /api/v1/personas/70f7cfd9-.../self-model` now
returns 200 with `{identityConstraints:[],commitments:[],
conscienceStandards:[]}` instead of 500.
Future changes go through PRs against this repo — no more bin-only
deploys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
453
internal/service/voice_agent.go
Normal file
453
internal/service/voice_agent.go
Normal file
@@ -0,0 +1,453 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// VoiceAgentService handles voice agent config and session operations
|
||||
type VoiceAgentService struct {
|
||||
pool *pgxpool.Pool
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewVoiceAgentService creates a new voice agent service
|
||||
func NewVoiceAgentService(pool *pgxpool.Pool, logger zerolog.Logger) *VoiceAgentService {
|
||||
return &VoiceAgentService{
|
||||
pool: pool,
|
||||
logger: logger.With().Str("service", "voice_agent").Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Voice Agent Configs
|
||||
// ============================================================================
|
||||
|
||||
// ListConfigs lists voice agent configs with optional filters
|
||||
func (s *VoiceAgentService) ListConfigs(ctx context.Context, params types.ListParams, tenantID *uuid.UUID) ([]types.VoiceAgentConfig, int64, error) {
|
||||
params = types.DefaultListParams(params)
|
||||
|
||||
countQuery := `SELECT COUNT(*) FROM voice_agent_configs WHERE 1=1`
|
||||
listQuery := `SELECT id, tenant_id, agent_id, greeting_text, goodbye_text,
|
||||
voice_id, language,
|
||||
stt_provider, stt_model, tts_provider, tts_model,
|
||||
max_call_duration_seconds, silence_timeout_seconds,
|
||||
barge_in_enabled, vad_sensitivity,
|
||||
transfer_enabled, transfer_number,
|
||||
business_hours_enabled, business_hours, after_hours_text,
|
||||
is_active, created_at, updated_at
|
||||
FROM voice_agent_configs WHERE 1=1`
|
||||
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if tenantID != nil {
|
||||
countQuery += fmt.Sprintf(" AND tenant_id = $%d", argIdx)
|
||||
listQuery += fmt.Sprintf(" AND tenant_id = $%d", argIdx)
|
||||
args = append(args, *tenantID)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if params.Search != "" {
|
||||
countQuery += fmt.Sprintf(" AND (greeting_text ILIKE $%d OR voice_id ILIKE $%d OR language ILIKE $%d)", argIdx, argIdx, argIdx)
|
||||
listQuery += fmt.Sprintf(" AND (greeting_text ILIKE $%d OR voice_id ILIKE $%d OR language ILIKE $%d)", argIdx, argIdx, argIdx)
|
||||
args = append(args, "%"+params.Search+"%")
|
||||
argIdx++
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count query failed: %w", err)
|
||||
}
|
||||
|
||||
listQuery += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
|
||||
args = append(args, params.Limit, params.Offset)
|
||||
|
||||
rows, err := s.pool.Query(ctx, listQuery, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
configs := make([]types.VoiceAgentConfig, 0)
|
||||
for rows.Next() {
|
||||
var c types.VoiceAgentConfig
|
||||
var businessHours []byte
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText,
|
||||
&c.VoiceID, &c.Language,
|
||||
&c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel,
|
||||
&c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds,
|
||||
&c.BargeInEnabled, &c.VADSensitivity,
|
||||
&c.TransferEnabled, &c.TransferNumber,
|
||||
&c.BusinessHoursEnabled, &businessHours, &c.AfterHoursText,
|
||||
&c.IsActive, &c.CreatedAt, &c.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
c.BusinessHours = json.RawMessage(businessHours)
|
||||
configs = append(configs, c)
|
||||
}
|
||||
|
||||
return configs, total, nil
|
||||
}
|
||||
|
||||
// GetConfig gets a voice agent config by ID
|
||||
func (s *VoiceAgentService) GetConfig(ctx context.Context, id uuid.UUID) (*types.VoiceAgentConfig, error) {
|
||||
var c types.VoiceAgentConfig
|
||||
var businessHours []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, tenant_id, agent_id, greeting_text, goodbye_text,
|
||||
voice_id, language,
|
||||
stt_provider, stt_model, tts_provider, tts_model,
|
||||
max_call_duration_seconds, silence_timeout_seconds,
|
||||
barge_in_enabled, vad_sensitivity,
|
||||
transfer_enabled, transfer_number,
|
||||
business_hours_enabled, business_hours, after_hours_text,
|
||||
is_active, created_at, updated_at
|
||||
FROM voice_agent_configs WHERE id = $1`, id).
|
||||
Scan(
|
||||
&c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText,
|
||||
&c.VoiceID, &c.Language,
|
||||
&c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel,
|
||||
&c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds,
|
||||
&c.BargeInEnabled, &c.VADSensitivity,
|
||||
&c.TransferEnabled, &c.TransferNumber,
|
||||
&c.BusinessHoursEnabled, &businessHours, &c.AfterHoursText,
|
||||
&c.IsActive, &c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("not found: %w", err)
|
||||
}
|
||||
c.BusinessHours = json.RawMessage(businessHours)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// CreateConfig creates a new voice agent config
|
||||
func (s *VoiceAgentService) CreateConfig(ctx context.Context, req *types.VoiceAgentConfigCreate) (*types.VoiceAgentConfig, error) {
|
||||
// Set defaults
|
||||
greeting := req.GreetingText
|
||||
if greeting == "" {
|
||||
greeting = "Hello, how can I help you today?"
|
||||
}
|
||||
goodbye := req.GoodbyeText
|
||||
if goodbye == "" {
|
||||
goodbye = "Goodbye, have a great day."
|
||||
}
|
||||
voiceID := req.VoiceID
|
||||
if voiceID == "" {
|
||||
voiceID = "alloy"
|
||||
}
|
||||
lang := req.Language
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
maxDuration := 1800
|
||||
if req.MaxCallDurationSeconds != nil {
|
||||
maxDuration = *req.MaxCallDurationSeconds
|
||||
}
|
||||
silenceTimeout := 30
|
||||
if req.SilenceTimeoutSeconds != nil {
|
||||
silenceTimeout = *req.SilenceTimeoutSeconds
|
||||
}
|
||||
bargeIn := true
|
||||
if req.BargeInEnabled != nil {
|
||||
bargeIn = *req.BargeInEnabled
|
||||
}
|
||||
vadSens := req.VADSensitivity
|
||||
if vadSens == "" {
|
||||
vadSens = "medium"
|
||||
}
|
||||
transfer := true
|
||||
if req.TransferEnabled != nil {
|
||||
transfer = *req.TransferEnabled
|
||||
}
|
||||
bizHoursEnabled := false
|
||||
if req.BusinessHoursEnabled != nil {
|
||||
bizHoursEnabled = *req.BusinessHoursEnabled
|
||||
}
|
||||
bizHours := req.BusinessHours
|
||||
if len(bizHours) == 0 {
|
||||
bizHours = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
var c types.VoiceAgentConfig
|
||||
var businessHoursOut []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO voice_agent_configs (
|
||||
tenant_id, agent_id, greeting_text, goodbye_text,
|
||||
voice_id, language,
|
||||
stt_provider, stt_model, tts_provider, tts_model,
|
||||
max_call_duration_seconds, silence_timeout_seconds,
|
||||
barge_in_enabled, vad_sensitivity,
|
||||
transfer_enabled, transfer_number,
|
||||
business_hours_enabled, business_hours, after_hours_text
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||
RETURNING id, tenant_id, agent_id, greeting_text, goodbye_text,
|
||||
voice_id, language,
|
||||
stt_provider, stt_model, tts_provider, tts_model,
|
||||
max_call_duration_seconds, silence_timeout_seconds,
|
||||
barge_in_enabled, vad_sensitivity,
|
||||
transfer_enabled, transfer_number,
|
||||
business_hours_enabled, business_hours, after_hours_text,
|
||||
is_active, created_at, updated_at`,
|
||||
req.TenantID, req.AgentID, greeting, goodbye,
|
||||
voiceID, lang,
|
||||
req.STTProvider, req.STTModel, req.TTSProvider, req.TTSModel,
|
||||
maxDuration, silenceTimeout,
|
||||
bargeIn, vadSens,
|
||||
transfer, req.TransferNumber,
|
||||
bizHoursEnabled, bizHours, req.AfterHoursText,
|
||||
).Scan(
|
||||
&c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText,
|
||||
&c.VoiceID, &c.Language,
|
||||
&c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel,
|
||||
&c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds,
|
||||
&c.BargeInEnabled, &c.VADSensitivity,
|
||||
&c.TransferEnabled, &c.TransferNumber,
|
||||
&c.BusinessHoursEnabled, &businessHoursOut, &c.AfterHoursText,
|
||||
&c.IsActive, &c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert failed: %w", err)
|
||||
}
|
||||
c.BusinessHours = json.RawMessage(businessHoursOut)
|
||||
s.logger.Info().Str("id", c.ID.String()).Str("agentId", c.AgentID.String()).Msg("Created voice agent config")
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// UpdateConfig updates a voice agent config
|
||||
func (s *VoiceAgentService) UpdateConfig(ctx context.Context, id uuid.UUID, req *types.VoiceAgentConfigUpdate) (*types.VoiceAgentConfig, error) {
|
||||
// Build dynamic SET clause
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
addField := func(clause string, val interface{}) {
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", clause, argIdx))
|
||||
args = append(args, val)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if req.GreetingText != nil {
|
||||
addField("greeting_text", *req.GreetingText)
|
||||
}
|
||||
if req.GoodbyeText != nil {
|
||||
addField("goodbye_text", *req.GoodbyeText)
|
||||
}
|
||||
if req.VoiceID != nil {
|
||||
addField("voice_id", *req.VoiceID)
|
||||
}
|
||||
if req.Language != nil {
|
||||
addField("language", *req.Language)
|
||||
}
|
||||
if req.STTProvider != nil {
|
||||
addField("stt_provider", *req.STTProvider)
|
||||
}
|
||||
if req.STTModel != nil {
|
||||
addField("stt_model", *req.STTModel)
|
||||
}
|
||||
if req.TTSProvider != nil {
|
||||
addField("tts_provider", *req.TTSProvider)
|
||||
}
|
||||
if req.TTSModel != nil {
|
||||
addField("tts_model", *req.TTSModel)
|
||||
}
|
||||
if req.MaxCallDurationSeconds != nil {
|
||||
addField("max_call_duration_seconds", *req.MaxCallDurationSeconds)
|
||||
}
|
||||
if req.SilenceTimeoutSeconds != nil {
|
||||
addField("silence_timeout_seconds", *req.SilenceTimeoutSeconds)
|
||||
}
|
||||
if req.BargeInEnabled != nil {
|
||||
addField("barge_in_enabled", *req.BargeInEnabled)
|
||||
}
|
||||
if req.VADSensitivity != nil {
|
||||
addField("vad_sensitivity", *req.VADSensitivity)
|
||||
}
|
||||
if req.TransferEnabled != nil {
|
||||
addField("transfer_enabled", *req.TransferEnabled)
|
||||
}
|
||||
if req.TransferNumber != nil {
|
||||
addField("transfer_number", *req.TransferNumber)
|
||||
}
|
||||
if req.BusinessHoursEnabled != nil {
|
||||
addField("business_hours_enabled", *req.BusinessHoursEnabled)
|
||||
}
|
||||
if len(req.BusinessHours) > 0 {
|
||||
addField("business_hours", req.BusinessHours)
|
||||
}
|
||||
if req.AfterHoursText != nil {
|
||||
addField("after_hours_text", *req.AfterHoursText)
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
addField("is_active", *req.IsActive)
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return s.GetConfig(ctx, id)
|
||||
}
|
||||
|
||||
// Always update updated_at
|
||||
setClauses = append(setClauses, "updated_at = NOW()")
|
||||
|
||||
query := fmt.Sprintf("UPDATE voice_agent_configs SET %s WHERE id = $%d",
|
||||
joinClauses(setClauses), argIdx)
|
||||
args = append(args, id)
|
||||
query += ` RETURNING id, tenant_id, agent_id, greeting_text, goodbye_text,
|
||||
voice_id, language,
|
||||
stt_provider, stt_model, tts_provider, tts_model,
|
||||
max_call_duration_seconds, silence_timeout_seconds,
|
||||
barge_in_enabled, vad_sensitivity,
|
||||
transfer_enabled, transfer_number,
|
||||
business_hours_enabled, business_hours, after_hours_text,
|
||||
is_active, created_at, updated_at`
|
||||
|
||||
var c types.VoiceAgentConfig
|
||||
var businessHours []byte
|
||||
err := s.pool.QueryRow(ctx, query, args...).Scan(
|
||||
&c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText,
|
||||
&c.VoiceID, &c.Language,
|
||||
&c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel,
|
||||
&c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds,
|
||||
&c.BargeInEnabled, &c.VADSensitivity,
|
||||
&c.TransferEnabled, &c.TransferNumber,
|
||||
&c.BusinessHoursEnabled, &businessHours, &c.AfterHoursText,
|
||||
&c.IsActive, &c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update failed: %w", err)
|
||||
}
|
||||
c.BusinessHours = json.RawMessage(businessHours)
|
||||
s.logger.Info().Str("id", id.String()).Msg("Updated voice agent config")
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// DeleteConfig deletes a voice agent config
|
||||
func (s *VoiceAgentService) DeleteConfig(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM voice_agent_configs WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete failed: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("config not found")
|
||||
}
|
||||
s.logger.Info().Str("id", id.String()).Msg("Deleted voice agent config")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Voice Sessions
|
||||
// ============================================================================
|
||||
|
||||
// ListSessions lists voice sessions for a specific agent
|
||||
func (s *VoiceAgentService) ListSessions(ctx context.Context, agentID uuid.UUID, params types.ListParams) ([]types.VoiceSession, int64, error) {
|
||||
params = types.DefaultListParams(params)
|
||||
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM voice_sessions WHERE agent_id = $1`, agentID).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count query failed: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, tenant_id, agent_id, caller_number, called_number,
|
||||
asterisk_call_id, agent_session_id,
|
||||
total_turns, stt_provider, tts_provider,
|
||||
stt_audio_seconds, tts_characters,
|
||||
started_at, ended_at, end_reason, metadata, created_at
|
||||
FROM voice_sessions WHERE agent_id = $1
|
||||
ORDER BY started_at DESC LIMIT $2 OFFSET $3`,
|
||||
agentID, params.Limit, params.Offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sessions := make([]types.VoiceSession, 0)
|
||||
for rows.Next() {
|
||||
var vs types.VoiceSession
|
||||
var metadata []byte
|
||||
if err := rows.Scan(
|
||||
&vs.ID, &vs.TenantID, &vs.AgentID, &vs.CallerNumber, &vs.CalledNumber,
|
||||
&vs.AsteriskCallID, &vs.AgentSessionID,
|
||||
&vs.TotalTurns, &vs.STTProvider, &vs.TTSProvider,
|
||||
&vs.STTAudioSeconds, &vs.TTSCharacters,
|
||||
&vs.StartedAt, &vs.EndedAt, &vs.EndReason, &metadata, &vs.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
vs.Metadata = json.RawMessage(metadata)
|
||||
sessions = append(sessions, vs)
|
||||
}
|
||||
|
||||
return sessions, total, nil
|
||||
}
|
||||
|
||||
// GetSession gets a voice session by ID, including turns
|
||||
func (s *VoiceAgentService) GetSession(ctx context.Context, sessionID uuid.UUID) (*types.VoiceSession, error) {
|
||||
var vs types.VoiceSession
|
||||
var metadata []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, tenant_id, agent_id, caller_number, called_number,
|
||||
asterisk_call_id, agent_session_id,
|
||||
total_turns, stt_provider, tts_provider,
|
||||
stt_audio_seconds, tts_characters,
|
||||
started_at, ended_at, end_reason, metadata, created_at
|
||||
FROM voice_sessions WHERE id = $1`, sessionID).
|
||||
Scan(
|
||||
&vs.ID, &vs.TenantID, &vs.AgentID, &vs.CallerNumber, &vs.CalledNumber,
|
||||
&vs.AsteriskCallID, &vs.AgentSessionID,
|
||||
&vs.TotalTurns, &vs.STTProvider, &vs.TTSProvider,
|
||||
&vs.STTAudioSeconds, &vs.TTSCharacters,
|
||||
&vs.StartedAt, &vs.EndedAt, &vs.EndReason, &metadata, &vs.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("session not found: %w", err)
|
||||
}
|
||||
vs.Metadata = json.RawMessage(metadata)
|
||||
|
||||
// Fetch turns
|
||||
turnRows, err := s.pool.Query(ctx,
|
||||
`SELECT id, session_id, turn_number, role, text,
|
||||
stt_confidence, agent_latency_ms, was_interrupted, created_at
|
||||
FROM voice_session_turns WHERE session_id = $1
|
||||
ORDER BY turn_number ASC`, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("turns query failed: %w", err)
|
||||
}
|
||||
defer turnRows.Close()
|
||||
|
||||
vs.Turns = make([]types.VoiceSessionTurn, 0)
|
||||
for turnRows.Next() {
|
||||
var t types.VoiceSessionTurn
|
||||
if err := turnRows.Scan(
|
||||
&t.ID, &t.SessionID, &t.TurnNumber, &t.Role, &t.Text,
|
||||
&t.STTConfidence, &t.AgentLatencyMs, &t.WasInterrupted, &t.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("turn scan failed: %w", err)
|
||||
}
|
||||
vs.Turns = append(vs.Turns, t)
|
||||
}
|
||||
|
||||
return &vs, nil
|
||||
}
|
||||
|
||||
// joinClauses joins SQL SET clauses with commas
|
||||
func joinClauses(clauses []string) string {
|
||||
result := ""
|
||||
for i, c := range clauses {
|
||||
if i > 0 {
|
||||
result += ", "
|
||||
}
|
||||
result += c
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user