readysite / website / internal / assist / summary.go
7.6 KB
summary.go
package assist

import (
	"encoding/json"
	"fmt"
	"log"
	"strings"

	"github.com/readysite/readysite/website/models"
)

// Summarization thresholds
const (
	SummarizationThreshold = 10 // Messages before triggering summarization
	MaxMessagesInContext   = 6  // Keep recent N messages in full detail
)

// ShouldSummarize returns true if the conversation has enough messages to warrant summarization.
func ShouldSummarize(conv *models.Conversation) bool {
	if conv == nil {
		return false
	}

	// Check if we already have a summary
	existing, _ := models.ConversationSummaries.First("WHERE ConversationID = ? ORDER BY CreatedAt DESC", conv.ID)

	// Get message count
	msgCount := conv.MessageCount()

	// If no existing summary, check if we have enough messages
	if existing == nil {
		return msgCount >= SummarizationThreshold
	}

	// If we have a summary, check if there are enough new messages since the last summary
	newMsgCount := models.Messages.Count("WHERE ConversationID = ? AND CreatedAt > ?", conv.ID, existing.CreatedAt)
	return newMsgCount >= SummarizationThreshold
}

// CreateSummary creates a rule-based summary of older messages in the conversation.
// This is a simple summarization that doesn't require an AI call.
func CreateSummary(conv *models.Conversation) error {
	if conv == nil {
		return fmt.Errorf("conversation is nil")
	}

	// Get all messages
	messages, err := conv.Messages()
	if err != nil {
		return fmt.Errorf("failed to get messages: %w", err)
	}

	// Need at least SummarizationThreshold messages
	if len(messages) < SummarizationThreshold {
		return nil
	}

	// Get existing summary to know where to start
	existing, _ := models.ConversationSummaries.First("WHERE ConversationID = ? ORDER BY CreatedAt DESC", conv.ID)

	// Find messages to summarize (all except the most recent MaxMessagesInContext)
	summarizeCount := len(messages) - MaxMessagesInContext
	if summarizeCount <= 0 {
		return nil
	}

	// If we have an existing summary, only summarize new messages since then
	startIdx := 0
	if existing != nil {
		for i, msg := range messages {
			if msg.ID == existing.EndMessageID {
				startIdx = i + 1
				break
			}
		}
		// Recalculate how many messages to summarize
		summarizeCount = len(messages) - MaxMessagesInContext - startIdx
		if summarizeCount <= 0 {
			return nil
		}
	}

	endIdx := startIdx + summarizeCount
	toSummarize := messages[startIdx:endIdx]

	if len(toSummarize) == 0 {
		return nil
	}

	// Build summary
	summary, keyActions := buildRuleBasedSummary(toSummarize)

	// Create summary record
	summaryRecord := &models.ConversationSummary{
		ConversationID: conv.ID,
		StartMessageID: toSummarize[0].ID,
		EndMessageID:   toSummarize[len(toSummarize)-1].ID,
		MessageCount:   len(toSummarize),
		Summary:        summary,
		KeyActions:     keyActions,
	}

	_, err = models.ConversationSummaries.Insert(summaryRecord)
	if err != nil {
		return fmt.Errorf("failed to insert summary: %w", err)
	}

	log.Printf("[SUMMARY] Created summary for conv=%s, messages=%d", conv.ID, len(toSummarize))
	return nil
}

// buildRuleBasedSummary creates a summary from messages without using AI.
func buildRuleBasedSummary(messages []*models.Message) (string, string) {
	var summaryParts []string
	var actions []string
	var userTopics []string

	for _, msg := range messages {
		switch msg.Role {
		case RoleUser:
			// Extract topics from user messages
			topic := extractTopic(msg.Content)
			if topic != "" {
				userTopics = append(userTopics, topic)
			}

		case RoleAssistant:
			// Extract actions from tool calls
			toolCalls, _ := GetToolCalls(msg)
			for _, tc := range toolCalls {
				action := describeAction(tc)
				if action != "" {
					actions = append(actions, action)
				}
			}
		}
	}

	// Build summary text
	if len(userTopics) > 0 {
		summaryParts = append(summaryParts, fmt.Sprintf("User discussed: %s", strings.Join(dedupe(userTopics), ", ")))
	}

	if len(actions) > 0 {
		summaryParts = append(summaryParts, fmt.Sprintf("Actions taken: %s", strings.Join(dedupe(actions), "; ")))
	}

	summary := strings.Join(summaryParts, ". ")
	if summary == "" {
		summary = fmt.Sprintf("General conversation with %d messages.", len(messages))
	}

	// Serialize key actions
	keyActionsJSON, _ := json.Marshal(dedupe(actions))

	return summary, string(keyActionsJSON)
}

// extractTopic extracts a brief topic from user message content.
func extractTopic(content string) string {
	// Take first sentence or first 50 chars
	content = strings.TrimSpace(content)
	if content == "" {
		return ""
	}

	// Find first sentence
	for _, sep := range []string{". ", "? ", "! ", "\n"} {
		if idx := strings.Index(content, sep); idx > 0 && idx < 100 {
			return strings.TrimSpace(content[:idx])
		}
	}

	// Truncate if too long
	if len(content) > 50 {
		// Find a word boundary
		if idx := strings.LastIndex(content[:50], " "); idx > 20 {
			return content[:idx] + "..."
		}
		return content[:50] + "..."
	}

	return content
}

// describeAction creates a brief description of a tool call action.
func describeAction(tc ToolCall) string {
	if tc.Error != "" {
		return "" // Skip failed actions
	}

	switch tc.Name {
	// Page tools
	case "create_page":
		return "created a page"
	case "update_page":
		return "updated a page"
	case "delete_page":
		return "deleted a page"
	case "get_page":
		return "retrieved a page"
	case "list_pages":
		return "listed pages"

	// Collection tools
	case "create_collection":
		return "created a collection"
	case "update_collection":
		return "updated a collection"
	case "delete_collection":
		return "deleted a collection"
	case "get_collection":
		return "retrieved a collection"
	case "list_collections":
		return "listed collections"

	// Document tools
	case "create_document":
		return "created a document"
	case "create_documents":
		return "created multiple documents"
	case "update_document":
		return "updated a document"
	case "delete_document":
		return "deleted a document"
	case "get_document":
		return "retrieved a document"
	case "query_documents":
		return "queried documents"

	// Partial tools
	case "create_partial":
		return "created a partial"
	case "update_partial":
		return "updated a partial"
	case "delete_partial":
		return "deleted a partial"
	case "get_partial":
		return "retrieved a partial"
	case "list_partials":
		return "listed partials"

	// File tools
	case "update_file":
		return "updated a file"
	case "get_file":
		return "retrieved a file"
	case "list_files":
		return "listed files"
	case "read_file":
		return "read file content"

	// Note tools
	case "create_note":
		return "saved a note"
	case "update_note":
		return "updated a note"
	case "delete_note":
		return "deleted a note"
	case "get_note":
		return "retrieved a note"
	case "list_notes":
		return "listed notes"

	// Utility tools
	case "validate_template":
		return "validated template"
	case "navigate_user":
		return "navigated user"

	default:
		return ""
	}
}

// dedupe removes duplicate strings from a slice.
func dedupe(items []string) []string {
	seen := make(map[string]bool)
	result := make([]string, 0, len(items))
	for _, item := range items {
		if !seen[item] {
			seen[item] = true
			result = append(result, item)
		}
	}
	return result
}

// GetLatestSummary returns the most recent summary for a conversation.
func GetLatestSummary(convID string) *models.ConversationSummary {
	summary, _ := models.ConversationSummaries.First("WHERE ConversationID = ? ORDER BY CreatedAt DESC", convID)
	return summary
}

// GetAllSummaries returns all summaries for a conversation in chronological order.
func GetAllSummaries(convID string) []*models.ConversationSummary {
	summaries, _ := models.ConversationSummaries.Search("WHERE ConversationID = ? ORDER BY CreatedAt", convID)
	return summaries
}
← Back