This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/summarize.go
2026-02-24 20:29:08 +00:00

271 lines
8.8 KiB
Go

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