11 KiB
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
- Читает
last_scan.json— какие UID уже обработаны по каждой папке - Сканирует
INBOX/**/email.md. Для каждого:- Если UID ≤ last_uid → skip
- Если email отправителя уже есть в
contacts.by_email→ skip (или обновить если last_seen > 30 дней) - Если mtime файла новее last_scan → обработать
- Очищает body (clean_body), передаёт Qwen3:8b через Ollama API
- Сохраняет результат, обновляет index.json, пересобирает contacts.vcf
Реализация call_llm
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-мусор забивает контекст.
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
Ты — экстрактор контактных данных из писем. Твоя задача — найти подпись
отправителя в конце письма и извлечь структурированные данные.
Правила поиска подписи:
- Подпись обычно отделена от тела письма разделителями:
"-- \\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-примере внутри промпта
конфликтуют с шаблоном. Решение — двойные скобки {{...}} для литеральных
{...} в шаблоне:
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
Дедупликация
- email — primary key. Если контакт с таким email уже есть → обновить поля (телефон мог поменяться, должность — повысили).
- Если email нет, но совпадает full_name (fuzzy) — создать новый, но
зафиксировать в
noteвозможный дубль. - Если нет ни email, ни full_name — не сохранять.
first_seen/last_seenв контакте для понимания актуальности.
Обработка reply/forward писем
В reply/forward письмах подпись может принадлежать НЕ отправителю в From:.
Например: Головлев пересылает письмо Елены Стороженко — её подпись в теле.
Это нормально — контакт Елены всё равно ценный. LLM не пытается сверять
отправителя с извлечёнными данными.