"""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//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//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/") 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/", 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}")