diff --git a/site/app.py b/site/app.py index 062f8d3..2b2e339 100644 --- a/site/app.py +++ b/site/app.py @@ -1,33 +1,72 @@ +""" +app.py — Точка входа и сборка слоёв. + +Бизнес-логики НОЛЬ. Только: + 1. Инициализация схемы БД (schema.ensure_schema) + 2. Регистрация Blueprint-ов (test, api — будут добавляться) + +Правило: если в app.py появляется if/for или бизнес-слово — это лишнее. +""" + import os from dotenv import load_dotenv from flask import Flask, render_template + import db +import schema from test_routes import test_bp load_dotenv() + class ContractsApp: + """ + Главный класс приложения. + Собирает все слои вместе, не содержит логики. + """ + def __init__(self): self.app = Flask(__name__) - self.add_routes() - def add_routes(self): - self.app.add_url_rule("/", "index", self.index) - self.app.add_url_rule("/health", "health", self.health) + # 1. Создать таблицы при старте (если их ещё нет) + schema.ensure_schema() + + # 2. Собрать маршруты + self._add_routes() + + def _add_routes(self): + """Регистрация всех Blueprint-ов и системных маршрутов.""" + + # -- Системные маршруты -- + self.app.add_url_rule("/", "index", self._index) + self.app.add_url_rule("/health", "health", self._health) + + # -- Blueprint-ы (каждый слой — отдельный Blueprint) -- self.app.register_blueprint(test_bp) + # self.app.register_blueprint(api_bp) # ← будет добавлен позже - def index(self): + def _index(self): + """Главная страница — статус сервиса.""" conn, _ = db.connect() db_status = "connected" if conn else "no DB" return render_template("index.html", db_status=db_status) - def health(self): + def _health(self): + """Health check для платформы.""" return "OK", 200, {"Content-Type": "text/plain"} def run(self): + """Запуск Flask (только для разработки, в production — gunicorn).""" self.app.run(host="0.0.0.0", port=5000) +# ── Точка входа ───────────────────────────────────────────────── + +if __name__ == "__main__": + app_instance = ContractsApp() + app_instance.run() + + if __name__ == "__main__": app_instance = ContractsApp() app_instance.run() diff --git a/site/db.py b/site/db.py index 5618fac..ab47bc5 100644 --- a/site/db.py +++ b/site/db.py @@ -1,10 +1,26 @@ +""" +db.py — Транспортный слой к базе данных. + +Только connect() и query(). Никакой бизнес-логики, никакого DDL. +DDL → schema.py. Бизнес-запросы → upload.py, extractor.py, api.py. + +Функции: + _pg_connect(dbname) — сырое подключение к ЛЮБОЙ базе (используется /test createdb) + connect() — подключение к ЦЕЛЕВОЙ базе (из DB_NAME) + query(sql, params) — выполнить запрос → (result, error) +""" + import os import psycopg2 -from psycopg2 import sql def _pg_connect(dbname): - """Сырое подключение к указанной базе.""" + """ + Сырое подключение к указанной базе данных. + Используется: + - connect() для целевой БД + - test_routes.py для /test createdb (подключение к 'postgres') + """ return psycopg2.connect( host=os.getenv("DB_HOST"), port=os.getenv("DB_PORT", 5432), @@ -16,36 +32,23 @@ def _pg_connect(dbname): def connect(): - """Подключение к целевой БД. Возвращает (connection, None) или (None, error).""" + """ + Подключение к ЦЕЛЕВОЙ базе данных (DB_NAME из переменных окружения). + Возвращает (connection, None) при успехе или (None, error) при ошибке. + """ try: return _pg_connect(os.getenv("DB_NAME")), None except Exception as e: return None, str(e) -def ensure_db(): - """Создать базу, если не существует.""" - db_name = os.getenv("DB_NAME", "contracts") - try: - conn = _pg_connect("postgres") - conn.autocommit = True - cur = conn.cursor() - cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,)) - if cur.fetchone(): - print(f"DB '{db_name}' exists") - else: - cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(db_name))) - print(f"DB '{db_name}' created") - cur.close() - conn.close() - return True - except Exception as e: - print(f"ensure_db error: {e}") - return False - - def query(sql_text, params=None): - """Выполнить запрос, вернуть (result, error).""" + """ + Выполнить SQL-запрос к целевой БД. + Возвращает (result, None) или (None, error). + + result = {"columns": [...], "rows": [[...], ...]} + """ conn, err = connect() if err: return None, f"connect: {err}" diff --git a/site/schema.py b/site/schema.py new file mode 100644 index 0000000..0d98a48 --- /dev/null +++ b/site/schema.py @@ -0,0 +1,122 @@ +""" +schema.py — Схема базы данных (DDL). + +Создаёт все таблицы при старте приложения. +Вызывается ОДИН раз из app.py: schema.ensure_schema(). + +Таблицы: + documents — исходные файлы + распарсенный текст + contracts — договоры (клиент, номер, дата) + supplements — допники (привязка к договору) + spec_rows — строки спецификаций (после LLM-извлечения) + spec_history — история изменений строк между допниками +""" + +import db + +# ── Таблица documents ────────────────────────────────────────── +# Хранит исходный файл и результат парсинга (текст). +# status отслеживает прогресс: uploaded → parsed → extracted → error + +DDL_DOCUMENTS = """ +CREATE TABLE IF NOT EXISTS documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + filename TEXT NOT NULL, -- оригинальное имя файла + mime_type TEXT NOT NULL, -- MIME-тип (docx/pdf/zip) + original_bytes BYTEA NOT NULL, -- исходный файл как есть + parsed_text TEXT, -- текст после parser.py + textify.py + status TEXT DEFAULT 'uploaded', -- uploaded | parsed | extracted | error + error_message TEXT, -- текст ошибки, если status=error + created_at TIMESTAMPTZ DEFAULT now() -- когда загружен +); +""" + +# ── Таблица contracts ────────────────────────────────────────── +# Один договор = одна запись. Может иметь цепочку допников. + +DDL_CONTRACTS = """ +CREATE TABLE IF NOT EXISTS contracts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + number TEXT NOT NULL, -- номер договора (например "03700_1") + client TEXT, -- клиент (ЗАО "XXX001") + date_signed DATE, -- дата подписания + status TEXT DEFAULT 'active', -- active | closed + created_at TIMESTAMPTZ DEFAULT now() +); +""" + +# ── Таблица supplements ──────────────────────────────────────── +# Каждый допник (включая изначальный договор) — отдельная запись. +# type: initial (начальная спецификация) | amendment (изменение) | termination + +DDL_SUPPLEMENTS = """ +CREATE TABLE IF NOT EXISTS supplements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID REFERENCES contracts(id), -- к какому договору + number TEXT, -- номер допника + date_signed DATE, -- дата подписания + type TEXT DEFAULT 'initial', -- initial | amendment | termination + document_id UUID REFERENCES documents(id), -- исходный файл + created_at TIMESTAMPTZ DEFAULT now() +); +""" + +# ── Таблица spec_rows ────────────────────────────────────────── +# Строки спецификации после LLM-извлечения. +# Одна строка = одна услуга в спецификации. + +DDL_SPEC_ROWS = """ +CREATE TABLE IF NOT EXISTS spec_rows ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + supplement_id UUID REFERENCES supplements(id), -- из какого допника + row_num INTEGER, -- № п/п (порядковый номер в таблице) + name TEXT, -- наименование услуги + price NUMERIC, -- цена за единицу + qty NUMERIC, -- объём (количество) + sum NUMERIC, -- сумма (price * qty) + date_start DATE, -- дата начала оказания + date_end DATE, -- дата окончания (может быть NULL) + created_at TIMESTAMPTZ DEFAULT now() +); +""" + +# ── Таблица spec_history ─────────────────────────────────────── +# История изменений: какая строка когда и как поменялась. +# change_type: added (новая) | changed (изменена) | deleted (удалена) | unchanged + +DDL_SPEC_HISTORY = """ +CREATE TABLE IF NOT EXISTS spec_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID REFERENCES contracts(id), -- для быстрых запросов + supplement_id UUID REFERENCES supplements(id), -- в каком допнике изменение + row_num INTEGER, -- № п/п строки + change_type TEXT NOT NULL, -- added | changed | deleted | unchanged + old_values JSONB, -- старые значения {price, qty, sum, ...} + new_values JSONB, -- новые значения {price, qty, sum, ...} + created_at TIMESTAMPTZ DEFAULT now() +); +""" + +# ── Список всех DDL ──────────────────────────────────────────── + +ALL_DDL = [ + ("documents", DDL_DOCUMENTS), + ("contracts", DDL_CONTRACTS), + ("supplements", DDL_SUPPLEMENTS), + ("spec_rows", DDL_SPEC_ROWS), + ("spec_history", DDL_SPEC_HISTORY), +] + + +def ensure_schema(): + """ + Создаёт все таблицы, если их ещё нет. + Вызывается при старте приложения (app.py). + Безопасна для многократного вызова (IF NOT EXISTS). + """ + for name, ddl in ALL_DDL: + result, err = db.query(ddl) + if err: + print(f"[schema] ⚠️ {name}: {err}") + else: + print(f"[schema] ✅ {name}") diff --git a/site/test_routes.py b/site/test_routes.py index 858d728..bf47e7a 100644 --- a/site/test_routes.py +++ b/site/test_routes.py @@ -1,4 +1,19 @@ +""" +test_routes.py — Тестовый слой (/test Blueprint). + +Мост к БД извне для отладки и диагностики. +Не зависит от бизнес-слоёв, только от db.py. + +Эндпоинты: + GET /test — статус БД + список доступных действий + POST /test status — только статус БД + POST /test createdb — создать базу данных (если не существует) + POST /test tables — список таблиц в public схеме + POST /test sql {...} — выполнить произвольный SQL +""" + import os +from psycopg2 import sql as psysql from flask import Blueprint, jsonify, request import db @@ -7,22 +22,27 @@ test_bp = Blueprint("test", __name__) @test_bp.route("/test", methods=["GET", "POST"]) def test(): - """Тестовый слой: проверка БД и выполнение запросов.""" + """ + Тестовый слой: проверка БД и выполнение запросов. + GET — статус подключения + POST — выполнить действие (status/tables/sql/createdb) + """ - # GET — статус + # ── GET — показать статус и список команд ──────────────────── if request.method == "GET": conn, err = db.connect() return jsonify({ "db": "ok" if conn else f"fail: {err}", "actions": [ "GET /test — статус БД", - "POST /test createdb — создать БД", + "POST /test status — статус БД (JSON)", + "POST /test createdb — создать БД (если нет)", "POST /test tables — список таблиц", - "POST /test SQL:... — выполнить запрос", + "POST /test sql {...} — выполнить SQL", ], }) - # POST — выполнить действие + # ── POST — выполнить действие ──────────────────────────────── data = request.get_json(silent=True) or {} action = data.get("action", "status") @@ -32,13 +52,15 @@ def test(): if action == "tables": result, err = db.query( - "SELECT table_name FROM information_schema.tables WHERE table_schema='public' ORDER BY table_name" + "SELECT table_name FROM information_schema.tables " + "WHERE table_schema = 'public' ORDER BY table_name" ) if err: return jsonify({"error": err}), 500 return jsonify({"tables": [r[0] for r in result["rows"]]}) if action == "createdb": + # Подключаемся к дефолтной БД 'postgres' и создаём целевую try: conn = db._pg_connect("postgres") conn.autocommit = True @@ -48,7 +70,7 @@ def test(): if cur.fetchone(): msg = f"DB '{db_name}' already exists" else: - cur.execute(db.sql.SQL("CREATE DATABASE {}").format(db.sql.Identifier(db_name))) + cur.execute(psysql.SQL("CREATE DATABASE {}").format(psysql.Identifier(db_name))) msg = f"DB '{db_name}' created" cur.close() conn.close() diff --git a/site/textify.py b/site/textify.py new file mode 100644 index 0000000..cec08ca --- /dev/null +++ b/site/textify.py @@ -0,0 +1,36 @@ +""" +Преобразование elements JSON → линейный текст для LLM. + +Ничего не фильтрует, не интерпретирует — только форматирует. +""" + + +def to_text(elements: list) -> str: + """ + Превращает список elements (из parser.py) в текстовое представление. + + Параграфы: [Style] text + Таблицы: отбивка ---, строки как | cell | cell | + """ + lines = [] + + for el in elements: + if el["type"] == "paragraph": + style = el.get("style", "") + prefix = f"[{style}] " if style else "" + lines.append(f"{prefix}{el['text']}") + + elif el["type"] == "table": + rows = el["rows"] + if not rows: + continue + ncols = len(rows[0]) + lines.append(f"\n--- Таблица ({len(rows)}×{ncols}) ---") + for row in rows: + # дополняем строку до ncols + padded = list(row) + [""] * (ncols - len(row)) + cells = [str(c).replace("\n", " ").replace("|", "\\|") for c in padded] + lines.append("| " + " | ".join(cells) + " |") + lines.append("") + + return "\n".join(lines)