This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/store/postgres/persona.go
2026-02-27 02:38:35 +00:00

323 lines
9.8 KiB
Go

package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type PersonaStore struct{}
func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
const personaCols = `id, name, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared, created_at, updated_at`
func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
return DB.QueryRowContext(ctx, `
INSERT INTO personas (name, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
RETURNING id, created_at, updated_at`,
p.Name, p.Description, p.Icon, p.Avatar, p.BaseModelID,
models.NullString(p.ProviderConfigID),
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
p.Scope, models.NullString(p.OwnerID), p.CreatedBy, p.IsActive, p.IsShared,
).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
}
func (s *PersonaStore) GetByID(ctx context.Context, id string) (*models.Persona, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE id = $1", personaCols), id)
p, err := scanPersona(row)
if err != nil {
return nil, err
}
// Load grants
grants, _ := s.GetGrants(ctx, id)
p.Grants = grants
return p, nil
}
func (s *PersonaStore) Update(ctx context.Context, id string, patch models.PersonaPatch) error {
b := NewUpdate("personas")
if patch.Name != nil {
b.Set("name", *patch.Name)
}
if patch.Description != nil {
b.Set("description", *patch.Description)
}
if patch.Icon != nil {
b.Set("icon", *patch.Icon)
}
if patch.Avatar != nil {
b.Set("avatar", *patch.Avatar)
}
if patch.BaseModelID != nil {
b.Set("base_model_id", *patch.BaseModelID)
}
if patch.ProviderConfigID != nil {
b.Set("provider_config_id", models.NullString(patch.ProviderConfigID))
}
if patch.SystemPrompt != nil {
b.Set("system_prompt", *patch.SystemPrompt)
}
if patch.Temperature != nil {
b.Set("temperature", models.NullFloat(patch.Temperature))
}
if patch.MaxTokens != nil {
b.Set("max_tokens", models.NullInt(patch.MaxTokens))
}
if patch.ThinkingBudget != nil {
b.Set("thinking_budget", models.NullInt(patch.ThinkingBudget))
}
if patch.TopP != nil {
b.Set("top_p", models.NullFloat(patch.TopP))
}
if patch.IsActive != nil {
b.Set("is_active", *patch.IsActive)
}
if patch.IsShared != nil {
b.Set("is_shared", *patch.IsShared)
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *PersonaStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM personas WHERE id = $1", id)
return err
}
// ListForUser returns all Personas visible to a user:
// global active + team-scoped (for user's teams) + personal + shared + group-granted.
func (s *PersonaStore) ListForUser(ctx context.Context, userID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf(`SELECT %s FROM personas WHERE is_active = true AND (
scope = 'global'
OR (scope = 'personal' AND created_by = $1)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
OR (scope = 'personal' AND is_shared = true)
OR id IN (
SELECT rg.resource_id FROM resource_grants rg
WHERE rg.resource_type = 'persona'
AND (
rg.grant_scope = 'global'
OR (rg.grant_scope = 'groups'
AND EXISTS (
SELECT 1 FROM group_members gm
WHERE gm.user_id = $1
AND gm.group_id = ANY(rg.granted_groups)
))
)
)
) ORDER BY scope, name`, personaCols), userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
func (s *PersonaStore) ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'team' AND owner_id = $1 AND is_active = true ORDER BY name", personaCols),
teamID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
func (s *PersonaStore) ListGlobal(ctx context.Context) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'global' ORDER BY name", personaCols))
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
func (s *PersonaStore) ListPersonal(ctx context.Context, userID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'personal' AND created_by = $1 ORDER BY name", personaCols),
userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
// ── Grants ──────────────────────────────────
// SetGrants replaces all grants for a Persona (delete + re-insert).
func (s *PersonaStore) SetGrants(ctx context.Context, personaID string, grants []models.Grant) error {
tx, err := DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, "DELETE FROM persona_grants WHERE persona_id = $1", personaID)
if err != nil {
return err
}
for _, g := range grants {
configJSON := ToJSON(g.Config)
_, err = tx.ExecContext(ctx, `
INSERT INTO persona_grants (persona_id, grant_type, grant_ref, config)
VALUES ($1, $2, $3, $4)`,
personaID, g.GrantType, g.GrantRef, configJSON)
if err != nil {
return fmt.Errorf("grant %s/%s: %w", g.GrantType, g.GrantRef, err)
}
}
return tx.Commit()
}
func (s *PersonaStore) GetGrants(ctx context.Context, personaID string) ([]models.Grant, error) {
rows, err := DB.QueryContext(ctx,
"SELECT id, persona_id, grant_type, grant_ref, config, created_at FROM persona_grants WHERE persona_id = $1 ORDER BY grant_type, grant_ref",
personaID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Grant
for rows.Next() {
var g models.Grant
var configJSON []byte
err := rows.Scan(&g.ID, &g.PersonaID, &g.GrantType, &g.GrantRef, &configJSON, &g.CreatedAt)
if err != nil {
return nil, err
}
json.Unmarshal(configJSON, &g.Config)
result = append(result, g)
}
return result, rows.Err()
}
// GetToolGrants returns just the tool names for a Persona.
func (s *PersonaStore) GetToolGrants(ctx context.Context, personaID string) ([]string, error) {
rows, err := DB.QueryContext(ctx,
"SELECT grant_ref FROM persona_grants WHERE persona_id = $1 AND grant_type = 'tool' ORDER BY grant_ref",
personaID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
result = append(result, name)
}
return result, rows.Err()
}
// UserCanAccess checks if a user can see/use a specific Persona.
func (s *PersonaStore) UserCanAccess(ctx context.Context, userID, personaID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM personas WHERE id = $2 AND is_active = true AND (
scope = 'global'
OR (scope = 'personal' AND created_by = $1)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
OR (scope = 'personal' AND is_shared = true)
OR id IN (
SELECT rg.resource_id FROM resource_grants rg
WHERE rg.resource_type = 'persona'
AND rg.resource_id = $2
AND (
rg.grant_scope = 'global'
OR (rg.grant_scope = 'groups'
AND EXISTS (
SELECT 1 FROM group_members gm
WHERE gm.user_id = $1
AND gm.group_id = ANY(rg.granted_groups)
))
)
)
)
)`, userID, personaID).Scan(&exists)
return exists, err
}
// ── Scanners ────────────────────────────────
func scanPersona(row *sql.Row) (*models.Persona, error) {
var p models.Persona
var providerConfigID, ownerID sql.NullString
var temp, topP sql.NullFloat64
var maxTokens, thinkingBudget sql.NullInt64
err := row.Scan(
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
&p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
&p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, err
}
p.ProviderConfigID = NullableStringPtr(providerConfigID)
p.OwnerID = NullableStringPtr(ownerID)
p.Temperature = NullableFloat64Ptr(temp)
p.MaxTokens = NullableIntPtr(maxTokens)
p.ThinkingBudget = NullableIntPtr(thinkingBudget)
p.TopP = NullableFloat64Ptr(topP)
return &p, nil
}
func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
var result []models.Persona
for rows.Next() {
var p models.Persona
var providerConfigID, ownerID sql.NullString
var temp, topP sql.NullFloat64
var maxTokens, thinkingBudget sql.NullInt64
err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
&p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
&p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, err
}
p.ProviderConfigID = NullableStringPtr(providerConfigID)
p.OwnerID = NullableStringPtr(ownerID)
p.Temperature = NullableFloat64Ptr(temp)
p.MaxTokens = NullableIntPtr(maxTokens)
p.ThinkingBudget = NullableIntPtr(thinkingBudget)
p.TopP = NullableFloat64Ptr(topP)
result = append(result, p)
}
return result, rows.Err()
}