Files
email-local-archive/references/contacts-extractor-architecture.md
T
2026-09-06 13:51:14 +00:00

238 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 не пытается сверять
отправителя с извлечёнными данными.