categorisation for gifs
This commit is contained in:
parent
5e18bd3830
commit
d0669bdc5e
15 changed files with 468 additions and 49 deletions
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/seed/catalog"
|
||||
)
|
||||
|
||||
// HandlesInlineBot reports whether botUserID is a built-in bot this service
|
||||
|
|
@ -39,7 +40,15 @@ func (s *Service) OnInlineQuery(ctx context.Context, botUserID, _ int64, query,
|
|||
if err != nil {
|
||||
return domain.BotInlineResults{}, false, err
|
||||
}
|
||||
entries = rankGifCatalogEntries(entries, query)
|
||||
if category := gifCategoryFromQuery(query); category != "" {
|
||||
// A category-icon tap, not a typed word (see gifCategoryFromQuery) --
|
||||
// filter by domain.GifCatalogEntry.Category instead of ranking by
|
||||
// title, since the query is an emoji/emoji-blob a title never
|
||||
// contains.
|
||||
entries = filterGifCatalogEntriesByCategory(entries, category)
|
||||
} else {
|
||||
entries = rankGifCatalogEntries(entries, query)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return domain.BotInlineResults{Gallery: true}, true, nil
|
||||
}
|
||||
|
|
@ -84,6 +93,66 @@ func (s *Service) OnInlineQuery(ctx context.Context, botUserID, _ int64, query,
|
|||
// with the closest titles first keeps every query useful while still honouring
|
||||
// the search term. Order within each group stays the admin-set
|
||||
// (sort_order, id) order the store already applied.
|
||||
// gifCategoryFromQuery recognizes a GIF-picker category-icon tap and returns
|
||||
// which domain.GifCatalogCategories entry it names, or "" for an ordinary
|
||||
// typed search.
|
||||
//
|
||||
// The two clients encode a tap completely differently, and neither sends the
|
||||
// category's name:
|
||||
// - Android (StickerCategoriesListView.EmojiCategory.remote, EmojiView.java)
|
||||
// concatenates the whole tapped group's emoticons with no separator and
|
||||
// sends that as the query -- an exact match against
|
||||
// strings.Join(group.Emoticons, "").
|
||||
// - TDesktop (GifSectionsValue, stickers_list_footer.cpp) reads a *separate*
|
||||
// fixed emoji list (app config's gif_search_emojies, defaulting to 10
|
||||
// emoji including some this server never configured) and sends the
|
||||
// single tapped emoji as the query -- an exact match against one
|
||||
// Emoticons entry.
|
||||
//
|
||||
// Both are checked against the same internal/seed/catalog data (the source of
|
||||
// truth messages.getEmojiGroups itself serves), so no client-side changes or
|
||||
// server-side emoji-list duplication are needed.
|
||||
func gifCategoryFromQuery(query string) string {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return ""
|
||||
}
|
||||
groups, _ := catalog.EmojiGroups()
|
||||
for _, g := range groups {
|
||||
if len(g.Emoticons) == 0 {
|
||||
continue
|
||||
}
|
||||
if strings.Join(g.Emoticons, "") == query {
|
||||
return g.Title
|
||||
}
|
||||
}
|
||||
for _, g := range groups {
|
||||
for _, e := range g.Emoticons {
|
||||
if e == query {
|
||||
return g.Title
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// filterGifCatalogEntriesByCategory keeps only entries tagged with category,
|
||||
// falling back to the full (enabled) catalog if none are tagged yet -- an
|
||||
// operator who hasn't categorized anything should still see every GIF on a
|
||||
// category tap, not an empty picker.
|
||||
func filterGifCatalogEntriesByCategory(entries []domain.GifCatalogEntry, category string) []domain.GifCatalogEntry {
|
||||
filtered := make([]domain.GifCatalogEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.Category == category {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return entries
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func rankGifCatalogEntries(entries []domain.GifCatalogEntry, query string) []domain.GifCatalogEntry {
|
||||
query = strings.TrimSpace(strings.ToLower(query))
|
||||
if query == "" {
|
||||
|
|
|
|||
|
|
@ -190,6 +190,59 @@ func (s *Service) AdminSetGifCatalogSortOrder(ctx context.Context, id int64, ord
|
|||
return true, nil
|
||||
}
|
||||
|
||||
// AdminSetGifCatalogCategory sets (or clears, via "") an entry's category.
|
||||
func (s *Service) AdminSetGifCatalogCategory(ctx context.Context, id int64, category string) (bool, error) {
|
||||
if s.gifCatalog == nil {
|
||||
return false, domain.ErrGifCatalogUnavailable
|
||||
}
|
||||
if !domain.ValidGifCatalogCategory(category) {
|
||||
return false, domain.ErrGifCatalogEntryInvalid
|
||||
}
|
||||
changed, err := s.gifCatalog.SetGifCatalogCategory(ctx, id, category)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !changed {
|
||||
return false, domain.ErrGifCatalogEntryNotFound
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// AdminAutoCategorizeGifCatalog runs ClassifyGifCategory against every
|
||||
// currently-uncategorized entry's title and assigns whatever category it
|
||||
// guesses (category stays "" -- i.e. the entry is left for manual tagging --
|
||||
// when nothing matches). Already-categorized entries are left untouched, so
|
||||
// this is safe to re-run after every new batch of GIFs lands (seeded or
|
||||
// admin-uploaded) without clobbering an operator's manual corrections.
|
||||
// Returns how many entries got a category assigned.
|
||||
func (s *Service) AdminAutoCategorizeGifCatalog(ctx context.Context) (int, error) {
|
||||
if s.gifCatalog == nil {
|
||||
return 0, domain.ErrGifCatalogUnavailable
|
||||
}
|
||||
entries, err := s.gifCatalog.ListGifCatalog(ctx, false)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
changed := 0
|
||||
for _, e := range entries {
|
||||
if e.Category != "" {
|
||||
continue
|
||||
}
|
||||
category := ClassifyGifCategory(e.Title)
|
||||
if category == "" {
|
||||
continue
|
||||
}
|
||||
ok, err := s.gifCatalog.SetGifCatalogCategory(ctx, e.ID, category)
|
||||
if err != nil {
|
||||
return changed, err
|
||||
}
|
||||
if ok {
|
||||
changed++
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// AdminDeleteGifCatalogEntry removes an entry from the catalog. The
|
||||
// referenced document is left alone.
|
||||
func (s *Service) AdminDeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error) {
|
||||
|
|
|
|||
85
internal/app/files/gif_classify.go
Normal file
85
internal/app/files/gif_classify.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package files
|
||||
|
||||
import "strings"
|
||||
|
||||
// gifCategoryKeywords maps each domain.GifCatalogCategories entry to a list
|
||||
// of lowercase words/phrases whose presence in a GIF title is decent
|
||||
// (imperfect, but reversible -- an operator can always correct it in the
|
||||
// admin panel) evidence the GIF belongs to that emotional category. Checked
|
||||
// in this order, first match wins, so more specific/less ambiguous
|
||||
// categories are listed before catch-alls like Neutral/Silly.
|
||||
var gifCategoryKeywords = map[string][]string{
|
||||
"Love": {
|
||||
"love", "heart", "kiss", "kissing", "crush", "romance", "romantic",
|
||||
"valentine", "adorable", "hug", "hugging", "xoxo", "sweetheart",
|
||||
},
|
||||
"Cheers": {
|
||||
"party", "celebrate", "celebration", "celebrating", "woohoo", "cheers",
|
||||
"congrats", "congratulations", "dance", "dancing", "yay", "hooray",
|
||||
"victory", "champagne", "toast", "birthday", "win", "winning",
|
||||
},
|
||||
"Laughter": {
|
||||
"laugh", "laughing", "lol", "lmao", "haha", "hilarious", "funny",
|
||||
"joke", "giggle", "chuckle", "rofl", "lulz",
|
||||
},
|
||||
"Astonishment": {
|
||||
"shock", "shocked", "shocking", "omg", "wow", "surprised", "surprise",
|
||||
"gasp", "whoa", "unbelievable", "stunned", "speechless", "mindblown",
|
||||
"mind blown", "jawdrop",
|
||||
},
|
||||
"Sadness": {
|
||||
"sad", "sadness", "cry", "crying", "cries", "tears", "depressed",
|
||||
"heartbroken", "sorrow", "sob", "sobbing", "disappointed", "unhappy",
|
||||
},
|
||||
"Anger": {
|
||||
"angry", "anger", "mad", "rage", "furious", "pissed", "annoyed",
|
||||
"irritated", "punch", "smash", "yell", "yelling", "scream", "fist",
|
||||
},
|
||||
"Disapproval": {
|
||||
"disgusting", "disgusted", "gross", "eww", "yuck", "ugh", "cringe",
|
||||
"boo", "nope", "fail",
|
||||
},
|
||||
"Approval": {
|
||||
"agree", "thumbsup", "thumbs up", "nailed it", "well done",
|
||||
"applause", "clap", "clapping", "bravo", "approved", "salute",
|
||||
"respect", "nice one",
|
||||
},
|
||||
"Doubt": {
|
||||
"confused", "confusion", "suspicious", "skeptical", "hmm", "hmmm",
|
||||
"thinking", "uncertain", "unsure", "really",
|
||||
},
|
||||
"Silly": {
|
||||
"silly", "goofy", "derp", "wacky", "ridiculous", "nonsense",
|
||||
},
|
||||
"Neutral": {
|
||||
"meh", "whatever", "shrug", "indifferent", "blank stare", "boring",
|
||||
},
|
||||
}
|
||||
|
||||
// ClassifyGifCategory guesses one of domain.GifCatalogCategories from a GIF's
|
||||
// title via keyword matching, or "" if nothing matches. See
|
||||
// gifCategoryKeywords for the word lists and Service.AdminAutoCategorizeGifCatalog
|
||||
// for how this gets applied in bulk.
|
||||
//
|
||||
// This is deliberately simple substring matching, not NLP: real GIF titles
|
||||
// range from descriptive ("Sad Tenant") to meaningless filename fragments
|
||||
// ("jIDL92q") that no text-based heuristic can classify -- those are left
|
||||
// uncategorized for an operator to tag by hand rather than guessed wrong.
|
||||
func ClassifyGifCategory(title string) string {
|
||||
lower := strings.ToLower(title)
|
||||
for _, category := range gifCategoryOrder {
|
||||
for _, kw := range gifCategoryKeywords[category] {
|
||||
if strings.Contains(lower, kw) {
|
||||
return category
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// gifCategoryOrder is gifCategoryKeywords' check order (Go map iteration is
|
||||
// unordered, and the order is meaningful -- see gifCategoryKeywords' doc).
|
||||
var gifCategoryOrder = []string{
|
||||
"Love", "Cheers", "Laughter", "Astonishment", "Sadness", "Anger",
|
||||
"Disapproval", "Approval", "Doubt", "Silly", "Neutral",
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue