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}