# Contacts Extractor — архитектура и промпт ## Концепция **Не внутри архиватора.** Архиватор (`mail_archive.py`) — тупой насос: забрал письмо → сохранил `email.md`. Без LLM. Иначе архивация встаёт на каждом письме (1-3 сек на Qwen), и при перезапуске парсит заново. ## Pipeline ``` mail_archive.py ──→ INBOX/UID/email.md (тупой насос, без LLM) ↓ contacts_extractor.py (LLM через Qwen3:8b Ollama) ↓ /opt/hermes/email/contacts/ ├── contacts.vcf ← vCard 4.0 для импорта ├── contacts.json ← машинная база ├── index.json ← email → contact_id └── last_scan.json ← трекинг (UID + mtime) ``` ## Принятые решения | Вопрос | Решение | |--------|---------| | Body целиком или последние N строк? | **Целиком** — контекст для точной подписи | | Поле department? | **Нет** — company + position достаточно | | vCard версия? | **4.0** (RFC 6350) — соцсети, фото | | Какие папки? | **Только входящие** (INBOX + подпапки) | | Трекинг обработанных | **Комбинированный** — UID для быстрой фильтрации + mtime для запоздалых | ## Логика extractor'a 1. Читает `last_scan.json` — какие UID уже обработаны по каждой папке 2. Сканирует `INBOX/**/email.md`. Для каждого: - Если UID ≤ last_uid → skip - Если email отправителя уже есть в `contacts.by_email` → skip (или обновить если last_seen > 30 дней) - Если mtime файла новее last_scan → обработать 3. Очищает body (clean_body), передаёт Qwen3:8b через Ollama API 4. Сохраняет результат, обновляет index.json, пересобирает contacts.vcf ## Реализация call_llm ```python def call_llm(body_text, max_retries=2): """Вызвать Qwen через Ollama API, вернуть JSON.""" # Варианты API: http://localhost:11434/api/generate (generate) # или http://localhost:11434/api/chat (chat) # Используем generate (не chat) — ответ "response" поле # Полный пример: import json, urllib.request body_text = clean_body(body_text) # очистка HTML truncated = body_text[:MAX_BODY_CHARS] # обрезка prompt = PROMPT_TEMPLATE.format(body=truncated) for attempt in range(max_retries + 1): payload = json.dumps({ "model": "qwen3:8b", "prompt": prompt, "stream": False, "options": { "temperature": 0.1, "num_predict": 1024, # минимум 1024 — 512 не хватает } }).encode("utf-8") req = urllib.request.Request( "http://localhost:11434/api/generate", data=payload, headers={"Content-Type": "application/json"}, method="POST", ) try: resp = urllib.request.urlopen(req, timeout=30) data = json.loads(resp.read().decode("utf-8")) response_text = data.get("response", "").strip() except Exception: if attempt < max_retries: continue return None # Парсинг JSON: try полный → brace-depth → fail try: return json.loads(response_text) except json.JSONDecodeError: pass # Brace-depth парсер (не re.DOTALL — обрезанный JSON) 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 if attempt < max_retries: continue return None ``` ### Почему не re.DOTALL `re.search(r"\{.*\}", text, re.DOTALL)` жадный — захватывает ВСЁ между первой `{` и последней `}`. Если в ответе два JSON-блока (пояснение + результат), или JSON обрезан на середине вложенности — re.DOTALL даёт мусор. Brace-depth парсер корректно обрабатывает: - Обрезанный JSON (Qwen не успела закончить `}`) - Два JSON-блока (берёт первый валидный) - JSON с пояснениями вокруг ## Проблема с пустыми ответами Ollama Qwen3:8b через `/api/generate` иногда возвращает `response=""`. Причины (гипотезы): - Конкуренция за GPU (если другой процесс жрёт VRAM) - Длинный контекст + маленький `num_predict` - Сбой внутри Ollama **Решение:** retry (2-3 попытки с `time.sleep(1)`). **Не путать:** `/api/generate` (generate) vs `/api/chat` (chat) — разные форматы ответов. ## Очистка тела письма Критично для распознавания подписи. HTML-мусор забивает контекст. ```python def clean_body(body): # Удаляем <#part ...> блоки body = re.sub(r'<#part[^>]*>|<#/part>', '', body) # Удаляем HTML-теги body = re.sub(r'<[^>]+>', '', body) # Удаляем mailto: ссылки (mailto:estorozhenko@...) body = re.sub(r'\(mailto:[^)]+\)', '', body) # Заменяем unicode-пробелы (NBSP \u00a0, zero-width \u2060 и др.) body = re.sub(r'[\u00a0\u2000-\u200f\u2028-\u202f\u2060]+', ' ', body) # Удаляем трекинг-пиксели и UTM-ссылки body = re.sub(r'https?://tn-eoc\.[^\s]+', '', body) body = re.sub(r'https?://[^\s]+\?utm_[^\s]+', '', body) # Схлопываем пустые строки body = re.sub(r'\n{3,}', '\n\n', body) return body.strip() ``` ## Промпт для LLM ```text Ты — экстрактор контактных данных из писем. Твоя задача — найти подпись отправителя в конце письма и извлечь структурированные данные. Правила поиска подписи: - Подпись обычно отделена от тела письма разделителями: "-- \\n", "---\\n", "С уважением,", "С наилучшими пожеланиями,", "Best regards,", "Kind regards,", "С ув.,", "—————" - Если разделителя нет — последние 5-15 строк письма это подпись - Не путай подпись с цитируемым текстом переписки (обычно начинается с ">" или "On ... wrote:" или "— Пересылаемое сообщение —") - Ignore boilerplate (disclaimers, confidentiality notices) Извлеки из подписи: 1. full_name — полное имя (ФИО) 2. email — email адрес (если есть в подписи, иначе null) 3. phone — основной телефон 4. phone_secondary — дополнительный телефон (если есть) 5. position — должность 6. company — название компании/организации 7. address — почтовый/юридический адрес (если есть) 8. raw_signature — полный текст найденной подписи (для отладки) Если никакой подписи не найдено — верни только full_name и email, остальные поля null. Верни ТОЛЬКО JSON, без пояснений: {"full_name": "...", "email": null, ...} ``` ### Экранирование JSON в Python str.format() При использовании `str.format()` фигурные скобки в JSON-примере внутри промпта конфликтуют с шаблоном. Решение — двойные скобки `{{...}}` для литеральных `{...}` в шаблоне: ```python PROMPT_TEMPLATE = """...Верни ТОЛЬКО JSON, без пояснений: {{"full_name": "...", "email": null, ...}} ... {body}""" ``` ## Формат vCard 4.0 ``` 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 ``` ## Дедупликация 1. **email — primary key.** Если контакт с таким email уже есть → обновить поля (телефон мог поменяться, должность — повысили). 2. Если email нет, но совпадает full_name (fuzzy) — создать новый, но зафиксировать в `note` возможный дубль. 3. Если нет ни email, ни full_name — не сохранять. 4. `first_seen` / `last_seen` в контакте для понимания актуальности. ## Обработка reply/forward писем В reply/forward письмах подпись может принадлежать НЕ отправителю в `From:`. Например: Головлев пересылает письмо Елены Стороженко — её подпись в теле. Это нормально — контакт Елены всё равно ценный. LLM не пытается сверять отправителя с извлечёнными данными.