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" ) // PersonaService handles persona operations against gsc_persona database type PersonaService struct { pool *pgxpool.Pool logger zerolog.Logger } // NewPersonaService creates a new persona service func NewPersonaService(pool *pgxpool.Pool, logger zerolog.Logger) *PersonaService { return &PersonaService{ pool: pool, logger: logger.With().Str("service", "persona").Logger(), } } // ListPersonas lists personas for a tenant with optional status filter func (s *PersonaService) ListPersonas(ctx context.Context, tenantID uuid.UUID, params types.ListParams) ([]types.PersonaSummary, int64, error) { params = types.DefaultListParams(params) countQuery := `SELECT COUNT(*) FROM persona.personas WHERE tenant_id = $1` listQuery := `SELECT id, tenant_id, name, archetype, status, is_default, created_at, updated_at FROM persona.personas WHERE tenant_id = $1` args := []interface{}{tenantID} argIdx := 2 if params.Status != "" { countQuery += fmt.Sprintf(" AND status = $%d", argIdx) listQuery += fmt.Sprintf(" AND status = $%d", argIdx) args = append(args, params.Status) 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() personas := make([]types.PersonaSummary, 0) for rows.Next() { var p types.PersonaSummary if err := rows.Scan(&p.ID, &p.TenantID, &p.Name, &p.Archetype, &p.Status, &p.IsDefault, &p.CreatedAt, &p.UpdatedAt); err != nil { return nil, 0, fmt.Errorf("scan failed: %w", err) } personas = append(personas, p) } return personas, total, nil } // GetPersona gets a full persona configuration by ID and tenant func (s *PersonaService) GetPersona(ctx context.Context, id, tenantID uuid.UUID) (*types.PersonaConfig, error) { var p types.PersonaConfig var positiveRules, negativeRules, guardrailsConfig []byte err := s.pool.QueryRow(ctx, ` SELECT id, tenant_id, name, archetype, voice_tone, mbti, openness, conscientiousness, extraversion, agreeableness, neuroticism, positive_rules, negative_rules, backstory, world_building, guardrails_config, topical_rails, status, default_model, temperature, max_tokens_per_turn, moral_care, moral_fairness, moral_rights, moral_loyalty, moral_authority, moral_sanctity, is_default, created_at, updated_at FROM persona.personas WHERE id = $1 AND tenant_id = $2 `, id, tenantID).Scan( &p.ID, &p.TenantID, &p.Name, &p.Archetype, &p.VoiceTone, &p.MBTI, &p.Openness, &p.Conscientiousness, &p.Extraversion, &p.Agreeableness, &p.Neuroticism, &positiveRules, &negativeRules, &p.Backstory, &p.WorldBuilding, &guardrailsConfig, &p.TopicalRails, &p.Status, &p.DefaultModel, &p.Temperature, &p.MaxTokensPerTurn, &p.MoralCare, &p.MoralFairness, &p.MoralRights, &p.MoralLoyalty, &p.MoralAuthority, &p.MoralSanctity, &p.IsDefault, &p.CreatedAt, &p.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("not found: %w", err) } p.PositiveRules = json.RawMessage(positiveRules) p.NegativeRules = json.RawMessage(negativeRules) p.GuardrailsConfig = json.RawMessage(guardrailsConfig) return &p, nil } // CreatePersona creates a new persona func (s *PersonaService) CreatePersona(ctx context.Context, req *types.PersonaCreate) (*types.PersonaConfig, error) { positiveRules := req.PositiveRules if len(positiveRules) == 0 { positiveRules = json.RawMessage(`[]`) } negativeRules := req.NegativeRules if len(negativeRules) == 0 { negativeRules = json.RawMessage(`[]`) } guardrailsConfig := req.GuardrailsConfig if len(guardrailsConfig) == 0 { guardrailsConfig = json.RawMessage(`{}`) } var p types.PersonaConfig var prOut, nrOut, gcOut []byte err := s.pool.QueryRow(ctx, ` INSERT INTO persona.personas ( tenant_id, name, archetype, voice_tone, mbti, openness, conscientiousness, extraversion, agreeableness, neuroticism, positive_rules, negative_rules, backstory, world_building, guardrails_config, topical_rails, default_model, temperature, max_tokens_per_turn, moral_care, moral_fairness, moral_rights, moral_loyalty, moral_authority, moral_sanctity ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25) RETURNING id, tenant_id, name, archetype, voice_tone, mbti, openness, conscientiousness, extraversion, agreeableness, neuroticism, positive_rules, negative_rules, backstory, world_building, guardrails_config, topical_rails, status, default_model, temperature, max_tokens_per_turn, moral_care, moral_fairness, moral_rights, moral_loyalty, moral_authority, moral_sanctity, is_default, created_at, updated_at`, req.TenantID, req.Name, req.Archetype, req.VoiceTone, req.MBTI, req.Openness, req.Conscientiousness, req.Extraversion, req.Agreeableness, req.Neuroticism, positiveRules, negativeRules, req.Backstory, req.WorldBuilding, guardrailsConfig, req.TopicalRails, req.DefaultModel, req.Temperature, req.MaxTokensPerTurn, req.MoralCare, req.MoralFairness, req.MoralRights, req.MoralLoyalty, req.MoralAuthority, req.MoralSanctity, ).Scan( &p.ID, &p.TenantID, &p.Name, &p.Archetype, &p.VoiceTone, &p.MBTI, &p.Openness, &p.Conscientiousness, &p.Extraversion, &p.Agreeableness, &p.Neuroticism, &prOut, &nrOut, &p.Backstory, &p.WorldBuilding, &gcOut, &p.TopicalRails, &p.Status, &p.DefaultModel, &p.Temperature, &p.MaxTokensPerTurn, &p.MoralCare, &p.MoralFairness, &p.MoralRights, &p.MoralLoyalty, &p.MoralAuthority, &p.MoralSanctity, &p.IsDefault, &p.CreatedAt, &p.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("insert failed: %w", err) } p.PositiveRules = json.RawMessage(prOut) p.NegativeRules = json.RawMessage(nrOut) p.GuardrailsConfig = json.RawMessage(gcOut) s.logger.Info().Str("id", p.ID.String()).Str("name", p.Name).Msg("Created persona") return &p, nil } // UpdatePersona updates an existing persona func (s *PersonaService) UpdatePersona(ctx context.Context, id, tenantID uuid.UUID, req *types.PersonaUpdate) (*types.PersonaConfig, error) { 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.Name != nil { addField("name", *req.Name) } if req.Archetype != nil { addField("archetype", *req.Archetype) } if req.VoiceTone != nil { addField("voice_tone", *req.VoiceTone) } if req.MBTI != nil { addField("mbti", *req.MBTI) } if req.Openness != nil { addField("openness", *req.Openness) } if req.Conscientiousness != nil { addField("conscientiousness", *req.Conscientiousness) } if req.Extraversion != nil { addField("extraversion", *req.Extraversion) } if req.Agreeableness != nil { addField("agreeableness", *req.Agreeableness) } if req.Neuroticism != nil { addField("neuroticism", *req.Neuroticism) } if len(req.PositiveRules) > 0 { addField("positive_rules", req.PositiveRules) } if len(req.NegativeRules) > 0 { addField("negative_rules", req.NegativeRules) } if req.Backstory != nil { addField("backstory", *req.Backstory) } if req.WorldBuilding != nil { addField("world_building", *req.WorldBuilding) } if len(req.GuardrailsConfig) > 0 { addField("guardrails_config", req.GuardrailsConfig) } if req.TopicalRails != nil { addField("topical_rails", *req.TopicalRails) } if req.Status != nil { addField("status", *req.Status) } if req.DefaultModel != nil { addField("default_model", *req.DefaultModel) } if req.Temperature != nil { addField("temperature", *req.Temperature) } if req.MaxTokensPerTurn != nil { addField("max_tokens_per_turn", *req.MaxTokensPerTurn) } if req.MoralCare != nil { addField("moral_care", *req.MoralCare) } if req.MoralFairness != nil { addField("moral_fairness", *req.MoralFairness) } if req.MoralRights != nil { addField("moral_rights", *req.MoralRights) } if req.MoralLoyalty != nil { addField("moral_loyalty", *req.MoralLoyalty) } if req.MoralAuthority != nil { addField("moral_authority", *req.MoralAuthority) } if req.MoralSanctity != nil { addField("moral_sanctity", *req.MoralSanctity) } if req.IsDefault != nil { addField("is_default", *req.IsDefault) } if len(setClauses) == 0 { return s.GetPersona(ctx, id, tenantID) } setClauses = append(setClauses, "updated_at = NOW()") query := fmt.Sprintf("UPDATE persona.personas SET %s WHERE id = $%d AND tenant_id = $%d", joinClauses(setClauses), argIdx, argIdx+1) args = append(args, id, tenantID) query += ` RETURNING id, tenant_id, name, archetype, voice_tone, mbti, openness, conscientiousness, extraversion, agreeableness, neuroticism, positive_rules, negative_rules, backstory, world_building, guardrails_config, topical_rails, status, default_model, temperature, max_tokens_per_turn, moral_care, moral_fairness, moral_rights, moral_loyalty, moral_authority, moral_sanctity, is_default, created_at, updated_at` var p types.PersonaConfig var positiveRules, negativeRules, guardrailsConfig []byte err := s.pool.QueryRow(ctx, query, args...).Scan( &p.ID, &p.TenantID, &p.Name, &p.Archetype, &p.VoiceTone, &p.MBTI, &p.Openness, &p.Conscientiousness, &p.Extraversion, &p.Agreeableness, &p.Neuroticism, &positiveRules, &negativeRules, &p.Backstory, &p.WorldBuilding, &guardrailsConfig, &p.TopicalRails, &p.Status, &p.DefaultModel, &p.Temperature, &p.MaxTokensPerTurn, &p.MoralCare, &p.MoralFairness, &p.MoralRights, &p.MoralLoyalty, &p.MoralAuthority, &p.MoralSanctity, &p.IsDefault, &p.CreatedAt, &p.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("update failed: %w", err) } p.PositiveRules = json.RawMessage(positiveRules) p.NegativeRules = json.RawMessage(negativeRules) p.GuardrailsConfig = json.RawMessage(guardrailsConfig) s.logger.Info().Str("id", id.String()).Msg("Updated persona") return &p, nil } // DeletePersona deletes a persona func (s *PersonaService) DeletePersona(ctx context.Context, id, tenantID uuid.UUID) error { tag, err := s.pool.Exec(ctx, `DELETE FROM persona.personas WHERE id = $1 AND tenant_id = $2`, id, tenantID) if err != nil { return fmt.Errorf("delete failed: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("persona not found") } s.logger.Info().Str("id", id.String()).Msg("Deleted persona") return nil } // GetSelfModel returns the self-model snapshot for a persona func (s *PersonaService) GetSelfModel(ctx context.Context, personaID, tenantID uuid.UUID) (*types.SelfModelSnapshot, error) { snapshot := &types.SelfModelSnapshot{ IdentityConstraints: make([]types.IdentityConstraint, 0), Commitments: make([]types.PersonaCommitment, 0), ConscienceStandards: make([]types.ConscienceStandard, 0), } // Identity constraints rows, err := s.pool.Query(ctx, ` SELECT constraint_type, constraint_text, description, source, strength FROM persona.identity_constraints WHERE persona_id = $1 AND tenant_id = $2 AND is_active = true ORDER BY strength DESC `, personaID, tenantID) if err != nil { return nil, fmt.Errorf("constraints query failed: %w", err) } defer rows.Close() for rows.Next() { var c types.IdentityConstraint var strength *float64 if err := rows.Scan(&c.ConstraintType, &c.ConstraintText, &c.Description, &c.Source, &strength); err != nil { return nil, fmt.Errorf("constraint scan failed: %w", err) } if strength != nil { c.Strength = *strength } else { c.Strength = 1.0 } snapshot.IdentityConstraints = append(snapshot.IdentityConstraints, c) } // Commitments // // persona_commitments tracks session-bound commitments the assistant // has made during conversation; it has no `source` or `strength` // columns (the active flag is `status='active'`, not `is_active`). // Synthesise both fields for the snapshot so the SelfModel contract // stays stable for callers. commitRows, err := s.pool.Query(ctx, ` SELECT commitment_text, COALESCE(commitment_type, '') FROM persona.persona_commitments WHERE persona_id = $1 AND tenant_id = $2 AND status = 'active' ORDER BY created_at DESC `, personaID, tenantID) if err != nil { return nil, fmt.Errorf("commitments query failed: %w", err) } defer commitRows.Close() commitSource := "learned" for commitRows.Next() { var c types.PersonaCommitment if err := commitRows.Scan(&c.CommitmentText, &c.CommitmentType); err != nil { return nil, fmt.Errorf("commitment scan failed: %w", err) } c.Source = &commitSource c.Strength = 1.0 snapshot.Commitments = append(snapshot.Commitments, c) } // Conscience standards stdRows, err := s.pool.Query(ctx, ` SELECT standard_text, standard_type, moral_foundation, strength FROM persona.conscience_standards WHERE persona_id = $1 AND tenant_id = $2 AND is_active = true ORDER BY strength DESC `, personaID, tenantID) if err != nil { return nil, fmt.Errorf("standards query failed: %w", err) } defer stdRows.Close() for stdRows.Next() { var s types.ConscienceStandard var strength *float64 if err := stdRows.Scan(&s.StandardText, &s.StandardType, &s.MoralFoundation, &strength); err != nil { return nil, fmt.Errorf("standard scan failed: %w", err) } if strength != nil { s.Strength = *strength } else { s.Strength = 1.0 } snapshot.ConscienceStandards = append(snapshot.ConscienceStandards, s) } return snapshot, nil } // SearchExperiences returns experiences for a persona ordered by importance func (s *PersonaService) SearchExperiences(ctx context.Context, personaID, tenantID uuid.UUID, limit int) ([]types.Experience, error) { if limit <= 0 || limit > 100 { limit = 20 } rows, err := s.pool.Query(ctx, ` SELECT id, event_summary, event_type, occurred_at, place, actors, outcome, outcome_detail, emotional_valence, lesson_learned, importance_score FROM persona.experiences WHERE persona_id = $1 AND tenant_id = $2 ORDER BY importance_score DESC, occurred_at DESC LIMIT $3 `, personaID, tenantID, limit) if err != nil { return nil, fmt.Errorf("experiences query failed: %w", err) } defer rows.Close() experiences := make([]types.Experience, 0) for rows.Next() { var e types.Experience if err := rows.Scan( &e.ID, &e.EventSummary, &e.EventType, &e.OccurredAt, &e.Place, &e.Actors, &e.Outcome, &e.OutcomeDetail, &e.EmotionalValence, &e.LessonLearned, &e.ImportanceScore, ); err != nil { return nil, fmt.Errorf("experience scan failed: %w", err) } experiences = append(experiences, e) } return experiences, nil } // GetEvaluations returns evaluations for a session func (s *PersonaService) GetEvaluations(ctx context.Context, sessionID uuid.UUID, limit int) ([]types.Evaluation, error) { if limit <= 0 || limit > 100 { limit = 10 } rows, err := s.pool.Query(ctx, ` SELECT e.role_fidelity, e.voice_consistency, e.safety_compliance, e.character_break, e.drift_score, e.evaluator_model, e.evaluated_at FROM persona.evaluations e JOIN persona.messages m ON m.id = e.message_id WHERE m.session_id = $1 ORDER BY e.evaluated_at DESC LIMIT $2 `, sessionID, limit) if err != nil { return nil, fmt.Errorf("evaluations query failed: %w", err) } defer rows.Close() evaluations := make([]types.Evaluation, 0) for rows.Next() { var e types.Evaluation if err := rows.Scan( &e.RoleFidelity, &e.VoiceConsistency, &e.SafetyCompliance, &e.CharacterBreak, &e.DriftScore, &e.EvaluatorModel, &e.EvaluatedAt, ); err != nil { return nil, fmt.Errorf("evaluation scan failed: %w", err) } evaluations = append(evaluations, e) } return evaluations, nil } // GetMoralPattern returns moral assessments for a session func (s *PersonaService) GetMoralPattern(ctx context.Context, sessionID, tenantID uuid.UUID) ([]types.MoralAssessment, error) { rows, err := s.pool.Query(ctx, ` SELECT activated_foundations, assessment_text, has_tension, tension_foundations, resolution_foundation, confidence FROM persona.moral_assessments WHERE session_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 5 `, sessionID, tenantID) if err != nil { return nil, fmt.Errorf("moral pattern query failed: %w", err) } defer rows.Close() assessments := make([]types.MoralAssessment, 0) for rows.Next() { var a types.MoralAssessment var activatedFoundations []byte if err := rows.Scan( &activatedFoundations, &a.AssessmentText, &a.HasTension, &a.TensionFoundations, &a.ResolutionFoundation, &a.Confidence, ); err != nil { return nil, fmt.Errorf("moral assessment scan failed: %w", err) } a.ActivatedFoundations = json.RawMessage(activatedFoundations) assessments = append(assessments, a) } return assessments, nil }