---
status: ready_for_review
date: 2026-05-10
project: tasks-bot
block: 1 (conversational core)
---

# Tasks-bot — разговорный режим (Block 1)

## Контекст и цель

Сейчас бот управляется командами (`/today /inbox /overdue /done /del /text /proj /date`). Это неудобно: приходится помнить номера задач из последней сводки, синтаксис команд, переключаться между режимами.

Цель — перевести взаимодействие на свободный текст: «сделала позвонить Ане», «удали идею про канал», «перенеси Лену на пятницу». Понимание берёт на себя Claude Haiku 4.5 через OpenRouter. Команды остаются как fallback на случай недоступности модели.

## Скоуп

**В MVP:**
- Создание, редактирование, закрытие, удаление задач свободным текстом
- Просмотр (`today / inbox / overdue / done`) свободным текстом
- Reply на сообщение бота как способ дать контекст («сделала первую и третью» в ответ на сводку)
- Все слеш-команды продолжают работать как сейчас
- Strict-режим: off-topic игнорируется (`reject` с фразой «я только про задачи»)

**Не в MVP (отдельные блоки/итерации):**
- Голосовые сообщения (Block 1.5 — Whisper API)
- Google Calendar (Block 2)
- Многоходовый диалог без reply
- Восстановление удалённых задач, изменение `done_at`
- Чит-чат, поиск, теги, приоритеты, повторяемость
- Пакетные операции по фильтру («удали всё из инбокса» одной фразой)

## Архитектура

```
Сообщение от пользователя
        │
        ├─► Если начинается с "/" ───► Существующие хендлеры команд
        │                                (без LLM, как сейчас)
        │
        └─► Свободный текст ────────► ConversationalRouter
                                          │
                                       Сборка контекста:
                                       • открытые задачи (id, text, project, deadline)
                                       • сегодняшняя дата
                                       • если reply на сообщение бота —
                                         цитата этого сообщения
                                       • системный промпт с правилами
                                          │
                                       OpenRouter → Claude Haiku 4.5
                                          │
                                       LLM возвращает один или несколько
                                       вызовов инструментов
                                          │
                                       Tool dispatcher выполняет их на БД
                                       (через те же методы, что команды)
                                          │
                                       Форматирование подробного ответа
```

Принципы:
- **Команды и LLM-канал независимы.** Команды используют те же DB-операции, что и инструменты LLM. Если LLM лежит, команды работают.
- **Никаких изменений в SyncWorker / BackupWorker / scheduler / классификатор сводок** — конверсационный роутер это новый источник writes в БД.
- **Никаких новых типов задач или статусов.** Работаем на текущей модели данных (`Task`, `TaskStatus`, `DeadlineKind`).

## Словарь действий LLM (tools)

| Инструмент | Что делает |
|---|---|
| `create_task(text, project, deadline_kind, deadline?)` | Новая задача с автоклассификацией в проект |
| `update_task(id, text?, project?, deadline_kind?, deadline?)` | Меняет любое из трёх полей (или несколько сразу) |
| `close_task(id)` | Помечает `TaskStatus.DONE` |
| `cancel_task(id)` | Помечает `TaskStatus.CANCELLED` (как сейчас в `/del`) |
| `list_tasks(filter, period?)` | Сводка по фильтру `today / inbox / overdue / done / all_by_project`. Для `done` доступен `period=today \| week \| all` |
| `defer_today_to_tomorrow()` | Перенос всех сегодняшних на завтра |
| `clarify(question, options[])` | Уточняющий вопрос при неоднозначности |
| `reject(reason)` | Off-topic / не понимаю |

**Несколько действий в одном сообщении:** LLM может вызвать несколько инструментов, если пользователь пишет «сделала позвонить Ане и удали идею про канал». Бот выполнит обе операции и отчитается о каждой.

**Принцип неоднозначности (главный риск проекта):** при упоминании задачи бот ищет семантически похожие в списке открытых.
- Ровно одна совпадает → действует сразу (для деструктивных — с уведомлением «🗑 удалила X»).
- Несколько совпадают → `clarify`.
- Ноль совпадает → `reject` с пояснением.

Это самая опасная часть системы: «сделала Лену» должно строго мапиться в одну задачу или приводить к уточнению — никогда «угадать одну из двух Лен». В системном промпте отдельно прописать: при сомнениях всегда `clarify`, не `close_task`. В тестах — отдельный сценарий «две задачи с одинаковым ключевым словом → бот зовёт `clarify`, а не действует».

## Контекст в каждом вызове LLM

**Системный промпт (фиксированный, кешируется):**
- Роль и тон бота (помощник Наташи, без чит-чата)
- Список проектов (mars / channel / consulting / life / _inbox) с описаниями
- Правила обработки дат (сегодня / завтра / этой неделей / без срока)
- Правила сопоставления задач при упоминании
- Описания всех 8 инструментов

**Динамический контекст (пересоздаётся каждый раз):**
- Сегодняшняя дата и день недели
- Список открытых задач: все `CONFIRMED` + просроченные. Поля: `id, text, project, deadline, deadline_kind`. Обычно 20–60 строк.
- Если сообщение это reply на сообщение бота — текст того сообщения как контекст
- Само сообщение пользователя

**Механизм reply-as-context:**
Telegram присылает в `Update.message.reply_to_message.text` полный текст сообщения, на которое ответили. Никакого отдельного хранилища не нужно. В сводках бот всегда рендерит номера и тексты задач явно (как сейчас), поэтому LLM, видя процитированную сводку и фразу «сделала первую и третью», самостоятельно мапит номера на тексты задач — и от текстов уже сопоставляет с `id` из текущего списка открытых. Никакая логика «номер → id» в роутере не нужна — это работа LLM на основе текста сводки.

**Что НЕ в контексте:**
- Закрытые / удалённые задачи (доступны через явный `list_tasks`)
- История прошлых сообщений (кроме reply-as-context)
- Содержимое markdown-файлов в вальте

## Бюджет токенов и стоимость

- Системный промпт + tools: ~1.5K (кешируется → 0.1× после первого вызова)
- Открытые задачи: ~1–2K
- Сообщение + reply: ~100–500
- **Вход:** 3–4K токенов
- **Выход:** ~100–300 токенов
- **На сообщение:** ~$0.005 без кеша, ~$0.002 с кешем
- **Месяц при 30 сообщениях/день:** $1.5–2

## Ответы пользователю

Стиль — **подробный с MVP, не «потом улучшим»** (по решению брейншторма):

```
Создала задачу:
  • позвонить Ане завтра — life, срок 2026-05-11

Закрыла:
  • написать пост про метафору — channel
```

Для каждой выполненной операции бот показывает текст, проект и срок. Это защита от misclassification: пользователь сразу видит, если задача попала не в тот проект, и поправляет одной фразой («это в марс, а не life»). Если formatter в начале будет лаконичным («✅ создано»), пользователь не заметит ошибочную классификацию и накопится мусор. Поэтому подробный формат — обязательная часть MVP, а не оптимизация.

## Fallbacks и ошибки

| Ситуация | Поведение |
|---|---|
| OpenRouter таймаут / 5xx | «⚠️ Не могу связаться с моделью. Попробуй ещё раз или используй /команды.» Логирование в journal. |
| LLM вернул мусор / невалидный tool call | «🤷 Не поняла, переформулируй или используй /команды.» Логирование. |
| Tool call с несуществующим `task_id` | Пропускаем действие, в финальном ответе: «⚠️ Задача N не найдена.» |
| `CONVERSATIONAL_MODE_ENABLED=false` | Свободный текст идёт по старому пути: классификатор `gpt-oss-120b:free` создаёт задачу. |
| Все слеш-команды | Работают независимо в любом случае. |

## Конфигурация (ENV)

Новые переменные на Pi (`~/tasks_bot/.env`):

```
CONVERSATIONAL_MODE_ENABLED=true
OPENROUTER_INTENT_MODEL=anthropic/claude-haiku-4.5
```

`OPENROUTER_API_KEY` — переиспользуется существующий (тот же, что для классификатора).

Kill switch: `CONVERSATIONAL_MODE_ENABLED=false` + `sudo systemctl restart tasks-bot`.

## Структура кода

Новые модули в `src/tasks_bot/`:
- `conversational/router.py` — main entry, принимает Update, дёргает LLM, выполняет tool calls
- `conversational/tools.py` — определения 8 инструментов (схемы для OpenRouter) + dispatcher
- `conversational/context.py` — `build_context(db, message, reply)`
- `conversational/prompt.py` — системный промпт (константа + рендер)
- `conversational/formatter.py` — форматирование ответа из списка выполненных действий

Изменения в существующих файлах:
- `src/tasks_bot/bot.py` — handler свободного текста дёргает `conversational.router` вместо текущего capture-flow (под флагом `CONVERSATIONAL_MODE_ENABLED`)
- `src/tasks_bot/config.py` — новые ENV переменные

## Тестирование

**Порядок (важен — каркас сначала, потом сборка):**

1. **Tool dispatcher и tools** — TDD на каждый из 8 инструментов. На вход dict как от LLM, на выход — изменения в БД. Эта часть полностью детерминирована, моков не требует. Тестируется на реальной in-memory SQLite (как существующие тесты).
2. **Formatter** — TDD на рендер ответа из списка выполненных операций. Чистая функция, моков нет.
3. **Context builder** — `build_context(db, message, reply)`: корректный список открытых, дата, цитата reply. Тоже без моков, на in-memory БД.
4. **Парсер дат** — расширение существующего `dates.py` под относительные «в пятницу», «через неделю».
5. **Router** — поверх готового каркаса. Здесь моки на OpenRouter (фейковый JSON-ответ от LLM) → проверяем что роутер корректно собирает контекст, парсит ответ, дёргает dispatcher и formatter, возвращает финальный текст.

**Сценарии для router-тестов:**
- Создание, редактирование, закрытие, удаление с уточнением (`clarify`), off-topic (`reject`), multi-action в одном сообщении, reply на сводку
- **Отдельный обязательный сценарий — двусмысленность matching:** в открытых задачах две с «Аня», LLM возвращает `close_task(id=...)` — наш роутер должен (через системный промпт) получить `clarify`, а не `close`. Если LLM в моке всё-таки вернул `close` без `clarify` — это сценарий безопасности, и его тоже надо потестировать (бот выполняет, но это ложится на качество промпта; в тесте проверяем что выполнение конкретного `task_id` корректно).

**Контрактные с реальным OpenRouter:**
- Один тест, `@pytest.mark.live` — дёргает Haiku 4.5 на типовом сообщении, проверяет валидный tool call. Запускается вручную.

**Не тестируем:**
- Качество классификации в проекты (уже на gpt-oss-120b, проверено практикой)
- Качество понимания LLM на широком сете фраз (тестируется живым использованием)

**Критерии готовности:**
- Все существующие 217 тестов остаются зелёными
- Новые тесты покрывают 8 инструментов, formatter, context builder и основные сценарии роутера
- Live-тест проходит на реальном Haiku 4.5

## Деплой

Стандартный пайплайн:
1. `git push origin main` с мака
2. `ssh pi "cd ~/tasks_bot && git pull && sudo systemctl restart tasks-bot"`
3. Перед первым деплоем — добавить `CONVERSATIONAL_MODE_ENABLED` и `OPENROUTER_INTENT_MODEL` в `~/tasks_bot/.env` на Pi.

## Открытые риски

1. **Главный риск — некорректный matching задач при операциях.** «Сделала Лену» при двух задачах про Лену не должно «угадать» одну. Митигация: жёсткое правило в системном промпте (`clarify`, не `close_task`, при любой неоднозначности) + отдельные тесты на двусмысленные совпадения. Защита подкреплена тем, что пользователь видит подробный ответ и может откатить через «верни в работу X» (если такое поведение поддержим — иначе через `update_task`).
2. **Регрессия в существующем capture-flow.** Митигация: под флагом `CONVERSATIONAL_MODE_ENABLED=false` свободный текст идёт по старому пути (классификатор `gpt-oss-120b:free` → создание задачи). Это даёт безопасную отступную после деплоя.
3. **Ошибки классификации проектов в свободном тексте.** Haiku 4.5 умнее `gpt-oss-120b`, но новые формулировки могут сбить. Митигация: подробный ответ показывает проект → пользователь сразу замечает и поправляет через `update_task`.
4. **Стоимость растёт со временем.** При 200+ открытых задачах контекст пухнет. Митигация: если станет проблемой, фильтруем в контекст только релевантные задачи (по словам из сообщения).
5. **OpenRouter rate limits.** Маловероятно при 30 сообщениях/день, но возможно. Митигация: команды как fallback.
