"""app.py — Точка входа и сборка слоёв.""" from dotenv import load_dotenv from flask import Flask, render_template, request, redirect, url_for, Response, jsonify import json 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 load_dotenv() class ContractsApp: def __init__(self): self.app = Flask(__name__) 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"]) self.app.add_url_rule("/health", "health", self._health) 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"} # ── Шаг 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() conn.close() 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" file_bytes = f.read() 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 jsonify({"contract_id": str(contract_id)}) 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") return { "type": "file_done", "name": name, "time_s": elapsed, "elements": len(elements), "paragraphs": paragraphs, "tables": tables, "table_rows": table_rows, "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" # Получить байты doc, _ = db.query_one("SELECT original_bytes FROM documents WHERE id=%s", (s["doc_id"],)) if not doc or not doc.get("original_bytes"): yield f"data: {json.dumps({'type': 'file_error', 'name': s['filename'], 'error': 'Нет данных'})}\n\n" continue raw = doc["original_bytes"] file_data = bytes(raw) if isinstance(raw, memoryview) else raw # Парсинг: 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, status='parsed' WHERE id=%s", (ztext, 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, status='parsed' WHERE id=%s", (text, 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): self.app.run(host="0.0.0.0", port=5000) if __name__ == "__main__": ContractsApp().run()