package embeddings import ( "fmt" "sort" "strings" "github.com/Silo-Server/silo-server/internal/models" ) const ( maxOverviewRunes = 1000 maxKeywords = 5 ) func truncateRunes(s string, limit int) string { if limit <= 0 { return "" } count := 0 for i := range s { if count == limit { return s[:i] } count++ } return s } // BuildEmbeddingText constructs a structured text representation of a media item // suitable for generating embeddings. Leads with semantic content (genres + overview) // to prevent title-word dominance in the embedding space. func BuildEmbeddingText(item *models.MediaItem) string { var parts []string // Lead with genres + type + overview for semantic dominance. typeName := "movie" if item.Type == "series" { typeName = "TV series" } if len(item.Genres) > 0 && item.Overview != "" { overview := truncateRunes(item.Overview, maxOverviewRunes) parts = append(parts, fmt.Sprintf("%s %s about %s", strings.Join(item.Genres, ", "), typeName, overview)) } else if len(item.Genres) > 0 { parts = append(parts, fmt.Sprintf("%s %s", strings.Join(item.Genres, ", "), typeName)) } else if item.Overview != "" { overview := truncateRunes(item.Overview, maxOverviewRunes) parts = append(parts, fmt.Sprintf("%s. %s", typeName, overview)) } // Title with year — present but no longer leading. if item.Year > 0 { parts = append(parts, fmt.Sprintf("%s (%d)", item.Title, item.Year)) } else { parts = append(parts, item.Title) } if item.ContentRating != "" { parts = append(parts, fmt.Sprintf("Rated %s", item.ContentRating)) } if item.Tagline != "" { parts = append(parts, fmt.Sprintf(`"%s"`, item.Tagline)) } // Top-billed cast with character names (up to 5). var actors []models.ItemPerson for _, p := range item.People { if p.Kind == models.PersonKindActor { actors = append(actors, p) } } sortItemPeople(actors) if len(actors) > 0 { credits := make([]string, 0, 5) for i, p := range actors { if i >= 5 { break } if p.Character != "" { credits = append(credits, fmt.Sprintf("%s as %s", p.Name, p.Character)) } else { credits = append(credits, p.Name) } } parts = append(parts, fmt.Sprintf("Cast: %s", strings.Join(credits, ", "))) } // Director(s). var directors []models.ItemPerson for _, p := range item.People { if p.Kind == models.PersonKindDirector { directors = append(directors, p) } } sortItemPeople(directors) if len(directors) > 0 { parts = append(parts, fmt.Sprintf("Directed by %s", strings.Join(itemPersonNames(directors), ", "))) } // Writer(s). var writers []models.ItemPerson for _, p := range item.People { if p.Kind == models.PersonKindWriter { writers = append(writers, p) } } sortItemPeople(writers) if len(writers) > 0 { parts = append(parts, fmt.Sprintf("Written by %s", strings.Join(itemPersonNames(writers), ", "))) } if len(item.Keywords) > 0 { keywords := item.Keywords if len(keywords) > maxKeywords { keywords = keywords[:maxKeywords] } parts = append(parts, fmt.Sprintf("Keywords: %s", strings.Join(keywords, ", "))) } if item.OriginalLanguage != "" { parts = append(parts, fmt.Sprintf("Original language: %s", item.OriginalLanguage)) } if len(item.Studios) > 0 { parts = append(parts, fmt.Sprintf("Studios: %s", strings.Join(item.Studios, ", "))) } if len(item.Networks) > 0 { parts = append(parts, fmt.Sprintf("Network: %s", strings.Join(item.Networks, ", "))) } if len(item.Countries) > 0 { countries := item.Countries if len(countries) > 2 { countries = countries[:2] } parts = append(parts, fmt.Sprintf("Country: %s", strings.Join(countries, ", "))) } return strings.ToValidUTF8(strings.Join(parts, ". "), "") } func sortItemPeople(people []models.ItemPerson) { sort.SliceStable(people, func(i, j int) bool { if people[i].SortOrder != people[j].SortOrder { return people[i].SortOrder < people[j].SortOrder } if people[i].Name != people[j].Name { return people[i].Name < people[j].Name } if people[i].Character != people[j].Character { return people[i].Character < people[j].Character } return people[i].ID < people[j].ID }) } func itemPersonNames(people []models.ItemPerson) []string { names := make([]string, 0, len(people)) for _, p := range people { names = append(names, p.Name) } return names }