diff --git a/deploy/app.js b/deploy/app.js
index bfc8587..fb24596 100644
--- a/deploy/app.js
+++ b/deploy/app.js
@@ -29,6 +29,26 @@ function syncDB() {
body: JSON.stringify({keep_ids: keepIds})
}).catch(function(){});
}
+// ── Pipeline stepper ─────────────────────────────────────────
+function stepDone(id) {
+ var el = document.getElementById(id);
+ if (el) { el.innerHTML = el.innerHTML.replace('○','✓'); el.style.color = 'var(--green)'; }
+}
+function stepActive(id) {
+ var el = document.getElementById(id);
+ if (el) { el.innerHTML = el.innerHTML.replace('○','⏳').replace('✓','⏳'); el.style.color = 'var(--brand-primary)'; }
+}
+function resetStepper(fromId) {
+ var steps = ['stepUpload','stepClassify','stepGroups','stepCompare'];
+ var reset = false;
+ steps.forEach(function(id) {
+ if (id === fromId) reset = true;
+ if (reset) {
+ var el = document.getElementById(id);
+ if (el) { el.innerHTML = el.innerHTML.replace('✓','○').replace('⏳','○'); el.style.color = 'var(--muted)'; }
+ }
+ });
+}
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
// вынесены в app_utils.js для облегчения анализа и отладки.
@@ -67,6 +87,8 @@ fileInput.addEventListener('change', async function() {
renderTable();
// Разблокировать кнопку классификации — состав файлов изменился
showClassifyBtn();
+ // Сбросить прогресс пайплайна
+ resetStepper('stepUpload');
for (var i = 0; i < newFiles.length; i++) {
var f = newFiles[i];
@@ -186,6 +208,8 @@ fileInput.addEventListener('change', async function() {
}
await refreshSupps();
+ stepDone('stepUpload');
+ stepActive('stepClassify');
// Синхронизировать БД с таблицей — удалить всё, чего нет в fileQueue
syncDB();
@@ -220,6 +244,7 @@ async function runClassify() {
var btn = document.getElementById('classifyBtn');
btn.disabled = true;
btn.innerHTML = ' Классификация... 0с';
+ stepActive('stepClassify');
var start = Date.now();
var timer = setInterval(function() {
@@ -253,6 +278,8 @@ async function runClassify() {
clearInterval(pollTimer);
if (data.ok) {
btn.innerHTML = '✓ ' + data.done + '/' + data.total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)';
+ stepDone('stepClassify');
+ stepActive('stepGroups');
loadGroups();
} else {
btn.innerHTML = '✗ ' + (data.error || 'ошибка');
@@ -277,6 +304,7 @@ async function loadGroups() {
var realGroups = data.groups.filter(function(g) { return g.contract_number !== '__unresolved__'; });
var unresolvedGroup = data.groups.find(function(g) { return g.contract_number === '__unresolved__'; });
diffBody.innerHTML = '
📋 Найдено групп: ' + realGroups.length + (unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') + '
';
+ stepDone('stepGroups');
// Сохраняем группы для compare
window._groupsData = data.groups;
@@ -346,6 +374,7 @@ window.runCompareForGroup = async function(gi) {
var cid = ad.contract_ids[0];
btn.innerHTML = ' Сравнение...';
+ stepActive('stepCompare');
var diffCard = document.getElementById('diffCard');
var diffBody = document.getElementById('diffBody');
@@ -539,6 +568,7 @@ document.getElementById('llmBtn').addEventListener('click', function() {
es.close();
diffStatus.textContent = '✓ ' + d.total_time_s + 'с';
btn.innerHTML = ' ✓ Готово';
+ stepDone('stepCompare');
if (d.total_unresolved > 0) {
diffBody.innerHTML += '⚠ ' + d.total_unresolved + ' неразрешённых строк —
разобрать ';
}
@@ -688,6 +718,22 @@ async function showText(i) {
if (f.supp_id) {
rightHtml += 'Тип ДС' + (f.supp_type || '—') + '
';
}
+
+ // ── Результаты классификации ──────────────────────────
+ if (qData.classify_status === 'classified' || qData.classify_raw) {
+ rightHtml += '🏷️ Классификация LLM
';
+ if (qData.doc_type) rightHtml += 'Тип' + qData.doc_type + '
';
+ if (qData.own_number) rightHtml += 'Номер' + qData.own_number + '
';
+ if (qData.parent_number) rightHtml += 'Родительский' + qData.parent_number + '
';
+ if (qData.doc_date) rightHtml += 'Дата' + qData.doc_date + '
';
+ if (qData.counterparty) rightHtml += 'Контрагент' + qData.counterparty + '
';
+ if (qData.classify_raw) {
+ var rawId = 'raw_' + docId.replace(/-/g,'');
+ rightHtml += 'Сырой ответ LLM
';
+ rightHtml += '' + escHtml(qData.classify_raw) + '
';
+ rightHtml += ' ';
+ }
+ }
} catch(e) {
leftHtml = 'Ошибка';
}
diff --git a/deploy/app_utils.js b/deploy/app_utils.js
index 1b55509..0a54d46 100644
--- a/deploy/app_utils.js
+++ b/deploy/app_utils.js
@@ -35,6 +35,8 @@ function removeFile(i) {
renderTable();
// Синхронизировать БД с таблицей
if (typeof syncDB === 'function') syncDB();
+ // Сбросить прогресс при изменении состава файлов
+ if (typeof resetStepper === 'function') resetStepper('stepUpload');
// Разблокировать кнопку классификации при изменении состава файлов
if (typeof showClassifyBtn === 'function') showClassifyBtn();
}
diff --git a/deploy/convert_server.py b/deploy/convert_server.py
index 006fa96..f5515ca 100755
--- a/deploy/convert_server.py
+++ b/deploy/convert_server.py
@@ -7,8 +7,10 @@ import json, re, os
# ── DB auto-seed ──────────────────────────────────────────────────────────
from db import prompts as db_prompts
-from db.connection import DB_CONFIG
+from db.connection import DB_CONFIG, execute
db_prompts.seed_defaults()
+# Schema migration: classify_raw column (idempotent)
+execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_raw text")
# ── DB modules ────────────────────────────────────────────────────────────
from db import supplements as db_supplements
@@ -146,6 +148,13 @@ class Handler(BaseHTTPRequestHandler):
"filename": doc["filename"],
"status": doc["status"],
"elements_json": doc.get("elements_json"),
+ "doc_type": doc.get("doc_type"),
+ "own_number": doc.get("own_number"),
+ "parent_number": doc.get("parent_number"),
+ "doc_date": doc.get("doc_date"),
+ "counterparty": doc.get("counterparty"),
+ "classify_status": doc.get("classify_status"),
+ "classify_raw": doc.get("classify_raw"),
})
def _handle_api_document_delete(self, parsed):
diff --git a/deploy/db/documents.py b/deploy/db/documents.py
index e6ec1a6..5ede938 100644
--- a/deploy/db/documents.py
+++ b/deploy/db/documents.py
@@ -36,13 +36,13 @@ def delete(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."""
+def set_classification(doc_id, doc_type, own_number, parent_number, doc_date, counterparty, classify_raw=None):
+ """Store LLM classification results + raw response."""
return execute(
"""UPDATE documents SET doc_type=%s, own_number=%s, parent_number=%s,
- doc_date=%s, counterparty=%s, classify_status='classified'
+ doc_date=%s, counterparty=%s, classify_status='classified', classify_raw=%s
WHERE id=%s""",
- (doc_type, own_number, parent_number, doc_date, counterparty, doc_id),
+ (doc_type, own_number, parent_number, doc_date, counterparty, classify_raw, doc_id),
)
@@ -73,7 +73,7 @@ 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
+ doc_date, counterparty, classify_status, error_message, classify_raw
FROM documents WHERE batch_id=%s ORDER BY created_at""",
(batch_id,),
)
diff --git a/deploy/services/classify.py b/deploy/services/classify.py
index 7064fa2..1bb5866 100644
--- a/deploy/services/classify.py
+++ b/deploy/services/classify.py
@@ -31,15 +31,14 @@ 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.
+ Возвращает (parsed_dict, raw_text).
"""
prompt, _ = build_classify_prompt(header_text)
payload = {
"model": LLM_MODEL,
"messages": [{"role": "user", "content": prompt}],
- "max_tokens": 1000, # достаточно для JSON с длинными названиями
- "temperature": 0.1, # минимальная температура для детерминированности
+ "max_tokens": 1000,
+ "temperature": 0.1,
}
with httpx.Client(http2=True, timeout=60, verify=False) as client:
resp = client.post(
@@ -50,7 +49,7 @@ def _call_llm_classify(header_text):
data = resp.json()
raw = data.get("choices", [{}])[0].get("message", {}).get("content", "")
- return _safe_json_parse(raw)
+ return _safe_json_parse(raw), raw
def classify_batch(batch_id):
@@ -74,7 +73,7 @@ def classify_batch(batch_id):
"""Классифицировать один документ: выжимка → LLM → сохранить результат."""
try:
text = _smart_extract(doc["elements_json"])
- result = _call_llm_classify(text)
+ result, raw = _call_llm_classify(text)
db_docs.set_classification(
doc["id"],
result.get("doc_type", "other"),
@@ -82,6 +81,7 @@ def classify_batch(batch_id):
result.get("parent_number"),
result.get("doc_date"),
result.get("counterparty"),
+ classify_raw=raw,
)
return True
except Exception as e:
diff --git a/deploy/services/grouping.py b/deploy/services/grouping.py
index 1f82af8..d3a4b0f 100644
--- a/deploy/services/grouping.py
+++ b/deploy/services/grouping.py
@@ -33,8 +33,9 @@ def group_documents(batch_id):
1. Отделить contract от supplement/specification
2. Каждый contract → якорь группы
3. Для каждого supplement: найти contract по parent_number (нормализованный)
- 4. Несматченные → группа "__unresolved__"
- 5. Внутри группы сортировка по doc_date
+ 4. Оставшиеся supplement/spec → виртуальные группы по parent_number/own_number
+ 5. Совсем без номеров → группа "__unresolved__"
+ 6. Внутри группы сортировка по doc_date
Возвращает {ok, groups: [{contract_number, counterparty, documents: [...]}], total_docs}.
"""
@@ -79,18 +80,43 @@ def group_documents(batch_id):
groups.append(group)
- # Unmatched documents → "unresolved" group (including failed/pending)
- unmatched = [s for s in supplements_list if s.get("doc_type") != "contract"]
+ # ── Virtual groups: unmatched supplements grouped by number ──────────
+ # Group remaining supplements/specs by normalized parent_number (priority) or own_number
+ virtual = {}
+ for s in supplements_list:
+ num = normalize_number(s.get("parent_number") or s.get("own_number") or "")
+ if not num:
+ continue # no number → stays in supplements_list for unresolved
+ if num not in virtual:
+ # Use counterparty from first doc in group
+ cp = s.get("counterparty") or ""
+ virtual[num] = {
+ "contract_number": s.get("parent_number") or s.get("own_number") or "?",
+ "counterparty": cp,
+ "documents": [],
+ }
+ virtual[num]["documents"].append(s)
+ # Update counterparty if current doc has a better one
+ if not virtual[num]["counterparty"] and s.get("counterparty"):
+ virtual[num]["counterparty"] = s.get("counterparty")
+
+ for vnum, vgroup in virtual.items():
+ vgroup["documents"].sort(key=lambda x: x.get("doc_date") or "")
+ groups.append(vgroup)
+ # Remove grouped docs from supplements_list
+ for s in vgroup["documents"]:
+ supplements_list.remove(s)
+
+ # ── Unresolved: everything left (no number, failed, pending, other) ──
+ unmatched = [s for s in supplements_list] # remaining after virtual grouping
unmatched += [d for d in docs 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,
- })
+ if unmatched:
+ groups.append({
+ "contract_number": "__unresolved__",
+ "counterparty": "",
+ "documents": unmatched,
+ })
return {"ok": True, "groups": groups, "total_docs": len(docs)}
diff --git a/index.cfm b/index.cfm
index f057388..3cc0d4a 100644
--- a/index.cfm
+++ b/index.cfm
@@ -75,7 +75,13 @@

-
Сверка договоров — LLM AI-driven Event Sourcing v1.0.174
+
Сверка договоров — LLM AI-driven Event Sourcing v1.0.175
+
+ ○ Загрузка→
+ ○ Классификация→
+ ○ Группировка→
+ ○ Сравнение
+
ℹ️ О сервисе