Files
remembos/spec/SEARCH.md
T
2026-02-12 10:43:58 +03:00

403 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Алгоритм поиска воспоминаний
## Обзор
Каждый день система выбирает одну заметку из 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 │
└─────────────────┘
```