Changeset 0.17.0 (#75)

This commit is contained in:
2026-02-27 16:25:39 +00:00
parent 8bb77710b9
commit c9141a6896
37 changed files with 2778 additions and 968 deletions

View File

@@ -0,0 +1,42 @@
-- v0.17.0: Persona-KB Binding + Enterprise KB Mode
--
-- New: persona_knowledge_bases join table
-- New: knowledge_bases.discoverable column
-- New: kb_direct_access platform policy
-- =========================================
-- 1. Persona-KB Binding
-- =========================================
CREATE TABLE IF NOT EXISTS persona_knowledge_bases (
persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
auto_search BOOLEAN NOT NULL DEFAULT false,
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (persona_id, kb_id)
);
CREATE INDEX IF NOT EXISTS idx_persona_kb_persona ON persona_knowledge_bases(persona_id);
CREATE INDEX IF NOT EXISTS idx_persona_kb_kb ON persona_knowledge_bases(kb_id);
COMMENT ON TABLE persona_knowledge_bases IS 'Binds KBs to Personas — the Persona becomes a gateway to its KBs';
COMMENT ON COLUMN persona_knowledge_bases.auto_search IS 'true = auto-prepend top-K results to context; false = kb_search tool only';
-- =========================================
-- 2. Enterprise KB Mode
-- =========================================
ALTER TABLE knowledge_bases
ADD COLUMN IF NOT EXISTS discoverable BOOLEAN NOT NULL DEFAULT true;
COMMENT ON COLUMN knowledge_bases.discoverable IS 'false = hidden from user KB lists, only accessible through Persona binding';
-- =========================================
-- 3. Platform Policy
-- =========================================
INSERT INTO platform_policies (key, value) VALUES
('kb_direct_access', 'true')
ON CONFLICT (key) DO NOTHING;
COMMENT ON COLUMN platform_policies.key IS 'kb_direct_access: when false, disables channel KB popup (strict enterprise mode)';

View File

@@ -53,6 +53,9 @@ var routeTable = map[string]Direction{
// Model/provider status
"model.status": DirToClient,
// Role alerts (v0.17.0)
"role.fallback": DirToClient,
// Plugin hooks — never cross the wire
"plugin.hook.": DirLocal,
"internal.": DirLocal,

View File

@@ -314,6 +314,19 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt")
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
// Paste-to-file threshold (admin-configurable via storage.paste_to_file_chars, default 2000)
pasteChars := 2000
if storageSettings, err := h.stores.GlobalConfig.Get(c.Request.Context(), "storage"); err == nil {
if v, ok := storageSettings["paste_to_file_chars"]; ok {
switch n := v.(type) {
case float64:
pasteChars = int(n)
case int:
pasteChars = n
}
}
}
// Only tell the user whether admin prompt exists — don't expose content
hasAdminPrompt := false
if content, ok := systemPrompt["content"].(string); ok && content != "" {
@@ -321,10 +334,11 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"banner": banner,
"branding": branding,
"has_admin_prompt": hasAdminPrompt,
"storage_configured": storageConfigured,
"banner": banner,
"branding": branding,
"has_admin_prompt": hasAdminPrompt,
"storage_configured": storageConfigured,
"paste_to_file_chars": pasteChars,
"policies": gin.H{
"allow_registration": policies["allow_registration"],
"allow_user_byok": policies["allow_user_byok"],

View File

@@ -94,12 +94,14 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
// ── Preset unwrap: preset overrides defaults, explicit request fields win ──
var presetSystemPrompt string
var personaID string // tracks active persona for KB scoping
if req.PresetID != "" {
preset := ResolvePreset(req.PresetID, userID)
preset := ResolvePreset(h.stores, req.PresetID, userID)
if preset == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found or not accessible"})
return
}
personaID = preset.ID
// Preset provides defaults; explicit request fields take priority
if req.Model == "" {
req.Model = preset.BaseModelID
@@ -138,7 +140,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
// Load conversation history
messages, err := h.loadConversation(channelID, userID, presetSystemPrompt)
messages, err := h.loadConversation(channelID, userID, presetSystemPrompt, personaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
return
@@ -218,9 +220,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
if stream {
h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope)
h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope, personaID)
} else {
h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope)
h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope, personaID)
}
}
@@ -356,9 +358,9 @@ func (h *CompletionHandler) streamCompletion(
provider providers.Provider,
cfg providers.ProviderConfig,
req providers.CompletionRequest,
channelID, userID, model, configID, providerScope string,
channelID, userID, model, configID, providerScope, personaID string,
) {
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID, h.hub)
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID, personaID, h.hub)
// Persist assistant response
if result.Content != "" {
@@ -380,7 +382,7 @@ func (h *CompletionHandler) syncCompletion(
provider providers.Provider,
cfg providers.ProviderConfig,
req providers.CompletionRequest,
channelID, userID, model, configID, providerScope string,
channelID, userID, model, configID, providerScope, personaID string,
) {
var totalInput, totalOutput int
var totalCacheCreation, totalCacheRead int
@@ -446,6 +448,7 @@ func (h *CompletionHandler) syncCompletion(
execCtx := tools.ExecutionContext{
UserID: userID,
ChannelID: channelID,
PersonaID: personaID,
}
for _, tc := range resp.ToolCalls {
call := tools.ToolCall{
@@ -747,7 +750,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
//
// Summary-aware: if the path contains a summary node (metadata.type = "summary"),
// messages before it are replaced by the summary content as a system message.
func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt string) ([]providers.Message, error) {
func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt, personaID string) ([]providers.Message, error) {
messages := make([]providers.Message, 0)
// ── Admin system prompt (always injected first, no opt out) ──
@@ -781,7 +784,7 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
}
// ── KB hint (nudge LLM to use kb_search when KBs are active) ──
if kbHint := BuildKBHint(context.Background(), h.stores, channelID, userID); kbHint != "" {
if kbHint := BuildKBHint(context.Background(), h.stores, channelID, userID, personaID); kbHint != "" {
messages = append(messages, providers.Message{
Role: "system",
Content: kbHint,

View File

@@ -120,6 +120,8 @@ func setupHarness(t *testing.T) *testHarness {
presets := NewPersonaHandler(stores)
protected.GET("/presets", presets.ListUserPersonas)
protected.POST("/presets", presets.CreateUserPersona)
protected.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
protected.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
// Notes
notes := NewNoteHandler()
@@ -149,6 +151,8 @@ func setupHarness(t *testing.T) *testHarness {
protected.POST("/knowledge-bases/:id/rebuild", kbH.RebuildKB)
protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0
protected.PUT("/knowledge-bases/:id/discoverable", kbH.SetDiscoverable) // v0.17.0
// Attachments (nil storage = upload returns 503, but metadata works)
attachH := NewAttachmentHandler(stores, nil, nil)
@@ -185,6 +189,8 @@ func setupHarness(t *testing.T) *testHarness {
admin.POST("/teams/:id/members", teams.AddMember)
admin.GET("/presets", presets.ListAdminPersonas)
admin.POST("/presets", presets.CreateAdminPersona)
admin.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
admin.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
// Admin groups (v0.16.0)
groupAdm := NewGroupHandler(stores)
@@ -2785,45 +2791,6 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
}
// Diagnostic: verify grant row exists
var grantCount int
database.DB.QueryRow(`SELECT COUNT(*) FROM resource_grants WHERE resource_type = 'persona' AND resource_id = $1`, personaID).Scan(&grantCount)
if grantCount == 0 {
t.Fatal("DIAG: resource_grant row missing after SET")
}
// Diagnostic: verify group membership
var memberCount int
database.DB.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id = $1 AND user_id = $2`, group.ID, userID).Scan(&memberCount)
if memberCount == 0 {
t.Fatal("DIAG: group_members row missing")
}
// Diagnostic: verify persona exists
var personaExists bool
database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM personas WHERE id = $1 AND is_active = true)`, personaID).Scan(&personaExists)
if !personaExists {
t.Fatal("DIAG: persona not found or not active")
}
// Diagnostic: run the subquery directly
var subqCount int
database.DB.QueryRow(`
SELECT COUNT(*) 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)
))
)
`, userID).Scan(&subqCount)
t.Logf("DIAG: grant_count=%d member_count=%d persona_exists=%v subquery_matches=%d personaID=%s groupID=%s userID=%s",
grantCount, memberCount, personaExists, subqCount, personaID, group.ID, userID)
// ── User should NOW see team persona via group grant ──
w = h.request("GET", "/api/v1/presets", userToken, nil)
if w.Code != http.StatusOK {
@@ -2857,3 +2824,214 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
}
}
// ═══════════════════════════════════════════════════════════════
// v0.17.0 Integration Tests — Persona-KB Binding + Debt Fixes
// ═══════════════════════════════════════════════════════════════
// ── Persona-KB Binding CRUD ──
func TestIntegration_PersonaKB_Binding(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// 1. Create a global persona via admin API
w := h.request("POST", "/api/v1/admin/presets", adminToken, map[string]interface{}{
"name": "KB Persona",
"base_model_id": "test-model",
"system_prompt": "You are a test persona.",
})
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Fatalf("create persona: want 200/201, got %d: %s", w.Code, w.Body.String())
}
var personaResp map[string]interface{}
decode(w, &personaResp)
personaID := personaResp["id"].(string)
// 2. Create a global KB
w = h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
"name": "Test KB",
"scope": "global",
})
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("create KB: want 200/201, got %d: %s", w.Code, w.Body.String())
}
var kbResp map[string]interface{}
decode(w, &kbResp)
kbID := kbResp["id"].(string)
// 3. Bind KB to persona
w = h.request("PUT",
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
adminToken, map[string]interface{}{
"kb_ids": []string{kbID},
"auto_search": map[string]bool{kbID: true},
})
if w.Code != http.StatusOK {
t.Fatalf("bind KB to persona: want 200, got %d: %s", w.Code, w.Body.String())
}
// 4. Read back bindings
w = h.request("GET",
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
}
var listResp map[string]interface{}
decode(w, &listResp)
data := listResp["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("expected 1 binding, got %d", len(data))
}
binding := data[0].(map[string]interface{})
if binding["kb_id"] != kbID {
t.Fatalf("binding kb_id mismatch: got %v, want %s", binding["kb_id"], kbID)
}
if binding["auto_search"] != true {
t.Fatal("auto_search should be true")
}
// 5. Unbind — send empty list
w = h.request("PUT",
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
adminToken, map[string]interface{}{
"kb_ids": []string{},
})
if w.Code != http.StatusOK {
t.Fatalf("unbind KB: want 200, got %d: %s", w.Code, w.Body.String())
}
// 6. Verify empty
w = h.request("GET",
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get empty persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
}
var emptyResp map[string]interface{}
decode(w, &emptyResp)
emptyData := emptyResp["data"].([]interface{})
if len(emptyData) != 0 {
t.Fatalf("expected 0 bindings after unbind, got %d", len(emptyData))
}
}
// ── KB Create Auth (was TODO — now enforced) ──
func TestIntegration_KB_CreateAuth(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
_ = adminToken
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
_, userToken := h.registerUser("kbuser", "kb@test.com", "password123")
// Regular user should NOT be able to create global KBs
w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
"name": "Sneaky Global KB",
"scope": "global",
})
if w.Code != http.StatusForbidden {
t.Fatalf("non-admin global KB create: want 403, got %d: %s", w.Code, w.Body.String())
}
// Regular user should NOT be able to create team KBs without team admin role
w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
"name": "Sneaky Team KB",
"scope": "team",
"team_id": "00000000-0000-0000-0000-000000000001",
})
if w.Code != http.StatusForbidden {
t.Fatalf("non-team-admin team KB create: want 403, got %d: %s", w.Code, w.Body.String())
}
// Regular user CAN create personal KBs
w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
"name": "My Personal KB",
"scope": "personal",
})
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("personal KB create: want 200/201, got %d: %s", w.Code, w.Body.String())
}
}
// ── KB Discoverable Flag ──
func TestIntegration_KB_Discoverable(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
_, userToken := h.registerUser("discuser", "disc@test.com", "password123")
// 1. Admin creates a global KB (discoverable=true by default)
w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
"name": "Visible KB",
"scope": "global",
})
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("create KB: want 200/201, got %d: %s", w.Code, w.Body.String())
}
var kbResp map[string]interface{}
decode(w, &kbResp)
kbID := kbResp["id"].(string)
// 2. User can see it in discoverable list
w = h.request("GET", "/api/v1/knowledge-bases-discoverable", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list discoverable: want 200, got %d: %s", w.Code, w.Body.String())
}
var discovResp map[string]interface{}
decode(w, &discovResp)
kbs, _ := discovResp["data"].([]interface{})
if kbs == nil {
t.Fatalf("discoverable list returned nil data (response: %s)", w.Body.String())
}
found := false
for _, kb := range kbs {
if kb.(map[string]interface{})["id"] == kbID {
found = true
break
}
}
if !found {
t.Fatalf("discoverable KB %s not visible to user (got %d KBs, response: %s)", kbID, len(kbs), w.Body.String())
}
// 3. Admin hides the KB
w = h.request("PUT",
fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", kbID),
adminToken, map[string]interface{}{"discoverable": false})
if w.Code != http.StatusOK {
t.Fatalf("set discoverable=false: want 200, got %d: %s", w.Code, w.Body.String())
}
// 4. User can no longer see it
w = h.request("GET", "/api/v1/knowledge-bases-discoverable", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list discoverable after hide: want 200, got %d: %s", w.Code, w.Body.String())
}
var discovResp2 map[string]interface{}
decode(w, &discovResp2)
kbs2, _ := discovResp2["data"].([]interface{})
for _, kb := range kbs2 {
if kb.(map[string]interface{})["id"] == kbID {
t.Fatal("hidden KB should not appear in discoverable list")
}
}
}
// ── Platform Policy: kb_direct_access seeded ──
func TestIntegration_KB_DirectAccessPolicy(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Verify the migration seeded the policy by reading it through admin settings
w := h.request("GET", "/api/v1/settings/public", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get public settings: want 200, got %d", w.Code)
}
// Policy existence is verified by the migration running without error
// (if kb_direct_access INSERT fails, the migration fails and no tests run)
_ = w
}

View File

@@ -147,9 +147,25 @@ func (h *KnowledgeBaseHandler) CreateKB(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team scope"})
return
}
// TODO: verify user is team admin
// Verify user is team admin (or system admin)
role, _ := c.Get("role")
if role != "admin" {
var teamRole string
err := database.DB.QueryRow(
`SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2`,
req.TeamID, userID).Scan(&teamRole)
if err != nil || teamRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required to create team knowledge bases"})
return
}
}
case "global":
// TODO: verify user is admin
// Verify user is system admin
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "admin access required to create global knowledge bases"})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be personal, team, or global"})
return
@@ -630,9 +646,7 @@ func (h *KnowledgeBaseHandler) getUserTeamIDs(c *gin.Context, userID string) []s
// updateDocStorageKey sets the storage_key on a document after upload.
func (h *KnowledgeBaseHandler) updateDocStorageKey(c *gin.Context, docID, storageKey string) {
database.DB.ExecContext(c.Request.Context(),
`UPDATE kb_documents SET storage_key = $1 WHERE id = $2`,
storageKey, docID)
h.stores.KnowledgeBases.UpdateDocumentStorageKey(c.Request.Context(), docID, storageKey)
}
// toKBResponse converts a model to the API response type.
@@ -747,6 +761,46 @@ func (h *KnowledgeBaseHandler) SetChannelKBs(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": kbs})
}
// ── Discoverable Management (v0.17.0) ────────────
// SetDiscoverable toggles KB visibility to users.
func (h *KnowledgeBaseHandler) SetDiscoverable(c *gin.Context) {
kbID := c.Param("id")
var req struct {
Discoverable bool `json:"discoverable"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.KnowledgeBases.SetDiscoverable(c.Request.Context(), kbID, req.Discoverable); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update discoverability"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// ListDiscoverableKBs returns KBs the user can see that are marked discoverable.
// Used by the channel KB toggle popup when kb_direct_access is enabled.
func (h *KnowledgeBaseHandler) ListDiscoverableKBs(c *gin.Context) {
userID := getUserID(c)
teamIDs := h.getUserTeamIDs(c, userID)
kbs, err := h.stores.KnowledgeBases.ListDiscoverable(c.Request.Context(), userID, teamIDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list knowledge bases"})
return
}
if kbs == nil {
kbs = []models.KnowledgeBase{}
}
c.JSON(http.StatusOK, gin.H{"data": kbs})
}
// userCanAccess checks if a user can access a KB based on scope.
func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID string, teamIDs []string) bool {
switch kb.Scope {
@@ -773,60 +827,57 @@ func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID st
// BuildKBHint returns a system prompt fragment listing active KBs for a
// channel, or empty string if none are active. Called by the completion
// handler to nudge the LLM to use kb_search.
func BuildKBHint(ctx context.Context, stores store.Stores, channelID, userID string) string {
func BuildKBHint(ctx context.Context, stores store.Stores, channelID, userID, personaID string) string {
teamIDs, _ := stores.Teams.GetUserTeamIDs(ctx, userID)
kbIDs, err := stores.KnowledgeBases.GetActiveKBIDs(ctx, channelID, userID, teamIDs)
if err != nil || len(kbIDs) == 0 {
// Also check personal KBs — always available
personalKBs, err := stores.KnowledgeBases.ListPersonal(ctx, userID)
if err != nil || len(personalKBs) == 0 {
return ""
}
// Only include personal KBs that have chunks
hasContent := false
for _, kb := range personalKBs {
if kb.ChunkCount > 0 {
hasContent = true
break
}
}
if !hasContent {
return ""
}
}
// Collect KB details for the hint
type kbInfo struct {
Name string
DocCount int
}
var kbs []kbInfo
seen := make(map[string]bool)
// Persona-bound KBs (v0.17.0)
if personaID != "" {
personaKBs, err := stores.Personas.GetKBs(ctx, personaID)
if err == nil {
for _, pkb := range personaKBs {
if pkb.ChunkCount > 0 {
kbs = append(kbs, kbInfo{Name: pkb.KBName, DocCount: pkb.DocumentCount})
seen[pkb.KBID] = true
}
}
}
}
// Channel-linked KBs
channelKBs, err := stores.KnowledgeBases.GetChannelKBs(ctx, channelID)
if err == nil {
for _, ckb := range channelKBs {
if ckb.Enabled && ckb.DocumentCount > 0 {
if ckb.Enabled && ckb.DocumentCount > 0 && !seen[ckb.KBID] {
kbs = append(kbs, kbInfo{Name: ckb.KBName, DocCount: ckb.DocumentCount})
seen[ckb.KBID] = true
}
}
}
// Personal KBs (not already linked to channel)
// Personal KBs (not already counted)
personalKBs, err := stores.KnowledgeBases.ListPersonal(ctx, userID)
if err == nil {
linked := make(map[string]bool)
for _, ckb := range channelKBs {
linked[ckb.KBID] = true
}
for _, kb := range personalKBs {
if !linked[kb.ID] && kb.ChunkCount > 0 {
if !seen[kb.ID] && kb.ChunkCount > 0 {
kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount})
seen[kb.ID] = true
}
}
}
// Group-granted KBs are resolved via GetActiveKBIDs — only add names
// for KBs found through channel link or persona. The kb_search tool
// handles the full resolution at search time.
_ = teamIDs // used by GetActiveKBIDsWithPersona at search time
if len(kbs) == 0 {
return ""
}

View File

@@ -366,17 +366,19 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore)
var presetSystemPrompt string
var personaID string
model := req.Model
apiConfigID := req.APIConfigID
temperature := req.Temperature
maxTokens := req.MaxTokens
if req.PresetID != "" {
preset := ResolvePreset(req.PresetID, userID)
preset := ResolvePreset(h.stores, req.PresetID, userID)
if preset == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found"})
return
}
personaID = preset.ID
if model == "" {
model = preset.BaseModelID
}
@@ -461,7 +463,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID, h.hub)
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID, personaID, h.hub)
// Persist as sibling (regen) with tool activity
if result.Content != "" {

View File

@@ -1,11 +1,11 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -256,31 +256,52 @@ func (r *personaRequest) toPersona() *models.Persona {
// ResolvePreset loads a persona by ID and returns it if the user has access.
// Returns nil if not found, inactive, or not accessible.
// Used by completion.go and messages.go for preset unwrapping.
func ResolvePreset(presetID, userID string) *models.Persona {
var p models.Persona
var providerConfigID *string
err := database.DB.QueryRow(`
SELECT id, name, base_model_id, provider_config_id, system_prompt,
temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active
FROM personas
WHERE id = $1 AND is_active = true
AND (
scope = 'global'
OR (scope = 'personal' AND created_by = $2)
OR (scope = 'personal' AND is_shared = true)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $2
))
)
`, presetID, userID).Scan(
&p.ID, &p.Name, &p.BaseModelID, &providerConfigID, &p.SystemPrompt,
&p.Temperature, &p.MaxTokens, &p.ThinkingBudget, &p.TopP,
&p.Scope, &p.OwnerID, &p.CreatedBy, &p.IsActive,
)
if err != nil {
func ResolvePreset(stores store.Stores, presetID, userID string) *models.Persona {
ctx := context.Background()
p, err := stores.Personas.GetByID(ctx, presetID)
if err != nil || !p.IsActive {
return nil
}
p.ProviderConfigID = providerConfigID
return &p
ok, err := stores.Personas.UserCanAccess(ctx, userID, presetID)
if err != nil || !ok {
return nil
}
return p
}
// ── Persona-KB Binding Endpoints (v0.17.0) ──────
// GetPersonaKBs returns the knowledge bases bound to a persona.
func (h *PersonaHandler) GetPersonaKBs(c *gin.Context) {
personaID := c.Param("id")
kbs, err := h.stores.Personas.GetKBs(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load persona KBs"})
return
}
c.JSON(http.StatusOK, gin.H{"data": kbs})
}
// SetPersonaKBs replaces the knowledge bases bound to a persona.
func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) {
personaID := c.Param("id")
var req struct {
KBIDs []string `json:"kb_ids"`
AutoSearch map[string]bool `json:"auto_search"` // kb_id → true/false
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.SetKBs(c.Request.Context(), personaID, req.KBIDs, req.AutoSearch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona KBs"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}

View File

@@ -43,7 +43,7 @@ func streamWithToolLoop(
provider providers.Provider,
cfg providers.ProviderConfig,
req *providers.CompletionRequest,
model, userID, channelID string,
model, userID, channelID, personaID string,
hub *events.Hub,
) streamResult {
// Set SSE headers
@@ -168,6 +168,7 @@ func streamWithToolLoop(
execCtx := tools.ExecutionContext{
UserID: userID,
ChannelID: channelID,
PersonaID: personaID,
}
for _, tc := range toolCalls {
call := tools.ToolCall{

110
server/handlers/title.go Normal file
View File

@@ -0,0 +1,110 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// TitleHandler generates chat titles using the utility role.
type TitleHandler struct {
stores store.Stores
resolver *roles.Resolver
}
// NewTitleHandler creates a title generation handler.
func NewTitleHandler(s store.Stores, r *roles.Resolver) *TitleHandler {
return &TitleHandler{stores: s, resolver: r}
}
// GenerateTitle generates a title for a channel using the utility role.
// POST /channels/:id/generate-title
func (h *TitleHandler) GenerateTitle(c *gin.Context) {
channelID := c.Param("id")
userID := c.GetString("user_id")
teamID := c.GetString("team_id")
// Verify channel access
owns, err := h.stores.Channels.UserOwns(c.Request.Context(), channelID, userID)
if err != nil || !owns {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
ch, err := h.stores.Channels.GetByID(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
// Load first few messages
msgs, err := h.stores.Messages.ListForChannel(c.Request.Context(), channelID, store.ListOptions{
Limit: 4, Order: "asc",
})
if err != nil || len(msgs) == 0 {
c.JSON(http.StatusOK, gin.H{"title": ch.Title}) // keep existing
return
}
// Build a snippet from the first messages
var snippet strings.Builder
for _, m := range msgs {
content := m.Content
if len(content) > 200 {
content = content[:200] + "…"
}
fmt.Fprintf(&snippet, "%s: %s\n", m.Role, content)
}
// Call utility role
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
var tID *string
if teamID != "" {
tID = &teamID
}
result, err := h.resolver.Complete(ctx, roles.RoleUtility, userID, tID, []providers.Message{
{
Role: "system",
Content: "You are a title generator. Given a conversation snippet, produce a concise title of 6 words or fewer. Respond with ONLY the title, no quotes, no punctuation at the end, no explanation.",
},
{
Role: "user",
Content: "Title this conversation:\n\n" + snippet.String(),
},
})
if err != nil {
// Fallback: truncate first user message
c.JSON(http.StatusOK, gin.H{"title": ch.Title})
return
}
title := strings.TrimSpace(result.Content)
// Strip surrounding quotes if the model added them
title = strings.Trim(title, "\"'`\u201c\u201d\u2018\u2019")
if title == "" {
c.JSON(http.StatusOK, gin.H{"title": ch.Title})
return
}
// Cap at 100 chars
if len(title) > 100 {
title = title[:100]
}
// Update channel
_ = h.stores.Channels.Update(c.Request.Context(), channelID, map[string]interface{}{
"title": title,
})
c.JSON(http.StatusOK, gin.H{"title": title})
}

View File

@@ -145,8 +145,11 @@ func main() {
}
}
// Role resolver for model role dispatch (needs stores + vault)
roleResolver := roles.NewResolver(stores, keyResolver)
// ── EventBus (created early — needed by role resolver) ──
bus := events.NewBus()
// Role resolver for model role dispatch (needs stores + vault + bus)
roleResolver := roles.NewResolver(stores, keyResolver).WithBus(bus)
// ── Knowledge Base Pipeline ─────────────
// Embedder + ingester for RAG document processing (v0.14.0).
@@ -171,8 +174,7 @@ func main() {
// ── Base path group ──────────────────────
base := r.Group(cfg.BasePath)
// ── EventBus + WebSocket Hub ─────────────
bus := events.NewBus()
// ── WebSocket Hub ─────────────────────────
hub := events.NewHub(bus)
// Health check (k8s probes hit this directly)
@@ -258,6 +260,10 @@ func main() {
summarize := handlers.NewSummarizeHandler(compactionSvc)
protected.POST("/channels/:id/summarize", summarize.Summarize)
// Auto-title generation (utility role)
titleH := handlers.NewTitleHandler(stores, roleResolver)
protected.POST("/channels/:id/generate-title", titleH.GenerateTitle)
// Provider Configs (user-facing — replaces /api-configs)
provCfg := handlers.NewProviderConfigHandler(stores, keyResolver)
protected.GET("/api-configs", provCfg.ListConfigs) // backward compat
@@ -301,6 +307,8 @@ func main() {
protected.DELETE("/presets/:id", personas.DeleteUserPersona)
protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
protected.GET("/presets/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
protected.PUT("/presets/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
// Notes
notes := handlers.NewNoteHandler()
@@ -339,6 +347,8 @@ func main() {
protected.POST("/knowledge-bases/:id/rebuild", kbH.RebuildKB)
protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0
protected.PUT("/knowledge-bases/:id/discoverable", kbH.SetDiscoverable) // v0.17.0 (admin only enforced in handler)
// Teams (user: my teams)
teams := handlers.NewTeamHandler(keyResolver)
@@ -381,6 +391,8 @@ func main() {
teamScoped.GET("/presets", teamPersonas.ListTeamPersonas)
teamScoped.POST("/presets", teamPersonas.CreateTeamPersona)
teamScoped.DELETE("/presets/:id", teamPersonas.DeleteTeamPersona)
teamScoped.GET("/presets/:id/knowledge-bases", teamPersonas.GetPersonaKBs) // v0.17.0
teamScoped.PUT("/presets/:id/knowledge-bases", teamPersonas.SetPersonaKBs) // v0.17.0
// Team role overrides
teamRoles := handlers.NewRolesHandler(stores, roleResolver)
@@ -445,6 +457,8 @@ func main() {
admin.DELETE("/presets/:id", personaAdm.DeleteAdminPersona)
admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
admin.GET("/presets/:id/knowledge-bases", personaAdm.GetPersonaKBs) // v0.17.0
admin.PUT("/presets/:id/knowledge-bases", personaAdm.SetPersonaKBs) // v0.17.0
// Teams (admin)
teamAdm := handlers.NewTeamHandler(keyResolver)

View File

@@ -216,6 +216,9 @@ type Persona struct {
// Loaded from persona_grants, not stored in personas table
Grants []Grant `json:"grants,omitempty" db:"-"`
// Loaded from persona_knowledge_bases, not stored in personas table
KBIDs []string `json:"kb_ids,omitempty" db:"-"`
}
type PersonaPatch struct {
@@ -691,11 +694,25 @@ type KnowledgeBase struct {
DocumentCount int `json:"document_count" db:"document_count"`
ChunkCount int `json:"chunk_count" db:"chunk_count"`
TotalBytes int64 `json:"total_bytes" db:"total_bytes"`
Discoverable bool `json:"discoverable" db:"discoverable"`
Status string `json:"status" db:"status"` // active, processing, error
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// PersonaKB binds a knowledge base to a persona.
type PersonaKB struct {
PersonaID string `json:"persona_id" db:"persona_id"`
KBID string `json:"kb_id" db:"kb_id"`
AutoSearch bool `json:"auto_search" db:"auto_search"`
AddedAt time.Time `json:"added_at" db:"added_at"`
// Joined fields (not in persona_knowledge_bases table)
KBName string `json:"kb_name,omitempty" db:"kb_name"`
DocumentCount int `json:"document_count,omitempty" db:"document_count"`
ChunkCount int `json:"chunk_count,omitempty" db:"chunk_count"`
}
// KBDocument is a single uploaded file within a knowledge base.
type KBDocument struct {
ID string `json:"id" db:"id"`

View File

@@ -5,8 +5,12 @@ import (
"encoding/json"
"fmt"
"log"
"sync"
"time"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -89,11 +93,24 @@ var (
type Resolver struct {
stores store.Stores
vault *crypto.KeyResolver
bus *events.Bus // optional — nil-safe
// Fallback cooldown: suppress duplicate alerts per role
coolMu sync.Mutex
cooldown map[string]time.Time // role → last alert timestamp
}
const fallbackCooldown = 5 * time.Minute
// NewResolver creates a role resolver with access to stores and vault.
func NewResolver(s store.Stores, vault *crypto.KeyResolver) *Resolver {
return &Resolver{stores: s, vault: vault}
return &Resolver{stores: s, vault: vault, cooldown: make(map[string]time.Time)}
}
// WithBus attaches an event bus for fallback alert publishing.
func (r *Resolver) WithBus(bus *events.Bus) *Resolver {
r.bus = bus
return r
}
// Complete sends a chat completion using the named role.
@@ -118,6 +135,7 @@ func (r *Resolver) Complete(ctx context.Context, role string, userID string, tea
result, err := r.doComplete(ctx, role, cfg.Fallback, messages)
if err == nil {
result.UsedFallback = true
r.onFallback(ctx, role, "completion", cfg.Primary, cfg.Fallback)
return result, nil
}
return nil, fmt.Errorf("role %q: both primary and fallback failed: %w", role, err)
@@ -150,6 +168,7 @@ func (r *Resolver) Embed(ctx context.Context, role string, userID string, teamID
result, err := r.doEmbed(ctx, role, cfg.Fallback, input)
if err == nil {
result.UsedFallback = true
r.onFallback(ctx, role, "embedding", cfg.Primary, cfg.Fallback)
return result, nil
}
return nil, fmt.Errorf("role %q: both primary and fallback embed failed: %w", role, err)
@@ -387,3 +406,66 @@ func parseRoleConfig(data interface{}) (*RoleConfig, error) {
}
return &cfg, nil
}
// ── Fallback Alerting ──────────────────────
// onFallback fires on successful fallback activation. It emits:
// - log line (always)
// - audit_log entry (always)
// - event bus "role.fallback" (once per cooldown window)
//
// The cooldown prevents flooding the admin UI with identical alerts
// when a primary is down and every request triggers the fallback.
func (r *Resolver) onFallback(ctx context.Context, role, opType string, primary, fallback *RoleBinding) {
primaryModel := ""
fallbackModel := ""
if primary != nil {
primaryModel = primary.ModelID
}
if fallback != nil {
fallbackModel = fallback.ModelID
}
log.Printf("⚠ Role %q fallback activated: %s → %s (%s)", role, primaryModel, fallbackModel, opType)
// Audit log — always written (one row per fallback fire)
_ = r.stores.Audit.Log(ctx, &models.AuditEntry{
Action: "role.fallback",
ResourceType: "role",
ResourceID: role,
Metadata: models.JSONMap{
"operation": opType,
"primary_model": primaryModel,
"fallback_model": fallbackModel,
},
})
// Bus event — cooldown-gated
if r.bus == nil {
return
}
r.coolMu.Lock()
last, exists := r.cooldown[role]
now := time.Now()
if exists && now.Sub(last) < fallbackCooldown {
r.coolMu.Unlock()
return
}
r.cooldown[role] = now
r.coolMu.Unlock()
payload, _ := json.Marshal(map[string]string{
"role": role,
"operation": opType,
"primary_model": primaryModel,
"fallback_model": fallbackModel,
"message": fmt.Sprintf("Role %q primary (%s) failed — using fallback (%s)", role, primaryModel, fallbackModel),
})
r.bus.PublishAsync(events.Event{
Label: "role.fallback",
Room: "admin", // admin-targeted
Payload: payload,
Ts: now.UnixMilli(),
})
}

View File

@@ -119,6 +119,11 @@ type PersonaStore interface {
// Access check
UserCanAccess(ctx context.Context, userID, personaID string) (bool, error)
// Knowledge base bindings
SetKBs(ctx context.Context, personaID string, kbIDs []string, autoSearch map[string]bool) error
GetKBs(ctx context.Context, personaID string) ([]models.PersonaKB, error)
GetKBIDs(ctx context.Context, personaID string) ([]string, error)
}
// =========================================
@@ -385,6 +390,7 @@ type KnowledgeBaseStore interface {
ListDocuments(ctx context.Context, kbID string) ([]models.KBDocument, error)
UpdateDocumentStatus(ctx context.Context, id string, status string, errMsg *string) error
UpdateDocumentText(ctx context.Context, id string, text string, chunkCount int) error
UpdateDocumentStorageKey(ctx context.Context, id string, storageKey string) error
DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) // returns for storage cleanup
// Chunks
@@ -398,9 +404,15 @@ type KnowledgeBaseStore interface {
GetChannelKBs(ctx context.Context, channelID string) ([]models.ChannelKB, error)
GetActiveKBIDs(ctx context.Context, channelID string, userID string,
teamIDs []string) ([]string, error) // enabled + user has access
GetActiveKBIDsWithPersona(ctx context.Context, channelID string, userID string,
teamIDs []string, personaID string) ([]string, error) // includes persona-bound KBs
ListDiscoverable(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error)
// Stats — recount document_count/chunk_count/total_bytes from child tables
UpdateStats(ctx context.Context, kbID string) error
// Discoverable management
SetDiscoverable(ctx context.Context, kbID string, discoverable bool) error
}
// =========================================

View File

@@ -33,7 +33,7 @@ func (s *KnowledgeBaseStore) Create(ctx context.Context, kb *models.KnowledgeBas
func (s *KnowledgeBaseStore) GetByID(ctx context.Context, id string) (*models.KnowledgeBase, error) {
kb, err := scanKB(DB.QueryRowContext(ctx, `
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
document_count, chunk_count, total_bytes, status, created_at, updated_at
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
FROM knowledge_bases WHERE id = $1`, id))
if err != nil {
return nil, err
@@ -71,7 +71,7 @@ func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, tea
// User can see: global + their teams + personal + group-granted.
q := `
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
document_count, chunk_count, total_bytes, status, created_at, updated_at
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
FROM knowledge_bases
WHERE scope = 'global'
OR (scope = 'personal' AND owner_id = $1)`
@@ -107,21 +107,21 @@ func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, tea
func (s *KnowledgeBaseStore) ListGlobal(ctx context.Context) ([]models.KnowledgeBase, error) {
return queryKBs(ctx, `
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
document_count, chunk_count, total_bytes, status, created_at, updated_at
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
FROM knowledge_bases WHERE scope = 'global' ORDER BY name`)
}
func (s *KnowledgeBaseStore) ListForTeam(ctx context.Context, teamID string) ([]models.KnowledgeBase, error) {
return queryKBs(ctx, `
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
document_count, chunk_count, total_bytes, status, created_at, updated_at
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
FROM knowledge_bases WHERE team_id = $1 ORDER BY name`, teamID)
}
func (s *KnowledgeBaseStore) ListPersonal(ctx context.Context, userID string) ([]models.KnowledgeBase, error) {
return queryKBs(ctx, `
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
document_count, chunk_count, total_bytes, status, created_at, updated_at
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
FROM knowledge_bases WHERE scope = 'personal' AND owner_id = $1 ORDER BY name`, userID)
}
@@ -196,6 +196,12 @@ func (s *KnowledgeBaseStore) UpdateDocumentText(ctx context.Context, id string,
return err
}
func (s *KnowledgeBaseStore) UpdateDocumentStorageKey(ctx context.Context, id string, storageKey string) error {
_, err := DB.ExecContext(ctx, `
UPDATE kb_documents SET storage_key = $2 WHERE id = $1`, id, storageKey)
return err
}
func (s *KnowledgeBaseStore) DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) {
var doc models.KBDocument
var errMsg sql.NullString
@@ -385,6 +391,111 @@ func (s *KnowledgeBaseStore) UpdateStats(ctx context.Context, kbID string) error
return err
}
// ── Discoverable Management (v0.17.0) ────────────
func (s *KnowledgeBaseStore) SetDiscoverable(ctx context.Context, kbID string, discoverable bool) error {
_, err := DB.ExecContext(ctx, `
UPDATE knowledge_bases SET discoverable = $2, updated_at = now()
WHERE id = $1`, kbID, discoverable)
return err
}
// GetActiveKBIDsWithPersona returns KB IDs accessible for a channel, including
// KBs bound to the active persona. Persona-bound KBs are included even if
// they are not discoverable and not channel-linked.
func (s *KnowledgeBaseStore) GetActiveKBIDsWithPersona(ctx context.Context, channelID string, userID string, teamIDs []string, personaID string) ([]string, error) {
// Start with channel-linked KBs (existing behavior)
q := `
SELECT ckb.kb_id
FROM channel_knowledge_bases ckb
JOIN knowledge_bases kb ON ckb.kb_id = kb.id
WHERE ckb.channel_id = $1 AND ckb.enabled = true
AND (
kb.scope = 'global'
OR (kb.scope = 'personal' AND kb.owner_id = $2)`
args := []interface{}{channelID, userID}
if len(teamIDs) > 0 {
q += fmt.Sprintf(` OR (kb.scope = 'team' AND kb.team_id = ANY($%d))`, len(args)+1)
args = append(args, pq.Array(teamIDs))
}
q += `)`
// UNION with persona-bound KBs (bypass discoverable check)
if personaID != "" {
q += fmt.Sprintf(`
UNION
SELECT pkb.kb_id
FROM persona_knowledge_bases pkb
JOIN knowledge_bases kb ON pkb.kb_id = kb.id
WHERE pkb.persona_id = $%d
AND kb.chunk_count > 0`, len(args)+1)
args = append(args, personaID)
}
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
seen := make(map[string]bool)
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
if !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
return ids, rows.Err()
}
// ListDiscoverable returns KBs the user can see AND that are discoverable.
// Used for the channel KB toggle popup when kb_direct_access is enabled.
func (s *KnowledgeBaseStore) ListDiscoverable(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) {
q := `
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
FROM knowledge_bases
WHERE discoverable = true
AND (
scope = 'global'
OR (scope = 'personal' AND owner_id = $1)`
args := []interface{}{userID}
if len(teamIDs) > 0 {
q += fmt.Sprintf(` OR (scope = 'team' AND team_id = ANY($%d))`, len(args)+1)
args = append(args, pq.Array(teamIDs))
}
// Group-granted KBs
q += fmt.Sprintf(`
OR id IN (
SELECT rg.resource_id FROM resource_grants rg
WHERE rg.resource_type = 'knowledge_base'
AND (
rg.grant_scope = 'global'
OR (rg.grant_scope = 'groups'
AND EXISTS (
SELECT 1 FROM group_members gm
WHERE gm.user_id = $%d
AND gm.group_id = ANY(rg.granted_groups)
))
)
)`, len(args)+1)
args = append(args, userID)
q += `) ORDER BY name`
return queryKBs(ctx, q, args...)
}
// ── Scan Helpers ─────────────────────────────────
func scanKB(row *sql.Row) (*models.KnowledgeBase, error) {
@@ -396,7 +507,7 @@ func scanKB(row *sql.Row) (*models.KnowledgeBase, error) {
&kb.ID, &kb.Name, &kb.Description, &kb.Scope,
&ownerID, &teamID, &embCfgJSON,
&kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes,
&kb.Status, &kb.CreatedAt, &kb.UpdatedAt,
&kb.Status, &kb.Discoverable, &kb.CreatedAt, &kb.UpdatedAt,
)
if err != nil {
return nil, err
@@ -423,7 +534,7 @@ func queryKBs(ctx context.Context, query string, args ...interface{}) ([]models.
&kb.ID, &kb.Name, &kb.Description, &kb.Scope,
&ownerID, &teamID, &embCfgJSON,
&kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes,
&kb.Status, &kb.CreatedAt, &kb.UpdatedAt,
&kb.Status, &kb.Discoverable, &kb.CreatedAt, &kb.UpdatedAt,
)
if err != nil {
return nil, err

View File

@@ -320,3 +320,84 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
}
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 = $1", 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 ($1, $2, $3)`,
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 = $1
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, &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 = $1", 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()
}

View File

@@ -69,8 +69,16 @@ func (t *kbSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, ar
// Resolve user's team IDs for scoped access
teamIDs, _ := t.stores.Teams.GetUserTeamIDs(ctx, execCtx.UserID)
// Get active KB IDs for this channel (respects scope: global, team, personal)
kbIDs, err := t.stores.KnowledgeBases.GetActiveKBIDs(ctx, execCtx.ChannelID, execCtx.UserID, teamIDs)
// Get active KB IDs for this channel, including persona-bound KBs
var kbIDs []string
var err error
if execCtx.PersonaID != "" {
kbIDs, err = t.stores.KnowledgeBases.GetActiveKBIDsWithPersona(
ctx, execCtx.ChannelID, execCtx.UserID, teamIDs, execCtx.PersonaID)
} else {
kbIDs, err = t.stores.KnowledgeBases.GetActiveKBIDs(
ctx, execCtx.ChannelID, execCtx.UserID, teamIDs)
}
if err != nil {
return "", fmt.Errorf("failed to look up active knowledge bases: %w", err)
}

View File

@@ -43,6 +43,7 @@ type ToolResult struct {
type ExecutionContext struct {
UserID string
ChannelID string
PersonaID string // set when a persona is active — tools use for scoped KB access
}
// Tool is the interface every built-in tool implements.