Files
contracts-app/site/app.py
T

282 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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/<cid>", "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")
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"
# Получить байты
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()