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

17 KiB
Raw Blame History

Алгоритм поиска воспоминаний

Обзор

Каждый день система выбирает одну заметку из 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 (путь настраивается).

Схема:

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   │
          └─────────────────┘