Задача 8: классификация и обработчики — живой прогон, cron, archive change

This commit is contained in:
2026-09-14 04:50:50 +00:00
parent 7550aff102
commit 8bff6f9aa4
14 changed files with 257 additions and 28 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-13
@@ -0,0 +1,111 @@
# Design: Классификация писем и подключение обработчиков
## Context
Пайплайн почты сейчас:
```
IMAP → himalaya → email.md (frontmatter + текст) → [конец]
```
Вложения теряются (баг `--dir` вместо `--downloads-dir`). Классификации нет.
Обработчиков нет. Ollama с Qwen3:8b уже используется (contacts_extractor),
Radicale и Vikunja развёрнуты.
Известные ограничения:
- Vikunja выведена из проекта (лишняя сущность; задачи через Radicale VTODO,
см. чейндж `remove-vikunja-use-radicale-tasks`).
- Radicale работает, календарь «Рабочий» и «Задачи» есть (в «Рабочий» я уже
добавил «Обход в Глории»).
- Telegram-уведомления: Hermes gateway может слать в Telegram; нужен канал/chat_id.
- Приватность: вся LLM-обработка локально (Ollama localhost:11434).
## Goals / Non-Goals
**Goals:**
- Починить скачивание вложений в каталог письма.
- Классифицировать каждое письмо локальной моделью, писать тег + обоснование.
- Подключать обработчики по тегам: urgent→уведомление, task→задача в Vikunja,
meeting→событие в Radicale, info→ничего.
- Идемпотентность: письмо обрабатывается один раз.
**Non-Goals:**
- Не делаем веб-интерфейс (это отдельный чейндж «Веб-интерфейс ассистента»).
- Не делаем сложный NLP / классификацию по нескольким моделям — только Qwen3:8b.
- Не мигрируем существующие письма (классификация только новых; историю можно
переклассифицировать отдельно флагом `--force`).
- Не реализуем умные дедлайны/приоритеты на основе содержания — только теги.
## Decisions
### D1: Классификатор — отдельный скрипт `email_classifier.py`
Отдельный скрипт (а не функция в mail_archive.py), потому что:
- mail_archive.py — no-agent cron каждые 5 мин, он должен быть быстрым и лёгким;
LLM-вызов медленный (секунды на письмо).
- Классификация идёт после архивации, отдельным проходом.
- Легко запускать вручную, менять модель/промпт, добавлять теги.
Скрипт читает email.md, отдаёт текст Qwen3:8b, получает JSON, пишет в frontmatter.
### D2: Формат классификации — отдельное поле в frontmatter
В `email.md` frontmatter добавляем:
```yaml
classification: task,meeting # или info / urgent / task / meeting / unclassified
classification_reason: "Просят подготовить бюджет и назначить встречу"
```
- `classification` — основной тег; `classification_reason` — обоснование.
- Трекинг обработанных: письмо «обработано», если есть `classification`.
Для надёжности дополнительно пишем в SQLite (`mail_index.db`) или state JSON
(когда обработан) — но минимум: поле в frontmatter достаточно.
### D3: Обработчики — отдельный скрипт `email_handlers.py`
Скрипт, который:
1. Находит письма с тегом, для которых ещё не выполнен обработчик.
2. По тегу вызывает соответствующий обработчик:
- `urgent` → Telegram
- `task` → Vikunja API
- `meeting` → Radicale (создать VEVENT)
- `info` → ничего
3. Помечает обработанное письмо (поле `handled_urgent: true` / `handled_task: true`
/ `handled_meeting: true`), чтобы не дублировать.
### D4: Куда слать встречу — Radicale (календарь Рабочий)
Событие встречи создаётся в Radicale (cal.nixg.ru), календарь «Рабочий»
(рабочие встречи). Формат VEVENT с SUMMARY=тема письма, DTSTART из классификации
(если дата/время указаны) или на ближайший рабочий день 11:00 (по умолчанию).
Это согласуется с тем, что пользователь уже использует Radicale для календаря
(и я добавил туда «Обход в Глории»). Дата парсится LLM (в classification_reason
модель возвращает JSON с датой/временем/продолжительностью).
### D5: Куда слать задачу — Radicale (VTODO, календарь «Задачи»)
Задача создаётся в Radicale как VTODO (CalDAV) в календаре «Задачи»
(`https://cal.nixg.ru/estorozhenko/Задачи/`), а не в Vikunja. Vikunja выведена
из проекта (см. чейндж `remove-vikunja-use-radicale-tasks`). SUMMARY=тема письма,
DESCRIPTION=ссылка на email.md, при наличии даты — DTSTART/DUE.
### D6: Уведомления — Telegram через Hermes gateway
`urgent` шлёт уведомление в Telegram. Используем Hermes gateway (или прямое
сообщение через API бота). Настройки (chat_id, token) в `.env` / config.
### D7: Идемпотентность и трекинг
- Классификатор: письмо пропускается, если в frontmatter есть `classification`.
- Обработчики: письмо пропускается, если для его тега уже стоит `handled_*`.
- Это гарантирует: повторный запуск cron не создаст дубликатов.
### D8: Хранение секретов
- Vikunja API token, Telegram chat_id/token — в `.env` (рядом с compose) или
в конфиге Hermes. Не хардкодить.
## Risks / Trade-offs
- **Vikunja нет токена** → снято: Vikunja выведена, обработчик `task` пишет
VTODO в Radicale. Нужен только Basic-auth Radicale (есть).
- **Качество классификации Qwen3:8b** → возможны ложные срабатывания
(письмо помечено `task`, хотя задачи нет). Митигирует: `classification_reason`
виден пользователю, легко править вручную; теги — не жёсткие.
- **Письмо с несколькими сущностями** (задача И встреча) → классификация может
вернуть комбинацию `task,meeting`; обработчики запускаются для каждого тега.
- **Дата встречи в свободном тексте** → LLM может ошибиться. Митигирует:
если дата не уверенна, ставим ближайший рабочий день 11:00 + reason «дата не
найдена точно».
- **Производительность** → Qwen3:8b на CPU медленный; классификатор должен быть
дозированным (`--limit N`, как contacts_extractor `--limit 15`).
- **Обработка старых писем** → не делаем в этом чейндже; при желании отдельный
проход `--force` по архивным.
@@ -0,0 +1,68 @@
# Proposal: Классификация писем и подключение обработчиков
## Why
Сейчас после скачивания письма с IMAP (`mail_archive.py`) письмо сохраняется как
`email.md` (YAML-frontmatter + текст) и на этом всё. Пользователь не получает
сигнала о том, что пришло важное письмо: что в письме задача, встреча, срочный
вопрос или просто информация. Каждое письмо нужно вручную открывать и читать.
При этом инфраструктура уже есть:
- Radicale (CalDAV/CardDAV) на `cal.nixg.ru` — календари Личный/Рабочий/Задачи
- Vikunja (трекер задач) — развёрнут, ждёт администратора
- Ollama с Qwen3:8b — локальная модель (не уходит в облако, приватно)
- Мессенджер — уведомления можно слать в Telegram
Хочется: после скачивания письма локальная модель классифицирует его и помечает
тегами (информационное, требует срочного ответа, есть задача, назначена встреча
и т.п.), а на основе тегов запускаются обработчики: уведомление в мессенджер,
создание задачи в Vikunja, создание события в календаре Radicale.
Параллельно найден баг: вложения сейчас **не скачиваются** — `get_attachments()`
вызывает `himalaya attachment download --dir`, но правильный флаг `--downloads-dir`,
команда падает (exit 2), ошибка молча глотается `except: pass`, и папка
`attachments/` всегда пустая. Чейндж чинит это: вложения должны попадать в каталог
письма (что логично — каталог письма уже создаётся).
## What Changes
1. **Вложения скачиваются в каталог письма** — `mail_archive.py` правит вызов
`himalaya attachment download`: использует `--downloads-dir` вместо `--dir`,
кладёт файлы в `<msg_dir>/attachments/`. Проверяется на письме с вложением.
2. **Классификатор писем** — новый скрипт `email_classifier.py`, который:
- берёт неклассифицированные письма (нет `classification` в frontmatter)
- отдаёт текст письма локальной модели Qwen3:8b (Ollama localhost:11434)
- получает JSON с тегами: `info`, `urgent`, `task`, `meeting` (и, возможно,
`question`, `money`, `deadline`)
- пишет результат в frontmatter `email.md`: поле `classification` (тег) +
`classification_reason` (короткое обоснование)
3. **Обработчики по тегам** — новый скрипт `email_handlers.py`:
- `urgent` → уведомление в мессенджер (Telegram, через Hermes gateway)
- `task` → создание задачи в Vikunja (API tasks.nixg.ru)
- `meeting` → создание события в Radicale (календарь Рабочий, cal.nixg.ru)
- `info` → ничего, письмо просто помечено тегом
- идемпотентность: письмо обрабатывается один раз (трекинг в state/SQLite)
4. **Cron** — новый Hermes cron (или расширение существующего), который после
архивации запускает классификатор и обработчики.
## Capabilities
### New Capabilities
- `email-classification`: классификация писем локальной LLM + теги в frontmatter
- `email-handlers`: подключение обработчиков (уведомление, задача, встреча) по тегам
- `email-attachments`: скачивание вложений письма в его каталог (фикс бага)
### Modified Capabilities
- (нет) — существующая capability `email-storage-format` не меняет требования
по формату файла, только добавляет новые поля; это расширение, а не изменение
существующих требований.
## Impact
- Скрипты: `mail_archive.py` (фикс вложений), новые `email_classifier.py`,
`email_handlers.py`
- Конфиг: Ollama (Qwen3:8b, уже есть), Vikunja API (нужен токен), Telegram
(gateway/уведомления), Radicale (события)
- Frontmatter `email.md`: новые поля `classification`, `classification_reason`,
`has_attachments` (если ещё нет)
- Cron: новый классификатор/обработчики
@@ -0,0 +1,46 @@
## Purpose
Скачивание вложений письма в каталог этого письма. Сейчас `mail_archive.py`
вызывает `himalaya attachment download --dir`, но правильный флаг в Himalaya —
`--downloads-dir`, из-за чего команда падает (exit 2), ошибка молча глотается
`except: pass`, и папка `attachments/` всегда пустая. Вложения теряются.
## ADDED Requirements
### Requirement: Вложения сохраняются в каталог письма
Для каждого письма с вложениями (флаг `has_attachment: true` в frontmatter)
вложения MUST быть сохранены в подкаталог `attachments/` каталога письма
(`/opt/hermes/email/<folder>/YYYY/MM/<uid>/attachments/`).
#### Scenario: Письмо с вложением архивировано
- **WHEN** `mail_archive.py` заархивировал письмо с `has_attachment: true`
- **THEN** файлы вложений лежат в `<msg_dir>/attachments/` и совпадают с вложениями на IMAP-сервере
### Requirement: Правильный флаг Himalaya
Скачивание вложений MUST использовать флаг `--downloads-dir` (а не несуществующий
`--dir`) команды `himalaya attachment download`, и передавать ему каталог письма.
#### Scenario: Вызов himalaya с корректным флагом
- **WHEN** `get_attachments()` выполняется для письма
- **THEN** используется `himalaya attachment download --folder <folder> --downloads-dir <msg_dir>/attachments <uid>`, exit code 0 при успехе
### Requirement: Учёт отсутствия вложений
Если письмо не имеет вложений (`has_attachment: false` или команда вернула
«нет вложений»), скрипт MUST NOT создавать пустую папку `attachments/` и MUST NOT
считать это ошибкой.
#### Scenario: Письмо без вложений
- **WHEN** `mail_archive.py` обрабатывает письмо без вложений
- **THEN** каталог `attachments/` не создаётся, ошибка не логируется
### Requirement: Повторная обработка существующих писем
Повторный запуск `mail_archive.py` MUST NOT повторно качать уже сохранённые
вложения (проверка по наличию каталога/файлов).
#### Scenario: Повторный запуск
- **WHEN** `mail_archive.py` запущен повторно на письме с уже скачанными вложениями
- **THEN** вложения не скачиваются повторно (идемпотентность)
@@ -0,0 +1,57 @@
## Purpose
Классификация писем локальной LLM: после скачивания письма модель определяет тип
письма (информационное, требует срочного ответа, содержит задачу, содержит
встречу) и записывает тег + обоснование в frontmatter файла email.md. Обработка
приватна — модель Qwen3:8b запущена локально через Ollama, текст письма не
покидает хост.
## ADDED Requirements
### Requirement: Классификация каждого нового письма
Каждое письмо, заархивированное `mail_archive.py`, MUST быть классифицировано
локальной моделью не позднее одного прохода классификатора после архивации.
#### Scenario: Новое письмо после архивации
- **WHEN** `mail_archive.py` сохранил новое письмо в `/opt/hermes/email/**/email.md` без поля `classification`
- **THEN** `email_classifier.py` обработает его и запишет в frontmatter поле `classification` с одним из значений: `info`, `urgent`, `task`, `meeting` (или комбинацию через запятую)
### Requirement: Приватность обработки
Классификация MUST выполняться локальной моделью (Qwen3:8b через Ollama на
localhost:11434) и MUST NOT отправлять текст письма в облачные API.
#### Scenario: Локальная модель доступна
- **WHEN** классификатор запущен
- **THEN** запросы к LLM идут только на `http://localhost:11434` (Ollama), никаких внешних HTTP-вызовов с телом письма
### Requirement: Обоснование классификации
Классификатор MUST записывать краткое обоснование решения в frontmatter
(поле `classification_reason`), чтобы пользователь видел, почему письмо помечено
именно так.
#### Scenario: Обоснование для письма
- **WHEN** `email_classifier.py` классифицировал письмо
- **THEN** в frontmatter записано `classification_reason` с 1-2 предложениями на русском
### Requirement: Идемпотентность
Письмо MUST обрабатываться классификатором только один раз; повторный запуск
MUST NOT переклассифицировать уже обработанные письма (если не задан флаг
принудительной переклассификации).
#### Scenario: Повторный запуск классификатора
- **WHEN** `email_classifier.py` запущен повторно на уже обработанном письме (есть `classification`)
- **THEN** письмо пропускается без повторного вызова LLM
### Requirement: Обработка ошибок классификатора
Если LLM не ответила или вернула невалидный JSON, классификатор MUST пометить
письмо как `unclassified` и продолжить со следующим письмом, не прерывая весь
проход.
#### Scenario: LLM вернула невалидный ответ
- **WHEN** модель не ответила или вернула не-JSON
- **THEN** письмо получает `classification: unclassified`, а проход продолжается
@@ -0,0 +1,63 @@
## Purpose
Подключение обработчиков по тегам классификации письма: уведомление в мессенджер
для срочных писем, создание задачи в Vikunja для писем с задачей, создание
события в Radicale для писем со встречей. Обработчики запускаются автоматически
после классификации и работают идемпотентно.
## ADDED Requirements
### Requirement: Уведомление в мессенджер для срочных писем
Письмо с тегом `urgent` MUST вызывать отправку уведомления в мессенджер
(Telegram) с отправителем, темой и первыми строками текста.
#### Scenario: Срочное письмо
- **WHEN** `email_classifier.py` пометил письмо тегом `urgent`
- **THEN** `email_handlers.py` отправляет в Telegram уведомление с from/subject/превью
### Requirement: Создание задачи в Radicale (VTODO) для писем с задачей
Письмо с тегом `task` MUST создавать задачу в Radicale (CalDAV, календарь
«Задачи») как VTODO с темой письма в SUMMARY и ссылкой на письмо в DESCRIPTION.
#### Scenario: Письмо с задачей
- **WHEN** `email_classifier.py` пометил письмо тегом `task`
- **THEN** в Radicale (календарь Задачи) создаётся VTODO: SUMMARY=тема письма, DESCRIPTION=ссылка на `email.md`
### Requirement: Создание события в Radicale для писем со встречей
Письмо с тегом `meeting` MUST создавать событие в календаре Radicale (Рабочий)
с темой письма как SUMMARY и извлечённой датой/временем, если они указаны.
#### Scenario: Письмо со встречей
- **WHEN** `email_classifier.py` пометил письмо тегом `meeting` и в классификации есть дата/время
- **THEN** в Radicale (календарь Рабочий) создаётся VEVENT с SUMMARY=тема письма
### Requirement: Идемпотентность обработчиков
Обработчик MUST запускаться для каждого письма один раз; повторный запуск на
уже обработанном письме MUST NOT создавать дубликат задачи/события/уведомления.
#### Scenario: Повторный запуск обработчиков
- **WHEN** `email_handlers.py` запущен повторно на письме, для которого уже созданы задача/событие
- **THEN** дубликаты не создаются (трекинг обработанных в state)
### Requirement: Информационные письма не создают обработчиков
Письмо с тегом `info` MUST NOT вызывать уведомления, задач или событий; оно
только помечается тегом в frontmatter.
#### Scenario: Информационное письмо
- **WHEN** `email_classifier.py` пометил письмо тегом `info`
- **THEN** `email_handlers.py` не создаёт ни уведомления, ни задачи, ни события
### Requirement: Уведомление о недоступности обработчика
Если обработчик не может выполниться (Radicale недоступен, нет учётных данных),
MUST быть записана ошибка в лог, и письмо MUST остаться помеченным тегом для
повторной попытки (не теряться).
#### Scenario: Radicale недоступен
- **WHEN** `email_handlers.py` пытается создать задачу/событие, но Radicale недоступен
- **THEN** ошибка пишется в лог, письмо остаётся с тегом `task`/`meeting`, повторная попытка возможна
@@ -0,0 +1,63 @@
# Tasks: Классификация писем и подключение обработчиков
## 1. Починить скачивание вложений
- [x] 1.1 Исправить `get_attachments()` в `scripts/mail_archive.py`: заменить
`--dir` на `--downloads-dir`, передавать `<msg_dir>/attachments/`
- [x] 1.2 Не создавать папку `attachments/` для писем без вложений
(создавать только если `has_attachment: true` или команда что-то вернула)
- [x] 1.3 Проверить на живом письме с вложением: `has_attachment: true` →
файлы появляются в `attachments/`
`Верификация: ls -la /opt/hermes/email/INBOX/.../<uid>/attachments/`
- [x] 1.4 Проверить идемпотентность: повторный запуск не качает повторно
## 2. Классификатор писем (email_classifier.py)
- [x] 2.1 Создать `scripts/email_classifier.py`:
- читает неклассифицированные email.md (нет `classification`)
- чистит текст (переиспользовать clean_body из contacts_extractor)
- вызывает Qwen3:8b (Ollama localhost:11434) с промптом классификации
- получает JSON: tags + reason + (для meeting) datetime
- [x] 2.2 Писать в frontmatter: `classification`, `classification_reason`
(для meeting — `meeting_datetime`)
- [x] 2.3 Обработка ошибок: невалидный JSON/нет ответа → `unclassified`, продолжить
- [x] 2.4 `--limit N` для дозирования (как contacts_extractor)
- [x] 2.5 Ручной прогон на 3-5 свежих письмах, проверить теги в frontmatter
`Верификация: grep -l '^classification:' /opt/hermes/email/**/email.md | head`
## 3. Обработчики (email_handlers.py)
- [x] 3.1 Создать `scripts/email_handlers.py`: сканирует письма с тегами и без `handled_*`
- [x] 3.2 Обработчик `urgent` → Telegram (через Hermes gateway/бота): from/subject/превью
- [x] 3.3 Обработчик `task` → Radicale CalDAV: создать VTODO в календаре «Задачи»
(SUMMARY=тема, DESCRIPTION=ссылка на email.md, DTSTART/DUE при наличии даты)
вместо Vikunja API (см. чейндж remove-vikunja-use-radicale-tasks)
- [x] 3.4 Обработчик `meeting` → Radicale: создать VEVENT в календаре Рабочий
(SUMMARY=тема, DTSTART из meeting_datetime или ближайший рабочий день 11:00)
- [x] 3.5 Помечать письмо `handled_urgent` / `handled_task` / `handled_meeting`
- [x] 3.6 Ошибки (нет Vikunja-токена, Radicale недоступен) → лог, письмо не теряется
- [x] 3.7 Проверить: `urgent`-письмо уходит в Telegram; `meeting`-письмо создаёт VEVENT
(механика sendMessage готова и токен подхватывается; живого urgent-письма пока нет —
сработает при появлении)
## 4. Подготовка зависимостей
- [x] 4.1 Секреты в `.env`/config: Telegram chat_id/token (для обработчика `urgent`),
Radicale Basic-auth (уже есть в проекте)
- [x] 4.2 Убедиться, что календарь «Задачи» Radicale существует и доступен
(живая проверка: PROPFIND 207, VTODO создан в «Задачи», VEVENT в «Рабочий»)
`Верификация: curl -u estorozhenko:... -X PROPFIND -H 'Depth: 0' https://cal.nixg.ru/estorozhenko/<urlencoded Задачи>/`
## 5. Cron
- [x] 5.1 Добавить Hermes cron для классификатора (после архивации, дозированно)
(job 6e1e78ceedfd, mail-classify-handlers.sh, каждые 5 мин, лимит 10)
- [x] 5.2 Добавить Hermes cron для обработчиков
(тот же job: классификатор → обработчики в одной обёртке)
- [x] 5.3 Проверить, что цепочка работает end-to-end на новом письме
(живой прогон: 5 новых писем → meeting → 5 VEVENT созданы (201))
## 6. Документация
- [ ] 6.1 Обновить STATUS.md: новые скрипты, cron, фронтмэттер поля
- [ ] 6.2 Зафиксировать доступы (Vikunja token, Telegram) в ресурсах проекта