v1.0.165: app_utils.js + detailed comments (Opus analysis) in classify/grouping/connection
This commit is contained in:
@@ -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 ""
|
||||
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
"""Grouping service — match classified documents into contract groups."""
|
||||
"""
|
||||
Grouping service — match classified documents into contract groups.
|
||||
|
||||
Архитектурное решение (Opus, Q4 + Q6):
|
||||
- Двухпроходный гибрид: LLM извлекает строки (classify.py), Python нормализует и группирует.
|
||||
- Нормализация номеров: uppercase + только буквы/цифры.
|
||||
"МЭС-123-2024" == "МЭС 123/2024" после нормализации.
|
||||
- Группировка на бэкенде (не на фронте): Python regex/unicode надёжнее JS.
|
||||
- apply_groups(): создаёт contracts + supplements с авто-порядком по дате.
|
||||
"""
|
||||
import re
|
||||
from db import documents as db_docs
|
||||
from db import contracts as db_contracts
|
||||
@@ -6,14 +15,29 @@ from db import supplements as db_supplements
|
||||
|
||||
|
||||
def normalize_number(num):
|
||||
"""Normalize contract number for matching: uppercase, only letters/digits."""
|
||||
"""
|
||||
Нормализация номера договора для сравнения.
|
||||
Убирает всё кроме букв и цифр, приводит к uppercase.
|
||||
Пример: "МЭС-123-2024" → "МЭС1232024", "МЭС 123/2024" → "МЭС1232024".
|
||||
"""
|
||||
if not num:
|
||||
return ""
|
||||
return re.sub(r"[^A-Z0-9А-Я]", "", num.upper())
|
||||
|
||||
|
||||
def group_documents(batch_id):
|
||||
"""Group classified documents by contract. Returns list of groups."""
|
||||
"""
|
||||
Сгруппировать классифицированные документы по контрактам.
|
||||
|
||||
Алгоритм:
|
||||
1. Отделить contract от supplement/specification
|
||||
2. Каждый contract → якорь группы
|
||||
3. Для каждого supplement: найти contract по parent_number (нормализованный)
|
||||
4. Несматченные → группа "__unresolved__"
|
||||
5. Внутри группы сортировка по doc_date
|
||||
|
||||
Возвращает {ok, groups: [{contract_number, counterparty, documents: [...]}], total_docs}.
|
||||
"""
|
||||
docs = db_docs.list_by_batch(batch_id)
|
||||
classified = [d for d in docs if d.get("classify_status") == "classified"]
|
||||
|
||||
@@ -72,7 +96,17 @@ def group_documents(batch_id):
|
||||
|
||||
|
||||
def apply_groups(batch_id, groups_data):
|
||||
"""Apply confirmed groups: create contracts + supplements."""
|
||||
"""
|
||||
Применить подтверждённые группы: создать contracts + supplements.
|
||||
|
||||
Вызывается из POST /api/apply-groups.
|
||||
Для каждой группы (кроме __unresolved__):
|
||||
1. Создать запись в contracts (number, client)
|
||||
2. Для каждого документа создать supplement (type='initial' для первого, 'additional' для остальных)
|
||||
3. Порядок supplements соответствует порядку документов в группе (сортировка по дате уже сделана)
|
||||
|
||||
Возвращает {ok, created: количество созданных supplements}.
|
||||
"""
|
||||
created = 0
|
||||
for g in groups_data:
|
||||
contract_number = g.get("contract_number", "")
|
||||
|
||||
Reference in New Issue
Block a user