""" 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 import httpx from db import documents as db_docs 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): """ Прямой вызов 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, # классификация укладывается в ~100 токенов ответа "temperature": 0.1, # минимальная температура для детерминированности } with httpx.Client(http2=True, timeout=60, verify=False) as client: resp = client.post( LLM_URL, json=payload, headers={"Authorization": f"Bearer {LLM_KEY}", "Content-Type": "application/json"}, ) resp.raise_for_status() data = resp.json() raw = data.get("choices", [{}])[0].get("message", {}).get("content", "") return _safe_json_parse(raw) def classify_batch(batch_id): """ Классифицировать все 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"} total = len(pending) done = 0 failed = 0 def _classify_one(doc): """Классифицировать один документ: выжимка → LLM → сохранить результат.""" try: text = _smart_extract(doc["elements_json"]) result = _call_llm_classify(text) db_docs.set_classification( doc["id"], result.get("doc_type", "other"), result.get("own_number"), result.get("parent_number"), result.get("doc_date"), result.get("counterparty"), ) return True except Exception as e: db_docs.set_classify_failed(doc["id"], str(e)) return False with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: futures = {pool.submit(_classify_one, d): d for d in pending} for f in as_completed(futures): if f.result(): done += 1 else: failed += 1 return {"ok": True, "total": total, "done": done, "failed": failed} def _safe_json_parse(raw): """Parse LLM response, fixing common JSON errors (multiline, markdown, unescaped chars).""" if not raw: raise ValueError("empty LLM response") # Strip markdown wrappers text = raw.strip() if "```json" in text: text = text.split("```json")[1].split("```")[0].strip() elif "```" in text: text = text.split("```")[1].split("```")[0].strip() # Try strict parse first try: return json.loads(text) except json.JSONDecodeError: pass # Fix common issues: unescaped newlines inside strings, unterminated strings # Replace literal newlines inside JSON values import re as _re # Collapse multiline to single line text = _re.sub(r'\n\s*', ' ', text) # Remove trailing commas before closing braces text = _re.sub(r',\s*}', '}', text) text = _re.sub(r',\s*]', ']', text) return json.loads(text) def _smart_extract(elements_json): """ Умная выжимка текста для классификации (решение Q3 от Opus). Вместо отправки всего документа (дорого) или только header (теряет зарытые номера), используется гибрид: 1. Первые ~1500 симв (титул, преамбула, стороны) 2. Regex-хиты по маркерам «договор|№|соглашение|приложение|спецификация» из ВСЕГО документа 3. Дедупликация, лимит 10 строк, склейка → ~3000 симв на вход LLM Это покрывает и титульную зону, и зарытые ссылки в середине документа. """ if not elements_json: return "" if isinstance(elements_json, str): try: elements = json.loads(elements_json) except json.JSONDecodeError: return elements_json[:2000] elif isinstance(elements_json, list): elements = elements_json else: return str(elements_json)[:2000] # Build full text lines = [] for el in elements: if isinstance(el, dict): t = el.get("type") or el.get("TYPE", "") if t == "paragraph": txt = el.get("text") or el.get("TEXT", "") if txt: lines.append(txt) elif t == "table": rows = el.get("rows") or el.get("ROWS", []) for row in rows: lines.append(" | ".join(str(c) for c in row)) full_text = "\n".join(lines) # Header: first ~1500 chars header = full_text[:1500] # Marker lines: grep for key patterns markers = re.findall( r'.{0,200}(?:договор|№|соглашен|приложен|специф|контрагент|заказчик|арендатор).{0,200}', full_text, re.IGNORECASE, ) unique_markers = list(dict.fromkeys(markers))[:10] combined = header + "\n---\n" + "\n".join(unique_markers) return combined[:3000]