Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4a6d78656 | ||
|
|
8b04eb93bd | ||
|
|
bec16def95 | ||
|
|
863026822c |
+6
-1
@@ -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()
|
||||
redis_worker.start_worker()
|
||||
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"}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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
@@ -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);
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/');
|
||||
xhr.upload.onprogress = function(e) {
|
||||
if (e.lengthComputable && onProgress) {
|
||||
onProgress(Math.round(e.loaded / e.total * 100));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user