# Conversational Mode (Block 1) — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Перевести взаимодействие с tasks-bot на свободный текст через Claude Haiku 4.5 (OpenRouter), сохранив команды как fallback. Спека: `docs/{vibe-coding} {plan} tasks-bot разговорный режим – 2026-05-10.md`.

**Architecture:** Новый пакет `src/tasks_bot/conversational/` с роутером, tool dispatcher, formatter, context builder и system prompt. Существующие команды и capture-flow не трогаем. Под флагом `CONVERSATIONAL_MODE_ENABLED` хендлер свободного текста идёт либо в новый роутер, либо в старый capture-flow.

**Tech Stack:** Python 3.11+, python-telegram-bot, aiosqlite, httpx, OpenRouter API (OpenAI-compatible tool calling), pytest + pytest-asyncio.

**Naming convention.** Файлы тестов в `tests/conversational/`, продукционный код в `src/tasks_bot/conversational/`. Тесты следуют существующему стилю (in-memory `Database`, `pytest-asyncio` режим auto).

---

## Файловая структура (что создаём / меняем)

**Новые файлы:**
- `src/tasks_bot/conversational/__init__.py` — пустой
- `src/tasks_bot/conversational/types.py` — dataclass'ы `ActionResult` для передачи между dispatcher и formatter
- `src/tasks_bot/conversational/tools.py` — реализация 8 инструментов + dispatcher
- `src/tasks_bot/conversational/schemas.py` — JSON-схемы tools для OpenRouter
- `src/tasks_bot/conversational/formatter.py` — рендер ответа из списка `ActionResult`
- `src/tasks_bot/conversational/context.py` — `build_context(db, message_text, reply_text, today)`
- `src/tasks_bot/conversational/prompt.py` — системный промпт
- `src/tasks_bot/conversational/openrouter.py` — обёртка над `/chat/completions` с tools
- `src/tasks_bot/conversational/router.py` — главная функция `route(db, settings, update_message, reply_text, now)`
- `tests/conversational/__init__.py` — пустой
- `tests/conversational/test_tools.py`
- `tests/conversational/test_formatter.py`
- `tests/conversational/test_context.py`
- `tests/conversational/test_prompt.py`
- `tests/conversational/test_openrouter.py`
- `tests/conversational/test_router.py`
- `tests/conversational/test_router_live.py` — `@pytest.mark.live`

**Модифицируем:**
- `src/tasks_bot/config.py` — две новые env-переменные
- `src/tasks_bot/bot.py` — `handle_text` проверяет флаг и роутит в conversational
- `src/tasks_bot/main.py` — `app.bot_data["settings"] = settings` чтобы handler видел флаги

---

### Task 1: Добавить env-переменные в config

**Files:**
- Modify: `src/tasks_bot/config.py`
- Test: `tests/test_config.py`

- [ ] **Step 1: Прочитать `tests/test_config.py` чтобы понять существующий стиль**

```bash
cat tests/test_config.py
```

- [ ] **Step 2: Дописать тест в конец `tests/test_config.py`**

```python
def test_conversational_mode_defaults(monkeypatch, tmp_path):
    monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "x")
    monkeypatch.setenv("TELEGRAM_OWNER_USER_ID", "1")
    monkeypatch.setenv("OPENROUTER_API_KEY", "k")
    monkeypatch.setenv("DATA_REPO_URL", "git@example:r.git")
    monkeypatch.setenv("DATA_REPO_PATH", str(tmp_path))
    monkeypatch.setenv("DB_PATH", str(tmp_path / "db.sqlite"))
    from tasks_bot.config import Settings
    s = Settings()
    assert s.conversational_mode_enabled is False
    assert s.openrouter_intent_model == "anthropic/claude-haiku-4.5"


def test_conversational_mode_enabled_via_env(monkeypatch, tmp_path):
    monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "x")
    monkeypatch.setenv("TELEGRAM_OWNER_USER_ID", "1")
    monkeypatch.setenv("OPENROUTER_API_KEY", "k")
    monkeypatch.setenv("DATA_REPO_URL", "git@example:r.git")
    monkeypatch.setenv("DATA_REPO_PATH", str(tmp_path))
    monkeypatch.setenv("DB_PATH", str(tmp_path / "db.sqlite"))
    monkeypatch.setenv("CONVERSATIONAL_MODE_ENABLED", "true")
    from tasks_bot.config import Settings
    s = Settings()
    assert s.conversational_mode_enabled is True
```

- [ ] **Step 3: Запустить тесты — должны упасть**

Run: `pytest tests/test_config.py -v`
Expected: FAIL — `Settings` не имеет полей `conversational_mode_enabled` / `openrouter_intent_model`.

- [ ] **Step 4: Добавить поля в `Settings` в `src/tasks_bot/config.py`**

```python
class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    telegram_bot_token: str
    telegram_owner_user_id: int
    openrouter_api_key: str
    openrouter_model: str = "meta-llama/llama-3.1-8b-instruct:free"
    openrouter_intent_model: str = "anthropic/claude-haiku-4.5"
    conversational_mode_enabled: bool = False
    data_repo_url: str
    data_repo_path: str
    db_path: str
    timezone: str = "Asia/Yekaterinburg"
```

- [ ] **Step 5: Запустить тесты — должны пройти**

Run: `pytest tests/test_config.py -v`
Expected: PASS

- [ ] **Step 6: Коммит**

```bash
git add src/tasks_bot/config.py tests/test_config.py
git commit -m "feat: add conversational_mode_enabled + openrouter_intent_model settings"
```

---

### Task 2: Скелет пакета `conversational` + типы `ActionResult`

**Files:**
- Create: `src/tasks_bot/conversational/__init__.py` (empty)
- Create: `src/tasks_bot/conversational/types.py`
- Create: `tests/conversational/__init__.py` (empty)
- Create: `tests/conversational/test_types.py`

- [ ] **Step 1: Создать пустые `__init__.py`**

```bash
mkdir -p src/tasks_bot/conversational tests/conversational
touch src/tasks_bot/conversational/__init__.py tests/conversational/__init__.py
```

- [ ] **Step 2: Написать тест `tests/conversational/test_types.py`**

```python
from datetime import date
from tasks_bot.models import DeadlineKind
from tasks_bot.conversational.types import (
    CreatedResult, UpdatedResult, ClosedResult, CancelledResult,
    ListedResult, DeferredResult, ClarifyResult, RejectedResult, ErrorResult,
)


def test_created_result_holds_task_fields():
    r = CreatedResult(text="позвонить Ане", project="life",
                      deadline=date(2026, 5, 11), deadline_kind=DeadlineKind.DATE)
    assert r.text == "позвонить Ане"
    assert r.project == "life"


def test_clarify_result_has_question_and_options():
    r = ClarifyResult(question="какую закрыть?",
                      options=["позвонить Ане", "позвонить Лене"])
    assert r.question == "какую закрыть?"
    assert len(r.options) == 2


def test_error_result_has_message():
    r = ErrorResult(message="task 999 not found")
    assert r.message == "task 999 not found"
```

- [ ] **Step 3: Запустить — должен упасть на ImportError**

Run: `pytest tests/conversational/test_types.py -v`
Expected: FAIL — `tasks_bot.conversational.types` не существует.

- [ ] **Step 4: Создать `src/tasks_bot/conversational/types.py`**

```python
from dataclasses import dataclass, field
from datetime import date
from typing import Optional
from ..models import DeadlineKind


@dataclass
class CreatedResult:
    text: str
    project: str
    deadline: Optional[date]
    deadline_kind: DeadlineKind
    task_id: Optional[int] = None


@dataclass
class UpdatedResult:
    task_id: int
    old_text: str
    new_text: Optional[str] = None
    new_project: Optional[str] = None
    new_deadline: Optional[date] = None
    new_deadline_kind: Optional[DeadlineKind] = None


@dataclass
class ClosedResult:
    task_id: int
    text: str
    project: str


@dataclass
class CancelledResult:
    task_id: int
    text: str
    project: str


@dataclass
class ListedResult:
    filter_name: str
    period: Optional[str]
    tasks: list  # list[Task]


@dataclass
class DeferredResult:
    count: int


@dataclass
class ClarifyResult:
    question: str
    options: list[str] = field(default_factory=list)


@dataclass
class RejectedResult:
    reason: str


@dataclass
class ErrorResult:
    message: str
```

- [ ] **Step 5: Запустить — должны пройти**

Run: `pytest tests/conversational/test_types.py -v`
Expected: PASS

- [ ] **Step 6: Коммит**

```bash
git add src/tasks_bot/conversational/ tests/conversational/__init__.py tests/conversational/test_types.py
git commit -m "feat: scaffold conversational package + ActionResult types"
```

---

### Task 3: Tool `create_task`

**Files:**
- Create: `src/tasks_bot/conversational/tools.py`
- Test: `tests/conversational/test_tools.py`

- [ ] **Step 1: Написать failing test**

```python
import pytest
from datetime import date, datetime
from tasks_bot.models import TaskStatus, DeadlineKind
from tasks_bot.conversational.tools import call_tool
from tasks_bot.conversational.types import CreatedResult, ErrorResult


async def test_create_task_inserts_confirmed_task(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    result = await call_tool(
        db,
        name="create_task",
        args={"text": "позвонить Ане", "project": "life",
              "deadline_kind": "date", "deadline": "2026-05-11"},
        today=today, now=now,
    )
    assert isinstance(result, CreatedResult)
    assert result.text == "позвонить Ане"
    assert result.project == "life"
    assert result.deadline == date(2026, 5, 11)
    assert result.task_id is not None
    task = await db.get_task(result.task_id)
    assert task.status == TaskStatus.CONFIRMED
    assert task.deadline_kind == DeadlineKind.DATE


async def test_create_task_with_no_deadline(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    result = await call_tool(
        db, name="create_task",
        args={"text": "идея для канала", "project": "channel",
              "deadline_kind": "none"},
        today=today, now=now,
    )
    assert isinstance(result, CreatedResult)
    assert result.deadline is None
    task = await db.get_task(result.task_id)
    assert task.deadline_kind == DeadlineKind.NONE
    assert task.deadline is None
```

- [ ] **Step 2: Запустить тест — должен упасть**

Run: `pytest tests/conversational/test_tools.py -v`
Expected: FAIL — `tasks_bot.conversational.tools` не существует.

- [ ] **Step 3: Создать `src/tasks_bot/conversational/tools.py` с минимальной реализацией `call_tool` + `_create_task`**

```python
from datetime import date, datetime
from typing import Any
from ..db import Database
from ..models import TaskStatus, DeadlineKind
from ..dates import nearest_sunday_inclusive
from .types import (
    CreatedResult, UpdatedResult, ClosedResult, CancelledResult,
    ListedResult, DeferredResult, ClarifyResult, RejectedResult, ErrorResult,
)


def _parse_deadline(args: dict, today: date) -> tuple[date | None, DeadlineKind]:
    kind_str = args.get("deadline_kind", "none")
    try:
        kind = DeadlineKind(kind_str)
    except ValueError:
        kind = DeadlineKind.NONE
    if kind == DeadlineKind.NONE:
        return None, DeadlineKind.NONE
    if kind == DeadlineKind.WEEK:
        return nearest_sunday_inclusive(today), DeadlineKind.WEEK
    deadline_str = args.get("deadline")
    if deadline_str:
        try:
            return date.fromisoformat(deadline_str), DeadlineKind.DATE
        except ValueError:
            return None, DeadlineKind.NONE
    return None, DeadlineKind.NONE


async def _create_task(db: Database, args: dict, today: date,
                        now: datetime) -> CreatedResult | ErrorResult:
    text = (args.get("text") or "").strip()
    project = args.get("project", "_inbox")
    if not text:
        return ErrorResult(message="create_task: empty text")
    deadline, kind = _parse_deadline(args, today)
    tid = await db.insert_task(
        text=text, project=project, deadline=deadline,
        deadline_kind=kind, status=TaskStatus.CONFIRMED, now=now,
    )
    return CreatedResult(text=text, project=project, deadline=deadline,
                          deadline_kind=kind, task_id=tid)


async def call_tool(db: Database, name: str, args: dict,
                     today: date, now: datetime):
    if name == "create_task":
        return await _create_task(db, args, today, now)
    return ErrorResult(message=f"unknown tool: {name}")
```

- [ ] **Step 4: Запустить тесты — должны пройти**

Run: `pytest tests/conversational/test_tools.py -v`
Expected: PASS (2 теста)

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/conversational/tools.py tests/conversational/test_tools.py
git commit -m "feat: add create_task tool"
```

---

### Task 4: Tool `update_task`

**Files:**
- Modify: `src/tasks_bot/conversational/tools.py`
- Modify: `tests/conversational/test_tools.py`

- [ ] **Step 1: Дописать тесты в `tests/conversational/test_tools.py`**

```python
async def test_update_task_changes_text(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    tid = await db.insert_task("старое", "life", None, DeadlineKind.NONE,
                                 TaskStatus.CONFIRMED, now)
    from tasks_bot.conversational.types import UpdatedResult
    result = await call_tool(db, name="update_task",
                              args={"task_id": tid, "text": "новое"},
                              today=today, now=now)
    assert isinstance(result, UpdatedResult)
    assert result.old_text == "старое"
    assert result.new_text == "новое"
    task = await db.get_task(tid)
    assert task.text == "новое"


async def test_update_task_changes_project_and_deadline(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    tid = await db.insert_task("задача", "_inbox", None, DeadlineKind.NONE,
                                 TaskStatus.CONFIRMED, now)
    result = await call_tool(db, name="update_task",
                              args={"task_id": tid, "project": "mars",
                                    "deadline_kind": "date",
                                    "deadline": "2026-05-15"},
                              today=today, now=now)
    task = await db.get_task(tid)
    assert task.project == "mars"
    assert task.deadline == date(2026, 5, 15)
    assert task.deadline_kind == DeadlineKind.DATE


async def test_update_task_missing_id_returns_error(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    from tasks_bot.conversational.types import ErrorResult
    result = await call_tool(db, name="update_task",
                              args={"task_id": 999, "text": "x"},
                              today=today, now=now)
    assert isinstance(result, ErrorResult)
    assert "999" in result.message
```

- [ ] **Step 2: Запустить — упадёт**

Run: `pytest tests/conversational/test_tools.py -v`
Expected: FAIL — `update_task` не реализован.

- [ ] **Step 3: Реализовать `_update_task` и добавить в `call_tool`**

В `tools.py` добавить:

```python
async def _update_task(db: Database, args: dict, today: date,
                        now: datetime) -> UpdatedResult | ErrorResult:
    tid = args.get("task_id")
    if tid is None:
        return ErrorResult(message="update_task: missing task_id")
    try:
        task = await db.get_task(int(tid))
    except Exception:
        return ErrorResult(message=f"task {tid} not found")
    if task is None:
        return ErrorResult(message=f"task {tid} not found")

    new_text = args.get("text")
    new_project = args.get("project")
    if new_text:
        await db.update_text(tid, new_text, now=now)
    if new_project:
        await db.update_project(tid, new_project, now=now)

    new_deadline = None
    new_kind = None
    if "deadline_kind" in args:
        d, k = _parse_deadline(args, today)
        await db.update_deadline(tid, d, k, now=now)
        new_deadline, new_kind = d, k

    return UpdatedResult(task_id=tid, old_text=task.text,
                          new_text=new_text, new_project=new_project,
                          new_deadline=new_deadline,
                          new_deadline_kind=new_kind)
```

И в `call_tool` добавить ветку:

```python
    if name == "update_task":
        return await _update_task(db, args, today, now)
```

Убедиться, что `UpdatedResult` импортируется в начале файла (уже импортирован).

Также `db.get_task` возвращает Task или None? Проверить — если бросает на отсутствии, обернуть в try/except (как сделано выше).

- [ ] **Step 4: Запустить тесты — все должны пройти**

Run: `pytest tests/conversational/test_tools.py -v`
Expected: PASS (5 тестов)

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/conversational/tools.py tests/conversational/test_tools.py
git commit -m "feat: add update_task tool"
```

---

### Task 5: Tool `close_task` + `cancel_task`

**Files:**
- Modify: `src/tasks_bot/conversational/tools.py`
- Modify: `tests/conversational/test_tools.py`

- [ ] **Step 1: Дописать тесты**

```python
async def test_close_task_marks_done(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    tid = await db.insert_task("позвонить Ане", "life", None, DeadlineKind.NONE,
                                 TaskStatus.CONFIRMED, now)
    from tasks_bot.conversational.types import ClosedResult
    result = await call_tool(db, name="close_task",
                              args={"task_id": tid}, today=today, now=now)
    assert isinstance(result, ClosedResult)
    assert result.text == "позвонить Ане"
    task = await db.get_task(tid)
    assert task.status == TaskStatus.DONE


async def test_cancel_task_marks_cancelled(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    tid = await db.insert_task("идея", "channel", None, DeadlineKind.NONE,
                                 TaskStatus.CONFIRMED, now)
    from tasks_bot.conversational.types import CancelledResult
    result = await call_tool(db, name="cancel_task",
                              args={"task_id": tid}, today=today, now=now)
    assert isinstance(result, CancelledResult)
    task = await db.get_task(tid)
    assert task.status == TaskStatus.CANCELLED


async def test_close_nonexistent_returns_error(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    from tasks_bot.conversational.types import ErrorResult
    result = await call_tool(db, name="close_task",
                              args={"task_id": 999}, today=today, now=now)
    assert isinstance(result, ErrorResult)
```

- [ ] **Step 2: Запустить — упадёт**

Run: `pytest tests/conversational/test_tools.py -v -k "close or cancel"`
Expected: FAIL

- [ ] **Step 3: Реализовать**

Добавить в `tools.py`:

```python
async def _close_task(db: Database, args: dict, now: datetime):
    tid = args.get("task_id")
    if tid is None:
        return ErrorResult(message="close_task: missing task_id")
    try:
        task = await db.get_task(int(tid))
    except Exception:
        return ErrorResult(message=f"task {tid} not found")
    if task is None:
        return ErrorResult(message=f"task {tid} not found")
    await db.update_status(tid, TaskStatus.DONE, now=now)
    return ClosedResult(task_id=tid, text=task.text, project=task.project)


async def _cancel_task(db: Database, args: dict, now: datetime):
    tid = args.get("task_id")
    if tid is None:
        return ErrorResult(message="cancel_task: missing task_id")
    try:
        task = await db.get_task(int(tid))
    except Exception:
        return ErrorResult(message=f"task {tid} not found")
    if task is None:
        return ErrorResult(message=f"task {tid} not found")
    await db.update_status(tid, TaskStatus.CANCELLED, now=now)
    return CancelledResult(task_id=tid, text=task.text, project=task.project)
```

И в `call_tool`:

```python
    if name == "close_task":
        return await _close_task(db, args, now)
    if name == "cancel_task":
        return await _cancel_task(db, args, now)
```

- [ ] **Step 4: Запустить — все тесты пройти**

Run: `pytest tests/conversational/test_tools.py -v`
Expected: PASS

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/conversational/tools.py tests/conversational/test_tools.py
git commit -m "feat: add close_task + cancel_task tools"
```

---

### Task 6: Tool `list_tasks`

**Files:**
- Modify: `src/tasks_bot/conversational/tools.py`
- Modify: `tests/conversational/test_tools.py`

- [ ] **Step 1: Дописать тесты**

```python
async def test_list_today(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    await db.insert_task("сегодня", "life", today, DeadlineKind.DATE,
                          TaskStatus.CONFIRMED, now)
    await db.insert_task("завтра", "life", date(2026, 5, 11), DeadlineKind.DATE,
                          TaskStatus.CONFIRMED, now)
    from tasks_bot.conversational.types import ListedResult
    result = await call_tool(db, name="list_tasks",
                              args={"filter": "today"},
                              today=today, now=now)
    assert isinstance(result, ListedResult)
    assert result.filter_name == "today"
    assert len(result.tasks) == 1
    assert result.tasks[0].text == "сегодня"


async def test_list_inbox(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    await db.insert_task("без срока", "_inbox", None, DeadlineKind.NONE,
                          TaskStatus.CONFIRMED, now)
    result = await call_tool(db, name="list_tasks",
                              args={"filter": "inbox"},
                              today=today, now=now)
    assert len(result.tasks) == 1


async def test_list_done_today(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    tid = await db.insert_task("сделано", "life", None, DeadlineKind.NONE,
                                 TaskStatus.CONFIRMED, now)
    await db.update_status(tid, TaskStatus.DONE, now=now)
    result = await call_tool(db, name="list_tasks",
                              args={"filter": "done", "period": "today"},
                              today=today, now=now)
    assert result.period == "today"
    assert len(result.tasks) == 1
```

- [ ] **Step 2: Запустить — упадёт**

Run: `pytest tests/conversational/test_tools.py -v -k list`
Expected: FAIL

- [ ] **Step 3: Реализовать**

```python
async def _list_tasks(db: Database, args: dict, today: date):
    filt = args.get("filter")
    period = args.get("period")
    if filt == "today":
        tasks = await db.fetch_due_today(today)
    elif filt == "inbox":
        tasks = await db.fetch_no_deadline()
    elif filt == "overdue":
        tasks = await db.fetch_overdue(today)
    elif filt == "done":
        if period == "today" or period is None:
            tasks = await db.fetch_done_today(today)
        else:
            return ErrorResult(message=f"list_tasks: period {period!r} not yet supported")
    else:
        return ErrorResult(message=f"list_tasks: unknown filter {filt!r}")
    return ListedResult(filter_name=filt, period=period, tasks=tasks)
```

И в `call_tool`:

```python
    if name == "list_tasks":
        return await _list_tasks(db, args, today)
```

- [ ] **Step 4: Запустить тесты**

Run: `pytest tests/conversational/test_tools.py -v`
Expected: PASS

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/conversational/tools.py tests/conversational/test_tools.py
git commit -m "feat: add list_tasks tool"
```

---

### Task 7: Tool `defer_today_to_tomorrow`, `clarify`, `reject`

**Files:**
- Modify: `src/tasks_bot/conversational/tools.py`
- Modify: `tests/conversational/test_tools.py`

- [ ] **Step 1: Тесты**

```python
async def test_defer_today_moves_tasks_to_tomorrow(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    await db.insert_task("a", "life", today, DeadlineKind.DATE,
                          TaskStatus.CONFIRMED, now)
    await db.insert_task("b", "life", today, DeadlineKind.DATE,
                          TaskStatus.CONFIRMED, now)
    from tasks_bot.conversational.types import DeferredResult
    result = await call_tool(db, name="defer_today_to_tomorrow",
                              args={}, today=today, now=now)
    assert isinstance(result, DeferredResult)
    assert result.count == 2


async def test_clarify_returns_question_and_options(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    from tasks_bot.conversational.types import ClarifyResult
    result = await call_tool(db, name="clarify",
                              args={"question": "какую закрыть?",
                                    "options": ["Аня", "Лена"]},
                              today=today, now=now)
    assert isinstance(result, ClarifyResult)
    assert result.question == "какую закрыть?"
    assert result.options == ["Аня", "Лена"]


async def test_reject_returns_reason(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    from tasks_bot.conversational.types import RejectedResult
    result = await call_tool(db, name="reject",
                              args={"reason": "только про задачи"},
                              today=today, now=now)
    assert isinstance(result, RejectedResult)
    assert "только про задачи" in result.reason
```

- [ ] **Step 2: Запустить — упадёт**

Run: `pytest tests/conversational/test_tools.py -v`
Expected: FAIL на трёх новых.

- [ ] **Step 3: Реализовать ветки**

```python
async def _defer_today(db: Database, today: date, now: datetime):
    count = await db.defer_today_to_tomorrow(today, now=now)
    return DeferredResult(count=count)


def _clarify(args: dict):
    return ClarifyResult(
        question=(args.get("question") or "").strip(),
        options=list(args.get("options") or []),
    )


def _reject(args: dict):
    return RejectedResult(reason=(args.get("reason") or "").strip())
```

И в `call_tool`:

```python
    if name == "defer_today_to_tomorrow":
        return await _defer_today(db, today, now)
    if name == "clarify":
        return _clarify(args)
    if name == "reject":
        return _reject(args)
```

- [ ] **Step 4: Запустить — все 11 тестов пройти**

Run: `pytest tests/conversational/test_tools.py -v`
Expected: PASS

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/conversational/tools.py tests/conversational/test_tools.py
git commit -m "feat: add defer_today_to_tomorrow + clarify + reject tools"
```

---

### Task 8: Dispatcher для нескольких tool calls

**Files:**
- Modify: `src/tasks_bot/conversational/tools.py`
- Modify: `tests/conversational/test_tools.py`

- [ ] **Step 1: Тесты**

```python
async def test_dispatch_multiple_calls(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    tid = await db.insert_task("закрыть", "life", None, DeadlineKind.NONE,
                                 TaskStatus.CONFIRMED, now)
    from tasks_bot.conversational.tools import dispatch_tool_calls
    calls = [
        {"name": "create_task",
         "args": {"text": "новая", "project": "life", "deadline_kind": "none"}},
        {"name": "close_task", "args": {"task_id": tid}},
    ]
    results = await dispatch_tool_calls(db, calls, today=today, now=now)
    assert len(results) == 2
    from tasks_bot.conversational.types import CreatedResult, ClosedResult
    assert isinstance(results[0], CreatedResult)
    assert isinstance(results[1], ClosedResult)


async def test_dispatch_unknown_tool_returns_error_in_list(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    from tasks_bot.conversational.tools import dispatch_tool_calls
    from tasks_bot.conversational.types import ErrorResult
    results = await dispatch_tool_calls(
        db, [{"name": "make_coffee", "args": {}}], today=today, now=now,
    )
    assert len(results) == 1
    assert isinstance(results[0], ErrorResult)
```

- [ ] **Step 2: Запустить — упадёт на ImportError**

Run: `pytest tests/conversational/test_tools.py -v -k dispatch`
Expected: FAIL

- [ ] **Step 3: Реализовать**

В конец `tools.py`:

```python
async def dispatch_tool_calls(db: Database, calls: list[dict],
                                today: date, now: datetime) -> list:
    results = []
    for c in calls:
        name = c.get("name", "")
        args = c.get("args") or {}
        results.append(await call_tool(db, name, args, today=today, now=now))
    return results
```

- [ ] **Step 4: Запустить тесты**

Run: `pytest tests/conversational/test_tools.py -v`
Expected: PASS (13 тестов)

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/conversational/tools.py tests/conversational/test_tools.py
git commit -m "feat: add dispatch_tool_calls for multi-action messages"
```

---

### Task 9: JSON-схемы tools для OpenRouter

**Files:**
- Create: `src/tasks_bot/conversational/schemas.py`
- Create: `tests/conversational/test_schemas.py`

- [ ] **Step 1: Тест**

```python
from tasks_bot.conversational.schemas import TOOL_SCHEMAS


def test_all_tools_have_schemas():
    names = {t["function"]["name"] for t in TOOL_SCHEMAS}
    expected = {
        "create_task", "update_task", "close_task", "cancel_task",
        "list_tasks", "defer_today_to_tomorrow", "clarify", "reject",
    }
    assert names == expected


def test_schema_format_is_openai_compatible():
    for t in TOOL_SCHEMAS:
        assert t["type"] == "function"
        assert "name" in t["function"]
        assert "description" in t["function"]
        assert "parameters" in t["function"]
        assert t["function"]["parameters"]["type"] == "object"


def test_create_task_required_fields():
    create = next(t for t in TOOL_SCHEMAS
                   if t["function"]["name"] == "create_task")
    required = create["function"]["parameters"]["required"]
    assert "text" in required
    assert "project" in required
    assert "deadline_kind" in required
```

- [ ] **Step 2: Запустить — упадёт**

Run: `pytest tests/conversational/test_schemas.py -v`
Expected: FAIL — модуль не существует.

- [ ] **Step 3: Создать `src/tasks_bot/conversational/schemas.py`**

```python
"""OpenRouter (OpenAI-compatible) tool schemas for the conversational router."""

PROJECTS_ENUM = ["mars", "channel", "consulting", "life", "_inbox"]
DEADLINE_KINDS = ["date", "week", "none"]


def _function(name: str, description: str, parameters: dict) -> dict:
    return {"type": "function",
            "function": {"name": name, "description": description,
                          "parameters": parameters}}


CREATE_TASK = _function(
    "create_task",
    "Создать новую задачу. Используй при формулировках вроде "
    "«позвонить Ане завтра», «надо купить кошке корм».",
    {
        "type": "object",
        "properties": {
            "text": {"type": "string",
                      "description": "Текст задачи без указаний типа «надо»"},
            "project": {"type": "string", "enum": PROJECTS_ENUM},
            "deadline_kind": {"type": "string", "enum": DEADLINE_KINDS,
                                "description": "date — конкретная дата; week — на этой неделе; none — без срока"},
            "deadline": {"type": "string",
                          "description": "ISO date YYYY-MM-DD, обязательно при deadline_kind=date"},
        },
        "required": ["text", "project", "deadline_kind"],
    },
)

UPDATE_TASK = _function(
    "update_task",
    "Изменить текст, проект или срок задачи. task_id брать из списка открытых.",
    {
        "type": "object",
        "properties": {
            "task_id": {"type": "integer"},
            "text": {"type": "string"},
            "project": {"type": "string", "enum": PROJECTS_ENUM},
            "deadline_kind": {"type": "string", "enum": DEADLINE_KINDS},
            "deadline": {"type": "string"},
        },
        "required": ["task_id"],
    },
)

CLOSE_TASK = _function(
    "close_task",
    "Пометить задачу выполненной. Используй при «сделала», «готово», «закрыла».",
    {
        "type": "object",
        "properties": {"task_id": {"type": "integer"}},
        "required": ["task_id"],
    },
)

CANCEL_TASK = _function(
    "cancel_task",
    "Удалить (отменить) задачу. Используй при «удали», «отмени», «забей на».",
    {
        "type": "object",
        "properties": {"task_id": {"type": "integer"}},
        "required": ["task_id"],
    },
)

LIST_TASKS = _function(
    "list_tasks",
    "Показать сводку задач по фильтру.",
    {
        "type": "object",
        "properties": {
            "filter": {"type": "string",
                        "enum": ["today", "inbox", "overdue", "done"]},
            "period": {"type": "string",
                        "enum": ["today", "week", "all"],
                        "description": "Только для filter=done"},
        },
        "required": ["filter"],
    },
)

DEFER_TODAY = _function(
    "defer_today_to_tomorrow",
    "Перенести все сегодняшние задачи на завтра.",
    {"type": "object", "properties": {}},
)

CLARIFY = _function(
    "clarify",
    "Если запрос неоднозначен (несколько подходящих задач, неясный срок) — "
    "задай уточняющий вопрос. ОБЯЗАТЕЛЬНО используй при двусмысленности — "
    "никогда не угадывай между несколькими вариантами.",
    {
        "type": "object",
        "properties": {
            "question": {"type": "string"},
            "options": {"type": "array", "items": {"type": "string"}},
        },
        "required": ["question"],
    },
)

REJECT = _function(
    "reject",
    "Если запрос не про задачи (болтовня, вопросы вне темы) — "
    "вежливо откажи в одной фразе.",
    {
        "type": "object",
        "properties": {"reason": {"type": "string"}},
        "required": ["reason"],
    },
)


TOOL_SCHEMAS = [
    CREATE_TASK, UPDATE_TASK, CLOSE_TASK, CANCEL_TASK,
    LIST_TASKS, DEFER_TODAY, CLARIFY, REJECT,
]
```

- [ ] **Step 4: Запустить тесты**

Run: `pytest tests/conversational/test_schemas.py -v`
Expected: PASS

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/conversational/schemas.py tests/conversational/test_schemas.py
git commit -m "feat: define OpenRouter tool schemas (8 tools)"
```

---

### Task 10: Formatter — `format_response` для всех результатов

**Files:**
- Create: `src/tasks_bot/conversational/formatter.py`
- Create: `tests/conversational/test_formatter.py`

- [ ] **Step 1: Тесты**

```python
from datetime import date
from tasks_bot.models import DeadlineKind
from tasks_bot.conversational.formatter import format_response
from tasks_bot.conversational.types import (
    CreatedResult, UpdatedResult, ClosedResult, CancelledResult,
    DeferredResult, ClarifyResult, RejectedResult, ErrorResult,
)


def test_format_created_with_date():
    r = CreatedResult(text="позвонить Ане", project="life",
                       deadline=date(2026, 5, 11), deadline_kind=DeadlineKind.DATE,
                       task_id=42)
    out = format_response([r])
    assert "позвонить Ане" in out
    assert "life" in out
    assert "2026-05-11" in out


def test_format_created_no_deadline():
    r = CreatedResult(text="идея", project="channel",
                       deadline=None, deadline_kind=DeadlineKind.NONE,
                       task_id=1)
    out = format_response([r])
    assert "без срока" in out


def test_format_closed_shows_text_and_project():
    r = ClosedResult(task_id=5, text="написать пост", project="channel")
    out = format_response([r])
    assert "написать пост" in out
    assert "channel" in out


def test_format_cancelled_uses_trash_emoji():
    r = CancelledResult(task_id=5, text="идея про канал", project="channel")
    out = format_response([r])
    assert "🗑" in out
    assert "идея про канал" in out


def test_format_updated_shows_changes():
    r = UpdatedResult(task_id=5, old_text="старое", new_text="новое")
    out = format_response([r])
    assert "новое" in out


def test_format_clarify_with_options():
    r = ClarifyResult(question="какую?", options=["Аня", "Лена"])
    out = format_response([r])
    assert "какую?" in out
    assert "Аня" in out
    assert "Лена" in out


def test_format_reject():
    r = RejectedResult(reason="я только про задачи")
    out = format_response([r])
    assert "я только про задачи" in out


def test_format_error_visible():
    r = ErrorResult(message="task 999 not found")
    out = format_response([r])
    assert "⚠" in out
    assert "999" in out


def test_format_deferred_count():
    out = format_response([DeferredResult(count=3)])
    assert "3" in out


def test_format_multiple_groups_actions():
    items = [
        CreatedResult(text="a", project="life", deadline=None,
                       deadline_kind=DeadlineKind.NONE, task_id=1),
        ClosedResult(task_id=2, text="b", project="life"),
    ]
    out = format_response(items)
    assert "Создала" in out or "создала" in out.lower()
    assert "Закрыла" in out or "закрыла" in out.lower()
    assert "a" in out
    assert "b" in out
```

- [ ] **Step 2: Запустить — упадёт**

Run: `pytest tests/conversational/test_formatter.py -v`
Expected: FAIL — модуль отсутствует.

- [ ] **Step 3: Реализовать `formatter.py`**

```python
from datetime import date
from .types import (
    CreatedResult, UpdatedResult, ClosedResult, CancelledResult,
    ListedResult, DeferredResult, ClarifyResult, RejectedResult, ErrorResult,
)
from ..models import DeadlineKind


def _deadline_str(deadline, kind: DeadlineKind) -> str:
    if kind == DeadlineKind.NONE or deadline is None:
        return "без срока"
    if kind == DeadlineKind.WEEK:
        return f"на этой неделе (до {deadline.isoformat()})"
    return f"срок {deadline.isoformat()}"


def _fmt_created(r: CreatedResult) -> str:
    return f"  • {r.text} — {r.project}, {_deadline_str(r.deadline, r.deadline_kind)}"


def _fmt_closed(r: ClosedResult) -> str:
    return f"  • {r.text} — {r.project}"


def _fmt_cancelled(r: CancelledResult) -> str:
    return f"  • {r.text} — {r.project}"


def _fmt_updated(r: UpdatedResult) -> str:
    parts = []
    if r.new_text:
        parts.append(f"текст: «{r.new_text}»")
    if r.new_project:
        parts.append(f"проект: {r.new_project}")
    if r.new_deadline_kind is not None:
        parts.append(_deadline_str(r.new_deadline, r.new_deadline_kind))
    body = ", ".join(parts) if parts else "без изменений"
    return f"  • #{r.task_id} ({r.old_text}) → {body}"


def _fmt_listed(r: ListedResult) -> str:
    if not r.tasks:
        return f"  (пусто в фильтре {r.filter_name})"
    lines = []
    for t in r.tasks:
        d = _deadline_str(t.deadline, t.deadline_kind)
        lines.append(f"  • {t.text} — {t.project}, {d}")
    return "\n".join(lines)


def format_response(results: list) -> str:
    if not results:
        return "🤷 Не поняла, переформулируй или используй /команды."

    groups: dict[str, list[str]] = {}

    def add(key: str, line: str):
        groups.setdefault(key, []).append(line)

    for r in results:
        if isinstance(r, CreatedResult):
            add("Создала задачу:", _fmt_created(r))
        elif isinstance(r, ClosedResult):
            add("✅ Закрыла:", _fmt_closed(r))
        elif isinstance(r, CancelledResult):
            add("🗑 Удалила:", _fmt_cancelled(r))
        elif isinstance(r, UpdatedResult):
            add("✏️ Изменила:", _fmt_updated(r))
        elif isinstance(r, ListedResult):
            label = f"📋 {r.filter_name}"
            if r.period:
                label += f" ({r.period})"
            add(label + ":", _fmt_listed(r))
        elif isinstance(r, DeferredResult):
            add("📅 Перенесла на завтра:", f"  • {r.count} задач(и)")
        elif isinstance(r, ClarifyResult):
            block = [r.question]
            for opt in r.options:
                block.append(f"  • {opt}")
            add("❓", "\n".join(block))
        elif isinstance(r, RejectedResult):
            add("", r.reason)
        elif isinstance(r, ErrorResult):
            add("⚠️", r.message)

    out_blocks = []
    for header, lines in groups.items():
        chunk = "\n".join(lines)
        out_blocks.append(f"{header}\n{chunk}" if header else chunk)
    return "\n\n".join(out_blocks)
```

- [ ] **Step 4: Запустить тесты — должны пройти**

Run: `pytest tests/conversational/test_formatter.py -v`
Expected: PASS (10 тестов)

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/conversational/formatter.py tests/conversational/test_formatter.py
git commit -m "feat: add response formatter (detailed style)"
```

---

### Task 11: Context builder

**Files:**
- Create: `src/tasks_bot/conversational/context.py`
- Create: `tests/conversational/test_context.py`

- [ ] **Step 1: Тесты**

```python
import pytest
from datetime import date, datetime
from tasks_bot.models import TaskStatus, DeadlineKind
from tasks_bot.conversational.context import build_context


async def test_context_has_today_and_open_tasks(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    await db.insert_task("задача 1", "life", today, DeadlineKind.DATE,
                          TaskStatus.CONFIRMED, now)
    await db.insert_task("задача 2", "_inbox", None, DeadlineKind.NONE,
                          TaskStatus.CONFIRMED, now)
    ctx = await build_context(db, message_text="что у меня?",
                                reply_text=None, today=today)
    assert today.isoformat() in ctx
    assert "задача 1" in ctx
    assert "задача 2" in ctx
    assert "что у меня?" in ctx


async def test_context_excludes_done_and_cancelled(db):
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    tid_done = await db.insert_task("сделано", "life", None, DeadlineKind.NONE,
                                      TaskStatus.CONFIRMED, now)
    await db.update_status(tid_done, TaskStatus.DONE, now=now)
    tid_cancel = await db.insert_task("отменено", "life", None, DeadlineKind.NONE,
                                        TaskStatus.CONFIRMED, now)
    await db.update_status(tid_cancel, TaskStatus.CANCELLED, now=now)
    await db.insert_task("активная", "life", None, DeadlineKind.NONE,
                          TaskStatus.CONFIRMED, now)
    ctx = await build_context(db, "x", None, today=today)
    assert "активная" in ctx
    assert "сделано" not in ctx
    assert "отменено" not in ctx


async def test_context_includes_reply_text(db):
    today = date(2026, 5, 10)
    ctx = await build_context(
        db, message_text="сделала первую и третью",
        reply_text="📋 сегодня:\n1) позвонить Ане\n2) пост\n3) Лене",
        today=today,
    )
    assert "позвонить Ане" in ctx
    assert "Лене" in ctx
    assert "сделала первую и третью" in ctx


async def test_context_no_reply_omits_reply_block(db):
    today = date(2026, 5, 10)
    ctx = await build_context(db, "x", None, today=today)
    assert "Reply" not in ctx and "Цитата" not in ctx
```

- [ ] **Step 2: Запустить — упадёт**

Run: `pytest tests/conversational/test_context.py -v`
Expected: FAIL — модуль отсутствует.

- [ ] **Step 3: Реализовать**

```python
from datetime import date
from ..db import Database
from ..models import TaskStatus


async def build_context(db: Database, message_text: str,
                          reply_text: str | None, today: date) -> str:
    """Render dynamic context (date + open tasks + optional reply + user msg)
    as a plain-text block for the user message in OpenRouter call."""
    lines: list[str] = []
    lines.append(f"Сегодня: {today.isoformat()} ({today.strftime('%A')})")
    lines.append("")
    lines.append("Открытые задачи:")
    rows = await db.fetchall(
        "SELECT id, text, project, deadline, deadline_kind FROM tasks "
        "WHERE status = ? ORDER BY id",
        (TaskStatus.CONFIRMED.value,),
    )
    if not rows:
        lines.append("  (нет открытых задач)")
    else:
        for r in rows:
            d = r["deadline"] or "—"
            kind = r["deadline_kind"]
            lines.append(
                f"  id={r['id']} project={r['project']} "
                f"deadline={d}({kind}) text={r['text']}"
            )
    if reply_text:
        lines.append("")
        lines.append("Цитата сообщения, на которое сделан reply:")
        lines.append(reply_text)
    lines.append("")
    lines.append("Сообщение пользователя:")
    lines.append(message_text)
    return "\n".join(lines)
```

- [ ] **Step 4: Запустить тесты**

Run: `pytest tests/conversational/test_context.py -v`
Expected: PASS

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/conversational/context.py tests/conversational/test_context.py
git commit -m "feat: add context builder (open tasks + reply-as-context)"
```

---

### Task 12: System prompt

**Files:**
- Create: `src/tasks_bot/conversational/prompt.py`
- Create: `tests/conversational/test_prompt.py`

- [ ] **Step 1: Тесты**

```python
from tasks_bot.conversational.prompt import SYSTEM_PROMPT


def test_prompt_lists_all_projects():
    for p in ("mars", "channel", "consulting", "life", "_inbox"):
        assert p in SYSTEM_PROMPT


def test_prompt_mentions_clarify_rule():
    assert "clarify" in SYSTEM_PROMPT
    # «никогда не угадывай» или эквивалент должно быть явно
    assert "не угадыв" in SYSTEM_PROMPT.lower()


def test_prompt_mentions_reject_for_offtopic():
    assert "reject" in SYSTEM_PROMPT
    assert "только про задач" in SYSTEM_PROMPT.lower()


def test_prompt_explains_reply_context():
    assert "reply" in SYSTEM_PROMPT.lower() or "цитат" in SYSTEM_PROMPT.lower()
```

- [ ] **Step 2: Запустить — упадёт**

Run: `pytest tests/conversational/test_prompt.py -v`
Expected: FAIL.

- [ ] **Step 3: Реализовать**

```python
SYSTEM_PROMPT = """\
Ты ассистент Наташи в Telegram-боте задач. Помогаешь создавать, редактировать,
закрывать и удалять задачи. Отвечаешь только через вызовы инструментов
(tool calls) — не пишешь свободный текст.

Проекты:
- mars — Школа музыки Марс (ученики, педагоги, концерты, расписание, набор, реклама)
- channel — Telegram-канал «Начальник тоже человек» (посты, идеи, темы)
- consulting — Консалтинг 1-1 (клиенты, подготовка, разборы)
- life — Личное (быт, здоровье, кот, родственники, покупки)
- _inbox — если непонятно куда

Правила работы со сроками:
- "сегодня" → deadline_kind=date, deadline=сегодняшняя дата (есть в контексте)
- "завтра" → deadline_kind=date, deadline=завтра
- день недели или конкретная дата → deadline_kind=date
- "на этой неделе", "к выходным" → deadline_kind=week (deadline можно опустить)
- ничего про срок не сказано → deadline_kind=none

Сопоставление задач при упоминании ("сделала позвонить Ане", "удали идею про канал"):
1. Ищи семантически похожие в списке открытых задач (он есть в контексте).
2. Если ровно одна совпадает — вызывай нужный инструмент с её id.
3. Если несколько совпадают — ОБЯЗАТЕЛЬНО вызови clarify с вопросом и списком кандидатов. Никогда не угадывай между двумя или более задачами — это критическое правило безопасности.
4. Если ноль совпадает — вызови reject с пояснением «не нашла такую задачу».

Reply-контекст:
Если пользователь делает reply на сообщение бота (цитата в контексте), это сообщение
обычно — последняя сводка задач с нумерацией. Используй её, чтобы понять «первую и
третью», «эту вторую» и т.п. Сопоставляй номер из цитаты с текстом задачи в цитате,
а текст — с id из списка открытых.

Off-topic:
Если пользователь пишет не про задачи (болтовня, вопросы по другим темам) —
вызывай reject с фразой вроде «я только про задачи и календарь умею».

Множественные действия:
Если в одном сообщении пользователь просит несколько вещей — вызывай несколько
tools последовательно (например create_task + close_task в одном ответе).
"""
```

- [ ] **Step 4: Запустить — все 4 теста пройти**

Run: `pytest tests/conversational/test_prompt.py -v`
Expected: PASS

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/conversational/prompt.py tests/conversational/test_prompt.py
git commit -m "feat: define system prompt with strict clarify rule"
```

---

### Task 13: OpenRouter client (HTTP wrapper)

**Files:**
- Create: `src/tasks_bot/conversational/openrouter.py`
- Create: `tests/conversational/test_openrouter.py`

- [ ] **Step 1: Тесты — мок httpx**

```python
import pytest
import httpx
from tasks_bot.conversational.openrouter import call_intent_model, parse_tool_calls


def _mock_response(payload: dict, status: int = 200):
    return httpx.Response(status, json=payload)


async def test_call_returns_tool_calls(monkeypatch):
    captured = {}

    class FakeClient:
        def __init__(self, *a, **kw): captured["init"] = kw
        async def __aenter__(self): return self
        async def __aexit__(self, *a): pass
        async def post(self, url, headers=None, json=None):
            captured["url"] = url
            captured["body"] = json
            return _mock_response({
                "choices": [{
                    "message": {
                        "content": None,
                        "tool_calls": [{
                            "id": "1", "type": "function",
                            "function": {
                                "name": "create_task",
                                "arguments": '{"text":"x","project":"life","deadline_kind":"none"}',
                            },
                        }],
                    },
                }],
            })

    monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
    response = await call_intent_model(
        api_key="k", model="anthropic/claude-haiku-4.5",
        system_prompt="sys", user_message="hi", tools=[{"x": 1}],
    )
    calls = parse_tool_calls(response)
    assert len(calls) == 1
    assert calls[0]["name"] == "create_task"
    assert calls[0]["args"]["text"] == "x"
    assert captured["body"]["model"] == "anthropic/claude-haiku-4.5"


async def test_parse_tool_calls_empty_when_no_calls():
    response = {"choices": [{"message": {"content": "hello"}}]}
    assert parse_tool_calls(response) == []


async def test_parse_tool_calls_skips_malformed_args():
    response = {"choices": [{"message": {"tool_calls": [
        {"function": {"name": "x", "arguments": "{not valid json"}},
        {"function": {"name": "y", "arguments": '{"a":1}'}},
    ]}}]}
    calls = parse_tool_calls(response)
    assert len(calls) == 1
    assert calls[0]["name"] == "y"
```

- [ ] **Step 2: Запустить — упадёт**

Run: `pytest tests/conversational/test_openrouter.py -v`
Expected: FAIL.

- [ ] **Step 3: Реализовать**

```python
import json
import httpx


OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"


async def call_intent_model(api_key: str, model: str,
                              system_prompt: str, user_message: str,
                              tools: list, timeout: float = 30.0) -> dict:
    async with httpx.AsyncClient(timeout=timeout) as client:
        resp = await client.post(
            OPENROUTER_URL,
            headers={"Authorization": f"Bearer {api_key}",
                      "Content-Type": "application/json"},
            json={
                "model": model,
                "messages": [
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": user_message},
                ],
                "tools": tools,
                "tool_choice": "auto",
                "temperature": 0.0,
            },
        )
        resp.raise_for_status()
        return resp.json()


def parse_tool_calls(response: dict) -> list[dict]:
    """Extract list of {name, args} from OpenAI-format response."""
    try:
        msg = response["choices"][0]["message"]
    except (KeyError, IndexError, TypeError):
        return []
    raw_calls = msg.get("tool_calls") or []
    out = []
    for c in raw_calls:
        fn = c.get("function") or {}
        name = fn.get("name")
        args_raw = fn.get("arguments", "{}")
        if not name:
            continue
        try:
            args = json.loads(args_raw) if isinstance(args_raw, str) else args_raw
        except json.JSONDecodeError:
            continue
        out.append({"name": name, "args": args or {}})
    return out
```

- [ ] **Step 4: Запустить тесты**

Run: `pytest tests/conversational/test_openrouter.py -v`
Expected: PASS

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/conversational/openrouter.py tests/conversational/test_openrouter.py
git commit -m "feat: add OpenRouter intent-model client"
```

---

### Task 14: Router — happy path

**Files:**
- Create: `src/tasks_bot/conversational/router.py`
- Create: `tests/conversational/test_router.py`

- [ ] **Step 1: Тест happy path с моком LLM**

```python
import pytest
from datetime import date, datetime
from tasks_bot.conversational.router import route_message
from tasks_bot.models import TaskStatus, DeadlineKind


async def test_router_creates_task_via_mocked_llm(db, monkeypatch):
    async def fake_call(api_key, model, system_prompt, user_message, tools,
                          timeout=30.0):
        return {"choices": [{"message": {"tool_calls": [{
            "function": {"name": "create_task",
                          "arguments": '{"text":"позвонить Ане","project":"life","deadline_kind":"date","deadline":"2026-05-11"}'},
        }]}}]}

    monkeypatch.setattr("tasks_bot.conversational.router.call_intent_model",
                          fake_call)

    out = await route_message(
        db=db, api_key="k", model="claude-haiku-4.5",
        message_text="позвонить Ане завтра", reply_text=None,
        today=date(2026, 5, 10), now=datetime(2026, 5, 10, 14),
    )
    assert "позвонить Ане" in out
    assert "life" in out
    rows = await db.fetchall("SELECT * FROM tasks WHERE status = ?",
                                (TaskStatus.CONFIRMED.value,))
    assert len(rows) == 1
```

- [ ] **Step 2: Запустить — упадёт**

Run: `pytest tests/conversational/test_router.py -v`
Expected: FAIL — модуль отсутствует.

- [ ] **Step 3: Реализовать `router.py`**

```python
from datetime import date, datetime
import logging

from ..db import Database
from .openrouter import call_intent_model, parse_tool_calls
from .schemas import TOOL_SCHEMAS
from .prompt import SYSTEM_PROMPT
from .context import build_context
from .tools import dispatch_tool_calls
from .formatter import format_response

log = logging.getLogger(__name__)


async def route_message(db: Database, api_key: str, model: str,
                          message_text: str, reply_text: str | None,
                          today: date, now: datetime) -> str:
    user_payload = await build_context(db, message_text, reply_text, today)
    try:
        response = await call_intent_model(
            api_key=api_key, model=model,
            system_prompt=SYSTEM_PROMPT, user_message=user_payload,
            tools=TOOL_SCHEMAS,
        )
    except Exception:
        log.exception("OpenRouter call failed")
        return ("⚠️ Не могу связаться с моделью. Попробуй ещё раз через минуту "
                "или используй /команды.")

    calls = parse_tool_calls(response)
    if not calls:
        return ("🤷 Не поняла, переформулируй или используй /команды.")

    results = await dispatch_tool_calls(db, calls, today=today, now=now)
    return format_response(results)
```

- [ ] **Step 4: Запустить тест**

Run: `pytest tests/conversational/test_router.py -v`
Expected: PASS

- [ ] **Step 5: Коммит**

```bash
git add src/tasks_bot/conversational/router.py tests/conversational/test_router.py
git commit -m "feat: router happy path with mocked OpenRouter"
```

---

### Task 15: Router — multi-action, clarify, reject

**Files:**
- Modify: `tests/conversational/test_router.py`

- [ ] **Step 1: Дописать сценарии**

```python
async def test_router_multi_action(db, monkeypatch):
    now = datetime(2026, 5, 10, 14)
    today = date(2026, 5, 10)
    tid = await db.insert_task("закрыть это", "life", None, DeadlineKind.NONE,
                                 TaskStatus.CONFIRMED, now)

    async def fake(*a, **kw):
        return {"choices": [{"message": {"tool_calls": [
            {"function": {"name": "create_task",
                           "arguments": '{"text":"новая","project":"life","deadline_kind":"none"}'}},
            {"function": {"name": "close_task",
                           "arguments": '{"task_id": ' + str(tid) + '}'}},
        ]}}]}

    monkeypatch.setattr("tasks_bot.conversational.router.call_intent_model", fake)
    out = await route_message(db, "k", "m", "сделала и заведи", None, today, now)
    assert "новая" in out
    assert "закрыть это" in out


async def test_router_clarify_returned_to_user(db, monkeypatch):
    today = date(2026, 5, 10)
    now = datetime(2026, 5, 10, 14)

    async def fake(*a, **kw):
        return {"choices": [{"message": {"tool_calls": [{
            "function": {"name": "clarify",
                          "arguments": '{"question":"какую закрыть?","options":["Аня","Лена"]}'},
        }]}}]}

    monkeypatch.setattr("tasks_bot.conversational.router.call_intent_model", fake)
    out = await route_message(db, "k", "m", "сделала позвонить", None, today, now)
    assert "какую закрыть?" in out
    assert "Аня" in out and "Лена" in out


async def test_router_reject_for_offtopic(db, monkeypatch):
    today = date(2026, 5, 10)
    now = datetime(2026, 5, 10, 14)

    async def fake(*a, **kw):
        return {"choices": [{"message": {"tool_calls": [{
            "function": {"name": "reject",
                          "arguments": '{"reason":"я только про задачи"}'},
        }]}}]}

    monkeypatch.setattr("tasks_bot.conversational.router.call_intent_model", fake)
    out = await route_message(db, "k", "m", "как дела?", None, today, now)
    assert "только про задачи" in out


async def test_router_ambiguity_safety_when_two_match(db, monkeypatch):
    """Сценарий безопасности: две задачи про Аню → промпт обязывает clarify."""
    now = datetime(2026, 5, 10, 14)
    today = date(2026, 5, 10)
    await db.insert_task("позвонить Ане", "life", None, DeadlineKind.NONE,
                          TaskStatus.CONFIRMED, now)
    await db.insert_task("написать Ане", "life", None, DeadlineKind.NONE,
                          TaskStatus.CONFIRMED, now)

    async def fake(*a, **kw):
        # имитируем правильное поведение модели — clarify
        return {"choices": [{"message": {"tool_calls": [{
            "function": {"name": "clarify",
                          "arguments": '{"question":"какую закрыть?","options":["позвонить Ане","написать Ане"]}'},
        }]}}]}

    monkeypatch.setattr("tasks_bot.conversational.router.call_intent_model", fake)
    out = await route_message(db, "k", "m", "сделала Ане", None, today, now)
    assert "какую" in out.lower()
    rows = await db.fetchall("SELECT status FROM tasks WHERE status = ?",
                                (TaskStatus.DONE.value,))
    assert len(rows) == 0  # ничего не закрылось
```

(Импорты `from datetime import date, datetime` и `TaskStatus, DeadlineKind` уже в файле.)

- [ ] **Step 2: Запустить — должны пройти (роутер уже умеет передавать список)**

Run: `pytest tests/conversational/test_router.py -v`
Expected: PASS (5 тестов)

- [ ] **Step 3: Коммит**

```bash
git add tests/conversational/test_router.py
git commit -m "test: router multi-action, clarify, reject, ambiguity-safety"
```

---

### Task 16: Router — fallbacks (LLM error / malformed)

**Files:**
- Modify: `tests/conversational/test_router.py`

- [ ] **Step 1: Тесты на ошибки**

```python
async def test_router_returns_friendly_message_on_openrouter_error(db, monkeypatch):
    today = date(2026, 5, 10)
    now = datetime(2026, 5, 10, 14)

    async def fake(*a, **kw):
        raise Exception("network down")

    monkeypatch.setattr("tasks_bot.conversational.router.call_intent_model", fake)
    out = await route_message(db, "k", "m", "что-то", None, today, now)
    assert "⚠️" in out
    assert "/команды" in out


async def test_router_on_no_tool_calls_returns_clarify_message(db, monkeypatch):
    today = date(2026, 5, 10)
    now = datetime(2026, 5, 10, 14)

    async def fake(*a, **kw):
        # модель вернула просто текст без tool calls
        return {"choices": [{"message": {"content": "Не уверен..."}}]}

    monkeypatch.setattr("tasks_bot.conversational.router.call_intent_model", fake)
    out = await route_message(db, "k", "m", "x", None, today, now)
    assert "🤷" in out or "переформулируй" in out.lower()
```

- [ ] **Step 2: Запустить — должны пройти (router уже это умеет)**

Run: `pytest tests/conversational/test_router.py -v`
Expected: PASS

- [ ] **Step 3: Коммит**

```bash
git add tests/conversational/test_router.py
git commit -m "test: router fallbacks for LLM error and malformed response"
```

---

### Task 17: Интеграция в `bot.py` — флаг роутит свободный текст

**Files:**
- Modify: `src/tasks_bot/bot.py:173-181`
- Modify: `src/tasks_bot/main.py`
- Create: `tests/test_bot_conversational.py`

- [ ] **Step 1: Тесты на роутинг по флагу**

```python
import pytest
from datetime import datetime, date
from unittest.mock import AsyncMock, MagicMock
from tasks_bot.bot import handle_text


@pytest.fixture
def app_context_factory(db):
    def make(conversational_enabled: bool):
        ctx = MagicMock()
        ctx.application.bot_data = {
            "owner_id": 1,
            "db": db,
            "conversational_enabled": conversational_enabled,
        }
        return ctx
    return make


async def test_handle_text_uses_old_path_when_flag_off(db, app_context_factory,
                                                         monkeypatch):
    # Старый путь дёргает classify(text, today)
    called = {}

    async def fake_classify(text, today):
        called["legacy"] = True
        from tasks_bot.classifier import ClassificationResult
        from tasks_bot.models import DeadlineKind
        return ClassificationResult(project="life", deadline=None,
                                      deadline_kind=DeadlineKind.NONE)

    monkeypatch.setattr("tasks_bot.bot.classify", fake_classify)

    update = MagicMock()
    update.effective_user.id = 1
    update.message.text = "позвонить"
    update.message.reply_to_message = None
    update.message.reply_text = AsyncMock()

    ctx = app_context_factory(conversational_enabled=False)
    await handle_text(update, ctx)
    assert called.get("legacy") is True


async def test_handle_text_uses_router_when_flag_on(db, app_context_factory,
                                                       monkeypatch):
    called = {}

    async def fake_route(**kw):
        called["routed"] = True
        called["reply_text"] = kw.get("reply_text")
        return "🤖 ok"

    monkeypatch.setattr("tasks_bot.bot.route_message", fake_route)

    update = MagicMock()
    update.effective_user.id = 1
    update.message.text = "позвонить Ане завтра"
    update.message.reply_to_message = None
    update.message.reply_text = AsyncMock()

    ctx = app_context_factory(conversational_enabled=True)
    await handle_text(update, ctx)
    assert called.get("routed") is True
    update.message.reply_text.assert_called_once()


async def test_handle_text_passes_reply_text_when_present(db, app_context_factory,
                                                            monkeypatch):
    captured = {}

    async def fake_route(**kw):
        captured.update(kw)
        return "ok"

    monkeypatch.setattr("tasks_bot.bot.route_message", fake_route)

    update = MagicMock()
    update.effective_user.id = 1
    update.message.text = "сделала первую"
    update.message.reply_to_message = MagicMock()
    update.message.reply_to_message.text = "📋 сегодня: 1) Аня"
    update.message.reply_text = AsyncMock()

    ctx = app_context_factory(conversational_enabled=True)
    await handle_text(update, ctx)
    assert "Аня" in captured["reply_text"]
```

- [ ] **Step 2: Запустить — упадёт (нет ветки роутинга, нет импорта `route_message`)**

Run: `pytest tests/test_bot_conversational.py -v`
Expected: FAIL.

- [ ] **Step 3: Изменить `bot.py:handle_text`**

Найти (line ~173):

```python
async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not is_owner(update, context.application.bot_data["owner_id"]):
        return
    db = context.application.bot_data["db"]
    settings = get_settings()
    today = today_tz(settings.timezone)
    reply = await handle_text_logic(db, update.message.text, today=today)
    keyboard = build_confirmation_keyboard(reply.task_id, needs_deadline=reply.needs_deadline_buttons)
    await update.message.reply_text(reply.text, reply_markup=keyboard)
```

Заменить на:

```python
from .conversational.router import route_message  # добавить в импорты сверху


async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not is_owner(update, context.application.bot_data["owner_id"]):
        return
    db = context.application.bot_data["db"]
    settings = get_settings()
    today = today_tz(settings.timezone)
    now = now_tz(settings.timezone)

    if context.application.bot_data.get("conversational_enabled"):
        reply_msg = update.message.reply_to_message
        reply_text = reply_msg.text if reply_msg else None
        out = await route_message(
            db=db, api_key=settings.openrouter_api_key,
            model=settings.openrouter_intent_model,
            message_text=update.message.text, reply_text=reply_text,
            today=today, now=now,
        )
        await update.message.reply_text(out)
        return

    legacy_reply = await handle_text_logic(db, update.message.text, today=today)
    keyboard = build_confirmation_keyboard(
        legacy_reply.task_id,
        needs_deadline=legacy_reply.needs_deadline_buttons,
    )
    await update.message.reply_text(legacy_reply.text, reply_markup=keyboard)
```

- [ ] **Step 4: В `main.py` пробросить флаг в bot_data**

Найти `app = build_application(...)` (line ~41) и сразу после неё добавить:

```python
    app.bot_data["conversational_enabled"] = settings.conversational_mode_enabled
```

- [ ] **Step 5: Запустить новые тесты + всё остальное**

Run: `pytest tests/ -v`
Expected: PASS — все 217 + ~40 новых тестов зелёные.

- [ ] **Step 6: Коммит**

```bash
git add src/tasks_bot/bot.py src/tasks_bot/main.py tests/test_bot_conversational.py
git commit -m "feat: route free text via conversational router under flag"
```

---

### Task 18: Live-тест с реальным OpenRouter

**Files:**
- Create: `tests/conversational/test_router_live.py`
- Modify: `pyproject.toml` (если ещё нет маркера live)

- [ ] **Step 1: Проверить, есть ли маркер `live` в `pyproject.toml`**

```bash
grep -A 5 "markers" pyproject.toml
```

Если нет — добавить в секцию `[tool.pytest.ini_options]`:

```toml
markers = [
    "live: tests that hit real external APIs (run manually)",
]
```

- [ ] **Step 2: Создать `tests/conversational/test_router_live.py`**

```python
"""Live test against real OpenRouter. Run manually:
    OPENROUTER_API_KEY=... pytest tests/conversational/test_router_live.py -m live -v
"""
import os
import pytest
from datetime import date, datetime
from tasks_bot.conversational.router import route_message
from tasks_bot.models import TaskStatus


@pytest.mark.live
async def test_live_create_simple_task(db):
    api_key = os.environ.get("OPENROUTER_API_KEY")
    if not api_key:
        pytest.skip("OPENROUTER_API_KEY not set")
    now = datetime(2026, 5, 10, 14, 0)
    today = date(2026, 5, 10)
    out = await route_message(
        db=db, api_key=api_key, model="anthropic/claude-haiku-4.5",
        message_text="позвонить Ане завтра", reply_text=None,
        today=today, now=now,
    )
    rows = await db.fetchall("SELECT * FROM tasks WHERE status = ?",
                                (TaskStatus.CONFIRMED.value,))
    assert len(rows) == 1, f"expected 1 task created, got {len(rows)}; bot said: {out}"
    assert "Ане" in rows[0]["text"] or "ане" in rows[0]["text"].lower()
```

- [ ] **Step 3: Запустить (опционально, требует ключ + интернет)**

```bash
OPENROUTER_API_KEY=$(grep OPENROUTER_API_KEY .env | cut -d= -f2) pytest tests/conversational/test_router_live.py -m live -v
```

Expected: PASS если ключ валидный.

- [ ] **Step 4: Убедиться, что обычный прогон НЕ запускает live**

```bash
pytest tests/ 2>&1 | tail -5
```

Expected: live тест помечен как deselected, не запущен.

- [ ] **Step 5: Коммит**

```bash
git add tests/conversational/test_router_live.py pyproject.toml
git commit -m "test: live OpenRouter contract test (manual, marked @live)"
```

---

### Task 19: Деплой на Pi

**Files:**
- Modify: `~/tasks_bot/.env` на Pi (через ssh)
- Push to GitHub

- [ ] **Step 1: Финальный прогон тестов локально**

```bash
cd /Users/nataliadudina/Desktop/ObsidianVault/projects/vibe-coding/tasks-bot
source .venv/bin/activate
pytest 2>&1 | tail -5
```

Expected: все тесты зелёные. Сосчитать: было 217 + ~40 новых = ~257.

- [ ] **Step 2: Push в GitHub**

```bash
git push origin main
```

- [ ] **Step 3: На Pi обновить `.env`**

```bash
ssh pi "cat >> ~/tasks_bot/.env <<'EOF'
CONVERSATIONAL_MODE_ENABLED=false
OPENROUTER_INTENT_MODEL=anthropic/claude-haiku-4.5
EOF"
```

(Намеренно `=false` для первого деплоя — проверим что старый capture-flow не сломался.)

- [ ] **Step 4: Pull + restart**

```bash
ssh pi "cd ~/tasks_bot && git pull && sudo systemctl restart tasks-bot && sleep 5 && sudo systemctl status tasks-bot --no-pager | head -8"
```

Expected: `Active: active (running)`.

- [ ] **Step 5: Smoke-тест в Telegram**

Написать боту любую фразу — должен ответить как раньше (с кнопками подтверждения проекта/срока). Это значит, что `conversational_enabled=false` корректно отправляет в legacy путь.

- [ ] **Step 6: Включить разговорный режим**

```bash
ssh pi "sed -i 's/CONVERSATIONAL_MODE_ENABLED=false/CONVERSATIONAL_MODE_ENABLED=true/' ~/tasks_bot/.env && sudo systemctl restart tasks-bot && sleep 5 && sudo journalctl -u tasks-bot -n 20 --no-pager | tail -15"
```

- [ ] **Step 7: Smoke-тест — проверить три сценария в Telegram:**

1. «позвонить Ане завтра» → должно быть «Создала задачу: позвонить Ане — life, срок 2026-05-11»
2. `/today` → должна показаться сводка (это команда, идёт по старому пути)
3. «как дела?» → должно прийти «я только про задачи» или подобный reject

Если что-то идёт не так — `ssh pi "sed -i 's/CONVERSATIONAL_MODE_ENABLED=true/CONVERSATIONAL_MODE_ENABLED=false/' ~/tasks_bot/.env && sudo systemctl restart tasks-bot"` для отката.

- [ ] **Step 8: Обновить память**

В файле `/Users/nataliadudina/.claude/projects/-Users-nataliadudina-Desktop-ObsidianVault/memory/project_tasks_bot.md`
добавить строку в раздел статуса: «Block 1 (разговорный режим) задеплоен 2026-05-XX». Обновить `OPENROUTER_INTENT_MODEL` в инфраструктуре.

---

## Self-review

- [x] Все 6 решений из брейншторма покрыты:
  - C (полный набор + команды fallback) — Tasks 17 + tools 3-8
  - B (только текст) — голосовых сообщений в плане нет
  - C (clarify только при неоднозначности) — `clarify` tool + промпт + Task 15 ambiguity-safety тест
  - C (reply-as-context) — Task 11 context, Task 17 пробрасывает `reply_text`
  - A (подробный formatter) — Task 10
  - A (strict offtopic) — `reject` tool + промпт
- [x] Главный риск (matching) защищён: правило в промпте (Task 12), отдельный тест на двусмысленность (Task 15)
- [x] Старый capture-flow не сломан: при `conversational_enabled=false` идёт по `handle_text_logic` (Task 17)
- [x] Все Tasks имеют конкретные файлы, тесты, код
- [x] Каждая Task имеет коммит
- [x] Тесты идут от каркаса (tools/formatter/context — Tasks 3-11) к роутеру (Tasks 14-16) к интеграции с ботом (Task 17)
- [x] Live-тест помечен `@pytest.mark.live` и не запускается в обычном прогоне (Task 18)
- [x] Деплой откатываем флагом без revert кода (Task 19)
