422 lines
12 KiB
Go
422 lines
12 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
|
)
|
|
|
|
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, memory_enabled, memory_extraction_prompt,
|
|
created_at, updated_at`
|
|
|
|
func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
|
|
p.ID = store.NewID()
|
|
now := time.Now().UTC()
|
|
p.CreatedAt = now
|
|
p.UpdatedAt = now
|
|
_, err := DB.ExecContext(ctx, `
|
|
INSERT INTO personas (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, memory_enabled, memory_extraction_prompt,
|
|
created_at, updated_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
p.ID, 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,
|
|
p.MemoryEnabled, p.MemoryExtractionPrompt,
|
|
now.Format(timeFmt), now.Format(timeFmt),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *PersonaStore) GetByID(ctx context.Context, id string) (*models.Persona, error) {
|
|
row := DB.QueryRowContext(ctx,
|
|
fmt.Sprintf("SELECT %s FROM personas WHERE id = ?", 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 patch.MemoryEnabled != nil {
|
|
b.Set("memory_enabled", *patch.MemoryEnabled)
|
|
}
|
|
if patch.MemoryExtractionPrompt != nil {
|
|
b.Set("memory_extraction_prompt", *patch.MemoryExtractionPrompt)
|
|
}
|
|
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 = ?", 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 = 1 AND (
|
|
scope = 'global'
|
|
OR (scope = 'personal' AND created_by = ?)
|
|
OR (scope = 'team' AND owner_id IN (
|
|
SELECT team_id FROM team_members WHERE user_id = ?
|
|
))
|
|
OR (scope = 'personal' AND is_shared = 1)
|
|
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
|
|
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
|
|
WHERE gm.user_id = ?
|
|
))
|
|
)
|
|
)
|
|
) ORDER BY scope, name`, personaCols), userID, userID, 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 = ? AND is_active = 1 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 = ? 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 = ?", personaID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, g := range grants {
|
|
configJSON := ToJSON(g.Config)
|
|
_, err = tx.ExecContext(ctx, `
|
|
INSERT INTO persona_grants (id, persona_id, grant_type, grant_ref, config)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
store.NewID(), 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 = ? 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, st(&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 = ? 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 = ? AND is_active = 1 AND (
|
|
scope = 'global'
|
|
OR (scope = 'personal' AND created_by = ?)
|
|
OR (scope = 'team' AND owner_id IN (
|
|
SELECT team_id FROM team_members WHERE user_id = ?
|
|
))
|
|
OR (scope = 'personal' AND is_shared = 1)
|
|
OR id IN (
|
|
SELECT rg.resource_id FROM resource_grants rg
|
|
WHERE rg.resource_type = 'persona'
|
|
AND rg.resource_id = ?
|
|
AND (
|
|
rg.grant_scope = 'global'
|
|
OR (rg.grant_scope = 'groups'
|
|
AND EXISTS (
|
|
SELECT 1 FROM group_members gm
|
|
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
|
|
WHERE gm.user_id = ?
|
|
))
|
|
)
|
|
)
|
|
)
|
|
)`, personaID, userID, userID, personaID, userID).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.MemoryEnabled, &p.MemoryExtractionPrompt,
|
|
st(&p.CreatedAt), st(&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.MemoryEnabled, &p.MemoryExtractionPrompt,
|
|
st(&p.CreatedAt), st(&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()
|
|
}
|
|
|
|
// ── Persona-KB Bindings (v0.17.0) ───────────
|
|
|
|
func (s *PersonaStore) SetKBs(ctx context.Context, personaID string, kbIDs []string, autoSearch map[string]bool) error {
|
|
tx, err := DB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
_, err = tx.ExecContext(ctx, "DELETE FROM persona_knowledge_bases WHERE persona_id = ?", personaID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, kbID := range kbIDs {
|
|
auto := false
|
|
if autoSearch != nil {
|
|
auto = autoSearch[kbID]
|
|
}
|
|
_, err = tx.ExecContext(ctx, `
|
|
INSERT INTO persona_knowledge_bases (persona_id, kb_id, auto_search)
|
|
VALUES (?, ?, ?)`,
|
|
personaID, kbID, auto)
|
|
if err != nil {
|
|
return fmt.Errorf("persona KB %s: %w", kbID, err)
|
|
}
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *PersonaStore) GetKBs(ctx context.Context, personaID string) ([]models.PersonaKB, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT pkb.persona_id, pkb.kb_id, pkb.auto_search, pkb.added_at,
|
|
kb.name AS kb_name, kb.document_count, kb.chunk_count
|
|
FROM persona_knowledge_bases pkb
|
|
JOIN knowledge_bases kb ON kb.id = pkb.kb_id
|
|
WHERE pkb.persona_id = ?
|
|
ORDER BY kb.name`, personaID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []models.PersonaKB
|
|
for rows.Next() {
|
|
var pkb models.PersonaKB
|
|
if err := rows.Scan(&pkb.PersonaID, &pkb.KBID, &pkb.AutoSearch, st(&pkb.AddedAt),
|
|
&pkb.KBName, &pkb.DocumentCount, &pkb.ChunkCount); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, pkb)
|
|
}
|
|
if result == nil {
|
|
result = make([]models.PersonaKB, 0)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (s *PersonaStore) GetKBIDs(ctx context.Context, personaID string) ([]string, error) {
|
|
rows, err := DB.QueryContext(ctx,
|
|
"SELECT kb_id FROM persona_knowledge_bases WHERE persona_id = ?", personaID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var ids []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
if ids == nil {
|
|
ids = make([]string, 0)
|
|
}
|
|
return ids, rows.Err()
|
|
}
|