17 KiB
Алгоритм поиска воспоминаний
Обзор
Каждый день система выбирает одну заметку из 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
Если на выбранном уровне нет кандидатов (после фильтрации):
- Пробуем следующие уровни по убыванию веса.
- Если все уровни пусты — уменьшаем
cooldown_daysдо 30 и повторяем. - Если всё ещё пусто — выбираем случайную заметку без ограничений (полный 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 (путь настраивается).
Схема:
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 = ? - полный лог — все строки
Проверка при выборке
-- Получить memo_name заметок, показанных за последние N дней (исключить из кандидатов)
SELECT DISTINCT memo_name
FROM show_history
WHERE shown_at > unixepoch() - :cooldown_days * 86400;
На практике: загружаем множество «заблокированных» memo_name один раз и фильтруем кандидатов в памяти.
Выбор с учётом количества показов
-- Для списка кандидатов получить количество показов
SELECT memo_name, COUNT(*) as show_count
FROM show_history
WHERE memo_name IN (?, ?, ...)
GROUP BY memo_name;
Заметки, отсутствующие в результате — ни разу не показывались (show_count = 0).
Запись после показа
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 на текущий день:
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 | Предпочитать более старые воспоминания |
Формат ответа
Результат работы алгоритма — структура, содержащая:
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 │
└─────────────────┘