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:
Claude (gsc-ops-api init)
2026-05-03 20:06:02 +02:00
commit 3847eb2036
68 changed files with 12982 additions and 0 deletions

View 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
}