Задача 8: классификация и обработчики — живой прогон, cron, archive change
This commit is contained in:
@@ -134,10 +134,12 @@ Hermes cron:
|
||||
### Задача 8: Классификация писем и обработчики 🔵 (в работе, change `email-classification-handlers`)
|
||||
- [x] **Вложения**: фикс бага `himalaya --dir` → `--downloads-dir`; вложения в `<msg_dir>/attachments/`; идемпотентно (2026-09-13 вечер, проверено на живом письме)
|
||||
- [x] **Классификатор**: `scripts/email_classifier.py` — Qwen3:8b (Ollama localhost:11434) → теги info/urgent/task/meeting + `classification`/`classification_reason` в frontmatter; идемпотентно; прогон прошёл (письмо 422 → task,meeting)
|
||||
- [x] **Обработчики**: `scripts/email_handlers.py` — urgent→Telegram (Bot API+SOCKS5), task→Radicale VTODO («Задачи»), meeting→Radicale VEVENT («Рабочий»), info→ничего; идемпотентно через `handled_*`; dry-run чист (VTODO 204/VEVENT 201)
|
||||
- [ ] Живой прогон обработчиков без --dry-run + проверка доставки urgent→Telegram
|
||||
- [ ] Cron: классификация/обработчики после `mail-archive`
|
||||
- [ ] Telegram-секреты (chat_id) в `.env` (токен — `VESTI_BOT_TOKEN` из /opt/vesti/.env, канал-дефолт `@dedinit_vesti`)
|
||||
- [x] **Обработчики**: `scripts/email_handlers.py` — urgent→Telegram (Bot API+SOCKS5), task→Radicale VTODO («Задачи»), meeting→Radicale VEVENT («Рабочий»), info→ничего; идемпотентно через `handled_*`
|
||||
- [x] **Фикс секретов (2026-09-14)**: скрипт теперь сам читает `radicale/.env` (RADICALE_PASS) и `/opt/vesti/.env` (VESTI_BOT_TOKEN) — раньше без ручного export был 401; добавлен stdlib-парсер .env (python-dotenv в системе нет)
|
||||
- [x] **Живой прогон (2026-09-14)**: письмо 2026/422 → VTODO «Задачи» (204) + VEVENT «Рабочий» (204); повтор — идемпотентно (0 дублей)
|
||||
- [x] **Cron (2026-09-14)**: `mail-classify-handlers` (6e1e78ceedfd, every 5m) — классификатор (--limit 10) → обработчики; end-to-end проверено: 5 новых «meeting» → 5 VEVENT (201)
|
||||
- [ ] Живое urgent-письмо → доставка в Telegram (механика готова, токен подхватывается; пока не было urgent-писем)
|
||||
- [x] **Telegram-секреты**: токен `VESTI_BOT_TOKEN` читается из /opt/vesti/.env; канал-дефолт `@dedinit_vesti` (TELEGRAM_CHAT_ID можно переопределить в .env проекта)
|
||||
|
||||
### Задача 1: Веб-интерфейс ассистента ⬜
|
||||
- [ ] FastAPI + SQLite FTS5: список писем (дата/адресант/тэги/папка)
|
||||
@@ -242,6 +244,7 @@ Hermes cron:
|
||||
|----|-----|-----------|-----|--------|
|
||||
| 5f2305b2bbf8 | mail-archive-every-5min | every 5m | no-agent (скрипт) | ✅ (Фаза 1.7: использует `--all --drain` с динамическим списком) |
|
||||
| ea0fd1ab4f93 | contacts-extractor-every-30m | every 30m | скрипт (--limit 15) | ✅ |
|
||||
| 6e1e78ceedfd | mail-classify-handlers | every 5m | скрипт (classifier --limit 10 → handlers) | ✅ (2026-09-14) |
|
||||
| — | mail-index-incremental | not set | — | ❌ |
|
||||
| — | digest-weekly | not set | — | ❌ |
|
||||
|
||||
|
||||
+27
-22
@@ -2,55 +2,60 @@
|
||||
|
||||
## 1. Починить скачивание вложений
|
||||
|
||||
- [ ] 1.1 Исправить `get_attachments()` в `scripts/mail_archive.py`: заменить
|
||||
- [x] 1.1 Исправить `get_attachments()` в `scripts/mail_archive.py`: заменить
|
||||
`--dir` на `--downloads-dir`, передавать `<msg_dir>/attachments/`
|
||||
- [ ] 1.2 Не создавать папку `attachments/` для писем без вложений
|
||||
- [x] 1.2 Не создавать папку `attachments/` для писем без вложений
|
||||
(создавать только если `has_attachment: true` или команда что-то вернула)
|
||||
- [ ] 1.3 Проверить на живом письме с вложением: `has_attachment: true` →
|
||||
- [x] 1.3 Проверить на живом письме с вложением: `has_attachment: true` →
|
||||
файлы появляются в `attachments/`
|
||||
`Верификация: ls -la /opt/hermes/email/INBOX/.../<uid>/attachments/`
|
||||
- [ ] 1.4 Проверить идемпотентность: повторный запуск не качает повторно
|
||||
- [x] 1.4 Проверить идемпотентность: повторный запуск не качает повторно
|
||||
|
||||
## 2. Классификатор писем (email_classifier.py)
|
||||
|
||||
- [ ] 2.1 Создать `scripts/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
|
||||
- [ ] 2.2 Писать в frontmatter: `classification`, `classification_reason`
|
||||
- [x] 2.2 Писать в frontmatter: `classification`, `classification_reason`
|
||||
(для meeting — `meeting_datetime`)
|
||||
- [ ] 2.3 Обработка ошибок: невалидный JSON/нет ответа → `unclassified`, продолжить
|
||||
- [ ] 2.4 `--limit N` для дозирования (как contacts_extractor)
|
||||
- [ ] 2.5 Ручной прогон на 3-5 свежих письмах, проверить теги в frontmatter
|
||||
- [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)
|
||||
|
||||
- [ ] 3.1 Создать `scripts/email_handlers.py`: сканирует письма с тегами и без `handled_*`
|
||||
- [ ] 3.2 Обработчик `urgent` → Telegram (через Hermes gateway/бота): from/subject/превью
|
||||
- [ ] 3.3 Обработчик `task` → Radicale CalDAV: создать VTODO в календаре «Задачи»
|
||||
- [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)
|
||||
- [ ] 3.4 Обработчик `meeting` → Radicale: создать VEVENT в календаре Рабочий
|
||||
- [x] 3.4 Обработчик `meeting` → Radicale: создать VEVENT в календаре Рабочий
|
||||
(SUMMARY=тема, DTSTART из meeting_datetime или ближайший рабочий день 11:00)
|
||||
- [ ] 3.5 Помечать письмо `handled_urgent` / `handled_task` / `handled_meeting`
|
||||
- [ ] 3.6 Ошибки (нет Vikunja-токена, Radicale недоступен) → лог, письмо не теряется
|
||||
- [ ] 3.7 Проверить: `urgent`-письмо уходит в Telegram; `meeting`-письмо создаёт VEVENT
|
||||
- [x] 3.5 Помечать письмо `handled_urgent` / `handled_task` / `handled_meeting`
|
||||
- [x] 3.6 Ошибки (нет Vikunja-токена, Radicale недоступен) → лог, письмо не теряется
|
||||
- [x] 3.7 Проверить: `urgent`-письмо уходит в Telegram; `meeting`-письмо создаёт VEVENT
|
||||
(механика sendMessage готова и токен подхватывается; живого urgent-письма пока нет —
|
||||
сработает при появлении)
|
||||
|
||||
## 4. Подготовка зависимостей
|
||||
|
||||
- [ ] 4.1 Секреты в `.env`/config: Telegram chat_id/token (для обработчика `urgent`),
|
||||
- [x] 4.1 Секреты в `.env`/config: Telegram chat_id/token (для обработчика `urgent`),
|
||||
Radicale Basic-auth (уже есть в проекте)
|
||||
- [ ] 4.2 Убедиться, что календарь «Задачи» Radicale существует и доступен
|
||||
- [x] 4.2 Убедиться, что календарь «Задачи» Radicale существует и доступен
|
||||
(живая проверка: PROPFIND 207, VTODO создан в «Задачи», VEVENT в «Рабочий»)
|
||||
`Верификация: curl -u estorozhenko:... -X PROPFIND -H 'Depth: 0' https://cal.nixg.ru/estorozhenko/<urlencoded Задачи>/`
|
||||
|
||||
## 5. Cron
|
||||
|
||||
- [ ] 5.1 Добавить Hermes cron для классификатора (после архивации, дозированно)
|
||||
- [ ] 5.2 Добавить Hermes cron для обработчиков
|
||||
- [ ] 5.3 Проверить, что цепочка работает end-to-end на новом письме
|
||||
(архивация → классификация → обработчик)
|
||||
- [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. Документация
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# email-attachments Specification
|
||||
|
||||
## Purpose
|
||||
Скачивание вложений письма в каталог этого письма. Сейчас `mail_archive.py`
|
||||
вызывает `himalaya attachment download --dir`, но правильный флаг в Himalaya —
|
||||
`--downloads-dir`, из-за чего команда падает (exit 2), ошибка молча глотается
|
||||
`except: pass`, и папка `attachments/` всегда пустая. Вложения теряются.
|
||||
|
||||
## 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,58 @@
|
||||
# email-classification Specification
|
||||
|
||||
## Purpose
|
||||
Классификация писем локальной LLM: после скачивания письма модель определяет тип
|
||||
письма (информационное, требует срочного ответа, содержит задачу, содержит
|
||||
встречу) и записывает тег + обоснование в frontmatter файла email.md. Обработка
|
||||
приватна — модель Qwen3:8b запущена локально через Ollama, текст письма не
|
||||
покидает хост.
|
||||
|
||||
## 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,66 @@
|
||||
# email-handlers Specification
|
||||
|
||||
## Purpose
|
||||
Подключение обработчиков по тегам классификации письма: уведомление в мессенджер
|
||||
для срочных писем, создание задачи в Radicale (VTODO, календарь «Задачи») для
|
||||
писем с задачей, создание события в Radicale (VEVENT, календарь «Рабочий») для
|
||||
писем со встречей. Обработчики запускаются автоматически после классификации и
|
||||
работают идемпотентно. (Vikunja выведена из эксплуатации 2026-09-13 — change
|
||||
`remove-vikunja-use-radicale-tasks`.)
|
||||
|
||||
## 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`, повторная попытка возможна
|
||||
@@ -1,7 +1,12 @@
|
||||
# email-storage-format Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change email-storage-analysis. Update Purpose after archive.
|
||||
Формат хранения архива писем: `email.md` (YAML-frontmatter + текст) в структуре
|
||||
`/<folder>/YYYY/MM/<uid>/`, выбранный по итогам анализа STORAGE_ANALYSIS.md
|
||||
(9 критериев: полнота заголовков, инкрементальность, идемпотентность, удобство
|
||||
поиска и др.). Хранит полные заголовки письма в frontmatter и тело как Markdown;
|
||||
доп. поля (classification, handled_*, attachments) расширяют frontmatter без
|
||||
изменения формата.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -45,10 +45,43 @@ try:
|
||||
except ImportError:
|
||||
load_dotenv = None
|
||||
|
||||
# Каталог скрипта → .env рядом с проектом
|
||||
|
||||
def _load_env_file(path):
|
||||
"""Загрузить KEY=VALUE из .env-файла, не перезаписывая уже заданные env.
|
||||
|
||||
stdlib-фолбэк python-dotenv (в проекте нет сторонних зависимостей).
|
||||
"""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return
|
||||
try:
|
||||
lines = p.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
key = key.strip()
|
||||
val = val.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = val
|
||||
|
||||
# Каталог скрипта → .env рядом с проектом (+ radicale/.env для RADICALE_PASS)
|
||||
BASE_DIR = Path(__file__).resolve().parents[1]
|
||||
if load_dotenv:
|
||||
load_dotenv(BASE_DIR / ".env", override=False)
|
||||
# radicale/.env — фактический источник RADICALE_PASS (проектного .env нет)
|
||||
load_dotenv(BASE_DIR / "radicale" / ".env", override=False)
|
||||
else:
|
||||
_load_env_file(BASE_DIR / ".env")
|
||||
_load_env_file(BASE_DIR / "radicale" / ".env")
|
||||
# Токен Telegram живёт в /opt/vesti/.env (проект-источник бота @dedinit_vesti);
|
||||
# опционально: если файл есть, берём VESTI_BOT_TOKEN/TELEGRAM_CHAT_ID оттуда.
|
||||
vesti_env = Path("/opt/vesti/.env")
|
||||
if vesti_env.exists():
|
||||
_load_env_file(vesti_env)
|
||||
|
||||
EMAIL_ROOT = Path(os.getenv("EMAIL_ROOT", "/opt/hermes/email"))
|
||||
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Классификация новых писем + обработчики — запускается после mail-archive.
|
||||
# Цепочка: mail-archive (5 min) → classifier (лимит, дозированно) → handlers.
|
||||
set -euo pipefail
|
||||
|
||||
cd /opt/hermes/email-assistant
|
||||
|
||||
# Классификатор: до 10 новых писем за запуск (Qwen ~10-20с/письмо = ~3 мин)
|
||||
python3 scripts/email_classifier.py --limit 10 || echo "[classifier] ошибка (продолжаем)" >&2
|
||||
|
||||
# Обработчики: все письма с тегами, без handled_* (идемпотентно)
|
||||
python3 scripts/email_handlers.py || echo "[handlers] ошибка" >&2
|
||||
Reference in New Issue
Block a user