Compare commits

4 Commits
Author SHA1 Message Date
naeel c4a6d78656 Make worker startup non-fatal 2026-06-26 12:51:10 +04:00
naeel 8b04eb93bd Add isolated teach flow on VM 2026-06-26 12:48:57 +04:00
naeel bec16def95 v2.0.10: прямая FormData загрузка вместо чанков 2026-06-19 13:50:17 +04:00
naeel 863026822c bump v2.0.9 2026-06-19 13:43:13 +04:00
5 changed files with 772 additions and 74 deletions
+5
View File
@@ -20,6 +20,7 @@ from api import api_bp
from v2.chunk_api import bp as v2_bp
import redis_client
import redis_worker
from teach import teach_bp
load_dotenv()
@@ -83,7 +84,10 @@ class ContractsApp:
def __init__(self):
self.app = Flask(__name__)
schema.ensure_schema()
try:
redis_worker.start_worker()
except Exception as e:
_log("redis_worker_start_error", str(e))
self._add_routes()
def _add_routes(self):
@@ -97,6 +101,7 @@ class ContractsApp:
self.app.register_blueprint(upload_bp)
self.app.register_blueprint(api_bp)
self.app.register_blueprint(v2_bp)
self.app.register_blueprint(teach_bp)
def _health(self):
return "OK", 200, {"Content-Type": "text/plain"}
+31
View File
@@ -98,6 +98,34 @@ CREATE TABLE IF NOT EXISTS spec_history (
);
"""
# ── Таблица feedback ──────────────────────────────────────────
# Изолированный журнал замечаний по строкам сравнения.
DDL_FEEDBACK = """
CREATE TABLE IF NOT EXISTS feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ DEFAULT now(),
contract_id UUID,
supplement_id UUID,
event_seq INTEGER,
scope TEXT NOT NULL,
verdict TEXT NOT NULL,
error_type TEXT,
field TEXT,
service_name TEXT,
llm_value JSONB,
correct_value JSONB,
prompt_version TEXT,
model_name TEXT,
doc_mode TEXT,
comment TEXT
);
"""
# ── Таблица chunks ─────────────────────────────────────────────
# Чанковая загрузка: части файла до сборки.
# upload_id связывает чанки одного файла.
@@ -135,6 +163,7 @@ ALL_DDL = [
("supplements", DDL_SUPPLEMENTS),
("spec_rows", DDL_SPEC_ROWS),
("spec_history", DDL_SPEC_HISTORY),
("feedback", DDL_FEEDBACK),
("chunks", DDL_CHUNKS),
("debug_log", DDL_DEBUG_LOG),
]
@@ -159,3 +188,5 @@ def ensure_schema():
db.execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS elements_json JSONB")
db.execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS original_b64 TEXT")
db.execute("ALTER TABLE documents ALTER COLUMN original_bytes DROP NOT NULL")
db.execute("ALTER TABLE feedback ADD COLUMN IF NOT EXISTS prompt_version TEXT")
db.execute("ALTER TABLE feedback ADD COLUMN IF NOT EXISTS model_name TEXT")
+208
View File
@@ -0,0 +1,208 @@
"""
teach.py — Isolated teaching/feedback page.
This blueprint is intentionally separate from the working compare pipeline.
It renders a standalone UI and stores feedback only in the feedback table.
"""
import json
import os
from flask import Blueprint, jsonify, render_template, request
import db
teach_bp = Blueprint("teach", __name__)
@teach_bp.route("/teach/api/meta", methods=["GET"])
def teach_meta_api():
"""Return default metadata for the isolated teaching page."""
return jsonify({
"ok": True,
"prompt_version": os.getenv("TEACH_PROMPT_VERSION", "vm-teach-v1"),
"model_name": os.getenv("TEACH_MODEL_NAME", "gpt-oss-120b"),
})
@teach_bp.route("/teach", methods=["GET"])
def teach_page():
"""Standalone feedback page for reviewing comparison output."""
return render_template("teach.html")
@teach_bp.route("/teach/api/contracts", methods=["GET"])
def teach_contracts_api():
"""List contracts and supplements for the teaching page."""
result, err = db.query(
"SELECT c.id AS contract_id, c.number AS contract_number, c.client, c.date_signed, "
"s.id AS supplement_id, s.number AS supplement_number, s.date_signed AS supplement_date, "
"s.type AS supplement_type, s.document_id, d.filename AS document_filename "
"FROM contracts c "
"LEFT JOIN supplements s ON s.contract_id = c.id "
"LEFT JOIN documents d ON d.id = s.document_id "
"ORDER BY c.created_at DESC, s.date_signed DESC NULLS LAST, s.created_at DESC"
)
if err:
return jsonify({"ok": False, "error": err}), 500
contracts = {}
for row in result["rows"]:
item = dict(zip(result["columns"], row))
cid = str(item["contract_id"])
if cid not in contracts:
contracts[cid] = {
"contract_id": cid,
"contract_number": item.get("contract_number"),
"client": item.get("client"),
"date_signed": item.get("date_signed"),
"supplements": [],
}
if item.get("supplement_id"):
contracts[cid]["supplements"].append({
"supplement_id": str(item["supplement_id"]),
"supplement_number": item.get("supplement_number"),
"supplement_date": item.get("supplement_date"),
"supplement_type": item.get("supplement_type"),
"document_id": str(item["document_id"]) if item.get("document_id") else None,
"document_filename": item.get("document_filename"),
})
return jsonify({"ok": True, "contracts": list(contracts.values())})
@teach_bp.route("/teach/api/context", methods=["GET"])
def teach_context_api():
"""Load rows and history for a chosen supplement."""
supplement_id = request.args.get("supplement_id")
if not supplement_id:
return jsonify({"ok": False, "error": "supplement_id required"}), 400
supp, err = db.query_one(
"SELECT s.id AS supplement_id, s.contract_id, s.number, s.date_signed, s.type, "
"c.number AS contract_number, c.client, d.filename AS document_filename "
"FROM supplements s "
"JOIN contracts c ON c.id = s.contract_id "
"LEFT JOIN documents d ON d.id = s.document_id "
"WHERE s.id = %s",
(supplement_id,),
)
if err:
return jsonify({"ok": False, "error": err}), 500
if not supp:
return jsonify({"ok": False, "error": "not found"}), 404
rows_result, err = db.query(
"SELECT id, row_num, name, price, qty, sum, date_start, date_end, created_at "
"FROM spec_rows WHERE supplement_id = %s ORDER BY row_num",
(supplement_id,),
)
if err:
return jsonify({"ok": False, "error": err}), 500
prev_rows_result, err = db.query(
"SELECT sr.row_num, sr.name, sr.price, sr.qty, sr.sum, sr.date_start "
"FROM spec_rows sr "
"JOIN supplements s ON s.id = sr.supplement_id "
"WHERE s.contract_id = %s AND s.id <> %s "
"ORDER BY s.date_signed DESC NULLS LAST, sr.row_num",
(supp["contract_id"], supplement_id),
)
if err:
return jsonify({"ok": False, "error": err}), 500
rows = [dict(zip(rows_result["columns"], row)) for row in rows_result["rows"]]
prev_rows = [dict(zip(prev_rows_result["columns"], row)) for row in prev_rows_result["rows"]]
return jsonify({
"ok": True,
"supplement": supp,
"current_rows": rows,
"previous_rows": prev_rows,
})
@teach_bp.route("/teach/api/feedback", methods=["GET", "POST"])
def teach_feedback_api():
"""Read or append isolated feedback records."""
if request.method == "GET":
contract_id = request.args.get("contract_id")
supplement_id = request.args.get("supplement_id")
sql = (
"SELECT id, created_at, contract_id, supplement_id, event_seq, scope, verdict, "
"error_type, field, service_name, llm_value, correct_value, prompt_version, model_name, doc_mode, comment "
"FROM feedback"
)
params = []
where = []
if contract_id:
where.append("contract_id = %s")
params.append(contract_id)
if supplement_id:
where.append("supplement_id = %s")
params.append(supplement_id)
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY created_at DESC LIMIT 200"
result, err = db.query(sql, tuple(params) if params else None)
if err:
return jsonify({"ok": False, "error": err}), 500
rows = [dict(zip(result["columns"], row)) for row in result["rows"]]
return jsonify({"ok": True, "rows": rows, "count": len(rows)})
data = request.get_json(silent=True) or {}
required = ["scope", "verdict"]
for key in required:
if not data.get(key):
return jsonify({"ok": False, "error": f"{key} is required"}), 400
sql = (
"INSERT INTO feedback (contract_id, supplement_id, event_seq, scope, verdict, error_type, field, "
"service_name, llm_value, correct_value, prompt_version, model_name, doc_mode, comment) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s, %s, %s, %s) RETURNING id"
)
params = (
data.get("contract_id"),
data.get("supplement_id"),
data.get("event_seq"),
data.get("scope"),
data.get("verdict"),
data.get("error_type"),
data.get("field"),
data.get("service_name"),
_json_or_none(data.get("llm_value")),
_json_or_none(data.get("correct_value")),
data.get("prompt_version"),
data.get("model_name"),
data.get("doc_mode"),
data.get("comment"),
)
conn, err = db.connect()
if err:
return jsonify({"ok": False, "error": err}), 500
try:
cur = conn.cursor()
cur.execute(sql, params)
row = cur.fetchone()
conn.commit()
cur.close()
return jsonify({"ok": True, "id": str(row[0]) if row else None})
except Exception as exc:
try:
conn.rollback()
except Exception:
pass
return jsonify({"ok": False, "error": str(exc)}), 500
finally:
db.put_conn(conn)
def _json_or_none(value):
"""Prepare JSONB payload for psycopg2."""
if value is None or value == "":
return None
return json.dumps(value, ensure_ascii=False)
+503
View File
@@ -0,0 +1,503 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сверка договоров — обучение</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}">
<script src="https://unpkg.com/lucide@latest"></script>
<style>
:root {
--brand-primary: #2563eb; --brand-primary-dark: #1d4ed8;
--brand-gray: #d1d5db; --brand-grey-light: #f3f4f6;
--background: #ffffff; --foreground: #1a1a1a;
--muted: #6b7280; --destructive: #ef4444; --green: #22c55e; --amber: #f59e0b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; font-size: 14px; color: var(--foreground); background: var(--brand-grey-light); min-height: 100vh; }
.topbar { background: var(--background); border-bottom: 1px solid var(--brand-gray); padding: 0 24px; height: 56px; display: flex; align-items: center; gap: 16px; position: sticky; top: 0; z-index: 50; }
.topbar img { height: 28px; }
.topbar .title { font-weight: 600; font-size: 16px; }
.content { max-width: 1200px; margin: 32px auto; padding: 0 16px; }
.card { background: var(--background); border-radius: 12px; border: 1px solid var(--brand-gray); box-shadow: 0 1px 2px rgba(0,0,0,.05); overflow: hidden; margin-bottom: 16px; }
.card-header { background: var(--brand-grey-light); padding: 12px 16px; font-weight: 600; font-size: 16px; display: flex; align-items: center; gap: 8px; }
.card-body { padding: 16px; }
.btn { height: 36px; border-radius: 6px; gap: 6px; padding: 0 14px; font-size: 14px; font-family: inherit; cursor: pointer; display: inline-flex; align-items: center; border: 1px solid var(--brand-gray); background: var(--background); }
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
.btn-primary:hover { background: var(--brand-primary-dark); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.split { display: grid; grid-template-columns: 320px 1fr; gap: 16px; align-items: start; }
.sidebar { max-height: 72vh; overflow-y: auto; }
.list { display: flex; flex-direction: column; gap: 8px; }
.item { border: 1px solid var(--brand-gray); border-radius: 8px; padding: 10px 12px; background: #fff; cursor: pointer; }
.item:hover { border-color: var(--brand-primary); }
.item.active { border-color: var(--brand-primary); box-shadow: inset 0 0 0 1px var(--brand-primary); }
.item .small { font-size: 12px; color: var(--muted); margin-top: 2px; }
.table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow-y: auto; max-height: 50vh; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { background: var(--brand-grey-light); text-transform: uppercase; padding: 6px 10px; border-right: 1px solid var(--brand-gray); text-align: left; font-weight: 600; font-size: 11px; color: var(--muted); }
td { padding: 6px 10px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); vertical-align: top; }
tr:hover td { background: rgba(243,244,246,.5); }
.name-cell { max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.num-cell { text-align: right; white-space: nowrap; }
.diff-added { background: rgba(34,197,94,.08); }
.diff-deleted { background: rgba(239,68,68,.08); }
.diff-changed { background: rgba(245,158,11,.08); }
.feedback-btn { cursor: pointer; border: 1px solid var(--brand-gray); border-radius: 6px; background: #fff; height: 28px; padding: 0 8px; font-size: 12px; }
.feedback-btn:hover { border-color: var(--brand-primary); color: var(--brand-primary); }
.feedback-btn.good { border-color: var(--green); color: var(--green); }
.feedback-btn.warn { border-color: var(--amber); color: var(--amber); }
.feedback-panel { margin-top: 8px; padding: 10px; border: 1px solid var(--brand-gray); border-radius: 8px; background: #fff; }
.row-form { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
.field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }
.field input, .field select, .field textarea { width: 100%; border: 1px solid var(--brand-gray); border-radius: 6px; padding: 8px; font: inherit; }
.field textarea { min-height: 70px; resize: vertical; }
.row-actions { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
.muted { color: var(--muted); }
.ok { color: var(--green); }
.err { color: var(--destructive); }
.empty-row td { color: var(--muted); text-align: center; padding: 24px; }
.preview { margin-top: 12px; font-size: 12px; color: var(--muted); }
.pill { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 999px; background: #eef2ff; color: var(--brand-primary); font-size: 11px; }
.notice { background: #eff6ff; border: 1px solid #bfdbfe; color: #1d4ed8; padding: 10px 12px; border-radius: 8px; font-size: 13px; margin-bottom: 12px; }
.loading { color: var(--muted); font-size: 13px; }
@media (max-width: 980px) { .split { grid-template-columns: 1fr; } .sidebar { max-height: none; } }
</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;">изолированная страница</span></span>
<span style="margin-left:auto;font-size:12px;color:var(--brand-primary);white-space:nowrap;">feedback only</span>
</div>
<div class="content">
<div class="notice">
Здесь не запускается рабочий compare-пайплайн. Страница только читает уже разобранные данные и пишет замечания в отдельную таблицу <b>feedback</b>.
</div>
<div class="card">
<div class="card-header">
<i data-lucide="book-open" style="width:18px;height:18px;"></i>
Выбор договора и допника
</div>
<div class="card-body split">
<div class="sidebar">
<div class="loading" id="contractsLoading">Загрузка списка договоров...</div>
<div class="list" id="contractsList"></div>
</div>
<div>
<div id="contextMeta" class="preview muted">Выберите допник слева</div>
<div class="row-actions" style="margin-top:10px;">
<button class="btn btn-primary" id="markCorrectBtn" disabled>✓ Всё верно</button>
<button class="btn" id="markMissedBtn" disabled> Пропущена позиция</button>
<span class="pill" id="saveStatus">0 замечаний</span>
</div>
<div class="table-wrap" style="margin-top:12px;">
<table>
<thead>
<tr>
<th style="width:56px;"></th>
<th>Действие</th>
<th>Услуга</th>
<th style="width:110px;">Цена</th>
<th style="width:90px;">Кол-во</th>
<th style="width:110px;">Сумма</th>
<th style="width:110px;">Дата</th>
<th style="width:60px;"></th>
</tr>
</thead>
<tbody id="rowsTableBody">
<tr class="empty-row"><td colspan="8">Выберите допник для просмотра строк</td></tr>
</tbody>
</table>
</div>
<div class="feedback-panel" id="feedbackPanel" style="display:none;">
<div style="font-weight:600;">Замечание по строке <span id="feedbackRowLabel"></span></div>
<div class="row-form">
<div class="field">
<label>Тип ошибки</label>
<select id="errorType">
<option value="wrong_price">неверная цена</option>
<option value="wrong_qty">неверное количество</option>
<option value="wrong_name">неверное наименование</option>
<option value="wrong_date">неверная дата</option>
<option value="extra_row">лишняя строка</option>
<option value="missed_row">пропущена строка</option>
<option value="wrong_action">неверное действие</option>
<option value="wrong_mode">неверный режим</option>
</select>
</div>
<div class="field">
<label>Поле</label>
<select id="errorField">
<option value="price">price</option>
<option value="qty">qty</option>
<option value="sum">sum</option>
<option value="name">name</option>
<option value="date_start">date_start</option>
<option value="action">action</option>
<option value="mode">mode</option>
</select>
</div>
</div>
<div class="row-form">
<div class="field">
<label>Правильное значение</label>
<textarea id="correctValue" placeholder='{"price":68000}'></textarea>
</div>
<div class="field">
<label>Комментарий</label>
<textarea id="comment" placeholder="Необязательно"></textarea>
</div>
</div>
<div class="row-actions">
<button class="btn btn-primary" id="saveFeedbackBtn">Сохранить</button>
<button class="btn" id="cancelFeedbackBtn">Отмена</button>
</div>
</div>
<div class="preview" id="feedbackPreview"></div>
</div>
</div>
</div>
</div>
<script>
lucide.createIcons();
const contractsList = document.getElementById('contractsList');
const contractsLoading = document.getElementById('contractsLoading');
const rowsTableBody = document.getElementById('rowsTableBody');
const contextMeta = document.getElementById('contextMeta');
const feedbackPanel = document.getElementById('feedbackPanel');
const feedbackRowLabel = document.getElementById('feedbackRowLabel');
const errorType = document.getElementById('errorType');
const errorField = document.getElementById('errorField');
const correctValue = document.getElementById('correctValue');
const comment = document.getElementById('comment');
const saveFeedbackBtn = document.getElementById('saveFeedbackBtn');
const cancelFeedbackBtn = document.getElementById('cancelFeedbackBtn');
const markCorrectBtn = document.getElementById('markCorrectBtn');
const markMissedBtn = document.getElementById('markMissedBtn');
const saveStatus = document.getElementById('saveStatus');
const feedbackPreview = document.getElementById('feedbackPreview');
const urlParams = new URLSearchParams(window.location.search);
let promptVersion = urlParams.get('prompt_version') || 'vm-teach-v1';
let modelName = urlParams.get('model') || 'gpt-oss-120b';
const appState = {
contracts: [],
currentSupplement: null,
currentRows: [],
previousRows: [],
selectedRow: null,
feedbackMode: 'row',
feedbackCount: 0,
};
function esc(text) {
return String(text == null ? '' : text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function fmtDate(value) {
if (!value) return '—';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
return d.toLocaleDateString('ru-RU');
}
function fmtNum(value) {
if (value == null || value === '') return '—';
return String(value);
}
function updateSaveStatus() {
saveStatus.textContent = appState.feedbackCount + ' замечаний';
}
function renderContracts() {
if (!appState.contracts.length) {
contractsList.innerHTML = '<div class="loading">Нет договоров с допниками</div>';
return;
}
contractsList.innerHTML = appState.contracts.map(function(contract, ci) {
const supplements = contract.supplements || [];
return `
<div class="item" data-contract-index="${ci}">
<div style="font-weight:600;">${esc(contract.contract_number || '—')}</div>
<div class="small">${esc(contract.client || '—')} ${contract.date_signed ? '· ' + esc(fmtDate(contract.date_signed)) : ''}</div>
<div style="margin-top:8px; display:flex; flex-direction:column; gap:6px;">
${supplements.map(function(supp, si) {
return `
<div class="item" style="margin-left:0;" data-supplement-id="${esc(supp.supplement_id)}" data-contract-index="${ci}" data-supp-index="${si}">
<div style="font-weight:600;">${esc(supp.supplement_number || 'допник')}</div>
<div class="small">${esc(supp.supplement_type || '—')} · ${supp.supplement_date ? esc(fmtDate(supp.supplement_date)) : '—'}</div>
<div class="small">${esc(supp.document_filename || '—')}</div>
</div>`;
}).join('')}
</div>
</div>`;
}).join('');
contractsList.querySelectorAll('[data-supplement-id]').forEach(function(el) {
el.addEventListener('click', function() {
const supplementId = el.getAttribute('data-supplement-id');
loadContext(supplementId);
contractsList.querySelectorAll('[data-supplement-id]').forEach(function(node) { node.classList.remove('active'); });
el.classList.add('active');
});
});
}
function renderRows() {
if (!appState.currentRows.length) {
rowsTableBody.innerHTML = '<tr class="empty-row"><td colspan="8">У допника нет строк спецификации</td></tr>';
return;
}
rowsTableBody.innerHTML = appState.currentRows.map(function(row) {
const cls = row.change_type === 'added' ? 'diff-added' : row.change_type === 'deleted' ? 'diff-deleted' : row.change_type === 'changed' ? 'diff-changed' : '';
return `
<tr class="${cls}" data-row-num="${esc(row.row_num)}">
<td class="num-cell">${esc(row.row_num)}</td>
<td>${esc(row.change_type || 'row')}</td>
<td class="name-cell">${esc(row.name)}</td>
<td class="num-cell">${esc(fmtNum(row.price))}</td>
<td class="num-cell">${esc(fmtNum(row.qty))}</td>
<td class="num-cell">${esc(fmtNum(row.sum))}</td>
<td class="num-cell">${esc(fmtDate(row.date_start))}</td>
<td><button class="feedback-btn" data-row-num="${esc(row.row_num)}">⚠</button></td>
</tr>`;
}).join('');
rowsTableBody.querySelectorAll('[data-row-num]').forEach(function(btn) {
btn.addEventListener('click', function() {
const rowNum = Number(btn.getAttribute('data-row-num'));
openFeedback(rowNum);
});
});
}
function openFeedback(rowNum) {
const row = appState.currentRows.find(function(r) { return Number(r.row_num) === Number(rowNum); });
if (!row) return;
appState.selectedRow = row;
appState.feedbackMode = 'row';
feedbackPanel.style.display = 'block';
feedbackRowLabel.textContent = '#' + row.row_num + ' · ' + (row.name || 'строка');
errorType.value = 'wrong_price';
errorField.value = 'price';
correctValue.value = row.price != null ? JSON.stringify({ price: row.price }, null, 2) : '';
comment.value = '';
feedbackPreview.textContent = 'LLM: ' + JSON.stringify({ action: row.change_type, row_num: row.row_num, name: row.name, price: row.price, qty: row.qty, sum: row.sum, date_start: row.date_start }, null, 2);
}
function closeFeedback() {
feedbackPanel.style.display = 'none';
appState.selectedRow = null;
appState.feedbackMode = 'row';
}
async function loadContracts() {
contractsLoading.textContent = 'Загрузка списка договоров...';
const resp = await fetch('/teach/api/contracts');
const data = await resp.json();
if (!data.ok) {
contractsLoading.textContent = 'Ошибка: ' + (data.error || 'неизвестно');
return;
}
appState.contracts = data.contracts || [];
contractsLoading.style.display = 'none';
renderContracts();
}
async function loadContext(supplementId) {
const resp = await fetch('/teach/api/context?supplement_id=' + encodeURIComponent(supplementId));
const data = await resp.json();
if (!data.ok) {
contextMeta.innerHTML = '<span class="err">Ошибка: ' + esc(data.error || 'неизвестно') + '</span>';
return;
}
appState.currentSupplement = data.supplement;
appState.currentRows = (data.current_rows || []).map(function(row) {
return {
row_num: row.row_num,
name: row.name,
price: row.price,
qty: row.qty,
sum: row.sum,
date_start: row.date_start,
date_end: row.date_end,
change_type: inferChangeType(row, data.previous_rows || []),
};
});
appState.previousRows = data.previous_rows || [];
contextMeta.innerHTML = '<span class="pill">' + esc(data.supplement.contract_number || '—') + '</span> ' +
'<span class="pill">' + esc(data.supplement.number || 'допник') + '</span> ' +
'<span class="muted">' + esc(data.supplement.document_filename || '—') + '</span>';
markCorrectBtn.disabled = false;
markMissedBtn.disabled = false;
renderRows();
closeFeedback();
}
function inferChangeType(row, previousRows) {
const prev = previousRows.find(function(r) { return Number(r.row_num) === Number(row.row_num); });
if (!prev) return 'added';
const keys = ['name', 'price', 'qty', 'sum', 'date_start'];
const same = keys.every(function(key) { return String(prev[key] ?? '') === String(row[key] ?? ''); });
return same ? 'unchanged' : 'changed';
}
async function saveFeedback(payload) {
const resp = await fetch('/teach/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!data.ok) throw new Error(data.error || 'save failed');
appState.feedbackCount += 1;
updateSaveStatus();
return data;
}
cancelFeedbackBtn.addEventListener('click', closeFeedback);
markCorrectBtn.addEventListener('click', async function() {
if (!appState.currentSupplement) return;
try {
await saveFeedback({
contract_id: appState.currentSupplement.contract_id,
supplement_id: appState.currentSupplement.supplement_id,
event_seq: null,
scope: 'document',
verdict: 'correct',
error_type: null,
field: null,
service_name: null,
llm_value: null,
correct_value: null,
prompt_version: promptVersion,
model_name: modelName,
doc_mode: appState.currentSupplement.supplement_type || null,
comment: null,
});
feedbackPreview.innerHTML = '<span class="ok">Документ помечен как верный</span>';
} catch (err) {
feedbackPreview.innerHTML = '<span class="err">' + esc(err.message) + '</span>';
}
});
markMissedBtn.addEventListener('click', function() {
if (!appState.currentSupplement) return;
appState.feedbackMode = 'missed';
appState.selectedRow = null;
feedbackPanel.style.display = 'block';
feedbackRowLabel.textContent = 'пропущенная позиция';
errorType.value = 'missed_row';
errorField.value = 'name';
correctValue.value = '';
feedbackPreview.textContent = 'Добавьте пропущенную строку в correct value (JSON)';
});
saveFeedbackBtn.addEventListener('click', async function() {
if (!appState.currentSupplement) return;
if (!appState.selectedRow && appState.feedbackMode !== 'missed') return;
if (appState.feedbackMode === 'missed') {
try {
await saveFeedback({
contract_id: appState.currentSupplement.contract_id,
supplement_id: appState.currentSupplement.supplement_id,
event_seq: null,
scope: 'missed',
verdict: 'error',
error_type: 'missed_row',
field: errorField.value,
service_name: null,
llm_value: null,
correct_value: parseJsonOrText(correctValue.value),
prompt_version: promptVersion,
model_name: modelName,
doc_mode: appState.currentSupplement.supplement_type || null,
comment: comment.value || null,
});
feedbackPreview.innerHTML = '<span class="ok">Сохранено</span>';
closeFeedback();
} catch (err) {
feedbackPreview.innerHTML = '<span class="err">' + esc(err.message) + '</span>';
}
return;
}
try {
await saveFeedback({
contract_id: appState.currentSupplement.contract_id,
supplement_id: appState.currentSupplement.supplement_id,
event_seq: appState.selectedRow.row_num,
scope: 'row',
verdict: 'error',
error_type: errorType.value,
field: errorField.value,
service_name: appState.selectedRow.name,
llm_value: {
row_num: appState.selectedRow.row_num,
action: appState.selectedRow.change_type,
name: appState.selectedRow.name,
price: appState.selectedRow.price,
qty: appState.selectedRow.qty,
sum: appState.selectedRow.sum,
date_start: appState.selectedRow.date_start,
},
correct_value: parseJsonOrText(correctValue.value),
prompt_version: promptVersion,
model_name: modelName,
doc_mode: appState.currentSupplement.supplement_type || null,
comment: comment.value || null,
});
feedbackPreview.innerHTML = '<span class="ok">Сохранено</span>';
closeFeedback();
} catch (err) {
feedbackPreview.innerHTML = '<span class="err">' + esc(err.message) + '</span>';
}
});
function parseJsonOrText(value) {
const trimmed = String(value || '').trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed);
} catch (err) {
return { text: trimmed };
}
}
async function loadMeta() {
try {
const resp = await fetch('/teach/api/meta');
const data = await resp.json();
if (data && data.ok) {
if (!urlParams.get('prompt_version') && data.prompt_version) {
promptVersion = data.prompt_version;
}
if (!urlParams.get('model') && data.model_name) {
modelName = data.model_name;
}
}
} catch (err) {
// Keep defaults if metadata endpoint is unavailable.
}
}
loadMeta()
.then(function() { return loadContracts(); })
.catch(function(err) {
contractsLoading.textContent = 'Ошибка: ' + err.message;
});
</script>
</body>
</html>
+24 -73
View File
@@ -60,7 +60,7 @@
<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;">v2.0.8</span></span>
<span class="title">Сверка договоров <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.10</span></span>
</div>
<div class="content">
@@ -156,81 +156,35 @@
window.removeFile = removeFile;
// ── Чанковая загрузка v2 (30KB + MD5) ────────────────────
// ── Прямая загрузка FormData ─────────────────────────────
function uploadFileChunked(file, cid, onProgress) {
function uploadFile(file, onProgress) {
return new Promise(function(resolve, reject) {
var reader = new FileReader();
reader.onload = function() {
var buffer = reader.result;
var bytes = new Uint8Array(buffer);
// Чанковая конвертация в base64 — apply не тянет >125K аргументов
var b64Chunks = [];
var B64_CHUNK = 0x8000; // 32768 bytes
for (var bi = 0; bi < bytes.length; bi += B64_CHUNK) {
b64Chunks.push(String.fromCharCode.apply(null, bytes.subarray(bi, bi + B64_CHUNK)));
}
var b64 = btoa(b64Chunks.join(''));
var checksum = SparkMD5.ArrayBuffer.hash(buffer);
var CHUNK = 60 * 1024; // ближе к лимиту 64KB → меньше чанков
var totalChunks = Math.ceil(b64.length / CHUNK);
var uploadId = 'u' + Date.now().toString(36) + Math.random().toString(36).substr(2, 7);
var done = 0;
var fd = new FormData();
fd.append('files', file);
if (contractId) fd.append('cid', contractId);
console.log('uploadFileChunked: ' + file.name + ' b64=' + b64.length + ' chunks=' + totalChunks + ' uploadId=' + uploadId);
function fetchWithRetry(url, body, retries) {
retries = retries || 3;
return fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
}).catch(function(e) {
if (retries > 0) {
console.log('retry ' + url + ' (' + retries + ' left): ' + e.message);
return new Promise(function(r) { setTimeout(r, 1000); }).then(function() {
return fetchWithRetry(url, body, retries - 1);
});
var xhr = new XMLHttpRequest();
xhr.open('POST', '/');
xhr.upload.onprogress = function(e) {
if (e.lengthComputable && onProgress) {
onProgress(Math.round(e.loaded / e.total * 100));
}
throw e;
});
}
function sendChunk(i) {
if (i >= totalChunks) {
console.log('finalize: ' + uploadId);
fetchWithRetry('/v2/finalize', {upload_id: uploadId, filename: file.name, total: totalChunks, checksum: checksum})
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}).then(function(data) {
if (data.ok) resolve({contract_id: data.contract_id});
else reject(new Error(data.error));
}).catch(function(e) { reject(e); });
return;
}
var piece = b64.substring(i * CHUNK, (i + 1) * CHUNK);
console.log('chunk ' + i + '/' + totalChunks + ' send ' + piece.length + ' bytes');
fetchWithRetry('/v2/chunk', {upload_id: uploadId, index: i, total: totalChunks, data: piece})
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}).then(function(data) {
if (!data.ok) { reject(new Error(data.error)); return; }
done++;
if (onProgress) onProgress({pct: Math.round(done / totalChunks * 100), done: done, total: totalChunks});
setTimeout(function() { sendChunk(i + 1); }, 3000);
}).catch(function(e) { reject(e); });
}
sendChunk(0);
};
reader.onerror = function() { reject(new Error('Read error')); };
reader.readAsArrayBuffer(file);
xhr.onload = function() {
try {
var r = JSON.parse(xhr.responseText);
if (r.contract_id) resolve(r);
else reject(new Error(r.error || 'Неизвестная ошибка'));
} catch(e) { reject(new Error('Некорректный ответ')); }
};
xhr.onerror = function() { reject(new Error('Сеть')); };
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
xhr.timeout = 120000;
xhr.send(fd);
});
}
// ── Старая загрузка (одним POST) ─────────────────────────────
// ── Выбор файла → сразу загрузка ────────────────────────────
fileInput.addEventListener('change', async function() {
@@ -252,16 +206,13 @@
var startTime = Date.now();
try {
var resp = await uploadFileChunked(f, contractId, function(info) {
fileQueue[rowIdx].status = '<span style="color:var(--muted);">↑ чанк ' + info.done + '/' + info.total + ' (' + info.pct + '%)</span>';
var resp = await uploadFile(f, function(pct) {
fileQueue[rowIdx].status = '<span style="color:var(--muted);">↑ ' + pct + '%</span>';
renderTable();
});
// uploadFileChunked10k: прогресс по чанкам
// но этот же resp приходит только с последнего чанка
if (resp && resp.contract_id) {
contractId = resp.contract_id;
}
// Если файл был один и он маленький (один чанк), contract_id уже есть
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
fileQueue[rowIdx].status = '<span class="status-ok">✓ ' + elapsed + 'с</span>';
fileQueue[rowIdx].uploaded = true;