mirror of
https://gitverse.ru/kpa39l/email-local-archive.git
synced 2026-09-29 09:15:11 +00:00
Initial commit: Hermes skill email-local-archive
This commit is contained in:
@@ -0,0 +1,528 @@
|
||||
---
|
||||
name: email-local-archive
|
||||
description: "Локальный архив почты: инкрементальное сохранение писем из IMAP в файловую структуру на локальный диск с метаданными, телом, вложениями и извлечением адресной книги через LLM."
|
||||
version: 1.6.0
|
||||
author: estorozhenko
|
||||
platforms: [linux]
|
||||
prerequisites:
|
||||
commands: [himalaya, python3]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [email, archive, imap, backup, contacts]
|
||||
---
|
||||
|
||||
# Email Local Archive
|
||||
|
||||
Этот навык позволяет агенту архивировать письма из почтового ящика
|
||||
в локальную файловую систему (`/opt/hermes/email/`) и извлекать адресную
|
||||
книгу из подписей входящих писем через локальную LLM.
|
||||
|
||||
**История:** архив был перенесён с Yandex Disk (WebDAV davfs2) на локальный
|
||||
диск `/opt/hermes/email/` из-за катастрофической медлительности WebDAV при
|
||||
rm/find/ls на больших деревьях.
|
||||
|
||||
## Структура хранилища
|
||||
|
||||
```
|
||||
/opt/hermes/email/
|
||||
├── INBOX/
|
||||
│ ├── YYYY/MM/UID/
|
||||
│ │ ├── email.md # YAML-frontmatter + тело письма
|
||||
│ │ └── attachments/ # вложения
|
||||
│ └── ...
|
||||
├── Отправленные/...
|
||||
├── Archive/...
|
||||
├── Sent/...
|
||||
├── state/
|
||||
│ └── mail-archive-last-*.json # last_uid per folder
|
||||
└── contacts/ # адресная книга (извлечение через LLM)
|
||||
├── contacts.vcf # vCard 4.0 для импорта
|
||||
├── contacts.json # машинный формат
|
||||
├── index.json # email → contact_id
|
||||
└── last_scan.json # трекинг обработанных писем (UID + mtime)
|
||||
```
|
||||
|
||||
Формат email.md — YAML-frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
id: 255
|
||||
folder: INBOX
|
||||
subject: "Re: ..."
|
||||
from: "Name <addr>"
|
||||
to: "Name <addr>"
|
||||
date: "2025-07-10 11:51+03:00"
|
||||
flags: ["Seen"]
|
||||
has_attachment: false
|
||||
message_id: <...@domain.ru>
|
||||
in_reply_to: <...@domain.ru>
|
||||
references: <...> <...>
|
||||
cc: "Name <addr>"
|
||||
content_type: multipart/mixed; boundary=...
|
||||
---
|
||||
Body text here...
|
||||
```
|
||||
|
||||
## Компоненты
|
||||
|
||||
| Компонент | Путь | Назначение |
|
||||
|-----------|------|------------|
|
||||
| Архиватор | `/opt/hermes/email-assistant/scripts/mail_archive.py` | Тупой насос: himalaya → email.md. Без LLM. |
|
||||
| Индексатор | `/opt/hermes/email-assistant/scripts/mail_index.py` | SQLite-индекс всех email.md для быстрого поиска |
|
||||
| Поиск (FTS5) | `/opt/hermes/email-assistant/scripts/sqlite_search.py` | Полнотекстовый поиск по SQLite-индексу |
|
||||
| Contacts Extractor | `/opt/hermes/email-assistant/scripts/contacts_extractor.py` | LLM-парсинг подписей через Qwen3:8b |
|
||||
| Дайджест | `/opt/hermes/email-assistant/scripts/digest.py` | Еженедельный дайджест почты через Qwen3:8b |
|
||||
| Shell-обёртка | `/opt/hermes/email-assistant/scripts/mail-archive.sh` | Для systemd/cron |
|
||||
| Документация | `/opt/hermes/email-assistant/context/CONTACTS.md` | Архитектура, промпт, формат vCard |
|
||||
|
||||
## SQLite-индекс (mail_index.py + sqlite_search.py)
|
||||
|
||||
Быстрый поиск по архиву без grep. База: `/opt/hermes/email/mail_index.db`.
|
||||
|
||||
**Схема:** таблица `emails` (2073 записи) + FTS5 virtual table `email_fts` для полнотекстового поиска. Триггеры синхронизируют FTS5 при INSERT/UPDATE/DELETE. Индексы: folder, uid, contacts_extracted, date.
|
||||
|
||||
**Контракт с contacts_extractor:** поля `contacts_extracted` (0/1) и `contacts_skipped` (0/1) — общие. mail_index.py создаёт схему, contacts_extractor.py пишет флаги.
|
||||
|
||||
**Поиск:**
|
||||
```bash
|
||||
python3 /opt/hermes/email-assistant/scripts/sqlite_search.py 'Стороженко'
|
||||
python3 /opt/hermes/email-assistant/scripts/sqlite_search.py 'битрикс OR контрагент'
|
||||
python3 /opt/hermes/email-assistant/scripts/sqlite_search.py 'from:example@mail'
|
||||
python3 /opt/hermes/email-assistant/scripts/sqlite_search.py --folder 'INBOX/!Отчеты'
|
||||
```
|
||||
|
||||
Операторы FTS5: AND (по умолчанию), OR, `"точная фраза"`, `-исключить`, `prefix*`.
|
||||
|
||||
Подробнее: `references/sqlite-index-architecture.md`
|
||||
|
||||
## Дайджест почты (digest.py)
|
||||
|
||||
Еженедельный дайджест через Qwen3:8b. Выборка из SQLite за N дней, группировка по папкам, LLM-генерация краткого обзора на русском. Сохраняется в `/opt/hermes/email/digests/digest-YYYY-MM-DD.md`.
|
||||
|
||||
```bash
|
||||
python3 /opt/hermes/email-assistant/scripts/digest.py # 7 дней
|
||||
python3 /opt/hermes/email-assistant/scripts/digest.py --days 14
|
||||
```
|
||||
|
||||
Подробнее: `references/digest-pipeline.md`
|
||||
|
||||
## Пайплайн (полный)
|
||||
|
||||
```
|
||||
mail_archive.py ──→ INBOX/UID/email.md (тупой насос, без LLM)
|
||||
↓
|
||||
contacts_extractor.py (LLM через delegate_task)
|
||||
↓
|
||||
/opt/hermes/email/contacts/
|
||||
├── contacts.vcf ← vCard 4.0, импорт в любой клиент
|
||||
├── contacts.json ← машинная база (дедупликация)
|
||||
├── index.json ← email → contact_id
|
||||
└── last_scan.json ← трекинг обработанных
|
||||
```
|
||||
|
||||
### Contacts Extractor: ключевые решения
|
||||
|
||||
| Вопрос | Решение |
|
||||
|--------|---------|
|
||||
| Body целиком или последние N строк? | **Целиком** — контекст для точного распознавания подписи |
|
||||
| Поле department? | **Нет** — достаточно company + position |
|
||||
| vCard версия? | **4.0** — соцсети, фото, расширенные поля |
|
||||
| Какие папки обрабатывать? | **Только входящие** (INBOX + подпапки) |
|
||||
| Как трекать обработанные? | **UID + mtime** — UID для быстрого skip, mtime для отлова запоздалых писем |
|
||||
|
||||
### Промпт для LLM (Qwen3:8b через Ollama)
|
||||
|
||||
```
|
||||
Ты — экстрактор контактов. Извлеки данные отправителя из подписи письма.
|
||||
|
||||
Отправитель (для сверки): {from_name} <{from_email}>
|
||||
|
||||
Найди подпись. Она обычно:
|
||||
- После "-- \n", "---\n", "Best regards,", "Kind regards,"
|
||||
- После "С уважением,", "С наилучшими пожеланиями,"
|
||||
- После "С ув.,", "Всего доброго,"
|
||||
- Если разделителя нет — последние 5-15 строк
|
||||
|
||||
НЕ путай подпись с:
|
||||
- Цитируемой предыдущей перепиской (строки с ">" или "On ... wrote:")
|
||||
- Пересланным сообщением ("— Пересылаемое сообщение —")
|
||||
- Дисклеймером/конфиденциальностью внизу письма
|
||||
|
||||
Верни JSON:
|
||||
{
|
||||
"full_name": null, // Полное имя (ФИО)
|
||||
"phone": null, // Основной телефон
|
||||
"phone_secondary": null, // Дополнительный телефон
|
||||
"position": null, // Должность
|
||||
"company": null, // Компания/организация
|
||||
"address": null, // Адрес (почтовый/юридический)
|
||||
"signature_found": false // Была ли подпись вообще
|
||||
}
|
||||
```
|
||||
|
||||
## Основные команды
|
||||
|
||||
```bash
|
||||
# Архивация INBOX (10 писем за раз для bulk sync)
|
||||
python3 /opt/hermes/email-assistant/scripts/mail_archive.py --folder INBOX --limit 10
|
||||
|
||||
# Архивация всех папок
|
||||
python3 /opt/hermes/email-assistant/scripts/mail_archive.py --all --limit 10
|
||||
|
||||
# Поиск по архиву (быстрый — локальный диск)
|
||||
grep -ril 'тема' /opt/hermes/email/**/email.md 2>/dev/null
|
||||
|
||||
# Статус архива
|
||||
cat /opt/hermes/email/state/mail-archive-last-*.json
|
||||
```
|
||||
|
||||
## Проблема: хардкод подпапок INBOX (18 из 137)
|
||||
|
||||
**Статус:** ❌ Не решено — задача зафиксирована в STATUS.md как Фаза 1.7.
|
||||
|
||||
**Проблема:** `mail_archive.py` содержит захардкоженный список `INBOX_SUBFOLDERS` (18 папок),
|
||||
но на IMAP-сервере реально **137 подпапок INBOX**, включая многоуровневые глубиной 2-3:
|
||||
|
||||
```
|
||||
INBOX/!Персонал/ОТ и ТБ
|
||||
INBOX/Бюджет/Винный город/CAPEX 2025
|
||||
INBOX/Контрагенты/iiko/Тихая гавань
|
||||
INBOX/!Реестр оплаты/Закупки/Сервер
|
||||
...
|
||||
```
|
||||
|
||||
**Требуется:** динамическое обнаружение IMAP-папок через `himalaya folder list`,
|
||||
рекурсивный обход всех подпапок INBOX (любой глубины), автоматическая архивация
|
||||
новых подпапок при их создании.
|
||||
|
||||
**План исправления:**
|
||||
1. Заменить хардкод `INBOX_SUBFOLDERS` на вызов `himalaya folder list` с парсингом JSON
|
||||
2. Фильтровать только папки, начинающиеся с `INBOX/` (не трогать Trash, Drafts, RSS и т.д.)
|
||||
3. Рекурсивно архивировать все уровни вложенности
|
||||
4. `mail-archive-every-5min` cron должен обновлять список папок динамически каждый раз
|
||||
5. State-файлы уже поддерживают произвольные имена папок (через хэш MD5 для не-ASCII)
|
||||
|
||||
Подробнее: `references/dynamic-folder-discovery.md`
|
||||
|
||||
## Bulk-sync cron strategy
|
||||
|
||||
Двухфазный подход для переноса архива:
|
||||
|
||||
| Фаза | Период | Лимит | Параметры cron |
|
||||
|------|--------|-------|----------------|
|
||||
| **Bulk sync** (слив существующего архива) | Каждые 5 мин | 10 писем на папку | `no_agent=True`, `script=` |
|
||||
| **Incremental** (только новые письма) | Каждые 30 мин | 50-100 | `no_agent=True`, `script=` |
|
||||
|
||||
В bulk-фазе лимит маленький (10), чтобы:
|
||||
- Не превышать таймаут при медленных папках
|
||||
- Не перегружать IMAP-сервер
|
||||
- Равномерно распределять нагрузку на все 22 папки
|
||||
|
||||
Когда state-файлы перестают обновляться (все письма заархивированы) — переключить на incremental.
|
||||
|
||||
### no_agent cron (рекомендуемый паттерн)
|
||||
|
||||
Для скриптовых recurring-задач без LLM:
|
||||
|
||||
```bash
|
||||
# Создать скрипт в ~/.hermes/scripts/
|
||||
cat > ~/.hermes/scripts/mail-archive.sh << 'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd /opt/hermes/email-assistant
|
||||
exec python3 scripts/mail_archive.py --all --limit 10
|
||||
SCRIPT
|
||||
|
||||
# Создать cron
|
||||
hermes cron create \
|
||||
--name "mail-archive-every-5min" \
|
||||
--schedule "every 5m" \
|
||||
--script mail-archive.sh \
|
||||
--no-agent
|
||||
|
||||
# Обновить для incremental
|
||||
hermes cron update <id> --schedule "every 30m" --script mail-archive.sh --no-agent
|
||||
```
|
||||
|
||||
**Важно:** `script` должен быть относительным путём в `~/.hermes/scripts/`.
|
||||
Абсолютные пути не принимаются.
|
||||
|
||||
### Shell wrapper с per-folder timeouts
|
||||
|
||||
При обработке 22+ папок одна медленная папка не должна валить весь запуск.
|
||||
Скрипт-обёртка обрабатывает каждую папку с отдельным `timeout`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd /opt/hermes/email-assistant
|
||||
|
||||
TIMEOUT=60
|
||||
LIMIT=10
|
||||
|
||||
FOLDERS=(
|
||||
"INBOX" "Отправленные" "Archive" "Sent"
|
||||
"INBOX/!Scan" "INBOX/!Битрикс" "INBOX/!ВГ Чек листы"
|
||||
"INBOX/!Документооборот" "INBOX/!Завки" "INBOX/!Материалы"
|
||||
"INBOX/!Отчеты" "INBOX/!Персонал" "INBOX/!Протоколы"
|
||||
"INBOX/!Реестр оплаты" "INBOX/!Торик" "INBOX/Бюджет"
|
||||
"INBOX/Контрагенты" "INBOX/ЛНД" "INBOX/Организация работы"
|
||||
"INBOX/Приемка и стройка" "INBOX/Системы" "INBOX/Эксплуатация"
|
||||
)
|
||||
|
||||
for folder in "${FOLDERS[@]}"; do
|
||||
echo "[$folder] start..."
|
||||
out=$(timeout $TIMEOUT python3 scripts/mail_archive.py --folder "$folder" --limit $LIMIT 2>&1) || true
|
||||
echo "$out"
|
||||
done
|
||||
```
|
||||
|
||||
## Contacts Extractor: реализация
|
||||
|
||||
### Инкрементальное сохранение (save_progress)
|
||||
|
||||
Раньше скрипт писал все файлы только в конце — при таймауте (600с на 354 письма при Qwen3:8b) весь прогресс терялся.
|
||||
|
||||
**Решение:** `save_progress()` вызывается после каждой обработанной папки:
|
||||
|
||||
```python
|
||||
def save_progress(contacts_db, contacts_dir, processed_uids, processed_emails):
|
||||
contacts_db["contacts"].sort(key=lambda c: c.get("email", ""))
|
||||
save_json(contacts_dir / "contacts.json", contacts_db)
|
||||
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
|
||||
generate_vcard(contacts_dir, contacts_db["contacts"])
|
||||
last_scan = {
|
||||
"last_processed": datetime.now().isoformat(timespec="seconds"),
|
||||
"processed_uids": processed_uids,
|
||||
"processed_emails": sorted(processed_emails),
|
||||
}
|
||||
save_json(contacts_dir / "last_scan.json", last_scan)
|
||||
```
|
||||
|
||||
### --limit для batch-запусков
|
||||
|
||||
Для локальной Qwen3:8b на CPU (~15-20 сек на письмо) полный прогон 354 писем
|
||||
занимает >1.5 часа. Добавлен `--limit`:
|
||||
|
||||
```bash
|
||||
python3 scripts/contacts_extractor.py --limit 15 # ~5 мин на CPU
|
||||
python3 scripts/contacts_extractor.py --limit 50 # ~2 мин на GPU
|
||||
```
|
||||
|
||||
Лимит считается на все папки суммарно. После каждой папки — `save_progress()`.
|
||||
|
||||
### None-safety при работе с JSON
|
||||
|
||||
Qwen через Ollama может вернуть JSON с `null` значениями. Python `.get(key, "")` возвращает `None` (не `""`), если ключ существует со значением `null`:
|
||||
|
||||
```python
|
||||
# ❌ Падает с AttributeError: 'NoneType' object has no attribute 'strip'
|
||||
full_name = llm_result.get("full_name", "").strip()
|
||||
|
||||
# ✅ Безопасно
|
||||
full_name = (llm_result.get("full_name") or "").strip()
|
||||
```
|
||||
|
||||
### Отсутствующий import hashlib
|
||||
|
||||
Скрипт использовал `hashlib.md5()` в `contact_id()` без импорта — падение при первом вызове. Исправлено добавлением `import hashlib`.
|
||||
|
||||
### Очистка тела письма перед LLM (clean_body)
|
||||
|
||||
Перед отправкой в LLM тело письма проходит очистку. Ключевые этапы:
|
||||
|
||||
```python
|
||||
def clean_body(body):
|
||||
# Удаляем <#part ...> блоки
|
||||
body = re.sub(r'<#part[^>]*>', '', body)
|
||||
body = re.sub(r'<#/part>', '', body)
|
||||
# Удаляем HTML-теги
|
||||
body = re.sub(r'<[^>]+>', '', body)
|
||||
# Удаляем mailto: ссылки
|
||||
body = re.sub(r'\\(mailto:[^)]+\\)', '', body)
|
||||
# Заменяем unicode-пробелы (NBSP, zero-width) на обычные
|
||||
body = re.sub(r'[\\u00a0\\u2000-\\u200f\\u2028-\\u202f\\u2060]+', ' ', body)
|
||||
# Удаляем трекинг-ссылки
|
||||
body = re.sub(r'https?://tn-eoc\\.[^\\s]+', '', body)
|
||||
body = re.sub(r'https?://[^\\s]+\\?utm_[^\\s]+', '', body)
|
||||
# Отрезаем цитируемую переписку — ищем САМЫЙ РАННИЙ маркер цитирования
|
||||
quote_patterns = [
|
||||
r'^[\\s]*_{4,}\\s*$', # _____
|
||||
r'От:.*\\n[\\s]*Отправлено:', # Russian Outlook (в любом месте строки)
|
||||
r'^[\\s]*From:.*\\n[\\s]*Sent:', # English Outlook headers
|
||||
r'——-.*Forwarded.*——-',
|
||||
r'——-.*Пересылаемое.*——-',
|
||||
r'——-.*Original Message.*——-',
|
||||
r'>.*\\bwrote:',
|
||||
]
|
||||
earliest = None
|
||||
earliest_pos = len(body)
|
||||
for qp in quote_patterns:
|
||||
for m in re.finditer(qp, body, re.MULTILINE):
|
||||
if m.start() < earliest_pos:
|
||||
earliest_pos = m.start()
|
||||
earliest = m
|
||||
if earliest:
|
||||
body = body[:earliest_pos].strip()
|
||||
```
|
||||
|
||||
**Ключевое изменение:** вместо `re.search()` по списку (первый сработавший) — `re.finditer()` по ВСЕМ паттернам, выбор самого раннего матча. Это критично для писем с двумя фрагментами цитирования (например, ответ на forwarded message), где второй матч (`From: ... Sent:`) срабатывает раньше первого (`От: Стороженко`), потому что `От:` не в начале строки.
|
||||
|
||||
**Fallback:** если ни один паттерн не совпал — ищем любой Outlook-заголовок (`От:`, `Отправлено:`, `From:`, `Sent:`, `Кому:`, `Subject:`) в последних 500 символах. Это ловит подписи, где `От:` вшит в строку с телефоном: `+7 962 854 22 01 От: Стороженко`
|
||||
|
||||
### Парсинг JSON из ответа LLM (brace-depth)
|
||||
|
||||
Qwen3:8b через Ollama может обрезать JSON на лимите `num_predict` или добавить
|
||||
пояснения. Вместо `re.search(r"\{.*\}", ..., re.DOTALL)` — парсинг по глубине
|
||||
скобок:
|
||||
|
||||
```python
|
||||
# Пробуем распарсить весь ответ как JSON
|
||||
try:
|
||||
return json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Если не получилось — ищем { ... } внутри
|
||||
brace_depth = 0
|
||||
json_start = None
|
||||
for i, ch in enumerate(response_text):
|
||||
if ch == '{':
|
||||
if brace_depth == 0:
|
||||
json_start = i
|
||||
brace_depth += 1
|
||||
elif ch == '}':
|
||||
brace_depth -= 1
|
||||
if brace_depth == 0 and json_start is not None:
|
||||
try:
|
||||
return json.loads(response_text[json_start:i+1])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
json_start = None
|
||||
```
|
||||
|
||||
### Retry при пустых ответах Ollama
|
||||
|
||||
Qwen3:8b иногда возвращает `response=""` (пустая строка) на длинных промптах.
|
||||
Стандартный retry (2-3 попытки с `time.sleep(1)`) решает проблему.
|
||||
|
||||
### Проблема: num_predict=512 недостаточно
|
||||
|
||||
Телефон + должность + компания не помещаются в 512 токенов для русского текста.
|
||||
Увеличено до 1024.
|
||||
|
||||
### Экранирование JSON в промпте
|
||||
|
||||
При использовании `str.format()` фигурные скобки в JSON-примере внутри промпта
|
||||
конфликтуют с шаблоном. Решение — двойные скобки `{{...}}` для литеральных
|
||||
`{...}` в шаблоне:
|
||||
|
||||
```python
|
||||
PROMPT_TEMPLATE = """...Верни ТОЛЬКО JSON, без пояснений:
|
||||
{{"full_name": "...", "email": null, ...}}
|
||||
{body}"""
|
||||
```
|
||||
|
||||
### Обработка reply/forward писем
|
||||
|
||||
В reply/forward письмах подпись может принадлежать НЕ отправителю в `From:`.
|
||||
Например: Головлев пересылает письмо Елены Стороженко — её подпись в теле.
|
||||
Это нормально — контакт Елены всё равно ценный. LLM не пытается сверять
|
||||
отправителя с извлечёнными данными.
|
||||
|
||||
## Оптимизация pagination
|
||||
|
||||
### Проблема
|
||||
`get_envelopes()` изначально тащил ВСЕ страницы INBOX (58 страниц × 200 писем = ~11559)
|
||||
через SOCKS5 — это висло на 60+ секунд и никогда не доходило до архивации.
|
||||
|
||||
### Решение
|
||||
```python
|
||||
def get_envelopes(folder, limit=100, last_uid=0):
|
||||
"""Ранняя остановка pagination при достижении лимита ИЛИ если все
|
||||
письма на странице уже обработаны (UID <= last_uid)."""
|
||||
page_size = 500 # 1 страница покрывает лимит
|
||||
# ...
|
||||
# Хватит — не тащим остальные страницы
|
||||
if total_new >= limit:
|
||||
break
|
||||
# Если все письма на этой странице старые — дальше только старее
|
||||
last_batch_min = min(int(e.get("uid")...) for e in batch)
|
||||
if last_batch_min <= last_uid:
|
||||
break
|
||||
```
|
||||
|
||||
**Ключевое:** одна страница `page_size=500` покрывает `limit=10` с запасом.
|
||||
Искать на последующих страницах нужно только если на этой меньше лимита новых писем.
|
||||
|
||||
## Cron orchestration
|
||||
|
||||
Два независимых cron-пайплайна поверх общего хранилища — см.
|
||||
`references/cron-workflows.md` для полной документации:
|
||||
|
||||
| Пайплайн | Расписание | Скрипт | `no_agent` | LLM |
|
||||
|-----------|-----------|--------|------------|-----|
|
||||
| Архивация | `every 5m` | `mail-archive.sh` | ✅ Да | ❌ Нет |
|
||||
| Контакты | `every 30m` | `contacts-cron.sh` | ❌ Нет | ✅ Qwen3:8b |
|
||||
|
||||
**Ключевые правила для cron с LLM:**
|
||||
- Контакты НЕ используют `--no-agent` — LLM-вызовы внутри Python-скрипта
|
||||
работают и без агента, но без агента результат не доставляется
|
||||
- `--limit` рассчитан на скорость Qwen3:8b: CPU = 15-20s/email → limit 15
|
||||
- Контакты всегда остаются инкрементальными (UID-трекинг в `last_scan.json`)
|
||||
|
||||
## Скорость Qwen3:8b как planning-ограничение
|
||||
|
||||
Локальная Qwen3:8b через Ollama — бутылочное горлышко для contacts extractor:
|
||||
|
||||
| Окружение | Время на письмо | limit за 5 мин | limit за 30 мин |
|
||||
|-----------|----------------|----------------|-----------------|
|
||||
| CPU (только процессор) | 15-20 с | 15 | 90 |
|
||||
| GPU (NVIDIA/AMD) | 2-3 с | 100 | 600 |
|
||||
|
||||
**Никогда не пытаться прогнать все 354 письма INBOX за один запуск при CPU.**
|
||||
Использовать `--limit` + cron (каждые 30 мин) для инкрементальной обработки.
|
||||
|
||||
## Поведение агента
|
||||
|
||||
### Когда архив был на Yandex Disk (WebDAV davfs2)
|
||||
Сейчас архив на `/opt/hermes/email/` — локальный диск, быстрый. Но код
|
||||
исторически работал с `/mnt/yandex-disk/` — медленным WebDAV. Если архив
|
||||
снова окажется на WebDAV:
|
||||
|
||||
- **Не делать** `rm -rf` на вложенных папках — шлёт HTTP DELETE на каждый файл
|
||||
- **Не делать** `find` на всём архиве — таймаутится
|
||||
- **Партии по 5-10 писем** за один запуск
|
||||
- **Делегировать batch-операции** локальной Qwen3:8b через `delegate_task`
|
||||
|
||||
### Текущий архив (локальный диск)
|
||||
- Батчи по 50-500 писем — диск быстрый
|
||||
- `grep` работает мгновенно
|
||||
- `find` / `rm` — без ограничений
|
||||
|
||||
## Himalaya: несколько аккаунтов
|
||||
|
||||
Конфиг может содержать несколько почтовых аккаунтов. Для переключения:
|
||||
|
||||
```bash
|
||||
himalaya envelope list --account <name> --limit 10
|
||||
# или через переменную окружения:
|
||||
HIMALAYA_ACCOUNT=hermes himalaya envelope list --limit 10
|
||||
```
|
||||
|
||||
При добавлении нового аккаунта:
|
||||
- Скопировать блок `[accounts.<name>]`, заменить данные
|
||||
- Если нужен SMTP — добавить `[accounts.<name>.message.send]`
|
||||
- Порт и шифрование подбираются под провайдера: STARTTLS (порт 143 для IMAP, 587 для SMTP) vs TLS (993/465)
|
||||
- Только один аккаунт может быть `default = true`
|
||||
|
||||
Документированные провайдеры:
|
||||
- **Jino (jino.ru):** `references/jino-server-setup.md` — порты, auth, диагностика
|
||||
|
||||
## Правила
|
||||
|
||||
- НЕ изменять файлы архива без явной команды
|
||||
- НЕ переписывать логику архивации сырыми командами Himalaya
|
||||
- Пароль в `backend.auth.raw` в конфиге — не показывать в логах
|
||||
- **Contacts Extractor — отдельный pipeline, НЕ внутри архиватора.** Не вызывать
|
||||
LLM в `mail_archive.py`. Архиватор должен оставаться тупым насосом.
|
||||
Reference in New Issue
Block a user