v1.0.155: VM PostgreSQL + modular db/ & services/ — Lucee stays SPA shell only
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
"""Database connection — psycopg2 connection pool."""
|
||||
import os
|
||||
import psycopg2
|
||||
import psycopg2.pool
|
||||
import psycopg2.extras
|
||||
|
||||
_pool = None
|
||||
|
||||
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():
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = psycopg2.pool.ThreadedConnectionPool(
|
||||
minconn=1, maxconn=10, **DB_CONFIG
|
||||
)
|
||||
return _pool
|
||||
|
||||
|
||||
def query(sql, params=None):
|
||||
"""SELECT → list of dicts (lowercase keys)."""
|
||||
pool = get_pool()
|
||||
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):
|
||||
"""INSERT/UPDATE/DELETE → rowcount."""
|
||||
pool = get_pool()
|
||||
conn = pool.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
pool.putconn(conn)
|
||||
|
||||
|
||||
def execute_returning(sql, params=None):
|
||||
"""INSERT/UPDATE/DELETE with RETURNING → first row dict."""
|
||||
pool = get_pool()
|
||||
conn = pool.getconn()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(sql, params)
|
||||
conn.commit()
|
||||
row = cur.fetchone()
|
||||
return {k.lower(): v for k, v in row.items()} if row else None
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
pool.putconn(conn)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Contracts CRUD."""
|
||||
from .connection import query, execute, execute_returning
|
||||
|
||||
|
||||
def insert(number, client=""):
|
||||
return execute_returning(
|
||||
"INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING *",
|
||||
(number, client),
|
||||
)
|
||||
|
||||
|
||||
def get(contract_id):
|
||||
rows = query("SELECT * FROM contracts WHERE id = %s", (contract_id,))
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def delete(contract_id):
|
||||
return execute("DELETE FROM contracts WHERE id = %s", (contract_id,))
|
||||
|
||||
|
||||
def delete_orphaned():
|
||||
"""Remove contracts with no supplements."""
|
||||
execute(
|
||||
"DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)"
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Documents CRUD."""
|
||||
from .connection import query, execute, execute_returning
|
||||
|
||||
|
||||
def insert(filename, mime_type, original_bytes, status="uploaded"):
|
||||
"""Insert document, return row dict."""
|
||||
return execute_returning(
|
||||
"""INSERT INTO documents (filename, mime_type, original_bytes, status)
|
||||
VALUES (%s, %s, %s, %s) RETURNING *""",
|
||||
(filename, mime_type, original_bytes, status),
|
||||
)
|
||||
|
||||
|
||||
def get(doc_id):
|
||||
rows = query("SELECT * FROM documents WHERE id = %s", (doc_id,))
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def set_parsed(doc_id, elements_json):
|
||||
"""Update elements_json + status='parsed'."""
|
||||
import json
|
||||
return execute(
|
||||
"UPDATE documents SET elements_json = %s::jsonb, status = 'parsed' WHERE id = %s",
|
||||
(json.dumps(elements_json, ensure_ascii=False), doc_id),
|
||||
)
|
||||
|
||||
|
||||
def set_error(doc_id, error_message):
|
||||
return execute(
|
||||
"UPDATE documents SET status = 'error', error_message = %s WHERE id = %s",
|
||||
(error_message, doc_id),
|
||||
)
|
||||
|
||||
|
||||
def delete(doc_id):
|
||||
return execute("DELETE FROM documents WHERE id = %s", (doc_id,))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Prompts CRUD."""
|
||||
from .connection import query, execute, execute_returning
|
||||
|
||||
|
||||
def get_active(role):
|
||||
rows = query(
|
||||
"SELECT * FROM prompts WHERE role = %s AND is_active = true ORDER BY created_at DESC LIMIT 1",
|
||||
(role,),
|
||||
)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def get(prompt_id):
|
||||
rows = query("SELECT * FROM prompts WHERE id = %s", (prompt_id,))
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def seed_defaults():
|
||||
"""Auto-seed default prompts if table is empty."""
|
||||
count = query("SELECT COUNT(*) as cnt FROM prompts")
|
||||
if count[0]["cnt"] > 0:
|
||||
return
|
||||
|
||||
execute("""
|
||||
INSERT INTO prompts (role, name, body, is_active, notes) VALUES (
|
||||
'extract', 'default-v1',
|
||||
'Ты — анализатор договоров облачного провайдера.\n\nНиже текст спецификации услуг из ПЕРВОГО документа (базовый договор).\nИзвлеки ВСЕ строки спецификации в JSON-массив.\n\nВерни СТРОГО JSON без пояснений. Не используй markdown-блоки.\n\nФОРМАТ:\n{\n "mode": "partial",\n "ops": [\n {"action": "ADD", "new_row": {"name": "полное наименование", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}, "comment": ""}\n ]\n}\n\nПРАВИЛА:\n1. Извлеки КАЖДУЮ строку таблицы спецификации как отдельную ADD-операцию.\n2. Пропускай итоговые строки ("Итого...") и строки с подписями.\n3. Если ячейка пустая — ставь null (не пиши 0).\n4. price, qty, sum — ЧИСЛА, не строки.\n\nТЕКСТ ДОКУМЕНТА:\n---\n{doc_text}\n---',
|
||||
true, 'Авто-создан из llm_prompt.py _build_initial'
|
||||
)
|
||||
""")
|
||||
execute("""
|
||||
INSERT INTO prompts (role, name, body, is_active, notes) VALUES (
|
||||
'diff', 'default-v1',
|
||||
'Ты — анализатор допсоглашений к договорам облачного провайдера.\n\nУ тебя есть текущая спецификация услуг и текст нового допсоглашения (ДС).\nТвоя задача — определить, какие изменения вносит ДС в текущую спецификацию.\n\nВерни СТРОГО JSON без пояснений. Не используй markdown-блоки.\n\nФОРМАТ:\n{\n "mode": "partial" | "full_replace",\n "ops": [\n {"action": "ADD", "new_row": {"name": "...", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}, "comment": "..."},\n {"action": "UPDATE", "target_id": "rN", "new_values": {"price": число}, "comment": "..."},\n {"action": "DELETE", "target_id": "rN", "comment": "..."},\n {"action": "UNRESOLVED", "new_values": {"name": "...", "price": число, ...}, "reason": "почему не смог сопоставить"}\n ]\n}\n\nПРАВИЛА:\n1. mode = "full_replace" — если в тексте есть фразы: «в следующей редакции», «заменить приложение», «излагается в следующей редакции». При full_replace — опиши ВСЕ новые строки как ADD.\n2. mode = "partial" — если ДС меняет только отдельные строки.\n3. Для UPDATE/DELETE — укажи target_id (r1, r2...) из списка текущей спецификации. НЕ придумывай новые id.\n4. new_values в UPDATE — только ИЗМЕНЁННЫЕ поля (не все).\n5. Если не можешь однозначно сопоставить строку — action: UNRESOLVED с reason.\n6. Пропускай итоговые строки и подписи.\n7. price, qty, sum — ЧИСЛА (не строки).\n\nТЕКУЩАЯ СПЕЦИФИКАЦИЯ:\n{spec_current}\n\nТЕКСТ ДОПСОГЛАШЕНИЯ:\n---\n{doc_text}\n---',
|
||||
true, 'Авто-создан из llm_prompt.py _build_diff'
|
||||
)
|
||||
""")
|
||||
@@ -0,0 +1,19 @@
|
||||
"""spec_current — текущее состояние спецификации."""
|
||||
from .connection import query
|
||||
|
||||
|
||||
def list_by_contract(contract_id):
|
||||
"""Return list of dicts with name_hash, name, price, qty, sum, date_start."""
|
||||
return query(
|
||||
"""SELECT name_hash, name, price, qty, sum, date_start
|
||||
FROM spec_current WHERE contract_id = %s ORDER BY name""",
|
||||
(contract_id,),
|
||||
)
|
||||
|
||||
|
||||
def get_elements_json(document_id):
|
||||
"""Get elements_json for a document."""
|
||||
rows = query(
|
||||
"SELECT elements_json FROM documents WHERE id = %s", (document_id,)
|
||||
)
|
||||
return rows[0]["elements_json"] if rows and rows[0]["elements_json"] else None
|
||||
@@ -0,0 +1,145 @@
|
||||
"""spec_events — event sourcing: apply ops, reset contract."""
|
||||
import json, uuid
|
||||
from .connection import query, execute
|
||||
|
||||
|
||||
def reset(contract_id):
|
||||
"""Clear spec_events + spec_current for contract."""
|
||||
execute("DELETE FROM spec_current WHERE contract_id = %s", (contract_id,))
|
||||
execute("DELETE FROM spec_events WHERE contract_id = %s", (contract_id,))
|
||||
|
||||
|
||||
def get_next_seq(contract_id):
|
||||
rows = query(
|
||||
"SELECT COALESCE(MAX(seq), 0) + 1 AS n FROM spec_events WHERE contract_id = %s",
|
||||
(contract_id,),
|
||||
)
|
||||
return rows[0]["n"] if rows else 1
|
||||
|
||||
|
||||
def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
|
||||
"""Apply ADD/UPDATE/DELETE ops. Returns summary dict."""
|
||||
added = 0
|
||||
updated = 0
|
||||
deleted = 0
|
||||
seq = get_next_seq(contract_id)
|
||||
|
||||
for op in ops:
|
||||
action = op.get("action", "").upper()
|
||||
|
||||
if action == "ADD":
|
||||
nr = op.get("new_row", {})
|
||||
name = nr.get("name", "")
|
||||
name_hash = _hash(name)
|
||||
execute(
|
||||
"""INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash,
|
||||
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
||||
VALUES (%s, %s, %s, 'ADD', %s, %s, %s, 'applied', %s, %s, %s)""",
|
||||
(
|
||||
contract_id, supplement_id, seq, name_hash,
|
||||
json.dumps(nr, ensure_ascii=False),
|
||||
op.get("comment", ""), prompt_id, document_id,
|
||||
json.dumps(raw_llm_response, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
_upsert_spec_current(contract_id, name_hash, nr)
|
||||
seq += 1
|
||||
added += 1
|
||||
|
||||
elif action == "UPDATE":
|
||||
nv = op.get("new_values", {})
|
||||
th = op.get("target_hash", "")
|
||||
execute(
|
||||
"""INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash,
|
||||
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
||||
VALUES (%s, %s, %s, 'UPDATE', %s, %s, %s, 'applied', %s, %s, %s)""",
|
||||
(
|
||||
contract_id, supplement_id, seq, th,
|
||||
json.dumps(nv, ensure_ascii=False),
|
||||
op.get("comment", ""), prompt_id, document_id,
|
||||
json.dumps(raw_llm_response, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
_update_spec_current(contract_id, th, nv)
|
||||
seq += 1
|
||||
updated += 1
|
||||
|
||||
elif action == "DELETE":
|
||||
th = op.get("target_hash", "")
|
||||
execute(
|
||||
"""INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash,
|
||||
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
||||
VALUES (%s, %s, %s, 'DELETE', %s, %s, %s, 'applied', %s, %s, %s)""",
|
||||
(
|
||||
contract_id, supplement_id, seq, th,
|
||||
json.dumps({}), op.get("comment", ""),
|
||||
prompt_id, document_id,
|
||||
json.dumps(raw_llm_response, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
execute("DELETE FROM spec_current WHERE contract_id = %s AND name_hash = %s", (contract_id, th))
|
||||
seq += 1
|
||||
deleted += 1
|
||||
|
||||
elif action == "UNRESOLVED":
|
||||
# Log but don't apply
|
||||
execute(
|
||||
"""INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash,
|
||||
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
||||
VALUES (%s, %s, %s, 'UNRESOLVED', %s, %s, %s, 'unresolved', %s, %s, %s)""",
|
||||
(
|
||||
contract_id, supplement_id, seq,
|
||||
op.get("target_hash", ""),
|
||||
json.dumps(op.get("new_values", {}), ensure_ascii=False),
|
||||
op.get("reason", op.get("comment", "")),
|
||||
prompt_id, document_id,
|
||||
json.dumps(raw_llm_response, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
seq += 1
|
||||
|
||||
return {"added": added, "updated": updated, "deleted": deleted}
|
||||
|
||||
|
||||
def _hash(name):
|
||||
import hashlib
|
||||
return hashlib.sha256(name.strip().lower().encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _upsert_spec_current(contract_id, name_hash, row):
|
||||
"""INSERT or UPDATE spec_current."""
|
||||
existing = query(
|
||||
"SELECT id FROM spec_current WHERE contract_id = %s AND name_hash = %s",
|
||||
(contract_id, name_hash),
|
||||
)
|
||||
if existing:
|
||||
execute(
|
||||
"""UPDATE spec_current SET name=%s, price=%s, qty=%s, sum=%s, date_start=%s, updated_at=now()
|
||||
WHERE contract_id=%s AND name_hash=%s""",
|
||||
(row.get("name"), row.get("price"), row.get("qty"), row.get("sum"),
|
||||
row.get("date_start"), contract_id, name_hash),
|
||||
)
|
||||
else:
|
||||
execute(
|
||||
"""INSERT INTO spec_current (contract_id, name_hash, name, price, qty, sum, date_start)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
|
||||
(contract_id, name_hash, row.get("name"), row.get("price"),
|
||||
row.get("qty"), row.get("sum"), row.get("date_start")),
|
||||
)
|
||||
|
||||
|
||||
def _update_spec_current(contract_id, name_hash, new_values):
|
||||
"""Update specific fields in spec_current."""
|
||||
sets = []
|
||||
params = []
|
||||
for field in ("name", "price", "qty", "sum", "date_start"):
|
||||
if field in new_values:
|
||||
sets.append(f"{field} = %s")
|
||||
params.append(new_values[field])
|
||||
if sets:
|
||||
sets.append("updated_at = now()")
|
||||
params.extend([contract_id, name_hash])
|
||||
execute(
|
||||
f"UPDATE spec_current SET {', '.join(sets)} WHERE contract_id = %s AND name_hash = %s",
|
||||
params,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Supplements CRUD."""
|
||||
from .connection import query, execute, execute_returning
|
||||
|
||||
|
||||
def insert(contract_id, document_id, supp_type="additional"):
|
||||
return execute_returning(
|
||||
"""INSERT INTO supplements (contract_id, document_id, type)
|
||||
VALUES (%s, %s, %s) RETURNING *""",
|
||||
(contract_id, document_id, supp_type),
|
||||
)
|
||||
|
||||
|
||||
def list_by_contract(contract_id):
|
||||
"""Supplements with parsed documents, ordered by created_at."""
|
||||
return query(
|
||||
"""SELECT s.id, s.type, s.document_id, d.filename
|
||||
FROM supplements s
|
||||
JOIN documents d ON s.document_id = d.id
|
||||
WHERE s.contract_id = %s AND d.elements_json IS NOT NULL
|
||||
ORDER BY s.created_at""",
|
||||
(contract_id,),
|
||||
)
|
||||
|
||||
|
||||
def get(supp_id):
|
||||
rows = query("SELECT * FROM supplements WHERE id = %s", (supp_id,))
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def delete_by_document(contract_id, filename):
|
||||
"""Delete supplement+document by contract+filename."""
|
||||
row = query(
|
||||
"""SELECT s.id as sid, s.document_id FROM supplements s
|
||||
JOIN documents d ON d.id = s.document_id
|
||||
WHERE s.contract_id = %s AND d.filename = %s""",
|
||||
(contract_id, filename),
|
||||
)
|
||||
if row:
|
||||
r = row[0]
|
||||
execute("DELETE FROM supplements WHERE id = %s", (r["sid"],))
|
||||
execute("DELETE FROM documents WHERE id = %s", (r["document_id"],))
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def delete(supp_id):
|
||||
return execute("DELETE FROM supplements WHERE id = %s", (supp_id,))
|
||||
Reference in New Issue
Block a user