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
|
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
@@ -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
@@ -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
|
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()
|
||||||
|
|||||||
@@ -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