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