246 lines
9.6 KiB
Python
246 lines
9.6 KiB
Python
"""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}")
|