add search algo
This commit is contained in:
@@ -8,26 +8,15 @@
|
|||||||
- Telegram Bot. Бот, который каждый день присылает одно воспоминание
|
- Telegram Bot. Бот, который каждый день присылает одно воспоминание
|
||||||
- web-сервис с одной страницей, где можно увидеть одно воспоминание
|
- web-сервис с одной страницей, где можно увидеть одно воспоминание
|
||||||
|
|
||||||
## Поиск воспоминаний
|
## Спецификации
|
||||||
|
|
||||||
Воспоминание (заметка в Memos) ищется относительно текущей даты.
|
- [Алгоритм поиска воспоминаний](spec/SEARCH.md)
|
||||||
|
|
||||||
Примерный алгоритм, он требует уточнения, чтобы воспоминания были из разных временных периодов
|
## Стек
|
||||||
и не часто повторялись.
|
|
||||||
|
|
||||||
Идея, чтобы показать что было "в этот день в прошлом".
|
- **Go** — основной язык
|
||||||
Этот день может быть точной датой, но в другом году, то же число в другом месяце,
|
- **SQLite** — хранение истории показов и кэша запросов
|
||||||
тот же число и день недели и так далее.
|
- **Memos API** — источник заметок
|
||||||
|
|
||||||
- Поиск воспоминания в этот же день в прошлые года
|
|
||||||
- Поиск воспоминания в этот же день месяца в прошлые месяцы
|
|
||||||
- Поиск воспоминания в диапазоне недели в прошлые года
|
|
||||||
- Поиск воспоминания в диапазоне месяца в прошлые года
|
|
||||||
- Поиск воспоминания в диапазоне квартала в прошлые года
|
|
||||||
- Поиск воспоминания в диапазоне полугода в прошлые года
|
|
||||||
- Поиск в недавние месяцы.
|
|
||||||
|
|
||||||
Нужно предусмотреть контроль повторений, чтобы воспоминания были доступны случайно и равномерно.
|
|
||||||
|
|
||||||
## Настройки
|
## Настройки
|
||||||
|
|
||||||
|
|||||||
+402
@@ -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 │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user