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

514
internal/service/persona.go Normal file
View File

@@ -0,0 +1,514 @@
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
}