v1.0.175: виртуальные группы + classify_raw + stepper
This commit is contained in:
@@ -29,6 +29,26 @@ function syncDB() {
|
|||||||
body: JSON.stringify({keep_ids: keepIds})
|
body: JSON.stringify({keep_ids: keepIds})
|
||||||
}).catch(function(){});
|
}).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) —
|
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
|
||||||
// вынесены в app_utils.js для облегчения анализа и отладки.
|
// вынесены в app_utils.js для облегчения анализа и отладки.
|
||||||
|
|
||||||
@@ -67,6 +87,8 @@ fileInput.addEventListener('change', async function() {
|
|||||||
renderTable();
|
renderTable();
|
||||||
// Разблокировать кнопку классификации — состав файлов изменился
|
// Разблокировать кнопку классификации — состав файлов изменился
|
||||||
showClassifyBtn();
|
showClassifyBtn();
|
||||||
|
// Сбросить прогресс пайплайна
|
||||||
|
resetStepper('stepUpload');
|
||||||
|
|
||||||
for (var i = 0; i < newFiles.length; i++) {
|
for (var i = 0; i < newFiles.length; i++) {
|
||||||
var f = newFiles[i];
|
var f = newFiles[i];
|
||||||
@@ -186,6 +208,8 @@ fileInput.addEventListener('change', async function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await refreshSupps();
|
await refreshSupps();
|
||||||
|
stepDone('stepUpload');
|
||||||
|
stepActive('stepClassify');
|
||||||
|
|
||||||
// Синхронизировать БД с таблицей — удалить всё, чего нет в fileQueue
|
// Синхронизировать БД с таблицей — удалить всё, чего нет в fileQueue
|
||||||
syncDB();
|
syncDB();
|
||||||
@@ -220,6 +244,7 @@ async function runClassify() {
|
|||||||
var btn = document.getElementById('classifyBtn');
|
var btn = document.getElementById('classifyBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<span class="spinner"></span> Классификация... 0с';
|
btn.innerHTML = '<span class="spinner"></span> Классификация... 0с';
|
||||||
|
stepActive('stepClassify');
|
||||||
|
|
||||||
var start = Date.now();
|
var start = Date.now();
|
||||||
var timer = setInterval(function() {
|
var timer = setInterval(function() {
|
||||||
@@ -253,6 +278,8 @@ async function runClassify() {
|
|||||||
clearInterval(pollTimer);
|
clearInterval(pollTimer);
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
btn.innerHTML = '✓ ' + data.done + '/' + data.total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)';
|
btn.innerHTML = '✓ ' + data.done + '/' + data.total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)';
|
||||||
|
stepDone('stepClassify');
|
||||||
|
stepActive('stepGroups');
|
||||||
loadGroups();
|
loadGroups();
|
||||||
} else {
|
} else {
|
||||||
btn.innerHTML = '✗ ' + (data.error || 'ошибка');
|
btn.innerHTML = '✗ ' + (data.error || 'ошибка');
|
||||||
@@ -277,6 +304,7 @@ async function loadGroups() {
|
|||||||
var realGroups = data.groups.filter(function(g) { return g.contract_number !== '__unresolved__'; });
|
var realGroups = data.groups.filter(function(g) { return g.contract_number !== '__unresolved__'; });
|
||||||
var unresolvedGroup = data.groups.find(function(g) { return g.contract_number === '__unresolved__'; });
|
var unresolvedGroup = data.groups.find(function(g) { return g.contract_number === '__unresolved__'; });
|
||||||
diffBody.innerHTML = '<div style="font-weight:600;margin-bottom:8px;">📋 Найдено групп: ' + realGroups.length + (unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') + '</div>';
|
diffBody.innerHTML = '<div style="font-weight:600;margin-bottom:8px;">📋 Найдено групп: ' + realGroups.length + (unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') + '</div>';
|
||||||
|
stepDone('stepGroups');
|
||||||
|
|
||||||
// Сохраняем группы для compare
|
// Сохраняем группы для compare
|
||||||
window._groupsData = data.groups;
|
window._groupsData = data.groups;
|
||||||
@@ -346,6 +374,7 @@ window.runCompareForGroup = async function(gi) {
|
|||||||
|
|
||||||
var cid = ad.contract_ids[0];
|
var cid = ad.contract_ids[0];
|
||||||
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
|
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
|
||||||
|
stepActive('stepCompare');
|
||||||
|
|
||||||
var diffCard = document.getElementById('diffCard');
|
var diffCard = document.getElementById('diffCard');
|
||||||
var diffBody = document.getElementById('diffBody');
|
var diffBody = document.getElementById('diffBody');
|
||||||
@@ -539,6 +568,7 @@ document.getElementById('llmBtn').addEventListener('click', function() {
|
|||||||
es.close();
|
es.close();
|
||||||
diffStatus.textContent = '✓ ' + d.total_time_s + 'с';
|
diffStatus.textContent = '✓ ' + d.total_time_s + 'с';
|
||||||
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
|
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
|
||||||
|
stepDone('stepCompare');
|
||||||
if (d.total_unresolved > 0) {
|
if (d.total_unresolved > 0) {
|
||||||
diffBody.innerHTML += '<div style="margin-top:8px;color:#eab308;">⚠ ' + d.total_unresolved + ' неразрешённых строк — <a href="/resolve.cfm?contract_id=' + contractId + '">разобрать</a></div>';
|
diffBody.innerHTML += '<div style="margin-top:8px;color:#eab308;">⚠ ' + d.total_unresolved + ' неразрешённых строк — <a href="/resolve.cfm?contract_id=' + contractId + '">разобрать</a></div>';
|
||||||
}
|
}
|
||||||
@@ -688,6 +718,22 @@ async function showText(i) {
|
|||||||
if (f.supp_id) {
|
if (f.supp_id) {
|
||||||
rightHtml += '<div class="kv"><span class="k">Тип ДС</span><span class="v">' + (f.supp_type || '—') + '</span></div>';
|
rightHtml += '<div class="kv"><span class="k">Тип ДС</span><span class="v">' + (f.supp_type || '—') + '</span></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Результаты классификации ──────────────────────────
|
||||||
|
if (qData.classify_status === 'classified' || qData.classify_raw) {
|
||||||
|
rightHtml += '<div style="margin-top:12px;font-weight:600;font-size:12px;border-top:1px solid var(--brand-gray);padding-top:8px;">🏷️ Классификация LLM</div>';
|
||||||
|
if (qData.doc_type) rightHtml += '<div class="kv"><span class="k">Тип</span><span class="v">' + qData.doc_type + '</span></div>';
|
||||||
|
if (qData.own_number) rightHtml += '<div class="kv"><span class="k">Номер</span><span class="v">' + qData.own_number + '</span></div>';
|
||||||
|
if (qData.parent_number) rightHtml += '<div class="kv"><span class="k">Родительский</span><span class="v">' + qData.parent_number + '</span></div>';
|
||||||
|
if (qData.doc_date) rightHtml += '<div class="kv"><span class="k">Дата</span><span class="v">' + qData.doc_date + '</span></div>';
|
||||||
|
if (qData.counterparty) rightHtml += '<div class="kv"><span class="k">Контрагент</span><span class="v">' + qData.counterparty + '</span></div>';
|
||||||
|
if (qData.classify_raw) {
|
||||||
|
var rawId = 'raw_' + docId.replace(/-/g,'');
|
||||||
|
rightHtml += '<details style="margin-top:8px;"><summary style="cursor:pointer;font-size:11px;color:var(--brand-primary);">Сырой ответ LLM</summary>';
|
||||||
|
rightHtml += '<pre style="font-size:10px;max-height:200px;overflow:auto;background:var(--brand-grey-light);padding:6px;border-radius:4px;margin-top:4px;">' + escHtml(qData.classify_raw) + '</pre>';
|
||||||
|
rightHtml += '</details>';
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
leftHtml = '<span style="color:var(--destructive);">Ошибка</span>';
|
leftHtml = '<span style="color:var(--destructive);">Ошибка</span>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ function removeFile(i) {
|
|||||||
renderTable();
|
renderTable();
|
||||||
// Синхронизировать БД с таблицей
|
// Синхронизировать БД с таблицей
|
||||||
if (typeof syncDB === 'function') syncDB();
|
if (typeof syncDB === 'function') syncDB();
|
||||||
|
// Сбросить прогресс при изменении состава файлов
|
||||||
|
if (typeof resetStepper === 'function') resetStepper('stepUpload');
|
||||||
// Разблокировать кнопку классификации при изменении состава файлов
|
// Разблокировать кнопку классификации при изменении состава файлов
|
||||||
if (typeof showClassifyBtn === 'function') showClassifyBtn();
|
if (typeof showClassifyBtn === 'function') showClassifyBtn();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import json, re, os
|
|||||||
|
|
||||||
# ── DB auto-seed ──────────────────────────────────────────────────────────
|
# ── DB auto-seed ──────────────────────────────────────────────────────────
|
||||||
from db import prompts as db_prompts
|
from db import prompts as db_prompts
|
||||||
from db.connection import DB_CONFIG
|
from db.connection import DB_CONFIG, execute
|
||||||
db_prompts.seed_defaults()
|
db_prompts.seed_defaults()
|
||||||
|
# Schema migration: classify_raw column (idempotent)
|
||||||
|
execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_raw text")
|
||||||
|
|
||||||
# ── DB modules ────────────────────────────────────────────────────────────
|
# ── DB modules ────────────────────────────────────────────────────────────
|
||||||
from db import supplements as db_supplements
|
from db import supplements as db_supplements
|
||||||
@@ -146,6 +148,13 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"filename": doc["filename"],
|
"filename": doc["filename"],
|
||||||
"status": doc["status"],
|
"status": doc["status"],
|
||||||
"elements_json": doc.get("elements_json"),
|
"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):
|
def _handle_api_document_delete(self, parsed):
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ 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):
|
def set_classification(doc_id, doc_type, own_number, parent_number, doc_date, counterparty, classify_raw=None):
|
||||||
"""Store LLM classification results."""
|
"""Store LLM classification results + raw response."""
|
||||||
return execute(
|
return execute(
|
||||||
"""UPDATE documents SET doc_type=%s, own_number=%s, parent_number=%s,
|
"""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""",
|
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."""
|
"""All documents in a batch with classification fields."""
|
||||||
return query(
|
return query(
|
||||||
"""SELECT id, filename, status, doc_type, own_number, parent_number,
|
"""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""",
|
FROM documents WHERE batch_id=%s ORDER BY created_at""",
|
||||||
(batch_id,),
|
(batch_id,),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,15 +31,14 @@ LLM_MODEL = "gpt-oss-120b"
|
|||||||
def _call_llm_classify(header_text):
|
def _call_llm_classify(header_text):
|
||||||
"""
|
"""
|
||||||
Прямой вызов LLM для классификации ОДНОГО документа.
|
Прямой вызов LLM для классификации ОДНОГО документа.
|
||||||
Не использует общий call_llm() из services/llm.py — здесь свой промпт и формат ответа.
|
Возвращает (parsed_dict, raw_text).
|
||||||
Возвращает распарсенный JSON dict с полями: doc_type, own_number, parent_number, doc_date, counterparty, confidence.
|
|
||||||
"""
|
"""
|
||||||
prompt, _ = build_classify_prompt(header_text)
|
prompt, _ = build_classify_prompt(header_text)
|
||||||
payload = {
|
payload = {
|
||||||
"model": LLM_MODEL,
|
"model": LLM_MODEL,
|
||||||
"messages": [{"role": "user", "content": prompt}],
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
"max_tokens": 1000, # достаточно для JSON с длинными названиями
|
"max_tokens": 1000,
|
||||||
"temperature": 0.1, # минимальная температура для детерминированности
|
"temperature": 0.1,
|
||||||
}
|
}
|
||||||
with httpx.Client(http2=True, timeout=60, verify=False) as client:
|
with httpx.Client(http2=True, timeout=60, verify=False) as client:
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
@@ -50,7 +49,7 @@ def _call_llm_classify(header_text):
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
raw = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
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):
|
def classify_batch(batch_id):
|
||||||
@@ -74,7 +73,7 @@ def classify_batch(batch_id):
|
|||||||
"""Классифицировать один документ: выжимка → LLM → сохранить результат."""
|
"""Классифицировать один документ: выжимка → LLM → сохранить результат."""
|
||||||
try:
|
try:
|
||||||
text = _smart_extract(doc["elements_json"])
|
text = _smart_extract(doc["elements_json"])
|
||||||
result = _call_llm_classify(text)
|
result, raw = _call_llm_classify(text)
|
||||||
db_docs.set_classification(
|
db_docs.set_classification(
|
||||||
doc["id"],
|
doc["id"],
|
||||||
result.get("doc_type", "other"),
|
result.get("doc_type", "other"),
|
||||||
@@ -82,6 +81,7 @@ def classify_batch(batch_id):
|
|||||||
result.get("parent_number"),
|
result.get("parent_number"),
|
||||||
result.get("doc_date"),
|
result.get("doc_date"),
|
||||||
result.get("counterparty"),
|
result.get("counterparty"),
|
||||||
|
classify_raw=raw,
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ def group_documents(batch_id):
|
|||||||
1. Отделить contract от supplement/specification
|
1. Отделить contract от supplement/specification
|
||||||
2. Каждый contract → якорь группы
|
2. Каждый contract → якорь группы
|
||||||
3. Для каждого supplement: найти contract по parent_number (нормализованный)
|
3. Для каждого supplement: найти contract по parent_number (нормализованный)
|
||||||
4. Несматченные → группа "__unresolved__"
|
4. Оставшиеся supplement/spec → виртуальные группы по parent_number/own_number
|
||||||
5. Внутри группы сортировка по doc_date
|
5. Совсем без номеров → группа "__unresolved__"
|
||||||
|
6. Внутри группы сортировка по doc_date
|
||||||
|
|
||||||
Возвращает {ok, groups: [{contract_number, counterparty, documents: [...]}], total_docs}.
|
Возвращает {ok, groups: [{contract_number, counterparty, documents: [...]}], total_docs}.
|
||||||
"""
|
"""
|
||||||
@@ -79,12 +80,37 @@ def group_documents(batch_id):
|
|||||||
|
|
||||||
groups.append(group)
|
groups.append(group)
|
||||||
|
|
||||||
# Unmatched documents → "unresolved" group (including failed/pending)
|
# ── Virtual groups: unmatched supplements grouped by number ──────────
|
||||||
unmatched = [s for s in supplements_list if s.get("doc_type") != "contract"]
|
# 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"]
|
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:
|
if unmatched:
|
||||||
groups.append({
|
groups.append({
|
||||||
"contract_number": "__unresolved__",
|
"contract_number": "__unresolved__",
|
||||||
|
|||||||
@@ -75,7 +75,13 @@
|
|||||||
<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.174</span></span>
|
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.175</span></span>
|
||||||
|
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
|
||||||
|
<span id="stepUpload">○ Загрузка</span><span>→</span>
|
||||||
|
<span id="stepClassify">○ Классификация</span><span>→</span>
|
||||||
|
<span id="stepGroups">○ Группировка</span><span>→</span>
|
||||||
|
<span id="stepCompare">○ Сравнение</span>
|
||||||
|
</div>
|
||||||
<span style="margin-left:auto;font-size:12px;color:var(--brand-primary);cursor:pointer;white-space:nowrap;" onclick="openAbout()">ℹ️ О сервисе</span>
|
<span style="margin-left:auto;font-size:12px;color:var(--brand-primary);cursor:pointer;white-space:nowrap;" onclick="openAbout()">ℹ️ О сервисе</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user