From 557a238fb8434e8c3626a1b18a2d33323516a789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Thu, 18 Jun 2026 08:10:23 +0400 Subject: [PATCH] =?UTF-8?q?v3.1.0:=20=D1=80=D0=B0=D0=B7=D0=B1=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20app.py=20=D0=BD=D0=B0=20routes/main,parse,misc=20?= =?UTF-8?q?=E2=80=94=2052=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20330?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/app.py | 524 +++------------------------------------- site/routes/__init__.py | 1 + site/routes/main.py | 156 ++++++++++++ site/routes/misc.py | 33 +++ site/routes/parse.py | 193 +++++++++++++++ 5 files changed, 416 insertions(+), 491 deletions(-) create mode 100644 site/routes/__init__.py create mode 100644 site/routes/main.py create mode 100644 site/routes/misc.py create mode 100644 site/routes/parse.py diff --git a/site/app.py b/site/app.py index 7ef786c..32850bd 100644 --- a/site/app.py +++ b/site/app.py @@ -1,522 +1,64 @@ -"""app.py — Точка входа и сборка слоёв.""" +"""app.py — Точка входа. Только Flask + регистрация маршрутов.""" from dotenv import load_dotenv -from flask import Flask, render_template, request, redirect, url_for, Response, jsonify -import json +from flask import Flask +# Слои import schema -import db -import parser as parser_mod -import textify -import extractor -import differ -import mimeutil from test_routes import test_bp from upload import upload_bp from api import api_bp +# Маршруты (разделены по файлам) +from routes.main import index +from routes.parse import parse +from routes.misc import health, logs + load_dotenv() -import time as _time -import re as _re - -# Логи в памяти (быстро, не тормозит ответ) -_log_memory = [] - -def _log(step, detail=""): - """Писать лог в память (мгновенно).""" - _log_memory.append({"step": step, "detail": str(detail)[:500], "time": _time.time()}) - if len(_log_memory) > 500: - _log_memory.pop(0) - - -def _save_file_to_db(filename, file_bytes, mime, contract_id, conn=None): - """Сохранить файл в БД. Если conn передан — использовать его, иначе свои подключения.""" - if conn: - cur = conn.cursor() - cur.execute( - "INSERT INTO documents (filename, mime_type, original_bytes, status) VALUES (%s,%s,%s,'uploaded')", - (filename, mime, file_bytes), - ) - cur.execute( - "SELECT id FROM documents WHERE filename=%s AND status='uploaded' ORDER BY created_at DESC LIMIT 1", - (filename,), - ) - row = cur.fetchone() - doc_id = row[0] if row else None - if doc_id: - cur.execute( - "INSERT INTO supplements (contract_id, document_id, type) VALUES (%s,%s,'initial')", - (str(contract_id), str(doc_id)), - ) - conn.commit() - cur.close() - return doc_id - - # Без соединения — каждое действие со своим (старый режим) - db.execute( - "INSERT INTO documents (filename, mime_type, original_bytes, status) VALUES (%s,%s,%s,'uploaded')", - (filename, mime, file_bytes), - ) - doc, _ = db.query_one( - "SELECT id FROM documents WHERE filename=%s AND status='uploaded' ORDER BY created_at DESC LIMIT 1", - (filename,), - ) - doc_id = doc["id"] if doc else None - if doc_id: - db.execute( - "INSERT INTO supplements (contract_id, document_id, type) VALUES (%s,%s,'initial')", - (str(contract_id), str(doc_id)), - ) - return doc_id - class ContractsApp: + """ + Главный класс приложения. + + Собирает Flask, регистрирует маршруты и blueprint'ы. + Запуск: ContractsApp().run() — dev-сервер + gunicorn app:application — production + """ + def __init__(self): self.app = Flask(__name__) + + # Создать таблицы в БД (IF NOT EXISTS — безопасно) schema.ensure_schema() + + # Зарегистрировать маршруты self._add_routes() def _add_routes(self): - self.app.add_url_rule("/", "index", self._index, methods=["GET", "POST"]) - self.app.add_url_rule("/parse/", "parse", self._parse, methods=["GET"]) + """Подключить все URL к обработчикам.""" + # Главные: загрузка, просмотр + self.app.add_url_rule("/", "index", index, methods=["GET", "POST"]) - self.app.add_url_rule("/health", "health", self._health) - self.app.add_url_rule("/logs", "logs", self._logs) + # Парсинг: SSE-поток + self.app.add_url_rule("/parse/", "parse", parse) + + # Служебные + self.app.add_url_rule("/health", "health", health) + self.app.add_url_rule("/logs", "logs", logs) + + # Blueprint'ы self.app.register_blueprint(test_bp) self.app.register_blueprint(upload_bp) self.app.register_blueprint(api_bp) - def _health(self): - return "OK", 200, {"Content-Type": "text/plain"} - - def _logs(self): - return jsonify(_log_memory[-50:]) # последние 50 записей - - # ── Шаг 1: загрузка файлов ────────────────────────────────── - - def _index(self): - if request.method == "POST": - return self._upload_files() - cid = request.args.get("id") - if cid: - return self._show_contract(cid) - return render_template("upload.html") - - def _upload_files(self): - """Сохранить файлы. ?cid=X — добавить к существующему договору.""" - files = request.files.getlist("files") - if not files or not any(f.filename for f in files): - return jsonify({"error": "Нет файлов"}), 400 - - cid = request.args.get("cid") - - if cid: - contract_id = cid - else: - conn, err = db.connect() - if err: - return jsonify({"error": f"БД: {err}"}), 500 - cur = conn.cursor() - cur.execute( - "INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id", - ("б/н " + __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M"), ""), - ) - contract_id = cur.fetchone()[0] - conn.commit() - cur.close() - db.put_conn(conn) - - for f in files: - if not f.filename: - continue - filename = f.filename - mime = mimeutil.guess_mime(filename) or f.content_type or "application/octet-stream" - _save_file_to_db(filename, f.read(), mime, str(contract_id)) - - return jsonify({"contract_id": str(contract_id)}) - - # ── Загрузка base64 (JSON) ────────────────────────────────── - - def _upload_json(self): - """Принять файл как base64. Без JSON-парсинга — сырой разбор.""" - _log("upload_entry", "start") - try: - raw = request.get_data(as_text=True) - import re - m = re.search(r'"data"\s*:\s*"([^"]*)"', raw) - if not m: - return jsonify({"error": "Нет data"}), 400 - b64 = m.group(1) - - m = re.search(r'"filename"\s*:\s*"([^"]*)"', raw) - filename = m.group(1) if m else "" - - m = re.search(r'"cid"\s*:\s*"([^"]*)"', raw) - cid = m.group(1) if m else None - - if not filename or not b64: - return jsonify({"error": "Нет filename или data"}), 400 - - mime = mimeutil.guess_mime(filename) or "application/octet-stream" - - # Контракт — синхронно (быстро, нужно для следующих загрузок) - if not cid: - conn, err = db.connect() - if err: - return jsonify({"error": f"БД: {err}"}), 500 - try: - cur = conn.cursor() - cur.execute( - "INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id", - ("б/н " + __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M"), ""), - ) - cid = cur.fetchone()[0] - conn.commit() - cur.close() - finally: - db.put_conn(conn) - - # Файл — в Redis (в фоне, не блокируем ответ) - import threading - task = {"filename": filename, "b64": b64, "mime": mime, "cid": str(cid)} - threading.Thread(target=redis_client.push, args=(task,), daemon=True).start() - - return jsonify({"contract_id": str(cid)}) - except Exception as e: - _log("upload_outer_error", str(e)) - return jsonify({"error": f"Крах: {e}"}), 500 - - # ── Чанковая загрузка base64 (30KB) ─────────────────────── - - def _chunk_json(self): - """Принять чанк base64 (в памяти). На последнем — в Redis.""" - data = request.get_json(silent=True) or {} - upload_id = data.get("upload_id", "") - filename = data.get("filename", "") - chunk_index = data.get("chunk_index", 0) - total_chunks = data.get("total_chunks", 1) - b64_piece = data.get("data", "") - cid = data.get("cid") - - if not upload_id or not b64_piece: - return jsonify({"error": "Нет upload_id или data"}), 400 - - if upload_id not in _chunks_mem: - _chunks_mem[upload_id] = {"chunks": [None]*total_chunks, "filename": filename, "cid": cid, "received": 0} - - store = _chunks_mem[upload_id] - if store["chunks"][chunk_index] is None: - store["chunks"][chunk_index] = b64_piece - store["received"] += 1 - - if store["received"] == total_chunks: - full_b64 = "".join(store["chunks"]) - fname = store["filename"] - fcid = store["cid"] - mime = mimeutil.guess_mime(fname) or "application/octet-stream" - import threading - task = {"filename": fname, "b64": full_b64, "mime": mime, "cid": fcid or ""} - threading.Thread(target=redis_client.push, args=(task,), daemon=True).start() - del _chunks_mem[upload_id] - return jsonify({"contract_id": fcid or "pending"}) - - return jsonify({"chunk": chunk_index, "received": store["received"], "total": total_chunks}) - - # ── Старая чанковая загрузка ─────────────────────────────── - - def _chunk_upload(self): - """Принять чанк. Хранить в БД. На последнем — собрать и сохранить.""" - upload_id = request.form.get("upload_id", "") - filename = request.form.get("filename", "") - chunk_index = int(request.form.get("chunk_index", 0)) - total_chunks = int(request.form.get("total_chunks", 1)) - cid = request.form.get("cid") or None - chunk_file = request.files.get("chunk") - if not chunk_file: - return jsonify({"error": "Нет чанка"}), 400 - - chunk_data = chunk_file.read() - mime = mimeutil.guess_mime(filename) or "application/octet-stream" - - _log("chunk_start", f"{filename} idx={chunk_index}/{total_chunks} size={len(chunk_data)} cid={cid}") - - # Сохранить чанк в БД (ON CONFLICT — идемпотентно) - rc, err = db.execute( - "INSERT INTO chunks (upload_id, chunk_index, chunk_data, filename, mime, total_chunks, cid) " - "VALUES (%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (upload_id, chunk_index) DO NOTHING", - (upload_id, chunk_index, chunk_data, filename, mime, total_chunks, cid), - ) - _log("chunk_insert", f"idx={chunk_index} rc={rc} err={err}") - - # Сколько уже получено - count_res, cerr = db.query( - "SELECT COUNT(*) FROM chunks WHERE upload_id=%s", (upload_id,) - ) - received = count_res["rows"][0][0] if count_res else 0 - _log("chunk_count", f"received={received}/{total_chunks} err={cerr}") - - if received == total_chunks: - _log("assembly_start", f"{filename} {total_chunks} chunks, {sum(len(c) if c else 0 for c in [chunk_data])}b this chunk") - try: - conn, err = db.connect() - _log("assembly_connect", f"err={err}") - if err: - return jsonify({"error": f"БД: {err}"}), 500 - cur = conn.cursor() - - cur.execute( - "SELECT chunk_data FROM chunks WHERE upload_id=%s ORDER BY chunk_index", - (upload_id,), - ) - all_chunks = cur.fetchall() - _log("assembly_select", f"{len(all_chunks)} rows") - - file_bytes = b"".join(r[0] for r in all_chunks) - _log("assembly_joined", f"{len(file_bytes)} bytes") - - cur.execute( - "SELECT filename, mime, cid FROM chunks WHERE upload_id=%s LIMIT 1", - (upload_id,), - ) - meta = cur.fetchone() - fname = meta[0] if meta else filename - fmime = meta[1] if meta else mime - fcid = meta[2] if meta else cid - _log("assembly_meta", f"fname={fname} mime={fmime} cid={fcid}") - - if fcid: - contract_id = fcid - else: - cur.execute( - "INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id", - ("б/н " + __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M"), ""), - ) - contract_id = cur.fetchone()[0] - _log("assembly_contract", f"new cid={contract_id}") - - conn.commit() - _log("assembly_commit", "ok") - - doc_id = _save_file_to_db(fname, file_bytes, fmime, str(contract_id), conn=conn) - _log("save_file", f"doc_id={doc_id}") - if not doc_id: - conn.rollback() - cur.close() - db.put_conn(conn) - return jsonify({"error": "Не удалось сохранить файл в БД"}), 500 - - cur.close() - db.put_conn(conn) - _log("assembly_done", f"cid={contract_id}") - except Exception as e: - _log("assembly_error", str(e)) - try: conn.rollback() - except: pass - try: cur.close() - except: pass - try: db.put_conn(conn) - except: pass - return jsonify({"error": f"Сборка: {e}"}), 500 - finally: - db.execute("DELETE FROM chunks WHERE upload_id=%s", (upload_id,)) - _log("chunks_cleanup", f"upload_id={upload_id}") - - return jsonify({"contract_id": str(contract_id)}) - - _log("chunk_ok", f"idx={chunk_index} received={received}/{total_chunks}") - return jsonify({"chunk": chunk_index, "received": received, "total": total_chunks}) - - def _show_contract(self, cid): - """Показать договор с кнопкой «Обработать».""" - contract, _ = db.query_one("SELECT id,number,created_at FROM contracts WHERE id=%s", (cid,)) - if not contract: - return render_template("upload.html", error="Договор не найден") - - supps, _ = db.query( - "SELECT s.id, s.created_at, d.filename, d.status, d.parsed_text IS NOT NULL as has_text " - "FROM supplements s JOIN documents d ON s.document_id=d.id " - "WHERE s.contract_id=%s ORDER BY s.created_at", - (cid,), - ) - supp_list = [dict(zip(supps["columns"], r)) for r in supps["rows"]] if supps else [] - - # Есть ли уже результаты? - rows_res, _ = 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 sr.supplement_id=s.id " - "WHERE s.contract_id=%s ORDER BY sr.row_num", - (cid,), - ) - all_rows = [dict(zip(rows_res["columns"], r)) for r in rows_res["rows"]] if rows_res else [] - - # Есть необработанные допники? - unprocessed = [s for s in supp_list if s["status"] in ("uploaded", "parsed")] - - return render_template("upload.html", - contract=contract, supplements=supp_list, - all_rows=all_rows, unprocessed=unprocessed) - - # ── Шаг 2: Парсинг (SSE) ──────────────────────────────────── - - def _parse(self, cid): - """SSE-поток: парсинг файлов договора (без LLM).""" - import time as time_mod - - def generate(): - start_time = time_mod.time() - total_bytes = 0 - files_processed = 0 - - supps, _ = db.query( - "SELECT s.id, d.id as doc_id, d.filename, d.mime_type, " - "LENGTH(d.original_bytes) as file_size " - "FROM supplements s JOIN documents d ON s.document_id=d.id " - "WHERE s.contract_id=%s", - (cid,), - ) - if not supps: - yield f"data: {json.dumps({'type': 'error', 'message': 'Нет файлов'})}\n\n" - return - - supp_rows = [dict(zip(supps["columns"], r)) for r in supps["rows"]] - - def _file_done(name, elapsed, elements, errors, text): - paragraphs = sum(1 for e in elements if e.get("type") == "paragraph") - tables = sum(1 for e in elements if e.get("type") == "table") - table_rows = sum(len(e.get("rows", [])) for e in elements if e.get("type") == "table") - table_headers = [ - e["rows"][0] if e.get("rows") else [] - for e in elements if e.get("type") == "table" - ] - return { - "type": "file_done", - "name": name, - "time_s": elapsed, - "elements": len(elements), - "paragraphs": paragraphs, - "tables": tables, - "table_rows": table_rows, - "table_headers": table_headers, - "errors": errors, - "text_len": len(text) if text else 0, - "text_preview": text[:200] if text else "", - } - - for s in supp_rows: - # Пропустить уже распарсенные - existing, _ = db.query( - "SELECT 1 FROM documents WHERE id=%s AND status IN ('parsed','expanded')", (s["doc_id"],) - ) - if existing and existing["rows"]: - continue - - file_start = time_mod.time() - file_bytes = s.get("file_size", 0) or 0 - total_bytes += file_bytes - - yield f"data: {json.dumps({'type': 'file_start', 'name': s['filename'], 'bytes': file_bytes})}\n\n" - - # Получить байты: сначала original_bytes, иначе original_b64 - doc, _ = db.query_one("SELECT original_bytes, original_b64 FROM documents WHERE id=%s", (s["doc_id"],)) - raw = doc.get("original_bytes") if doc else None - b64 = doc.get("original_b64") if doc else None - if raw: - file_data = bytes(raw) if isinstance(raw, memoryview) else raw - elif b64: - import base64 as b64mod - file_data = b64mod.b64decode(b64) - else: - yield f"data: {json.dumps({'type': 'file_error', 'name': s['filename'], 'error': 'Нет данных'})}\n\n" - continue - - # Парсинг: ZIP — распаковать и парсить каждый файл отдельно - if s["mime_type"] == "application/zip": - import io as io_mod, zipfile as zf_mod - zip_names = [] - try: - with zf_mod.ZipFile(io_mod.BytesIO(file_data)) as zf: - for zname in zf.namelist(): - if zname.endswith("/"): - continue - zdata = zf.read(zname) - zmime = mimeutil.guess_mime(zname) or "application/octet-stream" - - # Только PDF и Word - if zmime not in ( - "application/pdf", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/msword", - ): - continue - - zip_names.append(zname) - - # Сохранить как отдельный документ - db.execute( - "INSERT INTO documents (filename, mime_type, original_bytes, status) VALUES (%s,%s,%s,'uploaded')", - (zname, zmime, zdata), - ) - zdoc, _ = db.query_one( - "SELECT id FROM documents WHERE filename=%s ORDER BY created_at DESC LIMIT 1", - (zname,), - ) - zdoc_id = zdoc["id"] if zdoc else None - if zdoc_id: - db.execute( - "INSERT INTO supplements (contract_id, document_id, type) VALUES (%s,%s,'initial')", - (cid, str(zdoc_id)), - ) - - # Парсинг внутреннего файла - zf_start = time_mod.time() - total_bytes += len(zdata) - yield f"data: {json.dumps({'type': 'file_start', 'name': zname, 'bytes': len(zdata)})}\n\n" - - try: - zpr = parser_mod.parse(zdata, zmime) - zelements = zpr.get("elements", []) - ztext = textify.to_text(zelements) if zelements else "" - db.execute( - "UPDATE documents SET parsed_text=%s, elements_json=%s, status='parsed' WHERE id=%s", - (ztext, json.dumps(zelements, ensure_ascii=False), str(zdoc_id)), - ) - zelapsed = round(time_mod.time() - zf_start, 2) - files_processed += 1 - yield f"data: {json.dumps(_file_done(zname, zelapsed, zelements, zpr.get('errors',[]), ztext))}\n\n" - except Exception as e: - yield f"data: {json.dumps({'type': 'file_error', 'name': zname, 'error': str(e)})}\n\n" - - except Exception as e: - yield f"data: {json.dumps({'type': 'file_error', 'name': s['filename'], 'error': 'ZIP: ' + str(e)})}\n\n" - - # ZIP сам — expanded - db.execute("UPDATE documents SET status='expanded' WHERE id=%s", (s["doc_id"],)) - yield f"data: {json.dumps({'type': 'zip_expanded', 'name': s['filename'], 'count': len(zip_names)})}\n\n" - - else: - pr = parser_mod.parse(file_data, s["mime_type"]) - elements = pr.get("elements", []) - text = textify.to_text(elements) if elements else "" - db.execute( - "UPDATE documents SET parsed_text=%s, elements_json=%s, status='parsed' WHERE id=%s", - (text, json.dumps(elements, ensure_ascii=False), str(s["doc_id"])), - ) - elapsed = round(time_mod.time() - file_start, 2) - files_processed += 1 - yield f"data: {json.dumps(_file_done(s['filename'], elapsed, elements, pr.get('errors',[]), text))}\n\n" - - total_time = round(time_mod.time() - start_time, 2) - yield f"data: {json.dumps({'type': 'summary', 'total_time_s': total_time, 'total_bytes': total_bytes, 'files_processed': files_processed})}\n\n" - - return Response(generate(), mimetype="text/event-stream") - def run(self): + """Dev-сервер на порту 5000.""" self.app.run(host="0.0.0.0", port=5000) if __name__ == "__main__": ContractsApp().run() -# для gunicorn: app:application +# Для gunicorn: gunicorn app:application application = ContractsApp().app diff --git a/site/routes/__init__.py b/site/routes/__init__.py new file mode 100644 index 0000000..6338d57 --- /dev/null +++ b/site/routes/__init__.py @@ -0,0 +1 @@ +"""routes/__init__.py — Пакет маршрутов.""" diff --git a/site/routes/main.py b/site/routes/main.py new file mode 100644 index 0000000..85232ac --- /dev/null +++ b/site/routes/main.py @@ -0,0 +1,156 @@ +"""routes/main.py — Главные маршруты: загрузка файлов, просмотр договора.""" + +import datetime +from flask import render_template, request, jsonify +import db +import mimeutil + + +def _save_file_to_db(filename, file_bytes, mime, contract_id, conn=None): + """ + Сохранить загруженный файл в БД. + + Создаёт запись в documents и привязывает к договору через supplements. + Если conn передан — используется одно соединение для всей транзакции. + Иначе — каждый запрос в своём соединении. + + Возвращает doc_id или None при ошибке. + """ + if conn: + # Транзакционный режим: все операции в одном соединении + cur = conn.cursor() + cur.execute( + "INSERT INTO documents (filename, mime_type, original_bytes, status) VALUES (%s,%s,%s,'uploaded')", + (filename, mime, file_bytes), + ) + cur.execute( + "SELECT id FROM documents WHERE filename=%s AND status='uploaded' ORDER BY created_at DESC LIMIT 1", + (filename,), + ) + row = cur.fetchone() + doc_id = row[0] if row else None + if doc_id: + cur.execute( + "INSERT INTO supplements (contract_id, document_id, type) VALUES (%s,%s,'initial')", + (str(contract_id), str(doc_id)), + ) + conn.commit() + cur.close() + return doc_id + + # Автономный режим: каждый вызов db.execute сам управляет соединением + db.execute( + "INSERT INTO documents (filename, mime_type, original_bytes, status) VALUES (%s,%s,%s,'uploaded')", + (filename, mime, file_bytes), + ) + doc, _ = db.query_one( + "SELECT id FROM documents WHERE filename=%s AND status='uploaded' ORDER BY created_at DESC LIMIT 1", + (filename,), + ) + doc_id = doc["id"] if doc else None + if doc_id: + db.execute( + "INSERT INTO supplements (contract_id, document_id, type) VALUES (%s,%s,'initial')", + (str(contract_id), str(doc_id)), + ) + return doc_id + + +def index(): + """ + GET / — страница загрузки + GET /?id= — просмотр договора + POST / — приём файлов (multipart/form-data) + """ + if request.method == "POST": + return _upload_files() + cid = request.args.get("id") + if cid: + return _show_contract(cid) + return render_template("upload.html") + + +def _upload_files(): + """ + Принять файлы через multipart/form-data. + + Поле формы: 'files' (один или несколько). + ?cid=X — добавить файлы к существующему договору. + Если cid не указан — создаётся новый договор. + """ + files = request.files.getlist("files") + if not files or not any(f.filename for f in files): + return jsonify({"error": "Нет файлов"}), 400 + + cid = request.args.get("cid") + + if cid: + # Добавляем к существующему договору + contract_id = cid + else: + # Создаём новый договор + conn, err = db.connect() + if err: + return jsonify({"error": f"БД: {err}"}), 500 + cur = conn.cursor() + cur.execute( + "INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id", + ("б/н " + datetime.datetime.now().strftime("%Y%m%d-%H%M"), ""), + ) + contract_id = cur.fetchone()[0] + conn.commit() + cur.close() + db.put_conn(conn) + + # Сохраняем каждый файл + for f in files: + if not f.filename: + continue + filename = f.filename + mime = mimeutil.guess_mime(filename) or f.content_type or "application/octet-stream" + _save_file_to_db(filename, f.read(), mime, str(contract_id)) + + return jsonify({"contract_id": str(contract_id)}) + + +def _show_contract(cid): + """ + Показать страницу договора со списком файлов и кнопкой «Парсинг». + + Загружает: информацию о договоре, список допников/файлов, + существующие строки спецификации (если уже извлечены). + """ + contract, _ = db.query_one( + "SELECT id, number, created_at FROM contracts WHERE id = %s", + (cid,), + ) + if not contract: + return render_template("upload.html", error="Договор не найден") + + # Все файлы, привязанные к договору + supps, _ = db.query( + "SELECT s.id, s.created_at, d.filename, d.status, d.parsed_text IS NOT NULL as has_text " + "FROM supplements s JOIN documents d ON s.document_id = d.id " + "WHERE s.contract_id = %s ORDER BY s.created_at", + (cid,), + ) + supp_list = [dict(zip(supps["columns"], r)) for r in supps["rows"]] if supps else [] + + # Уже извлечённые строки спецификации + rows_res, _ = 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 sr.supplement_id = s.id " + "WHERE s.contract_id = %s ORDER BY sr.row_num", + (cid,), + ) + all_rows = [dict(zip(rows_res["columns"], r)) for r in rows_res["rows"]] if rows_res else [] + + # Файлы, готовые к парсингу + unprocessed = [s for s in supp_list if s["status"] in ("uploaded", "parsed")] + + return render_template("upload.html", + contract=contract, + supplements=supp_list, + all_rows=all_rows, + unprocessed=unprocessed, + ) diff --git a/site/routes/misc.py b/site/routes/misc.py new file mode 100644 index 0000000..cd2687d --- /dev/null +++ b/site/routes/misc.py @@ -0,0 +1,33 @@ +"""routes/misc.py — Служебные маршруты: health, логи.""" + +from flask import jsonify + +# Логи в памяти — здесь чтобы routes/main.py тоже мог писать +import time as _time +_log_memory = [] + + +def log(step, detail=""): + """ + Записать шаг в оперативную память (мгновенно, без БД). + + Используется для отладки. Лимит: 500 записей, старые вытесняются. + """ + _log_memory.append({"step": step, "detail": str(detail)[:500], "time": _time.time()}) + if len(_log_memory) > 500: + _log_memory.pop(0) + + +def health(): + """ + GET /health — проверка живости. + Всегда возвращает 200 OK. + """ + return "OK", 200, {"Content-Type": "text/plain"} + + +def logs(): + """ + GET /logs — последние 50 записей отладочного лога. + """ + return jsonify(_log_memory[-50:]) diff --git a/site/routes/parse.py b/site/routes/parse.py new file mode 100644 index 0000000..ff11fb8 --- /dev/null +++ b/site/routes/parse.py @@ -0,0 +1,193 @@ +"""routes/parse.py — SSE-парсинг: потоковый разбор документов договора.""" + +import json as _json +import time as _time +import io as _io +import zipfile as _zipfile +import base64 as _b64 + +from flask import Response + +import db +import parser as parser_mod +import textify +import mimeutil + + +def parse(cid): + """ + GET /parse/ — Server-Sent Events поток. + + Для каждого файла договора: + 1. Достаёт байты из БД (original_bytes или original_b64) + 2. Парсит через parser.parse() + 3. Сохраняет parsed_text + elements_json в БД + 4. Отправляет SSE-сообщения: file_start → file_done → summary + + ZIP-файлы распаковываются, каждый внутренний файл парсится отдельно. + """ + def generate(): + start_time = _time.time() + total_bytes = 0 + files_processed = 0 + + # ── Получить все файлы договора ───────────────────── + supps, _ = db.query( + "SELECT s.id, d.id as doc_id, d.filename, d.mime_type, " + "LENGTH(d.original_bytes) as file_size " + "FROM supplements s JOIN documents d ON s.document_id = d.id " + "WHERE s.contract_id = %s", + (cid,), + ) + if not supps: + yield f"data: {_json.dumps({'type': 'error', 'message': 'Нет файлов'})}\n\n" + return + + supp_rows = [dict(zip(supps["columns"], r)) for r in supps["rows"]] + + # ── Вспомогательная: собрать результат парсинга ───── + def _file_done(name, elapsed, elements, errors, text): + """ + Сформировать SSE-сообщение file_done со статистикой: + количество параграфов, таблиц, строк таблиц, заголовки таблиц. + """ + paragraphs = sum(1 for e in elements if e.get("type") == "paragraph") + tables = sum(1 for e in elements if e.get("type") == "table") + table_rows = sum(len(e.get("rows", [])) for e in elements if e.get("type") == "table") + table_headers = [ + e["rows"][0] if e.get("rows") else [] + for e in elements if e.get("type") == "table" + ] + return { + "type": "file_done", + "name": name, + "time_s": elapsed, + "elements": len(elements), + "paragraphs": paragraphs, + "tables": tables, + "table_rows": table_rows, + "table_headers": table_headers, + "errors": errors, + "text_len": len(text) if text else 0, + "text_preview": text[:200] if text else "", + } + + # ── Основной цикл: каждый файл ────────────────────── + for s in supp_rows: + # Пропустить уже распарсенные + existing, _ = db.query( + "SELECT 1 FROM documents WHERE id = %s AND status IN ('parsed', 'expanded')", + (s["doc_id"],), + ) + if existing and existing["rows"]: + continue + + file_start = _time.time() + file_bytes = s.get("file_size", 0) or 0 + total_bytes += file_bytes + + # SSE: начало обработки файла + yield f"data: {_json.dumps({'type': 'file_start', 'name': s['filename'], 'bytes': file_bytes})}\n\n" + + # ── Получить байты файла ──────────────────────── + doc, _ = db.query_one( + "SELECT original_bytes, original_b64 FROM documents WHERE id = %s", + (s["doc_id"],), + ) + raw = doc.get("original_bytes") if doc else None + b64 = doc.get("original_b64") if doc else None + + if raw: + # Обычный файл: original_bytes + file_data = bytes(raw) if isinstance(raw, memoryview) else raw + elif b64: + # Старый формат: base64 + file_data = _b64.b64decode(b64) + else: + yield f"data: {_json.dumps({'type': 'file_error', 'name': s['filename'], 'error': 'Нет данных'})}\n\n" + continue + + # ── ZIP: распаковать и парсить каждый файл ────── + if s["mime_type"] == "application/zip": + zip_names = [] + try: + with _zipfile.ZipFile(_io.BytesIO(file_data)) as zf: + for zname in zf.namelist(): + if zname.endswith("/"): + continue # пропускаем папки + + zdata = zf.read(zname) + zmime = mimeutil.guess_mime(zname) or "application/octet-stream" + + # Только PDF и Word внутри ZIP + if zmime not in ( + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/msword", + ): + continue + + zip_names.append(zname) + + # Сохранить как отдельный документ + db.execute( + "INSERT INTO documents (filename, mime_type, original_bytes, status) VALUES (%s,%s,%s,'uploaded')", + (zname, zmime, zdata), + ) + zdoc, _ = db.query_one( + "SELECT id FROM documents WHERE filename = %s ORDER BY created_at DESC LIMIT 1", + (zname,), + ) + zdoc_id = zdoc["id"] if zdoc else None + if zdoc_id: + db.execute( + "INSERT INTO supplements (contract_id, document_id, type) VALUES (%s,%s,'initial')", + (cid, str(zdoc_id)), + ) + + # Парсинг внутреннего файла + zf_start = _time.time() + total_bytes += len(zdata) + yield f"data: {_json.dumps({'type': 'file_start', 'name': zname, 'bytes': len(zdata)})}\n\n" + + try: + zpr = parser_mod.parse(zdata, zmime) + zelements = zpr.get("elements", []) + ztext = textify.to_text(zelements) if zelements else "" + db.execute( + "UPDATE documents SET parsed_text = %s, elements_json = %s, status = 'parsed' WHERE id = %s", + (ztext, _json.dumps(zelements, ensure_ascii=False), str(zdoc_id)), + ) + zelapsed = round(_time.time() - zf_start, 2) + files_processed += 1 + yield f"data: {_json.dumps(_file_done(zname, zelapsed, zelements, zpr.get('errors', []), ztext))}\n\n" + except Exception as e: + yield f"data: {_json.dumps({'type': 'file_error', 'name': zname, 'error': str(e)})}\n\n" + + except Exception as e: + yield f"data: {_json.dumps({'type': 'file_error', 'name': s['filename'], 'error': 'ZIP: ' + str(e)})}\n\n" + + # Пометить ZIP как expanded + db.execute("UPDATE documents SET status = 'expanded' WHERE id = %s", (s["doc_id"],)) + yield f"data: {_json.dumps({'type': 'zip_expanded', 'name': s['filename'], 'count': len(zip_names)})}\n\n" + + else: + # ── Обычный файл (PDF/DOCX) ───────────────── + pr = parser_mod.parse(file_data, s["mime_type"]) + elements = pr.get("elements", []) + text = textify.to_text(elements) if elements else "" + + # Сохранить результат в БД + db.execute( + "UPDATE documents SET parsed_text = %s, elements_json = %s, status = 'parsed' WHERE id = %s", + (text, _json.dumps(elements, ensure_ascii=False), str(s["doc_id"])), + ) + elapsed = round(_time.time() - file_start, 2) + files_processed += 1 + yield f"data: {_json.dumps(_file_done(s['filename'], elapsed, elements, pr.get('errors', []), text))}\n\n" + + # ── Итоговое сообщение ────────────────────────────── + total_time = round(_time.time() - start_time, 2) + yield f"data: {_json.dumps({'type': 'summary', 'total_time_s': total_time, 'total_bytes': total_bytes, 'files_processed': files_processed})}\n\n" + + return Response(generate(), mimetype="text/event-stream")