From 8b04eb93bd37c9769e848fa4eb068bcfbff8e87e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Fri, 26 Jun 2026 12:48:57 +0400 Subject: [PATCH] Add isolated teach flow on VM --- site/app.py | 2 + site/schema.py | 31 +++ site/teach.py | 208 ++++++++++++++++ site/templates/teach.html | 503 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 744 insertions(+) create mode 100644 site/teach.py create mode 100644 site/templates/teach.html diff --git a/site/app.py b/site/app.py index fb53d5d..b5b35e5 100644 --- a/site/app.py +++ b/site/app.py @@ -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() @@ -97,6 +98,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"} diff --git a/site/schema.py b/site/schema.py index 4bb83a8..ca5a9ec 100644 --- a/site/schema.py +++ b/site/schema.py @@ -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") diff --git a/site/teach.py b/site/teach.py new file mode 100644 index 0000000..8524358 --- /dev/null +++ b/site/teach.py @@ -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) diff --git a/site/templates/teach.html b/site/templates/teach.html new file mode 100644 index 0000000..d175a78 --- /dev/null +++ b/site/templates/teach.html @@ -0,0 +1,503 @@ + + + + + + Сверка договоров — обучение + + + + + +
+ Nubes + Сверка договоров — обучение изолированная страница + feedback only +
+ +
+
+ Здесь не запускается рабочий compare-пайплайн. Страница только читает уже разобранные данные и пишет замечания в отдельную таблицу feedback. +
+ +
+
+ + Выбор договора и допника +
+
+ +
+
Выберите допник слева
+
+ + + 0 замечаний +
+
+ + + + + + + + + + + + + + + + +
ДействиеУслугаЦенаКол-воСуммаДата
Выберите допник для просмотра строк
+
+ +
+
+
+
+
+ + + +