slug.go

  1package slug
  2
  3import (
  4	"context"
  5	"fmt"
  6	"log/slog"
  7	"regexp"
  8	"strings"
  9	"time"
 10
 11	"shelley.exe.dev/db"
 12	"shelley.exe.dev/llm"
 13	"shelley.exe.dev/models"
 14)
 15
 16// LLMServiceProvider defines the interface for getting LLM services
 17type LLMServiceProvider interface {
 18	GetService(modelID string) (llm.Service, error)
 19	GetAvailableModels() []string
 20	GetModelInfo(modelID string) *models.ModelInfo
 21}
 22
 23// GenerateSlug generates a slug for a conversation and updates the database
 24// If conversationModelID is provided, it will be used as a fallback if no model is tagged with "slug"
 25func GenerateSlug(ctx context.Context, llmProvider LLMServiceProvider, database *db.DB, logger *slog.Logger, conversationID, userMessage, conversationModelID string) (string, error) {
 26	baseSlug, err := generateSlugText(ctx, llmProvider, logger, userMessage, conversationModelID)
 27	if err != nil {
 28		return "", err
 29	}
 30
 31	// Try to update with the base slug first, then with numeric suffixes if needed
 32	slug := baseSlug
 33	for attempt := 0; attempt < 100; attempt++ {
 34		_, err = database.UpdateConversationSlug(ctx, conversationID, slug)
 35		if err == nil {
 36			// Success!
 37			logger.Info("Generated slug for conversation", "conversationID", conversationID, "slug", slug)
 38			return slug, nil
 39		}
 40
 41		// Check if this is a unique constraint violation
 42		if strings.Contains(strings.ToLower(err.Error()), "unique constraint failed") ||
 43			strings.Contains(strings.ToLower(err.Error()), "unique constraint") ||
 44			strings.Contains(strings.ToLower(err.Error()), "duplicate") {
 45			// Try with a numeric suffix
 46			slug = fmt.Sprintf("%s-%d", baseSlug, attempt+1)
 47			continue
 48		}
 49
 50		// Some other error occurred
 51		return "", fmt.Errorf("failed to update conversation slug: %w", err)
 52	}
 53
 54	// If we've tried 100 times and still failed, give up
 55	return "", fmt.Errorf("failed to generate unique slug after 100 attempts")
 56}
 57
 58// generateSlugText generates a human-readable slug for a conversation based on the user message
 59// Priority order:
 60// 1. If conversationModelID is "predictable", use it
 61// 2. Try models tagged with "slug"
 62// 3. Fall back to the conversation's model (conversationModelID)
 63func generateSlugText(ctx context.Context, llmProvider LLMServiceProvider, logger *slog.Logger, userMessage, conversationModelID string) (string, error) {
 64	var llmService llm.Service
 65	var err error
 66
 67	// If conversation is using predictable model, use it for slug generation too
 68	if conversationModelID == "predictable" {
 69		llmService, err = llmProvider.GetService("predictable")
 70		if err == nil {
 71			logger.Debug("Using predictable model for slug generation")
 72		} else {
 73			logger.Debug("Predictable model not available for slug generation", "error", err)
 74		}
 75	}
 76
 77	// Try models tagged with "slug"
 78	if llmService == nil {
 79		for _, modelID := range llmProvider.GetAvailableModels() {
 80			info := llmProvider.GetModelInfo(modelID)
 81			if info != nil && strings.Contains(info.Tags, "slug") {
 82				llmService, err = llmProvider.GetService(modelID)
 83				if err == nil {
 84					logger.Debug("Using slug-tagged model for slug generation", "model", modelID)
 85				} else {
 86					logger.Debug("Failed to get slug-tagged model", "model", modelID, "error", err)
 87				}
 88				break
 89			}
 90		}
 91	}
 92
 93	// Fall back to the conversation's model
 94	if llmService == nil && conversationModelID != "" && conversationModelID != "predictable" {
 95		llmService, err = llmProvider.GetService(conversationModelID)
 96		if err == nil {
 97			logger.Debug("Using conversation model for slug generation", "model", conversationModelID)
 98		} else {
 99			logger.Debug("Conversation model not available for slug generation", "model", conversationModelID, "error", err)
100		}
101	}
102
103	if llmService == nil {
104		return "", fmt.Errorf("no suitable model available for slug generation")
105	}
106
107	// Create a focused prompt for slug generation
108	slugPrompt := fmt.Sprintf(`Generate a short, descriptive slug (2-6 words, lowercase, hyphen-separated) for a conversation that starts with this user message:
109
110%s
111
112The slug should:
113- Be concise and descriptive
114- Use only lowercase letters, numbers, and hyphens
115- Capture the main topic or intent
116- Be suitable as a filename or URL path
117
118Respond with only the slug, nothing else.`, userMessage)
119
120	message := llm.Message{
121		Role: llm.MessageRoleUser,
122		Content: []llm.Content{
123			{Type: llm.ContentTypeText, Text: slugPrompt},
124		},
125	}
126
127	request := &llm.Request{
128		Messages: []llm.Message{message},
129	}
130
131	// Make LLM request with timeout
132	ctxWithTimeout, cancel := context.WithTimeout(ctx, 10*time.Second)
133	defer cancel()
134
135	response, err := llmService.Do(ctxWithTimeout, request)
136	if err != nil {
137		return "", fmt.Errorf("failed to generate slug: %w", err)
138	}
139
140	// Extract text from response
141	if len(response.Content) == 0 {
142		return "", fmt.Errorf("empty response from LLM")
143	}
144
145	slug := strings.TrimSpace(response.Content[0].Text)
146
147	// Clean and validate the slug
148	slug = Sanitize(slug)
149	if slug == "" {
150		return "", fmt.Errorf("generated slug is empty after sanitization")
151	}
152
153	return slug, nil
154}
155
156// Sanitize cleans a string to be a valid slug
157func Sanitize(input string) string {
158	// Convert to lowercase
159	slug := strings.ToLower(input)
160
161	// Replace spaces and underscores with hyphens
162	slug = regexp.MustCompile(`[\s_]+`).ReplaceAllString(slug, "-")
163
164	// Remove non-alphanumeric characters except hyphens
165	slug = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(slug, "")
166
167	// Remove multiple consecutive hyphens
168	slug = regexp.MustCompile(`-+`).ReplaceAllString(slug, "-")
169
170	// Remove leading/trailing hyphens
171	slug = strings.Trim(slug, "-")
172
173	// Limit length
174	if len(slug) > 60 {
175		slug = slug[:60]
176		slug = strings.Trim(slug, "-")
177	}
178
179	return slug
180}