mirror of
https://gitverse.ru/kpa39l/email-local-archive.git
synced 2026-09-29 09:15:11 +00:00
Initial commit: Hermes skill email-local-archive
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
# 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 не пытается сверять
|
||||
отправителя с извлечёнными данными.
|
||||
Reference in New Issue
Block a user