Changeset 0.17.0 (#75)
This commit is contained in:
42
server/database/migrations/002_v017_persona_kb.sql
Normal file
42
server/database/migrations/002_v017_persona_kb.sql
Normal 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)';
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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
110
server/handlers/title.go
Normal 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})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// =========================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user