v1.0.162: auto-classify backend — classify/grouping services, 7 new columns, /classify-batch, /api/groups, /apply-groups

This commit is contained in:
2026-06-24 08:22:21 +04:00
parent 740f75a791
commit 7a96f5b95b
8 changed files with 372 additions and 6 deletions
+59
View File
@@ -44,6 +44,10 @@ class Handler(BaseHTTPRequestHandler):
self._handle_app_js() self._handle_app_js()
elif parsed.path == "/api/supplements": elif parsed.path == "/api/supplements":
self._handle_api_supplements(parsed) self._handle_api_supplements(parsed)
elif parsed.path == "/api/groups":
self._handle_api_groups(parsed)
elif parsed.path == "/api/batch-progress":
self._handle_api_batch_progress(parsed)
elif parsed.path.startswith("/api/documents/"): elif parsed.path.startswith("/api/documents/"):
self._handle_api_document(parsed) self._handle_api_document(parsed)
else: else:
@@ -58,6 +62,10 @@ class Handler(BaseHTTPRequestHandler):
self._handle_llm_ops() self._handle_llm_ops()
elif self.path == "/convert-doc": elif self.path == "/convert-doc":
self._handle_convert_doc() self._handle_convert_doc()
elif self.path == "/classify-batch":
self._handle_classify_batch()
elif self.path == "/apply-groups":
self._handle_apply_groups()
else: else:
self.send_error(404) self.send_error(404)
@@ -119,6 +127,57 @@ class Handler(BaseHTTPRequestHandler):
"elements_json": doc.get("elements_json"), "elements_json": doc.get("elements_json"),
}) })
# ── /api/groups ?batch=X ─────────────────────────────────────────────
def _handle_api_groups(self, parsed):
params = parse_qs(parsed.query)
batch_id = params.get("batch", [None])[0]
if not batch_id:
self._json({"ok": False, "error": "batch required"}, 400)
return
from services.grouping import group_documents
result = group_documents(batch_id)
self._json(result)
# ── /api/batch-progress ?batch=X ─────────────────────────────────────
def _handle_api_batch_progress(self, parsed):
params = parse_qs(parsed.query)
batch_id = params.get("batch", [None])[0]
if not batch_id:
self._json({"ok": False, "error": "batch required"}, 400)
return
counts = db_documents.count_by_status(batch_id)
docs = db_documents.list_by_batch(batch_id)
self._json({"ok": True, "counts": counts, "total": len(docs)})
# ── POST /classify-batch ─────────────────────────────────────────────
def _handle_classify_batch(self):
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
batch_id = body.get("batch_id")
if not batch_id:
self._json({"ok": False, "error": "batch_id required"}, 400)
return
from services.classify import classify_batch
result = classify_batch(batch_id)
self._json(result)
# ── POST /apply-groups ───────────────────────────────────────────────
def _handle_apply_groups(self):
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
batch_id = body.get("batch_id")
groups = body.get("groups", [])
if not batch_id:
self._json({"ok": False, "error": "batch_id required"}, 400)
return
from services.grouping import apply_groups
result = apply_groups(batch_id, groups)
self._json(result)
# ── /app.js (static) ─────────────────────────────────────────────────── # ── /app.js (static) ───────────────────────────────────────────────────
def _handle_app_js(self): def _handle_app_js(self):
+48 -4
View File
@@ -2,12 +2,12 @@
from .connection import query, execute, execute_returning from .connection import query, execute, execute_returning
def insert(filename, mime_type, original_bytes, status="uploaded"): def insert(filename, mime_type, original_bytes, status="uploaded", batch_id=None):
"""Insert document, return row dict.""" """Insert document, return row dict."""
return execute_returning( return execute_returning(
"""INSERT INTO documents (filename, mime_type, original_bytes, status) """INSERT INTO documents (filename, mime_type, original_bytes, status, batch_id)
VALUES (%s, %s, %s, %s) RETURNING *""", VALUES (%s, %s, %s, %s, %s) RETURNING *""",
(filename, mime_type, original_bytes, status), (filename, mime_type, original_bytes, status, batch_id),
) )
@@ -34,3 +34,47 @@ def set_error(doc_id, error_message):
def delete(doc_id): def delete(doc_id):
return execute("DELETE FROM documents WHERE id = %s", (doc_id,)) return execute("DELETE FROM documents WHERE id = %s", (doc_id,))
def set_classification(doc_id, doc_type, own_number, parent_number, doc_date, counterparty):
"""Store LLM classification results."""
return execute(
"""UPDATE documents SET doc_type=%s, own_number=%s, parent_number=%s,
doc_date=%s, counterparty=%s, classify_status='classified'
WHERE id=%s""",
(doc_type, own_number, parent_number, doc_date, counterparty, doc_id),
)
def set_classify_failed(doc_id, error):
return execute(
"UPDATE documents SET classify_status='failed', error_message=%s WHERE id=%s",
(error, doc_id),
)
def list_pending(batch_id):
"""Documents waiting for classification."""
return query(
"SELECT id, filename, elements_json FROM documents WHERE batch_id=%s AND classify_status='pending' AND status='parsed'",
(batch_id,),
)
def list_by_batch(batch_id):
"""All documents in a batch with classification fields."""
return query(
"""SELECT id, filename, status, doc_type, own_number, parent_number,
doc_date, counterparty, classify_status, error_message
FROM documents WHERE batch_id=%s ORDER BY created_at""",
(batch_id,),
)
def count_by_status(batch_id):
"""Count documents by classify_status."""
rows = query(
"SELECT classify_status, count(*) as cnt FROM documents WHERE batch_id=%s GROUP BY classify_status",
(batch_id,),
)
return {r["classify_status"]: r["cnt"] for r in rows}
+15
View File
@@ -35,3 +35,18 @@ def seed_defaults():
true, 'Авто-создан из llm_prompt.py _build_diff' true, 'Авто-создан из llm_prompt.py _build_diff'
) )
""") """)
_ensure_classify_prompt()
def _ensure_classify_prompt():
"""Ensure classify prompt exists (idempotent)."""
existing = query("SELECT id FROM prompts WHERE role = 'classify' AND is_active = true LIMIT 1")
if existing:
return
execute("""
INSERT INTO prompts (role, name, body, is_active, notes) VALUES (
'classify', 'default-v1',
'Ты — классификатор договорных документов. Ниже фрагмент текста документа.\n\nОпредели:\n1. doc_type: "contract" (договор), "supplement" (допсоглашение/допник), "specification" (спецификация/приложение), "other"\n2. own_number: номер ЭТОГО документа (например "МЭС-123-2024" или "ДС №2")\n3. parent_number: номер родительского договора (для допников/спецификаций — "к Договору №..."). Для самого договора — null.\n4. doc_date: дата документа в формате YYYY-MM-DD\n5. counterparty: название контрагента (из поля "Заказчик", "Арендатор" или аналогичного)\n6. confidence: "ok" если уверен, "low" если сомневаешься\n\nВерни СТРОГО JSON без пояснений:\n{\n "doc_type": "...",\n "own_number": "..." или null,\n "parent_number": "..." или null,\n "doc_date": "YYYY-MM-DD" или null,\n "counterparty": "..." или null,\n "confidence": "ok" или "low"\n}\n\nДОКУМЕНТ:\n---\n{header_text}\n---',
true, 'Авто-создан: classify prompt'
)
""")
+27
View File
@@ -232,3 +232,30 @@ def build_prompt(current_spec: list, doc_text: str) -> tuple:
result = result.replace("{spec_current}", _build_spec_text(current_spec)) result = result.replace("{spec_current}", _build_spec_text(current_spec))
return result, prompt_id return result, prompt_id
def build_classify_prompt(header_text):
"""Build classify prompt. Returns (prompt, prompt_id)."""
from db import prompts as db_prompts
prompt = db_prompts.get_active("classify")
if prompt:
body = prompt["body"].replace("{header_text}", header_text)
return body, prompt.get("id", "")
# Fallback
body = """Ты — классификатор договорных документов. Ниже фрагмент текста документа.
Определи:
1. doc_type: "contract", "supplement", "specification", "other"
2. own_number: номер ЭТОГО документа
3. parent_number: номер родительского договора (для допников/спецификаций)
4. doc_date: дата в формате YYYY-MM-DD
5. counterparty: название контрагента
6. confidence: "ok" или "low"
Верни СТРОГО JSON: {"doc_type":"...","own_number":"...","parent_number":"...","doc_date":"...","counterparty":"...","confidence":"..."}
ДОКУМЕНТ:
---
{header_text}
---""".replace("{header_text}", header_text)
return body, ""
+124
View File
@@ -0,0 +1,124 @@
"""Classify service — LLM-based document classification."""
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__)
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."""
prompt, _ = build_classify_prompt(header_text)
payload = {
"model": LLM_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 500,
"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", "")
# Extract JSON from possible markdown wrapping
json_text = raw
if "```json" in json_text:
json_text = json_text.split("```json")[1].split("```")[0]
elif "```" in json_text:
json_text = json_text.split("```")[1].split("```")[0]
return json.loads(json_text.strip())
def classify_batch(batch_id):
"""Classify all pending documents in a batch. Returns summary dict."""
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):
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 _smart_extract(elements_json):
"""Extract smart header: first ~1500 chars + marker lines from full doc."""
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]
+95
View File
@@ -0,0 +1,95 @@
"""Grouping service — match classified documents into contract groups."""
import re
from db import documents as db_docs
from db import contracts as db_contracts
from db import supplements as db_supplements
def normalize_number(num):
"""Normalize contract number for matching: uppercase, only letters/digits."""
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."""
docs = db_docs.list_by_batch(batch_id)
classified = [d for d in docs if d.get("classify_status") == "classified"]
# Separate contracts and supplements
contracts_list = []
supplements_list = []
for d in classified:
if d.get("doc_type") == "contract":
contracts_list.append(d)
else:
supplements_list.append(d)
groups = []
# Each contract becomes a group
for c in contracts_list:
group = {
"contract_number": c.get("own_number") or c.get("filename", ""),
"counterparty": c.get("counterparty") or "",
"documents": [c],
}
# Find supplements matching this contract
c_norm = normalize_number(c.get("own_number"))
for s in supplements_list:
parent = normalize_number(s.get("parent_number") or "")
own = normalize_number(s.get("own_number") or "")
if parent == c_norm or own == c_norm:
if s not in group["documents"]:
group["documents"].append(s)
# Sort by date
group["documents"].sort(key=lambda x: x.get("doc_date") or "")
# Remove matched supplements from the pool
for s in group["documents"]:
if s in supplements_list:
supplements_list.remove(s)
groups.append(group)
# Unmatched documents → "unresolved" group
unmatched = [s for s in supplements_list if s.get("doc_type") != "contract"]
unmatched += [d for d in classified if d.get("classify_status") != "classified"]
if unmatched or len(contracts_list) == 0:
# Put unmatched into a separate group
if unmatched:
groups.append({
"contract_number": "__unresolved__",
"counterparty": "",
"documents": unmatched,
})
return {"ok": True, "groups": groups, "total_docs": len(classified)}
def apply_groups(batch_id, groups_data):
"""Apply confirmed groups: create contracts + supplements."""
created = 0
for g in groups_data:
contract_number = g.get("contract_number", "")
if contract_number == "__unresolved__":
continue
counterparty = g.get("counterparty", "")
docs = g.get("documents", [])
# Create contract
c = db_contracts.insert(contract_number, counterparty)
contract_id = c["id"]
# Create supplements in order
for i, d in enumerate(docs):
doc_id = d.get("id")
supp_type = "initial" if i == 0 else "additional"
db_supplements.insert(contract_id, doc_id, supp_type)
created += 1
return {"ok": True, "created": created}
+3 -1
View File
@@ -31,6 +31,8 @@ def handle_upload(rfile, content_type, content_length):
if "contract_id" in fs: if "contract_id" in fs:
contract_id = fs.getfirst("contract_id", "") contract_id = fs.getfirst("contract_id", "")
batch_id = fs.getfirst("batch_id", None) if "batch_id" in fs else None
if not filename or not file_data: if not filename or not file_data:
return {"ok": False, "error": "no file in request"} return {"ok": False, "error": "no file in request"}
@@ -40,7 +42,7 @@ def handle_upload(rfile, content_type, content_length):
if contract_id: if contract_id:
supplements.delete_by_document(contract_id, filename) supplements.delete_by_document(contract_id, filename)
doc = documents.insert(filename, mime, b64) doc = documents.insert(filename, mime, b64, batch_id=batch_id)
if not contract_id: if not contract_id:
from datetime import datetime from datetime import datetime
+1 -1
View File
@@ -75,7 +75,7 @@
<body> <body>
<div class="topbar"> <div class="topbar">
<img src="/nubes-logo.svg" alt="Nubes"> <img src="/nubes-logo.svg" alt="Nubes">
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.161 — Lucee</span></span> <span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.162 — Lucee</span></span>
</div> </div>
<div class="content"> <div class="content">