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) } }