# Алгоритм поиска воспоминаний ## Обзор Каждый день система выбирает одну заметку из 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 │ └─────────────────┘ ```