v1.0.165: app_utils.js + detailed comments (Opus analysis) in classify/grouping/connection

This commit is contained in:
2026-06-24 08:46:40 +04:00
parent d86a369d2b
commit fe1f76fa1d
6 changed files with 205 additions and 61 deletions
+42 -7
View File
@@ -1,4 +1,15 @@
"""Classify service — LLM-based document classification."""
"""
Classify service — LLM-based document classification.
Архитектурное решение (Opus):
- Отдельный сервис, не встроен в upload. Upload быстрый (0.5с), classify — медленный (2-10с/файл).
- ThreadPoolExecutor(max_workers=4) — параллельная классификация с ограничением конкурентности,
чтобы не положить api.aillm.ru при 2000 файлах.
- Умная выжимка (_smart_extract): header ~1500 симв + regex-хиты по маркерам (договор/№/соглашение)
из всего документа. Экономия токенов в 5-10 раз при сохранении точности.
- Двухпроходная архитектура: LLM извлекает строки (тип/номер/дата/контрагент),
Python в grouping.py нормализует и группирует детерминированно.
"""
import json, re, os
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -8,20 +19,27 @@ from llm_prompt import build_classify_prompt
log = __import__("logging").getLogger(__name__)
# Лимит одновременных запросов к LLM API
# Увеличивать осторожно — api.aillm.ru может троттлить
MAX_WORKERS = 4
LLM_URL = "https://api.aillm.ru/v1/chat/completions"
LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
LLM_MODEL = "gpt-oss-120b"
def _call_llm_classify(header_text):
"""Call LLM for classification. Returns parsed JSON dict."""
"""
Прямой вызов LLM для классификации ОДНОГО документа.
Не использует общий call_llm() из services/llm.py — здесь свой промпт и формат ответа.
Возвращает распарсенный JSON dict с полями: doc_type, own_number, parent_number, doc_date, counterparty, confidence.
"""
prompt, _ = build_classify_prompt(header_text)
payload = {
"model": LLM_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 500,
"temperature": 0.1,
"max_tokens": 500, # классификация укладывается в ~100 токенов ответа
"temperature": 0.1, # минимальная температура для детерминированности
}
with httpx.Client(http2=True, timeout=60, verify=False) as client:
resp = client.post(
@@ -32,7 +50,7 @@ def _call_llm_classify(header_text):
data = resp.json()
raw = data.get("choices", [{}])[0].get("message", {}).get("content", "")
# Extract JSON from possible markdown wrapping
# LLM может обернуть JSON в markdown-блок ```json ... ```
json_text = raw
if "```json" in json_text:
json_text = json_text.split("```json")[1].split("```")[0]
@@ -42,7 +60,12 @@ def _call_llm_classify(header_text):
def classify_batch(batch_id):
"""Classify all pending documents in a batch. Returns summary dict."""
"""
Классифицировать все pending-документы в batch.
Вызывается из эндпоинта POST /api/classify-batch.
Параллельно (ThreadPoolExecutor) обрабатывает до MAX_WORKERS документов.
Возвращает {ok, total, done, failed}.
"""
pending = db_docs.list_pending(batch_id)
if not pending:
return {"ok": False, "error": "no pending documents"}
@@ -52,6 +75,7 @@ def classify_batch(batch_id):
failed = 0
def _classify_one(doc):
"""Классифицировать один документ: выжимка → LLM → сохранить результат."""
try:
text = _smart_extract(doc["elements_json"])
result = _call_llm_classify(text)
@@ -80,7 +104,18 @@ def classify_batch(batch_id):
def _smart_extract(elements_json):
"""Extract smart header: first ~1500 chars + marker lines from full doc."""
"""
Умная выжимка текста для классификации (решение Q3 от Opus).
Вместо отправки всего документа (дорого) или только header (теряет зарытые номера),
используется гибрид:
1. Первые ~1500 симв (титул, преамбула, стороны)
2. Regex-хиты по маркерам «договор|№|соглашение|приложение|спецификация»
из ВСЕГО документа
3. Дедупликация, лимит 10 строк, склейка → ~3000 симв на вход LLM
Это покрывает и титульную зону, и зарытые ссылки в середине документа.
"""
if not elements_json:
return ""