From 12a4d9b7e48601af3d2361c6d34f316b92c67b9c Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Thu, 12 Feb 2026 10:43:58 +0300 Subject: [PATCH] add search algo --- README.md | 23 +-- spec/SEARCH.md | 402 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 408 insertions(+), 17 deletions(-) create mode 100644 spec/SEARCH.md diff --git a/README.md b/README.md index a40d43b..51ab934 100644 --- a/README.md +++ b/README.md @@ -8,26 +8,15 @@ - Telegram Bot. Бот, который каждый день присылает одно воспоминание - web-сервис с одной страницей, где можно увидеть одно воспоминание -## Поиск воспоминаний +## Спецификации -Воспоминание (заметка в Memos) ищется относительно текущей даты. +- [Алгоритм поиска воспоминаний](spec/SEARCH.md) -Примерный алгоритм, он требует уточнения, чтобы воспоминания были из разных временных периодов -и не часто повторялись. +## Стек -Идея, чтобы показать что было "в этот день в прошлом". -Этот день может быть точной датой, но в другом году, то же число в другом месяце, -тот же число и день недели и так далее. - -- Поиск воспоминания в этот же день в прошлые года -- Поиск воспоминания в этот же день месяца в прошлые месяцы -- Поиск воспоминания в диапазоне недели в прошлые года -- Поиск воспоминания в диапазоне месяца в прошлые года -- Поиск воспоминания в диапазоне квартала в прошлые года -- Поиск воспоминания в диапазоне полугода в прошлые года -- Поиск в недавние месяцы. - -Нужно предусмотреть контроль повторений, чтобы воспоминания были доступны случайно и равномерно. +- **Go** — основной язык +- **SQLite** — хранение истории показов и кэша запросов +- **Memos API** — источник заметок ## Настройки diff --git a/spec/SEARCH.md b/spec/SEARCH.md new file mode 100644 index 0000000..f758265 --- /dev/null +++ b/spec/SEARCH.md @@ -0,0 +1,402 @@ +# Алгоритм поиска воспоминаний + +## Обзор + +Каждый день система выбирает одну заметку из Memos и показывает её как «воспоминание». +Цель — создать ощущение «в этот день в прошлом», но с разнообразием: +не только точные совпадения дат, но и похожие периоды. + +## Взаимодействие с Memos API + +### Используемый эндпоинт + +``` +GET /api/v1/memos?filter={CEL}&pageSize={N}&orderBy=display_time+desc +``` + +### Фильтрация по дате + +API поддерживает CEL-фильтры по полю `created_ts` (Unix timestamp): + +``` +created_ts >= 1707696000 && created_ts < 1707782400 +``` + +Поле `display_time` (пользовательская дата) недоступно для фильтрации через CEL. +Поэтому используем `created_ts` как основу, а `display_time` учитываем при +пост-обработке, если он отличается от `created_ts`. + +### Ограничения + +- Максимальный размер страницы: 1000 +- Фильтрация по диапазонам дат требует отдельных запросов для каждого года/периода +- Нет встроенной случайной выборки — рандомизация на стороне клиента + +--- + +## Уровни поиска (тiers) + +Каждый уровень определяет стратегию выбора временного диапазона относительно +текущей даты. Уровни отсортированы от наиболее точного совпадения к наиболее +широкому. + +### Tier 1 — Точная дата в прошлые годы + +**Вес: 35%** + +Ищем заметки, созданные в этот же день и месяц, но в другие годы. + +Пример для 12 февраля 2026: +- 2025-02-12 +- 2024-02-12 +- 2023-02-12 +- ... + +**Запрос:** для каждого прошлого года — отдельный запрос с фильтром на один день. + +``` +created_ts >= startOfDay(YYYY-MM-DD) && created_ts < startOfDay(YYYY-MM-DD + 1 day) +``` + +**Особый случай — 29 февраля:** +Если сегодня 29 февраля, ищем только в прошлые високосные годы. +Если сегодня 28 февраля в невисокосном году, дополнительно проверяем 29 февраля +в прошлые високосные годы. + +### Tier 2 — Тот же день месяца в прошлые месяцы + +**Вес: 15%** + +Ищем заметки, созданные в тот же день месяца, но в другие месяцы. + +Пример для 12 февраля 2026: +- 2026-01-12, 2025-12-12, 2025-11-12, ..., 2025-02-12 (пропустить — уже в Tier 1) + +**Диапазон:** последние 24 месяца (исключая текущий месяц). + +**Запрос:** один запрос на каждый месяц. Для оптимизации можно объединять +несколько месяцев одного года в один запрос через `||`. + +**Особый случай:** если день = 31, а в месяце нет 31-го — пропускаем этот месяц. +Аналогично для 30-го и февраля. + +### Tier 3 — Та же неделя в прошлые годы + +**Вес: 15%** + +Ищем заметки в диапазоне ±3 дня от точной даты в прошлых годах. +Это расширение Tier 1, но исключает точное совпадение дня. + +Пример для 12 февраля 2026, год 2025: +- с 2025-02-09 по 2025-02-15 (исключая 2025-02-12) + +**Запрос:** один запрос на год — диапазон 7 дней. + +### Tier 4 — Тот же месяц в прошлые годы + +**Вес: 12%** + +Ищем заметки, созданные в тот же месяц (февраль), но в прошлые годы. +Исключаем заметки, уже попавшие в Tier 1 и Tier 3 (±3 дня от точной даты). + +Пример для февраля 2026: +- весь февраль 2025, кроме 09-15 февраля +- весь февраль 2024, кроме 09-15 февраля +- ... + +**Запрос:** один запрос на год — весь месяц. +Исключение дней Tier 1/3 — на стороне клиента при пост-обработке. + +### Tier 5 — Тот же квартал в прошлые годы + +**Вес: 10%** + +Ищем заметки, созданные в тот же квартал, но в прошлые годы. +Исключаем текущий месяц (покрыт Tier 4). + +Кварталы: Q1 (янв-мар), Q2 (апр-июн), Q3 (июл-сен), Q4 (окт-дек). + +Пример: февраль 2026 → Q1, ищем в январе и марте прошлых лет. + +**Запрос:** один запрос на год — два месяца квартала (без текущего месяца). + +### Tier 6 — То же полугодие в прошлые годы + +**Вес: 5%** + +Полугодия: H1 (янв-июн), H2 (июл-дек). +Исключаем текущий квартал (покрыт Tier 5). + +Пример: февраль 2026 → H1, ищем в апреле-июне прошлых лет. + +**Запрос:** один запрос на год — три месяца полугодия (без текущего квартала). + +### Tier 7 — Недавнее прошлое + +**Вес: 8%** + +Заметки от 2 до 6 месяцев назад. Свежие воспоминания, +которые ещё не стали «историей», но уже подзабылись. + +Пример для февраля 2026: с августа 2025 по декабрь 2025. + +**Запрос:** один запрос с диапазоном 4 месяца. + +--- + +## Алгоритм выбора + +### Шаг 1. Выбор уровня + +Генерируем случайное число и выбираем уровень по весам: + +| Уровень | Вес | Диапазон | +|---------|-----|-----------| +| Tier 1 | 35% | [0, 35) | +| Tier 2 | 15% | [35, 50) | +| Tier 3 | 15% | [50, 65) | +| Tier 4 | 12% | [65, 77) | +| Tier 5 | 10% | [77, 87) | +| Tier 6 | 5% | [87, 92) | +| Tier 7 | 8% | [92, 100) | + +### Шаг 2. Запрос кандидатов + +Для выбранного уровня формируем запросы к Memos API. + +**Оптимизация запросов:** +- Запрашиваем не все годы сразу, а начинаем с самого далёкого и идём к ближнему. + Это даёт приоритет более старым воспоминаниям внутри уровня. +- `pageSize = 50` — достаточно для выборки кандидатов. +- Запросы для разных лет можно выполнять параллельно (goroutines). + +### Шаг 3. Фильтрация по истории показов + +Из полученных кандидатов исключаем заметки, показанные за последние N дней. + +**Параметр:** `cooldown_days` — минимальный интервал между повторными показами +одной заметки. По умолчанию: 90 дней. + +### Шаг 4. Выбор заметки из кандидатов + +Если кандидаты найдены — выбираем одного с учётом весов: + +``` +score = base_score * recency_factor * show_count_penalty + +base_score = 1.0 (одинаковый для всех в пределах уровня) +recency_factor = годы_назад / max_годы_назад (старые заметки немного предпочтительнее) +show_count_penalty = 1 / (1 + show_count) (реже показанные — предпочтительнее) +``` + +Финальный выбор — взвешенная случайная выборка по `score`. + +### Шаг 5. Fallback + +Если на выбранном уровне нет кандидатов (после фильтрации): + +1. Пробуем следующие уровни по убыванию веса. +2. Если все уровни пусты — уменьшаем `cooldown_days` до 30 и повторяем. +3. Если всё ещё пусто — выбираем случайную заметку без ограничений + (полный fallback). + +``` +SelectMemory(today): + tiers = shuffledByWeight([Tier1..Tier7]) + + for tier in tiers: + candidates = queryTier(tier, today) + candidates = filterByHistory(candidates, cooldown=90 days) + if len(candidates) > 0: + return weightedSelect(candidates) + + // Ослабляем ограничения + for tier in tiers: + candidates = queryTier(tier, today) + candidates = filterByHistory(candidates, cooldown=30 days) + if len(candidates) > 0: + return weightedSelect(candidates) + + // Полный fallback — любая заметка + return randomMemo() +``` + +--- + +## Контроль повторений + +### Хранилище истории + +**SQLite** — единый файл базы данных, без внешних зависимостей. +Файл БД: `remembos.db` (путь настраивается). + +Схема: + +```sql +CREATE TABLE show_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memo_name TEXT NOT NULL, -- идентификатор заметки (memos/{id}) + shown_at INTEGER NOT NULL, -- Unix timestamp показа + tier INTEGER NOT NULL DEFAULT 0 -- уровень, на котором была найдена +); + +CREATE INDEX idx_show_history_memo ON show_history(memo_name); +CREATE INDEX idx_show_history_shown_at ON show_history(shown_at); +``` + +Из одной таблицы можно получить всё: +- последний показ заметки — `MAX(shown_at) WHERE memo_name = ?` +- количество показов — `COUNT(*) WHERE memo_name = ?` +- полный лог — все строки + +### Проверка при выборке + +```sql +-- Получить memo_name заметок, показанных за последние N дней (исключить из кандидатов) +SELECT DISTINCT memo_name +FROM show_history +WHERE shown_at > unixepoch() - :cooldown_days * 86400; +``` + +На практике: загружаем множество «заблокированных» memo_name один раз +и фильтруем кандидатов в памяти. + +### Выбор с учётом количества показов + +```sql +-- Для списка кандидатов получить количество показов +SELECT memo_name, COUNT(*) as show_count +FROM show_history +WHERE memo_name IN (?, ?, ...) +GROUP BY memo_name; +``` + +Заметки, отсутствующие в результате — ни разу не показывались (`show_count = 0`). + +### Запись после показа + +```sql +INSERT INTO show_history (memo_name, shown_at, tier) +VALUES (:memo_name, unixepoch(), :tier); +``` + +--- + +## Оптимизация количества запросов + +Ежедневный запуск — не критично по производительности, но стоит быть рациональным. + +### Оценка количества запросов на один вызов + +В худшем случае (все уровни пустые, fallback): +- Tier 1: 1 запрос на год × ~5 лет = 5 запросов +- Tier 2: 1 запрос (объединённый, до 24 месяцев, но нужен pageSize=50) = ~2 запроса +- Tier 3: 5 запросов (по годам) +- Tier 4: 5 запросов +- Tier 5: 5 запросов +- Tier 6: 5 запросов +- Tier 7: 1 запрос +- **Итого (worst case): ~28 запросов** + +В типичном случае (первый уровень срабатывает): +- 1-5 запросов к Memos API + 1-2 запроса к SQLite +- **Итого (typical): 3-7 запросов** + +### Кэширование + +Для уменьшения нагрузки на Memos можно кэшировать результаты запросов в SQLite +на текущий день: + +```sql +CREATE TABLE query_cache ( + cache_key TEXT PRIMARY KEY, -- "{tier}:{date_params}" + memo_names TEXT NOT NULL, -- JSON-массив memo_name + cached_at INTEGER NOT NULL -- Unix timestamp +); +``` + +Кэш валиден в течение суток. При запуске — удаляем записи старше 24 часов. + +Это полезно, если один и тот же инстанс обслуживает несколько пользователей +или вызывается повторно в течение дня (веб + бот). + +--- + +## Конфигурация + +| Параметр | Значение по умолчанию | Описание | +|-------------------------|-----------------------|----------------------------------------| +| `memos_url` | — | URL инстанса Memos | +| `memos_token` | — | Токен авторизации | +| `db_path` | `remembos.db` | Путь к файлу SQLite | +| `cooldown_days` | 90 | Минимум дней до повторного показа | +| `relaxed_cooldown_days` | 30 | Ослабленный cooldown для fallback | +| `tier_weights` | см. таблицу | Веса уровней (можно переопределить) | +| `page_size` | 50 | Размер страницы при запросах к API | +| `max_years_back` | 10 | Максимальная глубина поиска в годах | +| `prefer_older` | true | Предпочитать более старые воспоминания | + +--- + +## Формат ответа + +Результат работы алгоритма — структура, содержащая: + +```go +type Memory struct { + Memo *Memo // Заметка из Memos (содержит content, tags, attachments и т.д.) + Tier int // Уровень, на котором найдена (1-7) + YearsAgo int // Сколько лет назад (0 для Tier 7) + ShowCount int // Сколько раз уже показывалась + Date time.Time // Дата создания заметки +} +``` + +--- + +## Диаграмма потока + +``` + ┌─────────────────┐ + │ Текущая дата │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Выбор уровня │ + │ (weighted rand) │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Формирование │ + │ диапазонов дат │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Запросы к │ + │ Memos API │◄──── параллельно по годам + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Фильтрация по │ + │ истории (SQLite) │ + └────────┬────────┘ + │ + ┌────▼────┐ + │ Есть │ + │кандидаты?│ + └────┬────┘ + yes │ no + ┌────────▼──┐ ┌──▼──────────┐ + │Взвешенный │ │ Следующий │ + │ выбор │ │ уровень │──► (fallback) + └────────┬──┘ └─────────────┘ + │ + ┌────────▼────────┐ + │ Запись в SQLite │ + │ (history, count) │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Возврат Memory │ + └─────────────────┘ +```