Files
spsays-bff/internal/service/vani_service.go
T
2026-07-11 00:57:27 +05:30

236 lines
6.5 KiB
Go

// Package service — VaniService powers "Vāṇī of the Day": one piece of content
// (a prose chapter, a verse, or a transcript/lecture) surfaced on the home page
// each day. The pick is chosen once per UTC day and reused for everyone; the
// picker avoids anything served in the last 90 days.
package service
import (
"context"
"fmt"
"math/rand"
"regexp"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
const vaniNoRepeatDays = 90
type VaniService struct {
repo *repository.VaniRepo
logger *zap.Logger
}
func NewVaniService(repo *repository.VaniRepo, logger *zap.Logger) *VaniService {
return &VaniService{repo: repo, logger: logger}
}
// Today returns the current day's pick, choosing (and persisting) one on the
// first call of the day. Returns (nil, nil) only if there's genuinely no
// content to pick.
func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
now := time.Now().UTC()
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
if existing, err := s.repo.GetForDay(ctx, day); err != nil {
return nil, err
} else if existing != nil {
return &existing.Payload, nil
}
// Build the 90-day exclusion set, bucketed by kind.
recent, err := s.repo.RecentRefIDs(ctx, day.AddDate(0, 0, -vaniNoRepeatDays))
if err != nil {
return nil, err
}
exclude := map[string][]string{}
for _, rid := range recent {
if kind, hex, ok := splitRef(rid); ok {
exclude[kind] = append(exclude[kind], hex)
}
}
pick := s.choose(ctx, exclude, day)
if pick == nil {
// Everything in some kind exhausted the 90-day window? Relax it.
pick = s.choose(ctx, map[string][]string{}, day)
}
if pick == nil {
return nil, nil
}
if err := s.repo.Insert(ctx, pick); err != nil {
s.logger.Warn("vani: insert failed", zap.Error(err))
}
// Re-read so a concurrent writer's row (ON CONFLICT DO NOTHING) wins
// consistently for everyone.
if stored, err := s.repo.GetForDay(ctx, day); err == nil && stored != nil {
return &stored.Payload, nil
}
return &pick.Payload, nil
}
// choose tries the three content kinds in random order and returns the first
// successful sample, so each day's pick varies across content types.
func (s *VaniService) choose(ctx context.Context, exclude map[string][]string, day time.Time) *model.VaniPick {
kinds := []string{"verse", "prose", "transcript"}
rand.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] })
for _, kind := range kinds {
switch kind {
case "verse":
if v, err := s.repo.SampleVerse(ctx, exclude["verse"]); err == nil && v != nil {
return versePick(v, day)
}
case "prose":
if p, err := s.repo.SampleProse(ctx, exclude["prose"]); err == nil && p != nil {
return prosePick(p, s.repo.BookTitle(ctx, p.Book), day)
}
case "transcript":
if t, err := s.repo.SampleTranscript(ctx, exclude["transcript"]); err == nil && t != nil {
return transcriptPick(t, day)
}
}
}
return nil
}
// --- Payload builders ---
func versePick(v *repository.VerseDoc, day time.Time) *model.VaniPick {
title := v.Reference
if title == "" {
title = fmt.Sprintf("%s %d.%d", strings.ToUpper(v.Book), v.Chapter, v.Verse)
}
return &model.VaniPick{
Day: day,
Kind: "verse",
RefID: "verse:" + v.ID.Hex(),
Payload: model.VaniPayload{
Kind: "verse",
Label: "Verse",
Title: title,
Subtitle: excerpt(v.Translation, 140),
Href: verseURL(v.Book, v.Division, v.Chapter, v.Verse),
BookTitle: v.BookTitle,
},
}
}
func prosePick(p *repository.ProseChapterDoc, bookTitle string, day time.Time) *model.VaniPick {
first := ""
if len(p.Paragraphs) > 0 {
first = p.Paragraphs[0]
}
return &model.VaniPick{
Day: day,
Kind: "prose",
RefID: "prose:" + p.ID.Hex(),
Payload: model.VaniPayload{
Kind: "prose",
Label: "Chapter",
Title: p.Title,
Subtitle: excerpt(first, 140),
Href: proseURL(p.Book, p.Volume, p.Type, p.Slug),
BookTitle: bookTitle,
},
}
}
func transcriptPick(t *repository.TranscriptionDoc, day time.Time) *model.VaniPick {
label := "Lecture"
if t.Type == "conversation" {
label = "Conversation"
}
date := ""
if t.Date != nil {
date = *t.Date
}
return &model.VaniPick{
Day: day,
Kind: "transcript",
RefID: "transcript:" + t.ID.Hex(),
Payload: model.VaniPayload{
Kind: "transcript",
Label: label,
Title: t.Name,
Href: "/transcriptions/" + t.Slug,
Date: date,
},
}
}
// --- URL builders (mirror cmd/indexer buildVerseURL / prose URL logic) ---
func verseURL(book string, division *string, chapter, verse int) string {
switch book {
case "bg":
return fmt.Sprintf("/books/bg/%d/%d", chapter, verse)
case "sb":
if division != nil {
return fmt.Sprintf("/books/sb/%s/%d/%d", *division, chapter, verse)
}
return fmt.Sprintf("/books/sb/%d/%d", chapter, verse)
case "cc":
if division != nil {
return fmt.Sprintf("/books/cc/%s/%d/%d", *division, chapter, verse)
}
return fmt.Sprintf("/books/cc/%d/%d", chapter, verse)
case "sb1962":
return fmt.Sprintf("/books/sb1962/%d/%d", chapter, verse)
case "iso":
return fmt.Sprintf("/books/iso/%d", chapter)
case "noi":
return fmt.Sprintf("/books/noi/%d", chapter)
case "bs":
return fmt.Sprintf("/books/bs/5/%d", verse)
case "mm":
return fmt.Sprintf("/books/mm/%d/%d", chapter, verse)
case "nbs":
return fmt.Sprintf("/books/nbs/%d/%d", chapter, verse)
default:
return fmt.Sprintf("/books/%s/%d/%d", book, chapter, verse)
}
}
func proseURL(book string, volume int, docType, slug string) string {
switch {
case book == "kb":
return fmt.Sprintf("/books/kb/%d/%s", volume, slug)
case docType == "chapter-summary":
if parts := strings.SplitN(slug, "-", 2); len(parts) == 2 {
return fmt.Sprintf("/books/%s/%s/%s", book, parts[0], parts[1])
}
return fmt.Sprintf("/books/%s/%s", book, slug)
default:
return fmt.Sprintf("/books/%s/%s", book, slug)
}
}
// --- helpers ---
func splitRef(ref string) (kind, hex string, ok bool) {
if i := strings.IndexByte(ref, ':'); i > 0 {
return ref[:i], ref[i+1:], true
}
return "", "", false
}
var htmlTagRe = regexp.MustCompile(`<[^>]*>`)
var wsRe = regexp.MustCompile(`\s+`)
// excerpt strips HTML, collapses whitespace, and truncates to n runes with an
// ellipsis — a clean one-line teaser for the CTA.
func excerpt(html string, n int) string {
s := htmlTagRe.ReplaceAllString(html, "")
s = strings.TrimSpace(wsRe.ReplaceAllString(s, " "))
r := []rune(s)
if len(r) <= n {
return s
}
return strings.TrimSpace(string(r[:n])) + "…"
}