add web service
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/remembos/internal/memos"
|
||||
)
|
||||
|
||||
// Memory is the result of the search algorithm.
|
||||
type Memory struct {
|
||||
Memo *memos.Memo
|
||||
Tier int
|
||||
YearsAgo int
|
||||
ShowCount int
|
||||
Date time.Time
|
||||
}
|
||||
|
||||
// candidate is an intermediate struct during scoring.
|
||||
type candidate struct {
|
||||
memo *memos.Memo
|
||||
yearsAgo int
|
||||
showCount int
|
||||
}
|
||||
|
||||
// weightedSelect picks one candidate using weighted random selection.
|
||||
// preferOlder gives higher weight to older memos. maxYearsBack normalizes recency.
|
||||
func weightedSelect(candidates []candidate, preferOlder bool, maxYearsBack int) *candidate {
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(candidates) == 1 {
|
||||
return &candidates[0]
|
||||
}
|
||||
|
||||
scores := make([]float64, len(candidates))
|
||||
var total float64
|
||||
|
||||
for i, c := range candidates {
|
||||
var recencyFactor float64
|
||||
if preferOlder && maxYearsBack > 0 && c.yearsAgo > 0 {
|
||||
recencyFactor = float64(c.yearsAgo) / float64(maxYearsBack)
|
||||
} else {
|
||||
recencyFactor = 1.0
|
||||
}
|
||||
|
||||
showCountPenalty := 1.0 / (1.0 + float64(c.showCount))
|
||||
|
||||
score := recencyFactor * showCountPenalty
|
||||
if score < 0.01 {
|
||||
score = 0.01 // minimum weight
|
||||
}
|
||||
scores[i] = score
|
||||
total += score
|
||||
}
|
||||
|
||||
r := rand.Float64() * total
|
||||
var cumulative float64
|
||||
for i, s := range scores {
|
||||
cumulative += s
|
||||
if r <= cumulative {
|
||||
return &candidates[i]
|
||||
}
|
||||
}
|
||||
|
||||
return &candidates[len(candidates)-1]
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/remembos/internal/config"
|
||||
"git.vakhrushev.me/av/remembos/internal/memos"
|
||||
"git.vakhrushev.me/av/remembos/internal/storage"
|
||||
)
|
||||
|
||||
// Selector implements the memory search algorithm.
|
||||
type Selector struct {
|
||||
client *memos.Client
|
||||
store *storage.Storage
|
||||
cfg config.SearchConfig
|
||||
loc *time.Location
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewSelector(client *memos.Client, store *storage.Storage, cfg config.SearchConfig, loc *time.Location, logger *slog.Logger) *Selector {
|
||||
return &Selector{
|
||||
client: client,
|
||||
store: store,
|
||||
cfg: cfg,
|
||||
loc: loc,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// tierFunc returns date ranges for a given tier.
|
||||
type tierFunc func(today time.Time, maxYears int, loc *time.Location) []DateRange
|
||||
|
||||
// Select runs the full search algorithm and returns one Memory for the given day.
|
||||
func (s *Selector) Select(ctx context.Context, today time.Time) (*Memory, error) {
|
||||
weights := s.cfg.TierWeights.AsSlice()
|
||||
tierFuncs := [7]func(time.Time) []DateRange{
|
||||
func(t time.Time) []DateRange { return tier1Ranges(t, s.cfg.MaxYearsBack, s.loc) },
|
||||
func(t time.Time) []DateRange { return tier2Ranges(t, s.loc) },
|
||||
func(t time.Time) []DateRange { return tier3Ranges(t, s.cfg.MaxYearsBack, s.loc) },
|
||||
func(t time.Time) []DateRange { return tier4Ranges(t, s.cfg.MaxYearsBack, s.loc) },
|
||||
func(t time.Time) []DateRange { return tier5Ranges(t, s.cfg.MaxYearsBack, s.loc) },
|
||||
func(t time.Time) []DateRange { return tier6Ranges(t, s.cfg.MaxYearsBack, s.loc) },
|
||||
func(t time.Time) []DateRange { return tier7Ranges(t, s.loc) },
|
||||
}
|
||||
|
||||
// Build a weighted order of tiers to try
|
||||
order := weightedTierOrder(weights)
|
||||
|
||||
// Try with normal cooldown
|
||||
mem, err := s.tryTiers(ctx, today, order, tierFuncs, s.cfg.CooldownDays)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mem != nil {
|
||||
return mem, nil
|
||||
}
|
||||
|
||||
s.logger.Info("no candidates with normal cooldown, trying relaxed")
|
||||
|
||||
// Try with relaxed cooldown
|
||||
mem, err = s.tryTiers(ctx, today, order, tierFuncs, s.cfg.RelaxedCooldownDays)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mem != nil {
|
||||
return mem, nil
|
||||
}
|
||||
|
||||
s.logger.Info("no candidates with relaxed cooldown, full fallback")
|
||||
|
||||
// Full fallback: any memo
|
||||
return s.fullFallback(ctx)
|
||||
}
|
||||
|
||||
func (s *Selector) tryTiers(
|
||||
ctx context.Context,
|
||||
today time.Time,
|
||||
order []int,
|
||||
tierFuncs [7]func(time.Time) []DateRange,
|
||||
cooldownDays int,
|
||||
) (*Memory, error) {
|
||||
cooldownSet, err := s.store.GetCooldownMemoNames(ctx, cooldownDays)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get cooldown set: %w", err)
|
||||
}
|
||||
|
||||
for _, tierIdx := range order {
|
||||
tier := tierIdx + 1
|
||||
ranges := tierFuncs[tierIdx](today)
|
||||
if len(ranges) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
allMemos, err := s.fetchRanges(ctx, ranges)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch tier %d: %w", tier, err)
|
||||
}
|
||||
|
||||
// Filter by cooldown
|
||||
var filtered []*memos.Memo
|
||||
for _, m := range allMemos {
|
||||
if _, blocked := cooldownSet[m.Name]; !blocked {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
s.logger.Debug("tier empty after cooldown filter", "tier", tier, "total", len(allMemos))
|
||||
continue
|
||||
}
|
||||
|
||||
// Get show counts for scoring
|
||||
names := make([]string, len(filtered))
|
||||
for i, m := range filtered {
|
||||
names[i] = m.Name
|
||||
}
|
||||
showCounts, err := s.store.GetShowCounts(ctx, names)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get show counts: %w", err)
|
||||
}
|
||||
|
||||
// Build candidates
|
||||
candidates := make([]candidate, len(filtered))
|
||||
for i, m := range filtered {
|
||||
yearsAgo := today.Year() - m.DisplayTime.Year()
|
||||
if yearsAgo < 0 {
|
||||
yearsAgo = 0
|
||||
}
|
||||
candidates[i] = candidate{
|
||||
memo: m,
|
||||
yearsAgo: yearsAgo,
|
||||
showCount: showCounts[m.Name],
|
||||
}
|
||||
}
|
||||
|
||||
picked := weightedSelect(candidates, s.cfg.PreferOlder, s.cfg.MaxYearsBack)
|
||||
if picked == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
s.logger.Info("selected memory",
|
||||
"tier", tier,
|
||||
"memo", picked.memo.Name,
|
||||
"years_ago", picked.yearsAgo,
|
||||
)
|
||||
|
||||
return &Memory{
|
||||
Memo: picked.memo,
|
||||
Tier: tier,
|
||||
YearsAgo: picked.yearsAgo,
|
||||
ShowCount: picked.showCount,
|
||||
Date: picked.memo.DisplayTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// fetchRanges queries Memos API for all date ranges concurrently.
|
||||
func (s *Selector) fetchRanges(ctx context.Context, ranges []DateRange) ([]*memos.Memo, error) {
|
||||
type result struct {
|
||||
memos []*memos.Memo
|
||||
err error
|
||||
}
|
||||
|
||||
results := make([]result, len(ranges))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Limit concurrency to avoid overwhelming the API
|
||||
sem := make(chan struct{}, 5)
|
||||
|
||||
for i, dr := range ranges {
|
||||
wg.Add(1)
|
||||
go func(idx int, dr DateRange) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
filter := BuildCELFilter(dr)
|
||||
resp, err := s.client.ListMemos(ctx, filter, s.cfg.PageSize, "")
|
||||
results[idx] = result{
|
||||
memos: resp.GetMemos(),
|
||||
err: err,
|
||||
}
|
||||
}(i, dr)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
var all []*memos.Memo
|
||||
for _, r := range results {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
all = append(all, r.memos...)
|
||||
}
|
||||
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (s *Selector) fullFallback(ctx context.Context) (*Memory, error) {
|
||||
memo, err := s.client.GetRandomMemo(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("full fallback: %w", err)
|
||||
}
|
||||
if memo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &Memory{
|
||||
Memo: memo,
|
||||
Tier: 0,
|
||||
Date: memo.DisplayTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// weightedTierOrder returns tier indices (0-based) shuffled by weight.
|
||||
// Higher weight tiers come first, with randomization within the ordering.
|
||||
func weightedTierOrder(weights [7]int) []int {
|
||||
type entry struct {
|
||||
idx int
|
||||
weight int
|
||||
}
|
||||
entries := make([]entry, 7)
|
||||
for i, w := range weights {
|
||||
entries[i] = entry{idx: i, weight: w}
|
||||
}
|
||||
|
||||
// Shuffle using weighted random selection without replacement
|
||||
order := make([]int, 0, 7)
|
||||
remaining := make([]entry, len(entries))
|
||||
copy(remaining, entries)
|
||||
|
||||
for len(remaining) > 0 {
|
||||
totalWeight := 0
|
||||
for _, e := range remaining {
|
||||
totalWeight += e.weight
|
||||
}
|
||||
if totalWeight == 0 {
|
||||
// All remaining have zero weight, just append them
|
||||
for _, e := range remaining {
|
||||
order = append(order, e.idx)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
r := rand.IntN(totalWeight)
|
||||
cumulative := 0
|
||||
for i, e := range remaining {
|
||||
cumulative += e.weight
|
||||
if r < cumulative {
|
||||
order = append(order, e.idx)
|
||||
remaining = append(remaining[:i], remaining[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return order
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DateRange represents a time range [Start, End) in a specific year.
|
||||
type DateRange struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Year int // the year this range belongs to (for scoring)
|
||||
}
|
||||
|
||||
// BuildCELFilter creates a CEL filter string for a date range using created_ts.
|
||||
func BuildCELFilter(dr DateRange) string {
|
||||
return fmt.Sprintf("created_ts >= %d && created_ts < %d",
|
||||
dr.Start.Unix(), dr.End.Unix())
|
||||
}
|
||||
|
||||
func isLeapYear(year int) bool {
|
||||
return year%4 == 0 && (year%100 != 0 || year%400 == 0)
|
||||
}
|
||||
|
||||
func daysInMonth(year int, month time.Month) int {
|
||||
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||
}
|
||||
|
||||
// tier1Ranges returns date ranges for the exact same day in previous years.
|
||||
// Special handling for Feb 29 / Feb 28.
|
||||
func tier1Ranges(today time.Time, maxYears int, loc *time.Location) []DateRange {
|
||||
month := today.Month()
|
||||
day := today.Day()
|
||||
currentYear := today.Year()
|
||||
|
||||
var ranges []DateRange
|
||||
|
||||
for y := currentYear - maxYears; y < currentYear; y++ {
|
||||
if month == time.February && day == 29 {
|
||||
// Today is Feb 29 — only include leap years
|
||||
if !isLeapYear(y) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Check that this day exists in that year
|
||||
if day > daysInMonth(y, month) {
|
||||
continue
|
||||
}
|
||||
|
||||
start := time.Date(y, month, day, 0, 0, 0, 0, loc)
|
||||
end := start.AddDate(0, 0, 1)
|
||||
ranges = append(ranges, DateRange{Start: start, End: end, Year: y})
|
||||
}
|
||||
|
||||
// Special: if today is Feb 28 in a non-leap year, also check Feb 29 in past leap years
|
||||
if month == time.February && day == 28 && !isLeapYear(currentYear) {
|
||||
for y := currentYear - maxYears; y < currentYear; y++ {
|
||||
if isLeapYear(y) {
|
||||
start := time.Date(y, time.February, 29, 0, 0, 0, 0, loc)
|
||||
end := start.AddDate(0, 0, 1)
|
||||
ranges = append(ranges, DateRange{Start: start, End: end, Year: y})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
// tier2Ranges returns date ranges for the same day-of-month in previous months (last 24 months).
|
||||
// Excludes the current month (covered by Tier 1).
|
||||
func tier2Ranges(today time.Time, loc *time.Location) []DateRange {
|
||||
day := today.Day()
|
||||
currentYear := today.Year()
|
||||
currentMonth := today.Month()
|
||||
|
||||
var ranges []DateRange
|
||||
|
||||
for i := 1; i <= 24; i++ {
|
||||
t := time.Date(currentYear, currentMonth, 1, 0, 0, 0, 0, loc).AddDate(0, -i, 0)
|
||||
y := t.Year()
|
||||
m := t.Month()
|
||||
|
||||
// Skip if this day doesn't exist in that month
|
||||
if day > daysInMonth(y, m) {
|
||||
continue
|
||||
}
|
||||
|
||||
start := time.Date(y, m, day, 0, 0, 0, 0, loc)
|
||||
end := start.AddDate(0, 0, 1)
|
||||
ranges = append(ranges, DateRange{Start: start, End: end, Year: y})
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
// tier3Ranges returns ±3 day ranges around the exact date in previous years,
|
||||
// excluding the exact day itself (which is Tier 1).
|
||||
func tier3Ranges(today time.Time, maxYears int, loc *time.Location) []DateRange {
|
||||
month := today.Month()
|
||||
day := today.Day()
|
||||
currentYear := today.Year()
|
||||
|
||||
var ranges []DateRange
|
||||
|
||||
for y := currentYear - maxYears; y < currentYear; y++ {
|
||||
// The center date in that year
|
||||
if day > daysInMonth(y, month) {
|
||||
continue
|
||||
}
|
||||
center := time.Date(y, month, day, 0, 0, 0, 0, loc)
|
||||
weekStart := center.AddDate(0, 0, -3)
|
||||
weekEnd := center.AddDate(0, 0, 4) // +3 days inclusive = +4 exclusive
|
||||
|
||||
// We need to exclude the center day: split into [weekStart, center) and [center+1day, weekEnd)
|
||||
centerEnd := center.AddDate(0, 0, 1)
|
||||
|
||||
ranges = append(ranges,
|
||||
DateRange{Start: weekStart, End: center, Year: y},
|
||||
DateRange{Start: centerEnd, End: weekEnd, Year: y},
|
||||
)
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
// tier4Ranges returns the same month in previous years,
|
||||
// excluding the ±3 day window around the exact date (covered by Tier 1+3).
|
||||
func tier4Ranges(today time.Time, maxYears int, loc *time.Location) []DateRange {
|
||||
month := today.Month()
|
||||
day := today.Day()
|
||||
currentYear := today.Year()
|
||||
|
||||
var ranges []DateRange
|
||||
|
||||
for y := currentYear - maxYears; y < currentYear; y++ {
|
||||
monthStart := time.Date(y, month, 1, 0, 0, 0, 0, loc)
|
||||
monthEnd := time.Date(y, month+1, 1, 0, 0, 0, 0, loc)
|
||||
|
||||
d := min(day, daysInMonth(y, month))
|
||||
center := time.Date(y, month, d, 0, 0, 0, 0, loc)
|
||||
excludeStart := center.AddDate(0, 0, -3)
|
||||
excludeEnd := center.AddDate(0, 0, 4)
|
||||
|
||||
// Before the exclude window
|
||||
if excludeStart.After(monthStart) {
|
||||
ranges = append(ranges, DateRange{Start: monthStart, End: excludeStart, Year: y})
|
||||
}
|
||||
// After the exclude window
|
||||
if excludeEnd.Before(monthEnd) {
|
||||
ranges = append(ranges, DateRange{Start: excludeEnd, End: monthEnd, Year: y})
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
// tier5Ranges returns the same quarter in previous years, excluding the current month.
|
||||
func tier5Ranges(today time.Time, maxYears int, loc *time.Location) []DateRange {
|
||||
month := today.Month()
|
||||
currentYear := today.Year()
|
||||
|
||||
qStart := (month-1)/3*3 + 1 // first month of quarter (1, 4, 7, 10)
|
||||
var quarterMonths [3]time.Month
|
||||
for i := range 3 {
|
||||
quarterMonths[i] = time.Month(int(qStart) + i)
|
||||
}
|
||||
|
||||
var ranges []DateRange
|
||||
|
||||
for y := currentYear - maxYears; y < currentYear; y++ {
|
||||
for _, m := range quarterMonths {
|
||||
if m == month {
|
||||
continue // exclude current month (covered by Tier 4)
|
||||
}
|
||||
start := time.Date(y, m, 1, 0, 0, 0, 0, loc)
|
||||
end := time.Date(y, m+1, 1, 0, 0, 0, 0, loc)
|
||||
ranges = append(ranges, DateRange{Start: start, End: end, Year: y})
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
// tier6Ranges returns the same half-year in previous years, excluding the current quarter.
|
||||
func tier6Ranges(today time.Time, maxYears int, loc *time.Location) []DateRange {
|
||||
month := today.Month()
|
||||
currentYear := today.Year()
|
||||
|
||||
// Half-year: H1 = Jan-Jun, H2 = Jul-Dec
|
||||
var halfStart time.Month
|
||||
if month <= 6 {
|
||||
halfStart = 1
|
||||
} else {
|
||||
halfStart = 7
|
||||
}
|
||||
|
||||
// Current quarter start
|
||||
qStart := (month-1)/3*3 + 1
|
||||
|
||||
var ranges []DateRange
|
||||
|
||||
for y := currentYear - maxYears; y < currentYear; y++ {
|
||||
for m := halfStart; m < halfStart+6; m++ {
|
||||
// Skip months in the current quarter
|
||||
if m >= qStart && m < qStart+3 {
|
||||
continue
|
||||
}
|
||||
start := time.Date(y, m, 1, 0, 0, 0, 0, loc)
|
||||
end := time.Date(y, m+1, 1, 0, 0, 0, 0, loc)
|
||||
ranges = append(ranges, DateRange{Start: start, End: end, Year: y})
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
// tier7Ranges returns 2 to 6 months ago (recent past).
|
||||
func tier7Ranges(today time.Time, loc *time.Location) []DateRange {
|
||||
end := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, -2, 0)
|
||||
start := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, -6, 0)
|
||||
|
||||
return []DateRange{
|
||||
{Start: start, End: end, Year: 0}, // Year=0 means "recent"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user