Changeset 0.10.2 (#58)
This commit is contained in:
270
server/handlers/summarize.go
Normal file
270
server/handlers/summarize.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user