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

360 lines
15 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 csv
import io
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 import extractions as db_extractions
from db.connection import execute, query, transaction
from services.grouping import group_documents, apply_groups
from services.reconciliation import reconcile_dossier
from services.timeline_builder import build_dossier_timeline
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/dossiers")
def api_dossiers():
"""Вернуть dossier cards batch вместе с alias и связанными документами."""
batch_id = request.args.get("batch_id")
if not batch_id:
return jsonify(ok=False, error="batch_id required"), 400
dossiers = db_dossiers.list_dossiers_by_batch(batch_id)
extractions_by_document = {
extraction["document_id"]: extraction
for extraction in db_extractions.list_by_batch(batch_id)
}
for dossier in dossiers:
for document in dossier.get("documents", []):
extraction = extractions_by_document.get(document.get("document_id"))
validation = (extraction or {}).get("math_validation") or {}
document["math_status"] = validation.get("status", "UNKNOWN")
return jsonify(
ok=True,
dossiers=dossiers,
)
@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,
),
)
@api_bp.route("/api/reconcile", methods=["POST"])
def api_reconcile():
"""Сверить JSON или CSV строки внешнего биллинга с эталоном dossier."""
content_type = request.mimetype or ""
if content_type == "application/json":
body = request.get_json(silent=True) or {}
dossier_id = body.get("dossier_id")
rows = body.get("external_rows")
target = body.get("target_date_or_period")
catalog_entries = body.get("catalog_entries", [])
elif content_type == "multipart/form-data":
dossier_id = request.form.get("dossier_id")
upload = request.files.get("file")
if upload is None:
return jsonify(ok=False, error="CSV file required"), 400
try:
rows = list(csv.DictReader(io.StringIO(upload.stream.read().decode("utf-8-sig"))))
except (UnicodeDecodeError, csv.Error):
return jsonify(ok=False, error="CSV must be UTF-8 with a header row"), 400
target = request.form.get("target_date_or_period")
if target:
try:
target = json.loads(target)
except json.JSONDecodeError:
return jsonify(ok=False, error="target_date_or_period must be JSON"), 400
catalog_entries = request.form.get("catalog_entries", "[]")
try:
catalog_entries = json.loads(catalog_entries)
except json.JSONDecodeError:
return jsonify(ok=False, error="catalog_entries must be JSON"), 400
else:
return jsonify(ok=False, error="use application/json or multipart/form-data"), 415
if not isinstance(dossier_id, str) or not dossier_id.strip():
return jsonify(ok=False, error="dossier_id required"), 400
if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
return jsonify(ok=False, error="external_rows must be an array of objects"), 400
if not isinstance(catalog_entries, list) or any(not isinstance(row, dict) for row in catalog_entries):
return jsonify(ok=False, error="catalog_entries must be an array of objects"), 400
try:
result = reconcile_dossier(dossier_id, rows, target, catalog_entries)
except ValueError as error:
return jsonify(ok=False, error=str(error)), 400
if not result.get("ok"):
return jsonify(result), 404
return jsonify(result)
@api_bp.route("/api/exceptions/<exception_id>/resolve", methods=["POST"])
def api_resolve_exception(exception_id):
"""Сохранить выбор оператора и без LLM пересобрать dossier timeline."""
issue = db_exceptions.get_exception(exception_id)
if not issue:
return jsonify(ok=False, error="exception not found"), 404
if issue.get("status") != "OPEN":
return jsonify(ok=False, error="exception is not open"), 409
body = request.get_json(silent=True) or {}
resolution = body.get("resolution")
if not isinstance(resolution, dict) or not resolution:
return jsonify(ok=False, error="resolution object required"), 400
options = issue.get("options") or []
if "option_index" in resolution:
option_index = resolution["option_index"]
if type(option_index) is not int or not 0 <= option_index < len(options):
return jsonify(ok=False, error="option_index is invalid"), 400
resolution = {
"option_index": option_index,
"selected_option": options[option_index],
}
elif issue.get("stage") == "lineage_matching" and options:
return jsonify(ok=False, error="choose an option_index"), 400
if issue.get("dossier_id") and not db_dossiers.get_dossier(issue["dossier_id"]):
return jsonify(ok=False, error="dossier not found"), 404
resolved = db_exceptions.resolve_exception(exception_id, resolution)
if not resolved:
return jsonify(ok=False, error="exception is not open"), 409
rebuild = None
if issue.get("dossier_id"):
rebuild = build_dossier_timeline(issue["dossier_id"])
if not rebuild.get("ok"):
return jsonify(ok=False, error="timeline rebuild failed", rebuild=rebuild), 500
return jsonify(ok=True, exception=resolved, rebuild=rebuild)
# ── 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}")