Files
contracts-flask/site/routes/api_bp.py
T

246 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""API blueprint — groups, documents, supplements, sync, cleanup, spec-current, chat."""
import json
from datetime import date
from flask import Blueprint, request, jsonify
from db import documents, supplements, spec_current
from db import dossiers as db_dossiers
from db import exceptions as db_exceptions
from db import timeline as db_timeline
from db.connection import execute, query, transaction
from services.grouping import group_documents, apply_groups
from config import LLM_URL, LLM_KEY, LLM_MODEL
import httpx
api_bp = Blueprint("api", __name__)
# ── Contract timeline and exceptions ────────────────────────────
@api_bp.route("/api/dossiers/<dossier_id>/timeline")
def api_dossier_timeline(dossier_id):
"""Вернуть полную историю и активный срез обязательств dossier."""
dossier = db_dossiers.get_dossier(dossier_id)
if not dossier:
return jsonify(ok=False, error="dossier not found"), 404
as_of = request.args.get("as_of") or date.today().isoformat()
try:
as_of = date.fromisoformat(as_of).isoformat()
except ValueError:
return jsonify(ok=False, error="as_of must be YYYY-MM-DD"), 400
history = db_timeline.list_timeline(dossier_id)
active = db_timeline.list_timeline(dossier_id, as_of)
for interval in history + active:
try:
interval["config_params"] = json.loads(interval.get("config_params_json") or "{}")
except (TypeError, json.JSONDecodeError):
interval["config_params"] = {}
return jsonify(
ok=True,
dossier=dossier,
as_of=as_of,
timeline=history,
active=active,
exceptions=db_exceptions.list_exceptions(dossier_id=dossier_id, status="OPEN"),
)
@api_bp.route("/api/exceptions")
def api_exceptions():
"""Вернуть исключения только по явно заданной области batch или dossier."""
batch_id = request.args.get("batch_id")
dossier_id = request.args.get("dossier_id")
status = request.args.get("status")
if not batch_id and not dossier_id:
return jsonify(ok=False, error="batch_id or dossier_id required"), 400
if status and status not in {"OPEN", "RESOLVED"}:
return jsonify(ok=False, error="status must be OPEN or RESOLVED"), 400
return jsonify(
ok=True,
exceptions=db_exceptions.list_exceptions(
batch_id=batch_id, dossier_id=dossier_id, status=status,
),
)
# ── Supplements ──────────────────────────────────────────────────
@api_bp.route("/api/supplements")
def api_supplements():
cid = request.args.get("contract_id")
if not cid:
return jsonify(ok=False, error="contract_id required"), 400
rows = supplements.list_by_contract(cid)
return jsonify(ok=True, supplements=rows)
# ── Documents ────────────────────────────────────────────────────
@api_bp.route("/api/documents/<doc_id>")
def api_document(doc_id):
doc = documents.get(doc_id)
if not doc:
return jsonify(ok=False, error="not found"), 404
return jsonify(ok=True, **{
k: doc.get(k) for k in [
"id", "filename", "status", "elements_json", "doc_type",
"own_number", "parent_number", "doc_date", "counterparty",
"classify_status", "classify_raw", "classify_input",
]
})
@api_bp.route("/api/documents/<doc_id>", methods=["DELETE"])
def api_document_delete(doc_id):
if not documents.get(doc_id):
return jsonify(ok=False, error="not found"), 404
with transaction():
contract_ids = _delete_document(doc_id)
_delete_orphan_contracts(contract_ids)
return jsonify(ok=True)
def _delete_document(doc_id):
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (doc_id,))
contract_ids = {row["contract_id"] for row in supps}
for supp in supps:
execute(
"""DELETE FROM spec_current WHERE contract_id = %s
AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
(supp["contract_id"], supp["id"]),
)
execute("DELETE FROM spec_events WHERE supplement_id = %s", (supp["id"],))
execute("DELETE FROM supplements WHERE id = %s", (supp["id"],))
execute("DELETE FROM documents WHERE id = %s", (doc_id,))
return contract_ids
def _delete_orphan_contracts(contract_ids):
for contract_id in contract_ids:
remaining = query(
"SELECT id FROM supplements WHERE contract_id = %s LIMIT 1",
(contract_id,),
)
if not remaining:
execute("DELETE FROM contracts WHERE id = %s", (contract_id,))
# ── Sync ─────────────────────────────────────────────────────────
@api_bp.route("/api/sync", methods=["POST"])
def api_sync():
"""Remove documents absent from keep_ids in the specified batch only."""
body = request.get_json(silent=True) or {}
batch_id = body.get("batch_id")
keep_ids = body.get("keep_ids", [])
if not isinstance(batch_id, str) or not batch_id.strip():
return jsonify(ok=False, error="batch_id required"), 400
if not isinstance(keep_ids, list) or any(not isinstance(doc_id, str) for doc_id in keep_ids):
return jsonify(ok=False, error="keep_ids must be a list of document IDs"), 400
if len(keep_ids) > 1000:
return jsonify(ok=False, error="too many keep_ids (max 1000)"), 400
docs = query("SELECT id FROM documents WHERE batch_id = %s", (batch_id,))
deleted = 0
contract_ids = set()
keep_ids = set(keep_ids)
with transaction():
for doc in docs:
if doc["id"] in keep_ids:
continue
contract_ids.update(_delete_document(doc["id"]))
deleted += 1
_delete_orphan_contracts(contract_ids)
return jsonify(ok=True, deleted=deleted)
# ── Groups ───────────────────────────────────────────────────────
@api_bp.route("/api/groups")
def api_groups():
batch_id = request.args.get("batch")
if not batch_id:
return jsonify(ok=False, error="batch required"), 400
result = group_documents(batch_id)
return jsonify(result)
@api_bp.route("/api/batch-progress")
def api_batch_progress():
batch_id = request.args.get("batch")
if not batch_id:
return jsonify(ok=False, error="batch required"), 400
counts = documents.count_by_status(batch_id)
docs = documents.list_by_batch(batch_id)
return jsonify(ok=True, counts=counts, total=len(docs))
@api_bp.route("/api/apply-groups", methods=["POST"])
def api_apply_groups():
body = request.get_json()
batch_id = body.get("batch_id")
groups = body.get("groups", [])
if not batch_id:
return jsonify(ok=False, error="batch_id required"), 400
result = apply_groups(batch_id, groups)
return jsonify(result)
# ── Spec current ─────────────────────────────────────────────────
@api_bp.route("/api/spec-current")
def api_spec_current():
cid = request.args.get("contract_id")
if not cid:
return jsonify(ok=False, error="contract_id required"), 400
rows = spec_current.list_by_contract(cid)
return jsonify(ok=True, rows=rows)
# ── Chat (совместимость с Lucee /chat.cfm) ──────────────────────
@api_bp.route("/chat", methods=["POST"])
@api_bp.route("/chat.cfm", methods=["POST"])
def chat():
"""Чат с LLM по данным спецификации. Совместим с Lucee-форматом ответа."""
contract_id = request.args.get("contract_id", "")
question = request.form.get("question", "")
if not contract_id or not question:
return jsonify(OK=False, ERROR="contract_id and question required")
rows = spec_current.list_by_contract(contract_id)
if not rows:
return jsonify(OK=True, ANSWER="Нет данных. Сначала запустите сравнение.")
ctx = "\n".join(
f"{r.get('name','')} | цена={r.get('price')} | объём={r.get('qty')} | "
f"сумма={r.get('sum')} | начало={r.get('date_start')}"
for r in rows
)
prompt = (
f"Ты — анализатор договоров. Данные:\n{ctx}\n\n"
f"Вопрос: {question}\nОтветь кратко, только по данным."
)
try:
with httpx.Client(timeout=120) as client:
resp = client.post(
LLM_URL,
json={
"model": LLM_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 1000,
"temperature": 0.1,
},
headers={
"Authorization": f"Bearer {LLM_KEY}",
"Content-Type": "application/json",
},
)
resp.raise_for_status()
answer = resp.json()["choices"][0]["message"]["content"]
return jsonify(OK=True, ANSWER=answer)
except Exception as e:
return jsonify(OK=False, ERROR=f"LLM error: {e}")