feat: schema.py (DDL 5 таблиц), db.py без DDL, app.py минимальный, test_routes с комментариями

По рекомендации Sonnet:
- schema.py — все DDL (documents, contracts, supplements, spec_rows, spec_history)
- db.py — только транспорт (connect/query), DDL убран
- app.py — минимальный: ensure_schema() + register_blueprint()
- test_routes.py — подробные комментарии, исправлен импорт psysql
This commit is contained in:
2026-06-13 17:14:48 +04:00
parent 308be45ce8
commit cbfba0bc54
5 changed files with 260 additions and 38 deletions
+45 -6
View File
@@ -1,33 +1,72 @@
"""
app.py — Точка входа и сборка слоёв.
Бизнес-логики НОЛЬ. Только:
1. Инициализация схемы БД (schema.ensure_schema)
2. Регистрация Blueprint-ов (test, api — будут добавляться)
Правило: если в app.py появляется if/for или бизнес-слово — это лишнее.
"""
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import Flask, render_template from flask import Flask, render_template
import db import db
import schema
from test_routes import test_bp from test_routes import test_bp
load_dotenv() load_dotenv()
class ContractsApp: class ContractsApp:
"""
Главный класс приложения.
Собирает все слои вместе, не содержит логики.
"""
def __init__(self): def __init__(self):
self.app = Flask(__name__) self.app = Flask(__name__)
self.add_routes()
def add_routes(self): # 1. Создать таблицы при старте (если их ещё нет)
self.app.add_url_rule("/", "index", self.index) schema.ensure_schema()
self.app.add_url_rule("/health", "health", self.health)
# 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(test_bp)
# self.app.register_blueprint(api_bp) # ← будет добавлен позже
def index(self): def _index(self):
"""Главная страница — статус сервиса."""
conn, _ = db.connect() conn, _ = db.connect()
db_status = "connected" if conn else "no DB" db_status = "connected" if conn else "no DB"
return render_template("index.html", db_status=db_status) return render_template("index.html", db_status=db_status)
def health(self): def _health(self):
"""Health check для платформы."""
return "OK", 200, {"Content-Type": "text/plain"} return "OK", 200, {"Content-Type": "text/plain"}
def run(self): def run(self):
"""Запуск Flask (только для разработки, в production — gunicorn)."""
self.app.run(host="0.0.0.0", port=5000) self.app.run(host="0.0.0.0", port=5000)
# ── Точка входа ─────────────────────────────────────────────────
if __name__ == "__main__":
app_instance = ContractsApp()
app_instance.run()
if __name__ == "__main__": if __name__ == "__main__":
app_instance = ContractsApp() app_instance = ContractsApp()
app_instance.run() app_instance.run()
+28 -25
View File
@@ -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 os
import psycopg2 import psycopg2
from psycopg2 import sql
def _pg_connect(dbname): def _pg_connect(dbname):
"""Сырое подключение к указанной базе.""" """
Сырое подключение к указанной базе данных.
Используется:
- connect() для целевой БД
- test_routes.py для /test createdb (подключение к 'postgres')
"""
return psycopg2.connect( return psycopg2.connect(
host=os.getenv("DB_HOST"), host=os.getenv("DB_HOST"),
port=os.getenv("DB_PORT", 5432), port=os.getenv("DB_PORT", 5432),
@@ -16,36 +32,23 @@ def _pg_connect(dbname):
def connect(): def connect():
"""Подключение к целевой БД. Возвращает (connection, None) или (None, error).""" """
Подключение к ЦЕЛЕВОЙ базе данных (DB_NAME из переменных окружения).
Возвращает (connection, None) при успехе или (None, error) при ошибке.
"""
try: try:
return _pg_connect(os.getenv("DB_NAME")), None return _pg_connect(os.getenv("DB_NAME")), None
except Exception as e: except Exception as e:
return None, str(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): def query(sql_text, params=None):
"""Выполнить запрос, вернуть (result, error).""" """
Выполнить SQL-запрос к целевой БД.
Возвращает (result, None) или (None, error).
result = {"columns": [...], "rows": [[...], ...]}
"""
conn, err = connect() conn, err = connect()
if err: if err:
return None, f"connect: {err}" return None, f"connect: {err}"
+122
View File
@@ -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}")
+29 -7
View File
@@ -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 import os
from psycopg2 import sql as psysql
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
import db import db
@@ -7,22 +22,27 @@ test_bp = Blueprint("test", __name__)
@test_bp.route("/test", methods=["GET", "POST"]) @test_bp.route("/test", methods=["GET", "POST"])
def test(): def test():
"""Тестовый слой: проверка БД и выполнение запросов.""" """
Тестовый слой: проверка БД и выполнение запросов.
GET — статус подключения
POST — выполнить действие (status/tables/sql/createdb)
"""
# GET — статус # ── GET — показать статус и список команд ────────────────────
if request.method == "GET": if request.method == "GET":
conn, err = db.connect() conn, err = db.connect()
return jsonify({ return jsonify({
"db": "ok" if conn else f"fail: {err}", "db": "ok" if conn else f"fail: {err}",
"actions": [ "actions": [
"GET /test — статус БД", "GET /test — статус БД",
"POST /test createdb — создать БД", "POST /test status — статус БД (JSON)",
"POST /test createdb — создать БД (если нет)",
"POST /test tables — список таблиц", "POST /test tables — список таблиц",
"POST /test SQL:... — выполнить запрос", "POST /test sql {...} — выполнить SQL",
], ],
}) })
# POST — выполнить действие # ── POST — выполнить действие ────────────────────────────────
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
action = data.get("action", "status") action = data.get("action", "status")
@@ -32,13 +52,15 @@ def test():
if action == "tables": if action == "tables":
result, err = db.query( 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: if err:
return jsonify({"error": err}), 500 return jsonify({"error": err}), 500
return jsonify({"tables": [r[0] for r in result["rows"]]}) return jsonify({"tables": [r[0] for r in result["rows"]]})
if action == "createdb": if action == "createdb":
# Подключаемся к дефолтной БД 'postgres' и создаём целевую
try: try:
conn = db._pg_connect("postgres") conn = db._pg_connect("postgres")
conn.autocommit = True conn.autocommit = True
@@ -48,7 +70,7 @@ def test():
if cur.fetchone(): if cur.fetchone():
msg = f"DB '{db_name}' already exists" msg = f"DB '{db_name}' already exists"
else: 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" msg = f"DB '{db_name}' created"
cur.close() cur.close()
conn.close() conn.close()
+36
View File
@@ -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)