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.main import index
from routes.parse import parse from routes.parse import parse
from routes.misc import health, logs from routes.misc import health, logs
from routes.llm import process as llm_process
load_dotenv() load_dotenv()
@@ -43,6 +44,9 @@ class ContractsApp:
# Парсинг: SSE-поток # Парсинг: SSE-поток
self.app.add_url_rule("/parse/<cid>", "parse", parse) 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("/health", "health", health)
self.app.add_url_rule("/logs", "logs", logs) 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 .k { color: var(--muted); width: 110px; flex-shrink: 0; }
.modal-body .kv .v { word-break: break-all; } .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; } .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> </style>
</head> </head>
<body> <body>
<div class="topbar"> <div class="topbar">
<img src="{{ url_for('static', filename='nubes-logo.svg') }}" alt="Nubes"> <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>
<div class="content"> <div class="content">
@@ -83,8 +90,21 @@
<button class="btn btn-primary" id="parseBtn" style="width:100%;justify-content:center;margin-top:12px;" disabled> <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> Парсинг <i data-lucide="play" style="width:16px;height:16px;"></i> Парсинг
</button> </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> </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>
<div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)"> <div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
@@ -318,6 +338,12 @@
es.close(); es.close();
parseBtn.disabled = true; parseBtn.disabled = true;
parseBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово'; 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(); lucide.createIcons();
var summaryRow = document.createElement('tr'); var summaryRow = document.createElement('tr');
@@ -410,6 +436,134 @@
window.showInfo = showInfo; window.showInfo = showInfo;
window.closeModal = closeModal; 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'); var style = document.createElement('style');
style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }'; style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
document.head.appendChild(style); document.head.appendChild(style);