add web service

This commit is contained in:
2026-02-12 11:10:40 +03:00
parent b6f922897c
commit ed04c11eff
16 changed files with 1414 additions and 0 deletions
+67
View File
@@ -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]
}
+264
View File
@@ -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
}
+226
View File
@@ -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"
}
}