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
}