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 @@
+
+
+
+
+
+ Сверка договоров — обучение
+
+
+
+
+
+
+
 }})
+
Сверка договоров — обучение изолированная страница
+
feedback only
+
+
+
+
+ Здесь не запускается рабочий compare-пайплайн. Страница только читает уже разобранные данные и пишет замечания в отдельную таблицу feedback.
+
+
+
+
+
+
+
+
Выберите допник слева
+
+
+
+ 0 замечаний
+
+
+
+
+
+ | № |
+ Действие |
+ Услуга |
+ Цена |
+ Кол-во |
+ Сумма |
+ Дата |
+ |
+
+
+
+ | Выберите допник для просмотра строк |
+
+
+
+
+
Замечание по строке
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+