feat: классификация писем Qwen3:8b + обработчики (urgent→TG, task→VTODO, meeting→VEVENT)
- mail_archive.py: фикс вложений himalaya --dir → --downloads-dir; attachments/ только при has_attachment; идемпотентно - email_classifier.py: Qwen3:8b (Ollama) → теги info/urgent/task/meeting в frontmatter email.md - email_handlers.py: urgent→Telegram (Bot API+SOCKS5), task→Radicale VTODO, meeting→Radicale VEVENT; handled_* идемпотентность; --dry-run - .gitignore: игнор *.env.bak* - openspec: чейндж email-classification-handlers (в работе) - STATUS/TODO/WALKTHROUGH: прогресс сессии, подводные камни Radicale (http.client, Depth:1, формат дат)
This commit is contained in:
@@ -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,58 @@
|
||||
# Tasks: Классификация писем и подключение обработчиков
|
||||
|
||||
## 1. Починить скачивание вложений
|
||||
|
||||
- [ ] 1.1 Исправить `get_attachments()` в `scripts/mail_archive.py`: заменить
|
||||
`--dir` на `--downloads-dir`, передавать `<msg_dir>/attachments/`
|
||||
- [ ] 1.2 Не создавать папку `attachments/` для писем без вложений
|
||||
(создавать только если `has_attachment: true` или команда что-то вернула)
|
||||
- [ ] 1.3 Проверить на живом письме с вложением: `has_attachment: true` →
|
||||
файлы появляются в `attachments/`
|
||||
`Верификация: ls -la /opt/hermes/email/INBOX/.../<uid>/attachments/`
|
||||
- [ ] 1.4 Проверить идемпотентность: повторный запуск не качает повторно
|
||||
|
||||
## 2. Классификатор писем (email_classifier.py)
|
||||
|
||||
- [ ] 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`
|
||||
(для meeting — `meeting_datetime`)
|
||||
- [ ] 2.3 Обработка ошибок: невалидный JSON/нет ответа → `unclassified`, продолжить
|
||||
- [ ] 2.4 `--limit N` для дозирования (как contacts_extractor)
|
||||
- [ ] 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 в календаре «Задачи»
|
||||
(SUMMARY=тема, DESCRIPTION=ссылка на email.md, DTSTART/DUE при наличии даты)
|
||||
вместо Vikunja API (см. чейндж remove-vikunja-use-radicale-tasks)
|
||||
- [ ] 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
|
||||
|
||||
## 4. Подготовка зависимостей
|
||||
|
||||
- [ ] 4.1 Секреты в `.env`/config: Telegram chat_id/token (для обработчика `urgent`),
|
||||
Radicale Basic-auth (уже есть в проекте)
|
||||
- [ ] 4.2 Убедиться, что календарь «Задачи» Radicale существует и доступен
|
||||
`Верификация: 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 на новом письме
|
||||
(архивация → классификация → обработчик)
|
||||
|
||||
## 6. Документация
|
||||
|
||||
- [ ] 6.1 Обновить STATUS.md: новые скрипты, cron, фронтмэттер поля
|
||||
- [ ] 6.2 Зафиксировать доступы (Vikunja token, Telegram) в ресурсах проекта
|
||||
@@ -1,5 +1,10 @@
|
||||
# Proposal: Локальные сервисы календаря (Radicale) и задач (Vikunja)
|
||||
|
||||
> ⚠️ **SUPERSEDED (2026-09-13):** Часть про **Vikunja** заменена чейнджем
|
||||
> `remove-vikunja-use-radicale-tasks` — Vikunja выведена из проекта (лишняя
|
||||
> сущность), задачи ведутся через **Radicale VTODO** (календарь «Задачи»).
|
||||
> Radicale-часть актуальна.
|
||||
|
||||
## Why
|
||||
|
||||
Для синхронизации календаря и задач с Android-телефоном нужны локальные
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-13
|
||||
@@ -0,0 +1,83 @@
|
||||
# Design: Убрать Vikunja, задачи через Radicale (VTODO)
|
||||
|
||||
## Context
|
||||
|
||||
- Radicale (CalDAV/CardDAV) развёрнут, календари Личный/Рабочий/Задачи на ФС.
|
||||
Публично: cal.nixg.ru (Caddy vps02 → 10.8.0.2:5232).
|
||||
- Vikunja развёрнут (compose `/opt/hermes/email-assistant/vikunja/`, контейнеры
|
||||
`vikunja` + `vikunja-db` postgres, :3456), но админ/API-токен НЕ созданы —
|
||||
это блокер для обработчика `task` в classification-чейндже.
|
||||
- Календарь «Задачи» в Radicale уже существует (VTODO-совместимый).
|
||||
- Android: jtx board синхронизирует VTODO по CalDAV через DAVx5.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Убрать Vikunja как лишнюю сущность.
|
||||
- Задачи создаются через Radicale VTODO (календарь «Задачи»).
|
||||
- Обработчик `task` в classification-чейндже пишет VTODO, не зависит от Vikunja.
|
||||
- Обновить документацию/список задач.
|
||||
|
||||
**Non-Goals:**
|
||||
- Не мигрируем данные из Vikunja (их там нет — сервис не администрирован).
|
||||
- Не удаляем данные Radicale — только добавляем VTODO.
|
||||
- Не трогаем Caddy, если tasks.nixg.ru ещё не настроен (только не настраивать).
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Vikunja выводится из эксплуатации
|
||||
Контейнеры `vikunja` и `vikunja-db` останавливаются и удаляются:
|
||||
```bash
|
||||
cd /opt/hermes/email-assistant/vikunja
|
||||
docker compose down -v # или docker stop vikunja vikunja-db && docker rm ...
|
||||
```
|
||||
- Данные (volume `vikunja-db`) можно удалить (сервис не использовался),
|
||||
либо сделать бэкап перед удалением (аккуратно — «не удалять данные
|
||||
пользователя»). Решение: сделать копию volume/postgres-дампа на всякий случай,
|
||||
затем удалить контейнеры; compose.yml/.env пометить deprecated или удалить
|
||||
после подтверждения пользователя.
|
||||
|
||||
### D2: Обработчик task → Radicale VTODO
|
||||
В чейндже `email-classification-handlers` обработчик `task` меняется с
|
||||
«Vikunja API POST» на «Radicale CalDAV PUT VTODO»:
|
||||
- URL: `https://cal.nixg.ru/estorozhenko/<urlencoded 'Задачи'>/<uid>.ics`
|
||||
- Auth: Basic (estorozhenko:пароль Radicale)
|
||||
- Body: VCALENDAR + VTODO (SUMMARY=тема, DESCRIPTION=ссылка на email.md,
|
||||
при наличии даты — DTSTART/DUE)
|
||||
- Пометить `handled_task: true` после успешного PUT (201/204)
|
||||
- Идемпотентность: если `handled_task: true` — пропустить
|
||||
|
||||
### D3: tasks.nixg.ru
|
||||
- Если reverse proxy уже настроен в Caddy — закомментировать/убрать.
|
||||
- Если нет — не настраивать. Единственный домен: cal.nixg.ru.
|
||||
|
||||
### D4: Android — jtx board
|
||||
Для задач (VTODO) используется jtx board, синхронизация через DAVx5 (Radicale).
|
||||
В STATUS.md зафиксировать: «задачи = Radicale VTODO, jtx board».
|
||||
|
||||
### D5: Чейндж email-classification-handlers — правка
|
||||
В `email-classification-handlers`:
|
||||
- specs/email-handlers/spec.md: «Создание задачи в Vikunja» → «Создание задачи
|
||||
в Radicale (VTODO)»
|
||||
- design.md: убрать Vikunja-ветку, заменить на Radicale VTODO
|
||||
- tasks.md: задача 3.3 (Vikunja API) → Radicale VTODO; задача 4.1 (админ Vikunja)
|
||||
→ удалить
|
||||
Это правки в активном чейндже — внести сразу.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Vikunja данные** — если в Vikunja что-то было создано, удаление volume потеряет
|
||||
это. Митигирует: бэкап volume/postgres-дамп перед удалением.
|
||||
- **VTODO-совместимость клиентов** — Radicale хранит VTODO как файлы, jtx board
|
||||
их читает. Риск низкий (стандарт CalDAV).
|
||||
- **Ссылка на email.md в DESCRIPTION** — на телефоне путь недоступен (локальный
|
||||
диск), но виден в reason/задаче. Это ок: задача показывает тему + обоснование.
|
||||
- **Уже развёрнутый Vikunja** — вывод из эксплуатации надо делать аккуратно,
|
||||
с бэкапом и подтверждением (не удалять данные пользователя без спроса).
|
||||
|
||||
## Verification
|
||||
|
||||
1. `docker ps` — контейнеры vikunja/vikunja-db отсутствуют.
|
||||
2. `curl -X PROPFIND https://cal.nixg.ru/estorozhenko/Задачи/` — календарь доступен.
|
||||
3. Создать VTODO через обработчик → `curl GET .../Задачи/<uid>.ics` — VTODO есть.
|
||||
4. `openspec validate remove-vikunja-use-radicale-tasks` — valid.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Proposal: Убрать Vikunja, задачи через Radicale (VTODO)
|
||||
|
||||
## Why
|
||||
|
||||
В проекте email-assistant были развёрнуты два сервиса для календаря/задач:
|
||||
- **Radicale** (CalDAV/CardDAV) — календари Личный/Рабочий/Задачи
|
||||
- **Vikunja** (трекер задач) — отдельная сущность на :3456, tasks.nixg.ru
|
||||
|
||||
Это лишняя сложность: Radicale из коробки поддерживает **VTODO** (задачи через
|
||||
CalDAV), а календарь «Задачи» в Radicale уже создан. На Android задачи из
|
||||
CalDAV-VTODO прекрасно синхронизирует **jtx board** (и DAVx5), не требуя
|
||||
отдельного трекера.
|
||||
|
||||
Vikunja добавляет:
|
||||
- лишний docker-контейнер + PostgreSQL
|
||||
- отдельный API, токен, админа (не созданы — блокер)
|
||||
- отдельный домен tasks.nixg.ru (reverse proxy, сертификат)
|
||||
- дублирование логики «создать задачу» (Vikunja API вместо простого VTODO)
|
||||
- усложнение чейнджа классификации (обработчик `task` зависел от несуществующего токена)
|
||||
|
||||
Убираем Vikunja из проекта. Задачи — через Radicale (VTODO в календаре «Задачи»).
|
||||
Это упрощает архитектуру, убирает лишнюю сущность, не теряя функциональности.
|
||||
|
||||
## What Changes
|
||||
|
||||
1. **Обработчик задач переключается на Radicale VTODO** — в чейндже
|
||||
`email-classification-handlers` обработчик `task` создаёт не задачу в Vikunja,
|
||||
а **VTODO в календаре «Задачи» Radicale** (CalDAV PUT). Тема письма → SUMMARY,
|
||||
ссылка на письмо → DESCRIPTION.
|
||||
2. **Vikunja выводится из эксплуатации** — остановить и удалить контейнеры
|
||||
`vikunja` и `vikunja-db`, убрать docker-compose.yml, .env (или пометить
|
||||
deprecated), освободить порт 3456.
|
||||
3. **tasks.nixg.ru** — если reverse proxy уже настроен, убрать/закомментировать;
|
||||
если нет — не настраивать. Radicale остаётся единственным CalDAV-сервером.
|
||||
4. **Обновить документацию** — STATUS.md, TODO.md, design чейнджей убрать Vikunja,
|
||||
зафиксировать «задачи = Radicale VTODO, jtx board».
|
||||
5. **TODO/общий список** — задача «Vikunja» закрыта как «не нужна»,
|
||||
«Caddy tasks.nixg.ru» — отменена.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `radicale-tasks`: создание задач (VTODO) в Radicale через CalDAV — заменяет
|
||||
Vikunja для обработчика `task` в email-classification-handlers.
|
||||
|
||||
### Modified Capabilities
|
||||
- (нет) — Vikunja не является capability проекта; это внешний сервис, который
|
||||
выводится из эксплуатации. Radicale-tasks — новое поведение.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Docker**: остановить/удалить `vikunja`, `vikunja-db` (compose в
|
||||
`/opt/hermes/email-assistant/vikunja/`)
|
||||
- **Скрипты**: `email_handlers.py` (в чейндже classification) — обработчик `task`
|
||||
→ Radicale VTODO вместо Vikunja API
|
||||
- **Радикал**: календарь «Задачи» уже существует, туда пишутся VTODO
|
||||
- **Документация**: STATUS.md, TODO.md, design.md (local-calendar-tasks,
|
||||
email-classification-handlers) — убрать Vikunja, зафиксировать Radicale VTODO
|
||||
- **Caddy (если настроен)**: убрать/закомментировать tasks.nixg.ru
|
||||
- **TODO.md**: закрыть задачи Vikunja (2, 3, 6 — «создать задачу» теперь Radicale)
|
||||
@@ -0,0 +1,45 @@
|
||||
## Purpose
|
||||
|
||||
Создание задач через Radicale как VTODO-объектов в календаре «Задачи» (CalDAV).
|
||||
Используется обработчиком `task` в чейндже email-classification-handlers вместо
|
||||
Vikunja. Упрощает архитектуру: Radicale — единственный сервис календаря и задач.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Задача создаётся как VTODO в Radicale
|
||||
|
||||
Задача MUST создаваться как VTODO-объект (CalDAV) в календаре «Задачи» Radicale
|
||||
по адресу `https://cal.nixg.ru/estorozhenko/Задачи/`, а не через Vikunja API.
|
||||
|
||||
#### Scenario: Обработчик task
|
||||
- **WHEN** `email_handlers.py` обрабатывает письмо с тегом `task`
|
||||
- **THEN** в календаре «Задачи» Radicale создаётся VTODO (PUT по адресу
|
||||
`https://cal.nixg.ru/estorozhenko/<urlencoded Задачи>/<uid>.ics`)
|
||||
|
||||
### Requirement: Поля VTODO из письма
|
||||
|
||||
VTODO MUST содержать: SUMMARY — тема письма, DESCRIPTION — ссылка на файл
|
||||
`email.md` письма. При наличии даты/дедлайна в классификации — DTSTART/DUE.
|
||||
|
||||
#### Scenario: Создание VTODO из письма с задачей
|
||||
- **WHEN** `email_handlers.py` создаёт задачу из письма с тегом `task`
|
||||
- **THEN** VTODO имеет SUMMARY=тема письма, DESCRIPTION=путь к email.md,
|
||||
и (если указано) DTSTART/DUE из классификации
|
||||
|
||||
### Requirement: Идемпотентность задач Radicale
|
||||
|
||||
Повторный запуск обработчика MUST NOT создавать дубликат VTODO для одного письма
|
||||
(поле `handled_task: true` в frontmatter после успешного создания).
|
||||
|
||||
#### Scenario: Повторный запуск обработчика task
|
||||
- **WHEN** `email_handlers.py` запущен повторно на письме с уже созданной задачей (`handled_task: true`)
|
||||
- **THEN** новый VTODO не создаётся
|
||||
|
||||
### Requirement: Совместимость с jtx board / DAVx5
|
||||
|
||||
Созданный VTODO MUST быть читаемым стандартными CalDAV-клиентами (jtx board,
|
||||
DAVx5), т.е. валидным VCALENDAR с VTODO компонентом.
|
||||
|
||||
#### Scenario: Чтение задачи в jtx board
|
||||
- **WHEN** пользователь открывает календарь «Задачи» в jtx board (через DAVx5)
|
||||
- **THEN** VTODO отображается как задача с SUMMARY и DESCRIPTION
|
||||
@@ -0,0 +1,45 @@
|
||||
# Tasks: Убрать Vikunja, задачи через Radicale VTODO
|
||||
|
||||
## 1. Переключить обработчик task на Radicale VTODO
|
||||
|
||||
- [x] 1.1 В чейндже `email-classification-handlers` обновить specs/design/tasks:
|
||||
заменить «Vikunja API» на «Radicale CalDAV PUT VTODO» (календарь Задачи)
|
||||
- [x] 1.2 Уточнить формат VTODO: SUMMARY=тема, DESCRIPTION=ссылка на email.md,
|
||||
DTSTART/DUE при наличии даты из классификации
|
||||
- [x] 1.3 Верификация: чейндж `email-classification-handlers` остаётся валидным
|
||||
`Верификация: cd /opt/hermes/email-assistant && openspec validate email-classification-handlers`
|
||||
|
||||
## 2. Вывод Vikunja из эксплуатации
|
||||
|
||||
- [x] 2.1 Сделать бэкап данных Vikunja (если есть) перед удалением
|
||||
(volume vikunja-db / postgres-дамп в backups/) — **НЕ НУЖЕН** (решение пользователя 2026-09-13)
|
||||
- [x] 2.2 **Подтверждение пользователя на удаление** volume (данные Vikunja)
|
||||
— получено: «бэкап не нужен, выполняй остальные пункты»
|
||||
- [x] 2.3 Остановить и удалить контейнеры
|
||||
`cd /opt/hermes/email-assistant/vikunja && docker compose down -v`
|
||||
`Верификация: docker ps | grep -E 'vikunja|postgres' || echo 'Vikunja removed'`
|
||||
- [x] 2.4 Удалить каталог `/opt/hermes/email-assistant/vikunja/` (compose, .env)
|
||||
`Верификация: test ! -d /opt/hermes/email-assistant/vikunja`
|
||||
- [x] 2.5 Убрать/закомментировать reverse proxy tasks.nixg.ru из Caddy,
|
||||
если он настроен (Caddyfile на vps02) — закомментирован (строки 114-120), Caddy перезагружен
|
||||
|
||||
## 3. Обновить документацию и планы
|
||||
|
||||
- [x] 3.1 TODO.md: закрыть задачи Vikunja (2 «Vikunja развёрнут», 3 «Vikunja app»,
|
||||
6 «Vikunja API»), пометить «не нужна» (сделано 2026-09-13)
|
||||
- [x] 3.2 STATUS.md: убрать Vikunja из архитектуры, зафиксировать
|
||||
«задачи = Radicale VTODO, календарь Задачи, jtx board/DAVx5» (сделано 2026-09-13)
|
||||
- [x] 3.3 Обновить запись «Ресурсы проекта»: убрать Vikunja/tasks.nixg.ru,
|
||||
добавить Radicale VTODO (сделано 2026-09-13)
|
||||
- [x] 3.4 В чейндже `local-calendar-tasks` пометить Vikunja как не входящую
|
||||
(или заархивировать его как superseded) — proposal помечен SUPERSEDED
|
||||
|
||||
## 4. Проверка end-to-end
|
||||
|
||||
- [x] 4.1 Создать тестовое письмо с тегом `task` → обработчик создаёт VTODO
|
||||
в Radicale (календарь Задачи)
|
||||
`Верификация: curl -u estorozhenko:... -X PROPFIND -H 'Depth: 1' https://cal.nixg.ru/estorozhenko/<urlencoded Задачи>/ | grep -c 'VTODO\|ics'`
|
||||
— тестовый VTODO `test-vikunja-removal-2026` создан (PUT 201, GET 200) 2026-09-13
|
||||
- [x] 4.2 Верифицировать, что Vikunja отсутствует и ничего не сломано
|
||||
`docker ps | grep -i vikunja || echo OK` — контейнеров нет, порт 3456 свободен,
|
||||
cal.nixg.ru работает (207)
|
||||
Reference in New Issue
Block a user