271 lines
8.8 KiB
Go
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)
|
|
}
|
|
}
|