feat: PostgreSQL → SQLite — thread-local, WAL, session-key, os.remove cleanup
Deploy contracts-flask / validate (push) Successful in 0s

This commit is contained in:
2026-07-15 10:56:42 +04:00
parent 96718fc065
commit a7b53fde19
5 changed files with 214 additions and 132 deletions
-1
View File
@@ -3,4 +3,3 @@ gunicorn
httpx httpx
python-docx python-docx
pdfplumber pdfplumber
psycopg2-binary
+4 -26
View File
@@ -29,36 +29,14 @@ def create_app():
def _init_db(): def _init_db():
"""Schema migration + recovery: сброс застрявших classify.""" """Создать БД + схему + seed prompts. SQLite — всё в одном файле /tmp."""
from site.db.connection import init_db
init_db()
try: try:
from site.db.connection import execute
for sql in [
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_raw text",
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_input text",
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS zip_source text",
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS content_hash text",
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS batch_id uuid",
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_status text DEFAULT 'pending'",
"ALTER TABLE documents ALTER COLUMN classify_status SET DEFAULT 'pending'",
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS doc_type text",
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS own_number text",
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS parent_number text",
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS doc_date date",
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS counterparty text",
]:
execute(sql)
from site.db import prompts as db_prompts from site.db import prompts as db_prompts
db_prompts.seed_defaults() db_prompts.seed_defaults()
# Crash recovery: сбросить застрявшие classify
execute(
"UPDATE documents SET classify_status='pending', error_message=NULL "
"WHERE classify_status='processing'"
)
except Exception: except Exception:
pass # БД недоступна при старте — не фатально pass
app = create_app() app = create_app()
-8
View File
@@ -3,14 +3,6 @@ import os
VERSION = "2.0.0" VERSION = "2.0.0"
DB_CONFIG = {
"host": os.getenv("DB_HOST", "127.0.0.1"),
"port": int(os.getenv("DB_PORT", "5432")),
"dbname": os.getenv("DB_NAME", "baza"),
"user": os.getenv("DB_USER", "super"),
"password": os.getenv("DB_PASS", ""),
}
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions") LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
LLM_KEY = os.getenv("LLM_API_KEY", "") LLM_KEY = os.getenv("LLM_API_KEY", "")
LLM_MODEL = os.getenv("LLM_MODEL", "gpt-oss-120b") LLM_MODEL = os.getenv("LLM_MODEL", "gpt-oss-120b")
+202 -73
View File
@@ -1,91 +1,220 @@
""" """Database connection — SQLite with thread-local connections.
Database connection — psycopg2 connection pool.
Архитектурное решение (Opus): Архитектура (Sonnet review 2026-07-15):
- ThreadedConnectionPool(minconn=1, maxconn=10) — оптимально для ThreadingMixIn HTTP-сервера. - threading.local() — каждому потоку своё соединение
Каждый HTTP-запрос в отдельном потоке получает своё соединение из пула. - WAL-режим — readers не блокируют writer
- RealDictCursor для query() — возвращает dict с lowercase ключами (консистентно с Python API). - _db_session_key — защита от inode split-brain при cleanup+reuse потоков
- execute()/execute_returning() — для INSERT/UPDATE/DELETE с автокоммитом и откатом при ошибке. - busy_timeout=5000 — ждать при конкурентной записи
- Пул создаётся лениво (при первом запросе) через get_pool().
""" """
import sqlite3
import threading
import time
import os import os
import psycopg2
import psycopg2.pool
import psycopg2.extras
# Глобальный пул соединений (singleton) DB_PATH = "/tmp/contracts.db"
_pool = None _db_session_key = None
_local = threading.local()
# Параметры подключения из переменных окружения (systemd Environment)
DB_CONFIG = {
"host": os.getenv("DB_HOST", "127.0.0.1"),
"port": int(os.getenv("DB_PORT", "5432")),
"dbname": os.getenv("DB_NAME", "baza"),
"user": os.getenv("DB_USER", "super"),
"password": os.getenv("DB_PASS", ""),
}
def get_pool(): def init_db():
"""Возвращает глобальный пул соединений. Создаёт при первом вызове.""" """Создать/пересоздать БД + схему. Вызывается при старте и после cleanup."""
global _pool global _db_session_key
if _pool is None: _db_session_key = time.time()
_pool = psycopg2.pool.ThreadedConnectionPool(
minconn=1, maxconn=10, **DB_CONFIG # Удалить старый файл + WAL-сателлиты
) for f in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm"):
return _pool try:
os.remove(f)
except FileNotFoundError:
pass
conn = get_conn()
_create_schema(conn)
return conn
def _create_schema(conn):
"""Создать все таблицы (idempotent)."""
conn.executescript("""
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
mime_type TEXT DEFAULT 'application/octet-stream',
original_bytes TEXT,
status TEXT DEFAULT 'uploaded',
error_message TEXT,
elements_json TEXT,
batch_id TEXT,
zip_source TEXT,
content_hash TEXT,
classify_status TEXT DEFAULT 'pending',
doc_type TEXT,
own_number TEXT,
parent_number TEXT,
doc_date TEXT,
counterparty TEXT,
classify_raw TEXT,
classify_input TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS contracts (
id TEXT PRIMARY KEY,
number TEXT NOT NULL,
client TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS supplements (
id TEXT PRIMARY KEY,
contract_id TEXT NOT NULL REFERENCES contracts(id),
document_id TEXT NOT NULL REFERENCES documents(id),
type TEXT DEFAULT 'additional',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS spec_events (
id TEXT PRIMARY KEY,
contract_id TEXT NOT NULL,
supplement_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS spec_current (
id TEXT PRIMARY KEY,
contract_id TEXT NOT NULL,
name_hash TEXT NOT NULL,
name TEXT,
price REAL,
qty REAL,
sum REAL,
date_start TEXT,
last_event_id TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS prompts (
id TEXT PRIMARY KEY,
role TEXT NOT NULL,
name TEXT DEFAULT '',
body TEXT NOT NULL,
is_active INTEGER DEFAULT 0,
notes TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS upload_chunks (
id TEXT PRIMARY KEY,
upload_id TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
data TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_documents_batch ON documents(batch_id);
CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(batch_id, content_hash);
CREATE INDEX IF NOT EXISTS idx_supplements_contract ON supplements(contract_id);
CREATE INDEX IF NOT EXISTS idx_spec_current_contract ON spec_current(contract_id);
CREATE INDEX IF NOT EXISTS idx_spec_events_supplement ON spec_events(supplement_id);
""")
def cleanup_db():
"""Удалить БД полностью. Вызывает init_db() для создания новой."""
init_db()
def get_conn():
"""Thread-local соединение. Пересоздаётся при смене сессии."""
global _db_session_key
conn = getattr(_local, "conn", None)
local_key = getattr(_local, "session_key", None)
if conn is None or local_key != _db_session_key:
if conn is not None:
try:
conn.close()
except Exception:
pass
conn = sqlite3.connect(DB_PATH, timeout=5, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA foreign_keys=ON")
_local.conn = conn
_local.session_key = _db_session_key
return conn
def query(sql, params=None): def query(sql, params=None):
""" """SELECT → list[dict]."""
SELECT → list[dict] с lowercase ключами. conn = get_conn()
Используется всеми db/*.py модулями для чтения данных. sql = _pg_to_sqlite(sql)
""" cur = conn.execute(sql, params or [])
pool = get_pool() return [dict(r) for r in cur.fetchall()]
conn = pool.getconn()
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql, params)
rows = cur.fetchall()
return [{k.lower(): v for k, v in r.items()} for r in rows]
finally:
pool.putconn(conn)
def execute(sql, params=None): def execute(sql, params=None):
""" """INSERT/UPDATE/DELETE → rowcount."""
INSERT/UPDATE/DELETE → количество затронутых строк. conn = get_conn()
Автокоммит. При ошибке — rollback и проброс исключения. sql = _pg_to_sqlite(sql)
""" cur = conn.execute(sql, params or [])
pool = get_pool()
conn = pool.getconn()
try:
with conn.cursor() as cur:
cur.execute(sql, params)
conn.commit() conn.commit()
return cur.rowcount return cur.rowcount
except Exception:
conn.rollback()
raise
finally:
pool.putconn(conn)
def execute_returning(sql, params=None): def execute_returning(sql, params=None):
""" """INSERT с RETURNING → dict (эмулируется через lastrowid)."""
INSERT/UPDATE/DELETE с RETURNING → dict первой строки. conn = get_conn()
Используется для insert с автогенерацией UUID (gen_random_uuid()). table = _extract_table(sql)
""" sql = _pg_to_sqlite(sql)
pool = get_pool()
conn = pool.getconn() if " RETURNING " in sql.upper():
try: sql = sql[:sql.upper().rfind(" RETURNING ")]
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql, params) cur = conn.execute(sql, params or [])
conn.commit() conn.commit()
row = cur.fetchone() rowid = cur.lastrowid
return {k.lower(): v for k, v in row.items()} if row else None
except Exception: if table and rowid:
conn.rollback() row = conn.execute(f"SELECT * FROM {table} WHERE rowid = ?", (rowid,)).fetchone()
raise if row:
finally: return dict(row)
pool.putconn(conn)
# Fallback: если нет таблицы или rowid — просто вернуть последнюю строку
if table:
row = conn.execute(f"SELECT * FROM {table} ORDER BY rowid DESC LIMIT 1").fetchone()
return dict(row) if row else None
return None
def _pg_to_sqlite(sql):
"""Конвертировать PostgreSQL-специфичный SQL в SQLite."""
# %s → ?
sql = sql.replace("%s", "?")
# ::jsonb → убрать (SQLite не типизирует)
sql = sql.replace("::jsonb", "")
# gen_random_uuid() → хекс-UUID через randomblob
if "gen_random_uuid()" in sql:
import uuid
sql = sql.replace("gen_random_uuid()", "?")
# BOOLEAN → INTEGER
sql = sql.replace(" BOOLEAN ", " INTEGER ")
sql = sql.replace(" bool ", " INTEGER ")
# ILIKE → LIKE (SQLite LIKE case-insensitive для ASCII)
sql = sql.replace(" ILIKE ", " LIKE ")
# FALSE/TRUE → 0/1
sql = sql.replace(" FALSE", " 0").replace(" TRUE", " 1")
sql = sql.replace(" false", " 0").replace(" true", " 1")
return sql
def _extract_table(sql):
"""Извлечь имя таблицы из INSERT INTO <table>."""
import re
m = re.search(r"INSERT\s+INTO\s+(\w+)", sql, re.IGNORECASE)
return m.group(1) if m else None
+5 -21
View File
@@ -3,21 +3,12 @@ from flask import Blueprint, request, jsonify
from site.db import documents, supplements, spec_current from site.db import documents, supplements, spec_current
from site.db.connection import execute, query from site.db.connection import execute, query
from site.services.grouping import group_documents, apply_groups from site.services.grouping import group_documents, apply_groups
from site.config import API_KEY, LLM_URL, LLM_KEY, LLM_MODEL from site.config import LLM_URL, LLM_KEY, LLM_MODEL
import httpx import httpx
api_bp = Blueprint("api", __name__) api_bp = Blueprint("api", __name__)
def _require_auth():
"""Проверка API-ключа для опасных операций."""
if API_KEY:
key = request.headers.get("X-Api-Key", "")
if key != API_KEY:
return False
return True
# ── Supplements ────────────────────────────────────────────────── # ── Supplements ──────────────────────────────────────────────────
@api_bp.route("/api/supplements") @api_bp.route("/api/supplements")
@@ -181,18 +172,11 @@ def chat():
return jsonify(OK=False, ERROR=f"LLM error: {e}") return jsonify(OK=False, ERROR=f"LLM error: {e}")
# ── Cleanup (требует API-ключ) ──────────────────────────────────── # ── Cleanup (атомарное удаление БД) ──────────────────────────────
@api_bp.route("/api/cleanup", methods=["POST"]) @api_bp.route("/api/cleanup", methods=["POST"])
def api_cleanup(): def api_cleanup():
"""Полная очистка всех данных (кроме prompts).""" """Полная очистка: os.remove(DB) + init новой. Данные гарантированно стёрты."""
if not _require_auth(): from site.db.connection import cleanup_db
return jsonify(ok=False, error="unauthorized"), 401 cleanup_db()
execute("DELETE FROM spec_current")
execute("DELETE FROM spec_events")
execute("DELETE FROM supplements")
execute("DELETE FROM upload_chunks")
execute("DELETE FROM documents")
execute("DELETE FROM contracts")
return jsonify(ok=True, message="all data cleaned") return jsonify(ok=True, message="all data cleaned")