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:
+45
-6
@@ -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()
|
||||
|
||||
+28
-25
@@ -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}"
|
||||
|
||||
+122
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user