Files
silo-server/internal/audiobooks/smartcoll/evaluator.go
T
RXWatcherandClaude Opus 4.7 a65852ab9b feat(audiobooks): smartcoll DSL evaluator + tests
In-memory rule evaluator over Candidate{Item, IsFinished, ProgressPct,
CurrentSeconds, LastPlayedAt, BookmarkCount, PlayCount}. Covers all
15 fields + 7 operators + 9 sort keys including deterministic
seeded random. Personalized rules drop to false (silent) when
opts.AllowPersonalized is false. Decoupled from silo's catalog
model via the local Item struct — handler adapts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:27:49 +02:00

482 lines
10 KiB
Go

package smartcoll
import (
"context"
"hash/fnv"
"math/rand"
"sort"
"strconv"
"strings"
"time"
)
// Item is the audiobook-domain projection the evaluator walks. The
// handler builds these from silo's *models.MediaItem + people/series
// lookups (see siloItemToSmartcollItem in smart_collections_handler.go).
type Item struct {
ID string
Title string
Authors []string
Narrators []string
Series []string
Genres []string
Year int
Rating float64
Language string
Publisher string
AddedAt time.Time
DurationSeconds int
}
// Candidate is the input shape to Evaluate — one Item plus the optional
// per-user state needed by personalized rules.
type Candidate struct {
Item Item
IsFinished bool
ProgressPct float32
CurrentSeconds int
LastPlayedAt time.Time
BookmarkCount int
PlayCount int
}
// EvaluateOptions controls non-rule aspects of evaluation.
type EvaluateOptions struct {
AllowPersonalized bool
UserSeed string
Now time.Time
AbandonedAfter time.Duration
}
// Evaluate filters the candidate list by qd's rule tree and sorts the
// survivors by qd.Sort. Pure function — no side effects, no I/O.
func Evaluate(ctx context.Context, qd QueryDefinition, candidates []Candidate, opts EvaluateOptions) []Candidate {
_ = ctx
if opts.Now.IsZero() {
opts.Now = time.Now()
}
if opts.AbandonedAfter == 0 {
opts.AbandonedAfter = 60 * 24 * time.Hour
}
qd = qd.Normalize()
out := make([]Candidate, 0, len(candidates))
for _, c := range candidates {
if matchDefinition(qd, c, opts) {
out = append(out, c)
}
}
sortCandidates(out, qd.Sort, opts)
if qd.Limit != nil && *qd.Limit > 0 && *qd.Limit < len(out) {
out = out[:*qd.Limit]
}
return out
}
func matchDefinition(qd QueryDefinition, c Candidate, opts EvaluateOptions) bool {
if len(qd.Groups) == 0 {
return true
}
if qd.Match == "any" {
for _, g := range qd.Groups {
if matchGroup(g, c, opts) {
return true
}
}
return false
}
for _, g := range qd.Groups {
if !matchGroup(g, c, opts) {
return false
}
}
return true
}
func matchGroup(g QueryGroup, c Candidate, opts EvaluateOptions) bool {
if len(g.Rules) == 0 {
return true
}
if g.Match == "any" {
for _, r := range g.Rules {
if matchRule(r, c, opts) {
return true
}
}
return false
}
for _, r := range g.Rules {
if !matchRule(r, c, opts) {
return false
}
}
return true
}
func matchRule(r QueryRule, c Candidate, opts EvaluateOptions) bool {
def, ok := queryFieldDefs[r.Field]
if !ok {
return false
}
if def.personalized && !opts.AllowPersonalized {
return false
}
switch r.Field {
case "title":
return matchString(c.Item.Title, r.Op, r.Value)
case "author":
return matchStringSlice(c.Item.Authors, r.Op, r.Value)
case "narrator":
return matchStringSlice(c.Item.Narrators, r.Op, r.Value)
case "series":
return matchStringSlice(c.Item.Series, r.Op, r.Value)
case "genre":
return matchStringSlice(c.Item.Genres, r.Op, r.Value)
case "year":
return matchInt(c.Item.Year, r.Op, r.Value)
case "rating":
return matchFloat(c.Item.Rating, r.Op, r.Value)
case "language":
return matchString(c.Item.Language, r.Op, r.Value)
case "publisher":
return matchString(c.Item.Publisher, r.Op, r.Value)
case "added_at":
return matchTime(c.Item.AddedAt, r.Op, r.Value, opts.Now)
case "duration_seconds":
return matchInt(c.Item.DurationSeconds, r.Op, r.Value)
case "finished":
return matchBool(c.IsFinished, r.Op, r.Value)
case "in_progress":
inProg := !c.IsFinished && c.CurrentSeconds > 0
return matchBool(inProg, r.Op, r.Value)
case "last_played":
return matchTime(c.LastPlayedAt, r.Op, r.Value, opts.Now)
case "abandoned":
inProg := !c.IsFinished && c.CurrentSeconds > 0
ab := inProg && !c.LastPlayedAt.IsZero() && opts.Now.Sub(c.LastPlayedAt) > opts.AbandonedAfter
return matchBool(ab, r.Op, r.Value)
case "bookmark_count":
return matchInt(c.BookmarkCount, r.Op, r.Value)
}
return false
}
func matchString(field string, op string, val any) bool {
s, ok := stringValue(val)
if !ok {
return false
}
a, b := strings.ToLower(field), strings.ToLower(s)
switch op {
case "is":
return a == b
case "is_not":
return a != b
case "contains":
return strings.Contains(a, b)
}
return false
}
func matchStringSlice(field []string, op string, val any) bool {
s, ok := stringValue(val)
if !ok {
return false
}
target := strings.ToLower(s)
switch op {
case "is":
for _, v := range field {
if strings.ToLower(v) == target {
return true
}
}
return false
case "is_not":
for _, v := range field {
if strings.ToLower(v) == target {
return false
}
}
return true
case "contains":
for _, v := range field {
if strings.Contains(strings.ToLower(v), target) {
return true
}
}
return false
}
return false
}
func matchInt(field int, op string, val any) bool {
switch op {
case "between":
low, high, ok := pairValue(val)
if !ok {
return false
}
l, lok := numericValue(low)
h, hok := numericValue(high)
if !lok || !hok {
return false
}
f := float64(field)
return f >= l && f <= h
}
n, ok := numericValue(val)
if !ok {
return false
}
f := float64(field)
switch op {
case "is":
return f == n
case "is_not":
return f != n
case "gt":
return f > n
case "gte":
return f >= n
case "lt":
return f < n
case "lte":
return f <= n
}
return false
}
func matchFloat(field float64, op string, val any) bool {
switch op {
case "between":
low, high, ok := pairValue(val)
if !ok {
return false
}
l, lok := numericValue(low)
h, hok := numericValue(high)
if !lok || !hok {
return false
}
return field >= l && field <= h
}
n, ok := numericValue(val)
if !ok {
return false
}
switch op {
case "gt":
return field > n
case "gte":
return field >= n
case "lt":
return field < n
case "lte":
return field <= n
}
return false
}
func matchBool(field bool, op string, val any) bool {
if op != "is" {
return false
}
b, ok := val.(bool)
if !ok {
return false
}
return field == b
}
func matchTime(field time.Time, op string, val any, now time.Time) bool {
switch op {
case "in_last":
s, ok := stringValue(val)
if !ok {
return false
}
d, err := parseDurationLoose(s)
if err != nil {
return false
}
if field.IsZero() {
return false
}
return now.Sub(field) <= d
case "between":
low, high, ok := pairValue(val)
if !ok {
return false
}
lt, lok := timeValue(low)
ht, hok := timeValue(high)
if !lok || !hok || field.IsZero() {
return false
}
return !field.Before(lt) && !field.After(ht)
}
t, ok := timeValue(val)
if !ok || field.IsZero() {
return false
}
switch op {
case "gt":
return field.After(t)
case "gte":
return field.After(t) || field.Equal(t)
case "lt":
return field.Before(t)
case "lte":
return field.Before(t) || field.Equal(t)
}
return false
}
func sortCandidates(out []Candidate, s QuerySort, opts EvaluateOptions) {
field := s.Field
if field == "" {
field = defaultSortField
}
desc := s.Order == "desc"
if field == "random" {
seed := uint64(0)
if opts.UserSeed != "" {
h := fnv.New64a()
_, _ = h.Write([]byte(opts.UserSeed))
seed = h.Sum64()
}
rng := rand.New(rand.NewSource(int64(seed))) //nolint:gosec
rng.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] })
return
}
sort.SliceStable(out, func(i, j int) bool {
less := compareCandidates(out[i], out[j], field)
if desc {
return !less && !equalCandidates(out[i], out[j], field)
}
return less
})
}
func compareCandidates(a, b Candidate, field string) bool {
switch field {
case "title":
return strings.ToLower(a.Item.Title) < strings.ToLower(b.Item.Title)
case "added_at":
return a.Item.AddedAt.Before(b.Item.AddedAt)
case "year":
return a.Item.Year < b.Item.Year
case "duration_seconds":
return a.Item.DurationSeconds < b.Item.DurationSeconds
case "rating":
return a.Item.Rating < b.Item.Rating
case "progress":
return a.ProgressPct < b.ProgressPct
case "last_played":
return a.LastPlayedAt.Before(b.LastPlayedAt)
case "plays":
return a.PlayCount < b.PlayCount
}
return false
}
func equalCandidates(a, b Candidate, field string) bool {
switch field {
case "title":
return strings.EqualFold(a.Item.Title, b.Item.Title)
case "added_at":
return a.Item.AddedAt.Equal(b.Item.AddedAt)
case "year":
return a.Item.Year == b.Item.Year
case "duration_seconds":
return a.Item.DurationSeconds == b.Item.DurationSeconds
case "rating":
return a.Item.Rating == b.Item.Rating
case "progress":
return a.ProgressPct == b.ProgressPct
case "last_played":
return a.LastPlayedAt.Equal(b.LastPlayedAt)
case "plays":
return a.PlayCount == b.PlayCount
}
return false
}
func stringValue(v any) (string, bool) {
switch x := v.(type) {
case string:
return x, true
case float64:
return strconv.FormatFloat(x, 'f', -1, 64), true
case int:
return strconv.Itoa(x), true
case int64:
return strconv.FormatInt(x, 10), true
}
return "", false
}
func numericValue(v any) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case int:
return float64(x), true
case int64:
return float64(x), true
case string:
f, err := strconv.ParseFloat(x, 64)
if err == nil {
return f, true
}
}
return 0, false
}
func timeValue(v any) (time.Time, bool) {
s, ok := stringValue(v)
if !ok {
return time.Time{}, false
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, true
}
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
return time.Unix(n, 0), true
}
return time.Time{}, false
}
func pairValue(v any) (any, any, bool) {
switch x := v.(type) {
case []any:
if len(x) == 2 {
return x[0], x[1], true
}
case [2]any:
return x[0], x[1], true
}
return nil, nil, false
}
func parseDurationLoose(s string) (time.Duration, error) {
s = strings.TrimSpace(strings.ToLower(s))
if len(s) >= 2 {
suffix := s[len(s)-1]
body := s[:len(s)-1]
if suffix == 'd' {
n, err := strconv.Atoi(body)
if err == nil {
return time.Duration(n) * 24 * time.Hour, nil
}
}
if suffix == 'w' {
n, err := strconv.Atoi(body)
if err == nil {
return time.Duration(n) * 7 * 24 * time.Hour, nil
}
}
}
return time.ParseDuration(s)
}