v3.2.0: LLM-извлечение + сравнение допников — кнопка Сравнить
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user