mirror of
https://gitverse.ru/kpa39l/email-local-archive.git
synced 2026-09-29 09:15:11 +00:00
238 lines
11 KiB
Markdown
238 lines
11 KiB
Markdown
# 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 не пытается сверять
|
||
отправителя с извлечёнными данными.
|