Changeset 0.10.2 (#58)

This commit is contained in:
2026-02-24 20:29:08 +00:00
parent 13772ebd6c
commit 4061e4a145
20 changed files with 1223 additions and 41 deletions

View File

@@ -480,6 +480,9 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
// loadConversation builds the message history for the LLM by walking the
// active path through the message tree. Only the current branch is sent —
// sibling branches are never included in context.
//
// 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) {
messages := make([]providers.Message, 0)
@@ -519,10 +522,32 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
return nil, err
}
for _, m := range path {
// Find the most recent summary node in the path
summaryIdx := -1
for i, m := range path {
if isSummaryMessage(&m) {
summaryIdx = i
}
}
// If we found a summary, inject it as a system message and skip prior messages
startIdx := 0
if summaryIdx >= 0 {
messages = append(messages, providers.Message{
Role: "system",
Content: "Previous conversation summary:\n" + path[summaryIdx].Content,
})
startIdx = summaryIdx + 1
}
for _, m := range path[startIdx:] {
if m.Role == "system" {
continue // system prompts handled above
}
// Skip summary nodes that aren't the boundary (shouldn't happen, but defensive)
if isSummaryMessage(&m) {
continue
}
messages = append(messages, providers.Message{
Role: m.Role,
Content: m.Content,
@@ -532,6 +557,18 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
return messages, nil
}
// isSummaryMessage checks if a PathMessage has summary metadata.
func isSummaryMessage(m *PathMessage) bool {
if m.Metadata == nil {
return false
}
var meta map[string]interface{}
if err := json.Unmarshal(*m.Metadata, &meta); err != nil {
return false
}
return meta["type"] == "summary"
}
// ── Message Persistence ─────────────────────
// persistMessage inserts a message into the tree, updates the cursor, and

View File

@@ -1611,6 +1611,7 @@ func TestIntegration_Roles_ListReturnsSeeded(t *testing.T) {
decode(w, &resp)
// Migration 004 seeds utility, embedding, generation
// (generation removed from ValidRoles in v0.10.2 but still in JSONB seed data)
for _, role := range []string{"utility", "embedding", "generation"} {
if _, ok := resp[role]; !ok {
t.Errorf("expected role %q in response, got: %v", role, resp)
@@ -2174,3 +2175,48 @@ func TestIntegration_Pricing_RejectBYOKUpsert(t *testing.T) {
t.Fatalf("pricing BYOK upsert: want 403, got %d: %s", w.Code, w.Body.String())
}
}
// ── GetRole Endpoint (exercises GetConfig signature) ──
// The existing Roles_UpdateAndGet test validates via ListRoles.
// This test hits GET /admin/roles/:role which routes through
// resolver.GetConfig — the exact call site that broke in v0.10.2
// when the signature changed from 3-arg to 4-arg.
func TestIntegration_Roles_GetSingleRole(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin_getrole", "admin_getrole@test.com")
// Create provider + configure utility role
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "getrole-provider", "provider": "openai",
"endpoint": "http://localhost:1/v1", "api_key": "sk-getrole",
})
if w.Code != http.StatusCreated {
t.Fatalf("create config: %d: %s", w.Code, w.Body.String())
}
var cfg map[string]interface{}
decode(w, &cfg)
cfgID := cfg["id"].(string)
w = h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
"primary": map[string]string{
"provider_config_id": cfgID,
"model_id": "gpt-4o-mini",
},
})
if w.Code != http.StatusOK {
t.Fatalf("update role: %d: %s", w.Code, w.Body.String())
}
// GET /admin/roles/utility — the endpoint that broke
w = h.request("GET", "/api/v1/admin/roles/utility", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get single role: want 200, got %d: %s", w.Code, w.Body.String())
}
// Seeded role with null config via GetRole → 200 (migration seeds all roles)
w = h.request("GET", "/api/v1/admin/roles/embedding", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get seeded-but-empty role: want 200, got %d: %s", w.Code, w.Body.String())
}
}

View File

@@ -44,7 +44,7 @@ func (h *RolesHandler) GetRole(c *gin.Context) {
return
}
cfg, err := h.resolver.GetConfig(c.Request.Context(), role, nil)
cfg, err := h.resolver.GetConfig(c.Request.Context(), role, "", nil)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -104,7 +104,7 @@ func (h *RolesHandler) TestRole(c *gin.Context) {
if role == roles.RoleEmbedding {
// Test embedding
result, err := h.resolver.Embed(c.Request.Context(), role, nil, []string{"test embedding"})
result, err := h.resolver.Embed(c.Request.Context(), role, "", nil, []string{"test embedding"})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
@@ -120,7 +120,7 @@ func (h *RolesHandler) TestRole(c *gin.Context) {
}
// Test completion with a minimal prompt
result, err := h.resolver.Complete(c.Request.Context(), role, nil, []providers.Message{
result, err := h.resolver.Complete(c.Request.Context(), role, "", nil, []providers.Message{
{Role: "user", Content: "Say 'ok' and nothing else."},
})
if err != nil {

View File

@@ -0,0 +1,270 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"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/providers"
"git.gobha.me/xcaliber/chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// SummarizeHandler handles conversation summarization using the utility model role.
type SummarizeHandler struct {
stores store.Stores
resolver *roles.Resolver
}
// NewSummarizeHandler creates a new handler.
func NewSummarizeHandler(s store.Stores, resolver *roles.Resolver) *SummarizeHandler {
return &SummarizeHandler{stores: s, resolver: resolver}
}
// ── Summarize & Continue ──────────────────
// POST /channels/:id/summarize
//
// Calls the utility role to summarize the conversation history, inserts
// the summary as a special message node in the tree, and returns it.
// Subsequent completions will use the summary as context boundary.
func (h *SummarizeHandler) Summarize(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
// ── Verify channel ownership ──
var ownerID string
err := database.DB.QueryRow(
`SELECT user_id FROM channels WHERE id = $1 AND deleted_at IS NULL`, channelID,
).Scan(&ownerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
// ── Check utility role is configured ──
if !h.resolver.IsConfigured(c.Request.Context(), roles.RoleUtility) {
// If user doesn't have a personal override either, it's not available
if !h.resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility) {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Utility model role is not configured. Ask your admin to set one, or configure your own in Settings → Model Roles.",
})
return
}
}
// ── Rate limiting (org-funded calls only) ──
isPersonal := h.resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility)
if !isPersonal {
if err := h.checkRateLimit(c.Request.Context(), userID); err != nil {
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
return
}
}
// ── Load active path ──
path, err := getActivePath(channelID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
return
}
if len(path) < 4 {
c.JSON(http.StatusBadRequest, gin.H{"error": "conversation too short to summarize"})
return
}
// Find existing summary boundary (if any) and only summarize after it
startIdx := 0
for i, m := range path {
if isSummaryMessage(&m) {
startIdx = i + 1
}
}
// Build messages to summarize (skip system messages, skip previous summaries)
var toSummarize []string
messagesInScope := 0
var lastMessageID string
for _, m := range path[startIdx:] {
if m.Role == "system" || isSummaryMessage(&m) {
continue
}
toSummarize = append(toSummarize, fmt.Sprintf("%s: %s", m.Role, m.Content))
messagesInScope++
lastMessageID = m.ID
}
if messagesInScope < 4 {
c.JSON(http.StatusBadRequest, gin.H{"error": "not enough new messages since last summary"})
return
}
// ── Build the summarization prompt ──
conversationText := strings.Join(toSummarize, "\n\n")
summaryPrompt := []providers.Message{
{
Role: "system",
Content: `You are a conversation summarizer. Create a concise but comprehensive summary of the following conversation that preserves:
- Key decisions and conclusions reached
- Important facts, names, numbers, and technical details mentioned
- Action items or commitments made
- The overall context and topic flow
Write the summary in a way that would allow the conversation to continue naturally. Use clear, factual language. Do not include meta-commentary about the summarization process.`,
},
{
Role: "user",
Content: "Summarize this conversation:\n\n" + conversationText,
},
}
// ── Resolve team context for the user ──
teamID := h.getUserTeamID(c.Request.Context(), userID)
// ── Call utility role ──
log.Printf("📝 Summarizing channel %s for user %s (%d messages)", channelID, userID, messagesInScope)
result, err := h.resolver.Complete(c.Request.Context(), roles.RoleUtility, userID, teamID, summaryPrompt)
if err != nil {
log.Printf("⚠ Summarize failed for channel %s: %v", channelID, err)
c.JSON(http.StatusBadGateway, gin.H{"error": "summarization failed: " + err.Error()})
return
}
// ── Log usage ──
h.logSummaryUsage(c.Request.Context(), channelID, userID, result)
// ── Insert summary message as a tree node ──
// The summary message is inserted as an "assistant" message with special metadata.
// Its parent is the last message that was summarized, making it part of the tree.
metadata := models.JSONMap{
"type": "summary",
"summarized_until_id": lastMessageID,
"summarized_count": messagesInScope,
"utility_model": result.Model,
"used_fallback": result.UsedFallback,
}
metaJSON, _ := json.Marshal(metadata)
siblingIdx := nextSiblingIndex(channelID, &lastMessageID)
var summaryMsgID string
err = database.DB.QueryRow(`
INSERT INTO messages (channel_id, parent_id, role, content, model, metadata, sibling_index, participant_type)
VALUES ($1, $2, 'assistant', $3, $4, $5, $6, 'system')
RETURNING id
`, channelID, lastMessageID, result.Content, result.Model, string(metaJSON), siblingIdx).Scan(&summaryMsgID)
if err != nil {
log.Printf("⚠ Failed to persist summary for channel %s: %v", channelID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save summary"})
return
}
// Update cursor to point to the summary node
if err := updateCursor(channelID, userID, summaryMsgID); err != nil {
log.Printf("⚠ Failed to update cursor after summarize: %v", err)
}
log.Printf("✅ Summary created for channel %s: %s (%d messages → %d chars)",
channelID, summaryMsgID, messagesInScope, len(result.Content))
c.JSON(http.StatusOK, gin.H{
"summary_id": summaryMsgID,
"summarized_count": messagesInScope,
"model": result.Model,
"used_fallback": result.UsedFallback,
"content": result.Content,
})
}
// ── Rate Limiting ─────────────────────────
func (h *SummarizeHandler) checkRateLimit(ctx context.Context, userID string) error {
// Load rate limit from global settings (default: 20/hour, 0 = unlimited)
limit := 20
settings, err := h.stores.GlobalConfig.Get(ctx, "utility_rate_limit")
if err == nil {
if v, ok := settings["value"]; ok {
switch n := v.(type) {
case float64:
limit = int(n)
case int:
limit = n
}
}
}
if limit <= 0 {
return nil // unlimited
}
since := time.Now().Add(-1 * time.Hour)
count, err := h.stores.Usage.CountRecentByRole(ctx, userID, roles.RoleUtility, since)
if err != nil {
log.Printf("⚠ Rate limit check failed: %v", err)
return nil // fail open — don't block on DB errors
}
if count >= limit {
return fmt.Errorf("rate limit exceeded: %d utility calls in the last hour (limit: %d)", count, limit)
}
return nil
}
// ── Helpers ───────────────────────────────
func (h *SummarizeHandler) getUserTeamID(ctx context.Context, userID string) *string {
// Get the user's first team (for role override resolution)
var teamID string
err := database.DB.QueryRow(`
SELECT team_id FROM team_members WHERE user_id = $1 LIMIT 1
`, userID).Scan(&teamID)
if err != nil || teamID == "" {
return nil
}
return &teamID
}
func (h *SummarizeHandler) logSummaryUsage(ctx context.Context, channelID, userID string, result *roles.CompletionResult) {
role := roles.RoleUtility
entry := &models.UsageEntry{
ChannelID: &channelID,
UserID: userID,
ProviderConfigID: &result.ConfigID,
ProviderScope: result.ProviderScope,
ModelID: result.Model,
Role: &role,
PromptTokens: result.InputTokens,
CompletionTokens: result.OutputTokens,
CacheCreationTokens: result.CacheCreationTokens,
CacheReadTokens: result.CacheReadTokens,
}
// Calculate cost from pricing
pricing, err := h.stores.Pricing.GetForModel(ctx, result.ConfigID, result.Model)
if err == nil && pricing != nil {
if pricing.InputPerM != nil {
costIn := float64(result.InputTokens) / 1_000_000 * *pricing.InputPerM
entry.CostInput = &costIn
}
if pricing.OutputPerM != nil {
costOut := float64(result.OutputTokens) / 1_000_000 * *pricing.OutputPerM
entry.CostOutput = &costOut
}
}
if err := h.stores.Usage.Log(ctx, entry); err != nil {
log.Printf("⚠ Failed to log summary usage: %v", err)
}
}

View File

@@ -20,6 +20,7 @@ type PathMessage struct {
Model *string `json:"model"`
TokensUsed *int `json:"tokens_used,omitempty"`
ToolCalls *json.RawMessage `json:"tool_calls,omitempty"`
Metadata *json.RawMessage `json:"metadata,omitempty"`
SiblingCount int `json:"sibling_count"`
SiblingIndex int `json:"sibling_index"`
ParticipantType string `json:"participant_type,omitempty"`
@@ -98,7 +99,7 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
rows, err := database.DB.Query(`
WITH RECURSIVE path AS (
-- Anchor: start at the leaf
SELECT id, parent_id, role, content, model, tokens_used, tool_calls,
SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata,
participant_type, participant_id, sibling_index, created_at,
0 AS depth
FROM messages
@@ -107,14 +108,14 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
UNION ALL
-- Walk up via parent_id
SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls,
SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata,
m.participant_type, m.participant_id, m.sibling_index, m.created_at,
p.depth + 1
FROM messages m
JOIN path p ON m.id = p.parent_id
WHERE m.deleted_at IS NULL
)
SELECT id, parent_id, role, content, model, tokens_used, tool_calls,
SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata,
participant_type, participant_id, sibling_index, created_at
FROM path
ORDER BY depth DESC
@@ -128,10 +129,10 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
for rows.Next() {
var m PathMessage
var participantType, participantID sql.NullString
var toolCallsJSON []byte
var toolCallsJSON, metadataJSON []byte
if err := rows.Scan(
&m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
&toolCallsJSON,
&toolCallsJSON, &metadataJSON,
&participantType, &participantID, &m.SiblingIndex, &m.CreatedAt,
); err != nil {
return nil, fmt.Errorf("getPathToLeaf scan: %w", err)
@@ -140,6 +141,10 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
raw := json.RawMessage(toolCallsJSON)
m.ToolCalls = &raw
}
if len(metadataJSON) > 0 && string(metadataJSON) != "null" && string(metadataJSON) != "{}" {
raw := json.RawMessage(metadataJSON)
m.Metadata = &raw
}
if participantType.Valid {
m.ParticipantType = participantType.String
}