v3.2.0: LLM-извлечение + сравнение допников — кнопка Сравнить

This commit is contained in:
2026-06-18 08:54:39 +04:00
parent 17413e65c4
commit e534d014ef
3 changed files with 289 additions and 1 deletions
+4
View File
@@ -13,6 +13,7 @@ from api import api_bp
from routes.main import index
from routes.parse import parse
from routes.misc import health, logs
from routes.llm import process as llm_process
load_dotenv()
@@ -43,6 +44,9 @@ class ContractsApp:
# Парсинг: SSE-поток
self.app.add_url_rule("/parse/<cid>", "parse", parse)
# LLM: извлечение + сравнение
self.app.add_url_rule("/llm/process/<cid>", "llm_process", llm_process)
# Служебные
self.app.add_url_rule("/health", "health", health)
self.app.add_url_rule("/logs", "logs", logs)
+130
View File
@@ -0,0 +1,130 @@
"""routes/llm.py — LLM-извлечение + сравнение: один SSE-поток."""
import json as _json
import time as _time
from flask import Response
import db
import extractor
import differ
def process(cid):
"""
GET /llm/process/<cid> — SSE-поток полного цикла:
1. Для каждого файла договора: parsed_text → LLM → spec_rows
2. Сравнение допников через differ.diff()
3. Результат → SSE-сообщения в браузер
"""
def generate():
start_time = _time.time()
# ── Получить все файлы договора ─────────────────────
supps, _ = db.query(
"SELECT s.id as supp_id, s.type, d.id as doc_id, d.filename, d.parsed_text "
"FROM supplements s JOIN documents d ON s.document_id = d.id "
"WHERE s.contract_id = %s AND d.parsed_text IS NOT NULL "
"ORDER BY s.created_at",
(cid,),
)
if not supps:
yield f"data: {_json.dumps({'type': 'error', 'message': 'Нет распарсенных файлов. Сначала нажмите Парсинг.'})}\n\n"
return
supp_rows = [dict(zip(supps["columns"], r)) for r in supps["rows"]]
extracted = {} # supp_id → [rows]
# ── Шаг 1: LLM-извлечение для каждого файла ─────────
for s in supp_rows:
# Пропустить уже извлечённые
existing, _ = db.query(
"SELECT 1 FROM spec_rows WHERE supplement_id = %s LIMIT 1",
(s["supp_id"],),
)
if existing and existing["rows"]:
# Загрузить существующие
rows_res, _ = db.query(
"SELECT row_num, name, price, qty, sum, date_start "
"FROM spec_rows WHERE supplement_id = %s ORDER BY row_num",
(s["supp_id"],),
)
rows = [dict(zip(rows_res["columns"], r)) for r in rows_res["rows"]] if rows_res else []
extracted[s["supp_id"]] = rows
yield f"data: {_json.dumps({'type': 'extract_skip', 'name': s['filename'], 'reason': 'уже извлечено', 'count': len(rows)})}\n\n"
continue
yield f"data: {_json.dumps({'type': 'extract_start', 'name': s['filename']})}\n\n"
t0 = _time.time()
result = extractor.extract(s["parsed_text"])
if "error" in result:
yield f"data: {_json.dumps({'type': 'extract_error', 'name': s['filename'], 'error': result['error']})}\n\n"
continue
rows = result.get("rows", [])
unresolved = result.get("unresolved", [])
elapsed = round(_time.time() - t0, 2)
# Сохранить в БД
db.execute("DELETE FROM spec_rows WHERE supplement_id = %s", (s["supp_id"],))
for row in rows:
db.execute(
"INSERT INTO spec_rows (supplement_id, row_num, name, price, qty, sum, date_start) "
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
(
s["supp_id"],
row.get("row_num"),
row.get("name"),
row.get("price"),
row.get("qty"),
row.get("sum"),
row.get("date_start"),
),
)
extracted[s["supp_id"]] = rows
yield f"data: {_json.dumps({'type': 'extract_done', 'name': s['filename'], 'time_s': elapsed, 'count': len(rows), 'unresolved': unresolved})}\n\n"
# ── Шаг 2: Сравнение допников ────────────────────────
yield f"data: {_json.dumps({'type': 'diff_start', 'files': len(supp_rows)})}\n\n"
# Группируем: initial + amendments
initial_rows = None
amendments = []
for s in supp_rows:
if s["type"] == "initial":
initial_rows = extracted.get(s["supp_id"], [])
else:
amendments.append({
"supp_id": s["supp_id"],
"filename": s["filename"],
"type": s["type"],
"rows": extracted.get(s["supp_id"], []),
})
all_changes = []
if initial_rows is not None:
for am in amendments:
if not am["rows"]:
continue
d = differ.diff(initial_rows, am["rows"])
changes = d.get("changes", [])
summary = d.get("summary", {})
all_changes.append({
"filename": am["filename"],
"type": am["type"],
"changes": changes,
"summary": summary,
})
yield f"data: {_json.dumps({'type': 'diff_file', 'name': am['filename'], 'changes': len(changes), 'summary': summary})}\n\n"
# ── Итог ────────────────────────────────────────────
total_time = round(_time.time() - start_time, 2)
yield f"data: {_json.dumps({'type': 'done', 'total_time_s': total_time, 'all_changes': all_changes})}\n\n"
return Response(generate(), mimetype="text/event-stream")
+155 -1
View File
@@ -54,12 +54,19 @@
.modal-body .kv .k { color: var(--muted); width: 110px; flex-shrink: 0; }
.modal-body .kv .v { word-break: break-all; }
.modal-body pre { background: var(--brand-grey-light); padding: 10px; border-radius: 6px; font-size: 12px; max-height: 200px; overflow-y: auto; white-space: pre-wrap; margin-top: 4px; }
.diff-added { background: rgba(34,197,94,.1); }
.diff-deleted { background: rgba(239,68,68,.1); text-decoration: line-through; }
.diff-changed { background: rgba(234,179,8,.1); }
.diff-added td { color: var(--green); }
.diff-deleted td { color: var(--destructive); }
.diff-field-old { color: var(--destructive); text-decoration: line-through; font-size: 11px; }
.diff-field-new { color: var(--green); font-weight: 600; }
</style>
</head>
<body>
<div class="topbar">
<img src="{{ url_for('static', filename='nubes-logo.svg') }}" alt="Nubes">
<span class="title">Сверка договоров <span style="font-weight:400;color:var(--muted);font-size:12px;">v3.1.1</span></span>
<span class="title">Сверка договоров <span style="font-weight:400;color:var(--muted);font-size:12px;">v3.2.0</span></span>
</div>
<div class="content">
@@ -83,8 +90,21 @@
<button class="btn btn-primary" id="parseBtn" style="width:100%;justify-content:center;margin-top:12px;" disabled>
<i data-lucide="play" style="width:16px;height:16px;"></i> Парсинг
</button>
<button class="btn btn-primary" id="llmBtn" style="width:100%;justify-content:center;margin-top:8px;display:none;">
<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить
</button>
</div>
</div>
<!-- Результаты сравнения -->
<div class="card" id="diffCard" style="display:none;">
<div class="card-header">
<i data-lucide="file-diff" style="width:18px;height:18px;"></i>
Результаты сравнения <span id="diffStatus" style="font-weight:400;font-size:12px;margin-left:8px;"></span>
</div>
<div class="card-body" id="diffBody"></div>
</div>
</div>
<div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
@@ -318,6 +338,12 @@
es.close();
parseBtn.disabled = true;
parseBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
// Показать кнопку «Сравнить»
var llmBtn = document.getElementById('llmBtn');
llmBtn.style.display = 'block';
llmBtn.disabled = false;
llmBtn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить';
lucide.createIcons();
var summaryRow = document.createElement('tr');
@@ -410,6 +436,134 @@
window.showInfo = showInfo;
window.closeModal = closeModal;
// ── LLM: Сравнить ────────────────────────────────────────────
document.getElementById('llmBtn').addEventListener('click', function() {
if (!contractId) return;
var llmBtn = document.getElementById('llmBtn');
llmBtn.disabled = true;
llmBtn.innerHTML = '<span style="display:inline-block;width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;"></span> Анализ...';
var diffCard = document.getElementById('diffCard');
var diffBody = document.getElementById('diffBody');
var diffStatus = document.getElementById('diffStatus');
diffCard.style.display = 'block';
diffBody.innerHTML = '<span style="color:var(--muted);">⏳ LLM-извлечение...</span>';
var es = new EventSource('/llm/process/' + contractId);
var filesDone = 0;
es.onmessage = function(e) {
var d = JSON.parse(e.data);
if (d.type === 'extract_start') {
diffBody.innerHTML += '<div style="font-size:12px;color:var(--muted);">🔍 ' + d.name + ' — извлечение...</div>';
}
else if (d.type === 'extract_done') {
filesDone++;
diffStatus.textContent = '✓ ' + filesDone + ' файлов, ' + d.count + ' строк';
diffBody.innerHTML += '<div style="font-size:12px;color:var(--green);">✓ ' + d.name + ' — ' + d.count + ' строк (' + d.time_s + 'с)</div>';
}
else if (d.type === 'extract_skip') {
filesDone++;
diffBody.innerHTML += '<div style="font-size:12px;color:var(--muted);">⏭ ' + d.name + ' — уже извлечено (' + d.count + ' строк)</div>';
}
else if (d.type === 'extract_error') {
diffBody.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + d.name + ': ' + d.error + '</div>';
}
else if (d.type === 'diff_start') {
diffBody.innerHTML += '<div style="margin-top:8px;font-weight:600;">📊 Сравнение допников:</div>';
}
else if (d.type === 'diff_file') {
var added = d.summary ? (d.summary.added || 0) : 0;
var changed = d.summary ? (d.summary.changed || 0) : 0;
var deleted = d.summary ? (d.summary.deleted || 0) : 0;
diffBody.innerHTML += '<div style="font-size:12px;"> ' + d.name + ': <span style="color:var(--green);">+' + added + '</span> <span style="color:#eab308;">~' + changed + '</span> <span style="color:var(--destructive);">-' + deleted + '</span></div>';
}
else if (d.type === 'done') {
es.close();
llmBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
lucide.createIcons();
diffStatus.textContent = '✓ ' + d.total_time_s + 'с';
// Построить таблицу изменений
var all = d.all_changes || [];
if (all.length === 0) {
diffBody.innerHTML += '<div style="color:var(--muted);margin-top:8px;">Нет допников для сравнения.</div>';
return;
}
all.forEach(function(ch) {
diffBody.innerHTML += '<div style="font-weight:600;margin-top:12px;">📄 ' + ch.filename + ' (' + ch.type + ')</div>';
if (!ch.changes || ch.changes.length === 0) {
diffBody.innerHTML += '<div style="color:var(--muted);font-size:12px;">Нет изменений</div>';
return;
}
var tbl = '<div class="table-wrap" style="margin-top:4px;"><table><thead><tr>' +
'<th>№</th><th>Услуга</th><th>Цена</th><th>Объём</th><th>Сумма</th><th>Начало</th></tr></thead><tbody>';
ch.changes.forEach(function(c) {
var cls = '';
if (c.change_type === 'added') cls = 'diff-added';
else if (c.change_type === 'deleted') cls = 'diff-deleted';
else if (c.change_type === 'changed') cls = 'diff-changed';
var vals = c.new_values || c.old_values || {};
var price = _diffCell(c, 'price');
var qty = _diffCell(c, 'qty');
var sum = _diffCell(c, 'sum');
tbl += '<tr class="' + cls + '">' +
'<td>' + (c.row_num || '') + '</td>' +
'<td>' + (vals.name || '') + '</td>' +
'<td class="num-cell">' + price + '</td>' +
'<td class="num-cell">' + qty + '</td>' +
'<td class="num-cell">' + sum + '</td>' +
'<td>' + (vals.date_start || '') + '</td>' +
'</tr>';
});
tbl += '</tbody></table></div>';
diffBody.innerHTML += tbl;
});
}
else if (d.type === 'error') {
diffBody.innerHTML += '<div style="color:var(--destructive);margin-top:8px;">✗ ' + d.message + '</div>';
}
};
es.addEventListener('error', function() {
es.close();
llmBtn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить';
llmBtn.disabled = false;
lucide.createIcons();
});
});
function _diffCell(c, field) {
if (c.change_type === 'changed' && c.old_values && c.new_values) {
var ov = c.old_values[field];
var nv = c.new_values[field];
if (ov !== nv && ov !== undefined) {
return '<span class="diff-field-old">' + (ov !== null ? ov : '—') + '</span> → <span class="diff-field-new">' + (nv !== null ? nv : '—') + '</span>';
}
}
var vals = c.new_values || c.old_values || {};
var v = vals[field];
return v !== null && v !== undefined ? v : '—';
}
var style = document.createElement('style');
style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
document.head.appendChild(style);