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

11 KiB
Raw Permalink Blame History

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

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

Дедупликация

  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 не пытается сверять отправителя с извлечёнными данными.