# Contacts Extractor — извлечение адресной книги из писем ## Описание Автоматическое извлечение контактных данных отправителей из входящих писем. Использует локальную LLM (Qwen3:8b через Ollama) для парсинга подписей — данные, которые регуляркой не вытянуть: должности, отделы, компании, несколько телефонов. ## Пайплайн ``` mail_archive.py ──→ INBOX/UID/email.md (тупой насос, без LLM) ↓ contacts_extractor.py (LLM через delegate_task) ↓ /mnt/yandex-disk/hermes/email/contacts/ ├── contacts.vcf ← импорт в Thunderbird/Outlook/Apple Mail ├── contacts.json ← машинная база (дедупликация) ├── index.json ← uid → contact_id (для быстрых ответов) └── last_scan.json ← трекинг: какие письма уже обработаны ``` ## Трекинг обработанных писем Хранится в `contacts/last_scan.json`: ```json { "last_processed": "2026-07-16T18:00:00", "processed_uids": { "INBOX": 5787, "INBOX/!Протоколы": 0, "Archive": 22 }, "processed_emails": [ "ivanov@example.com", "petrov@example.com" ] } ``` **Логика работы:** 1. При запуске читает `last_scan.json` 2. Сканирует файловую структуру `INBOX/YYYY/MM/UID/email.md` (только входящие — не `Sent`) 3. Для каждого `email.md` проверяет: - Если UID ≤ last_uid по папке → уже обработано этим запуском - Если email отправителя уже есть в `contacts.json` → пропустить (или обновить, если прошло >30 дней) - Если файл `email.md` новее даты `last_processed` → обработать 4. После обработки обновляет `last_scan.json` **Почему не по UID только:** письма могут добавиться задним числом (например, после переархивации). Поэтому лучше комбинировать: last_uid + дата модификации `email.md`. ## Промпт для LLM ### Цель промпта Передаётся full text письма (YAML frontmatter отбрасывается, остаётся body). LLM должна: 1. Найти подпись в конце письма 2. Извлечь из неё контактные данные 3. Вернуть строгий JSON ### Вариант промпта (черновик) ``` Ты — экстрактор контактных данных из писем. Твоя задача — найти подпись отправителя в конце письма и извлечь структурированные данные. Правила поиска подписи: - Подпись обычно отделена от тела письма разделителями: "-- \n", "---\n", "С уважением,", "С наилучшими пожеланиями,", "Best regards,", "Kind regards,", "С ув.,", "————"—" - Если разделителя нет — последние 5-15 строк письма это подпись - Не путай подпись с цитируемым текстом переписки (обычно начинается с ">" или "On ... wrote:" или "————— Forwarded message —————") - Ignore boilerplate (disclaimers, confidentiality notices) Извлеки из подписи: 1. full_name — полное имя (ФИО) 2. email — email адрес (если есть в подписи, иначе null) 3. phone — основной телефон (в международном или местном формате) 4. phone_secondary — дополнительный телефон (если есть) 5. position — должность 6. company — название компании/организации 7. department — отдел (если указан) 8. address — почтовый/юридический адрес (если есть) 9. raw_signature — полный текст найденной подписи (для отладки) Если никакой подписи не найдено — верни только full_name (из from) и email, остальные поля null. Верни ТОЛЬКО JSON, без пояснений: {"full_name": "...", "email": "...", "phone": null, ...} Вот текст письма: [body] ``` ### Обсуждение промпта (решения) | Вопрос | Решение | |--------|---------| | Передаём body целиком или последние 50 строк? | **Целиком** — чтобы Qwen видела контекст и не путала подпись с цитатой | | Поле department нужно? | **Нет** — достаточно company + position | | vCard версия? | **4.0** — поддержка соцсетей, фото, расширенных полей | | Отправленные/Sent обрабатываем? | **Только входящие** — INBOX + вложенные папки | ### Архитектура хранилища ``` /opt/hermes/email/ # ← локально (быстрый диск) ├── INBOX/ │ └── ...email.md ├── state/ │ └── mail-archive-last-*.json # last_uid per folder └── contacts/ # адресная книга ├── contacts.vcf # vCard 4.0 для импорта ├── contacts.json # машинный формат ├── index.json # email → contact_id └── last_scan.json # трекинг обработанных писем ``` ## Трекинг обработанных писем ### Формат vCard Для импорта в почтовые клиенты (vCard 4.0, RFC 6350): ``` BEGIN:VCARD VERSION:4.0 FN:Иванов Иван Иванович N:Иванов;Иван;Иванович;;; EMAIL;TYPE=WORK:ivan@example.com TEL;TYPE=WORK:+7-123-456-78-90 TITLE:Генеральный директор ORG:ООО "Ромашка" ADR;TYPE=WORK:;;ул. Ленина, д.1;Москва;;123456;Россия NOTE:Извлечено из письма от 2026-07-16 (UID 11559, INBOX) END:VCARD ``` ### Структура contacts.json Для дедупликации и машинной обработки: ```json { "version": 1, "contacts": [ { "id": "md5_of_email", "email": "ivan@example.com", "full_name": "Иванов Иван Иванович", "phone": "+7-123-456-78-90", "phone_secondary": null, "position": "Генеральный директор", "company": "ООО \"Ромашка\"", "department": null, "address": null, "first_seen": "2026-07-16", "last_seen": "2026-07-16", "source_uids": ["INBOX/2026/07/11559"], "source_folders": ["INBOX"] } ], "by_email": { "ivan@example.com": 0 } } ``` - `by_email` — индекс для O(1) дедупликации - `source_uids` — массив, чтобы можно было посмотреть, из каких писем извлечён контакт - `first_seen`/`last_seen` — для понимания актуальности ### Дедупликация 1. **email — primary key.** Если контакт с таким email уже есть → обновить поля (телефон мог поменяться, должность — повысили) 2. Если email нет, но совпадает full_name (fuzzy) — всё равно создать новый, но зафиксировать в `note` 3. Если нет ни email, ни full_name (редко) — не сохранять ### Интеграция с будущим профилем Hermes Когда появится отдельный профиль с SOUL.md: - SOUL.md будет задавать personality и язык - Contacts extractor переедет туда как skill - Промпт может расшириться: «вытащи не только подпись, но и все упомянутые в письме контакты» ## План реализации 1. Создать `scripts/contacts_extractor.py` 2. Логика: сканирование `email.md` → дедупликация по `contacts.json` → вызов Qwen через субпроцесс (curl Ollama API) → запись 3. Промпт — hardcoded в скрипте (потом можно будет вынести в config/prompts/) 4. Systemd timer: mail-contacts.service + mail-contacts.timer (через час после архивации) 5. Создать Hermes-скилл `email-contacts`