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