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)
 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}