Spaces:
Running
Running
| """ | |
| src/api/server.py | |
| FastAPI server: agent execution + Slack approval integration + Human-in-the-Loop resume | |
| Endpoints: | |
| GET /api/health — health check + public URL | |
| POST /api/run — run agent (sends Slack alert on interrupt) | |
| GET /api/status/{thread_id} — query current execution state | |
| GET /api/approve?thread_id=&approved=— handle Slack button click → resume graph | |
| POST /api/ingest — re-ingest RAG documents | |
| ngrok integration: | |
| Set NGROK_AUTHTOKEN in .env to automatically open an ngrok tunnel on startup. | |
| The public URL is used in Slack button links automatically. | |
| If not set, falls back to SERVER_BASE_URL env var (default: http://localhost:8000). | |
| """ | |
| import asyncio | |
| import json | |
| import logging | |
| import os | |
| import uuid | |
| from urllib.parse import parse_qs | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| from contextlib import asynccontextmanager | |
| from fastapi import FastAPI, HTTPException, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response | |
| from src.api.schemas import ApproveResponse, RunRequest, RunResponse | |
| from src.preprocess import preprocess_email | |
| from src.slack.notifier import ( | |
| open_reason_modal, | |
| send_approval_request_async, | |
| update_message_via_response_url, | |
| verify_slack_signature, | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # Public URL management (ngrok or SERVER_BASE_URL) | |
| # --------------------------------------------------------------------------- | |
| _public_url: str = os.getenv("SERVER_BASE_URL", "http://localhost:8000") | |
| def get_public_url() -> str: | |
| return _public_url | |
| # --------------------------------------------------------------------------- | |
| # App lifespan (ngrok auto-start / teardown) | |
| # --------------------------------------------------------------------------- | |
| async def lifespan(app: FastAPI): | |
| global _public_url | |
| ngrok_token = os.getenv("NGROK_AUTHTOKEN", "") | |
| if ngrok_token: | |
| try: | |
| from pyngrok import conf, ngrok as pyngrok_client | |
| conf.get_default().auth_token = ngrok_token | |
| # 고정 도메인이 지정되면 항상 같은 공개 URL로 터널을 연다. | |
| # (Slack Interactivity Request URL을 매번 다시 입력할 필요가 없어짐) | |
| ngrok_domain = os.getenv("NGROK_DOMAIN", "").strip() | |
| if ngrok_domain: | |
| tunnel = pyngrok_client.connect(8000, proto="http", domain=ngrok_domain) | |
| else: | |
| tunnel = pyngrok_client.connect(8000, proto="http") | |
| _public_url = tunnel.public_url | |
| logger.info("[server] ngrok tunnel active: %s", _public_url) | |
| print(f"\n PUBLIC URL : {_public_url}") | |
| print(f" Swagger UI : {_public_url}/docs\n") | |
| except ImportError: | |
| logger.warning("[server] pyngrok not installed -- run 'pip install pyngrok'.") | |
| except Exception as e: | |
| logger.error("[server] Failed to start ngrok: %s", e) | |
| else: | |
| logger.info("[server] SERVER_BASE_URL: %s (ngrok disabled)", _public_url) | |
| print(f"\n PUBLIC URL : {_public_url} (NGROK_AUTHTOKEN not set)") | |
| print( " Add NGROK_AUTHTOKEN to .env for external access.\n") | |
| # Build graph with AsyncSqliteSaver (required for async FastAPI endpoints) | |
| import aiosqlite | |
| from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver | |
| from src.graph_builder import _build_state_graph | |
| from src.config import get_config | |
| checkpoint_db = get_config().paths.checkpoint_db | |
| async with aiosqlite.connect(checkpoint_db) as aio_conn: | |
| memory = AsyncSqliteSaver(aio_conn) | |
| app.state.graph = _build_state_graph().compile(checkpointer=memory) | |
| yield | |
| # Teardown: close ngrok tunnel | |
| if ngrok_token: | |
| try: | |
| from pyngrok import ngrok as pyngrok_client | |
| pyngrok_client.kill() | |
| logger.info("[server] ngrok tunnel closed") | |
| except Exception: | |
| pass | |
| # --------------------------------------------------------------------------- | |
| # FastAPI 앱 | |
| # --------------------------------------------------------------------------- | |
| app = FastAPI( | |
| title="SAP ERP AI Agent API", | |
| version="0.1.0", | |
| description="SAP ERP AI Agent — Human-in-the-Loop approval workflow", | |
| lifespan=lifespan, | |
| ) | |
| # CORS: allow the Vercel static demo page (different origin) to call this API. | |
| # 시연용으로 전체 허용. 운영 시 Vercel 도메인으로 제한 가능. | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Endpoints | |
| # --------------------------------------------------------------------------- | |
| async def index() -> FileResponse: | |
| """데모 프론트엔드(web/index.html)를 동일 출처로 서빙 → 단일 배포 링크로 UI+API 제공.""" | |
| from pathlib import Path | |
| return FileResponse(Path(__file__).resolve().parents[2] / "web" / "index.html") | |
| async def health() -> dict: | |
| """Health check — server status and current public URL""" | |
| return { | |
| "status": "ok", | |
| "public_url": get_public_url(), | |
| } | |
| async def run_agent(request: RunRequest) -> RunResponse: | |
| """ | |
| Agent execution endpoint | |
| Flow: | |
| 1. Run LangGraph graph | |
| 2. Auto-pauses on PENDING_APPROVAL (interrupt_before=["human_loop"]) | |
| 3. Sends Slack DM approval request when paused | |
| 4. Returns thread_id (resume later via /api/approve) | |
| """ | |
| graph = app.state.graph | |
| thread_id = request.thread_id or str(uuid.uuid4()) | |
| config = {"configurable": {"thread_id": thread_id}} | |
| # LangGraph 진입 전 이메일 전처리 (블로킹 동기 호출 — 다른 비동기 작업과 동시에 돌릴 | |
| # 필요는 없으나 이벤트 루프 점유 우려가 있으면 to_thread로 옮길 것). 실패해도 흐름은 | |
| # 끊기지 않고 preprocess_ok=False 로 표시될 뿐이다. | |
| email_ctx = await asyncio.to_thread(preprocess_email, request.email_text) | |
| initial_state = { | |
| "user_input": request.email_text, | |
| "email_context": email_ctx.model_dump(), | |
| "error_messages": [], | |
| "requires_human_approval": False, | |
| "human_approved": None, | |
| } | |
| result = await graph.ainvoke(initial_state, config=config) | |
| requires_approval = result.get("requires_human_approval", False) | |
| erp_action = result.get("erp_action") | |
| # ── Send Slack approval request ─────────────────────────────────────────── | |
| if requires_approval and erp_action: | |
| sent = await send_approval_request_async( | |
| action=erp_action, | |
| thread_id=thread_id, | |
| ) | |
| if sent: | |
| logger.info("[api/run] Slack approval request sent - thread_id=%s", thread_id) | |
| else: | |
| logger.warning("[api/run] Slack send failed (check SLACK_WEBHOOK_URL)") | |
| return RunResponse( | |
| thread_id=thread_id, | |
| intent=result.get("intent"), | |
| erp_status=result.get("erp_action_status"), | |
| final_response=result.get("final_response"), | |
| requires_approval=requires_approval, | |
| ) | |
| async def get_status(thread_id: str) -> dict: | |
| """Fetch current state from the checkpoint store""" | |
| graph = app.state.graph | |
| config = {"configurable": {"thread_id": thread_id}} | |
| state = await graph.aget_state(config) | |
| if not state or not state.values: | |
| raise HTTPException(status_code=404, detail="thread_id not found.") | |
| return { | |
| "thread_id": thread_id, | |
| "intent": state.values.get("intent"), | |
| "erp_action_status": state.values.get("erp_action_status"), | |
| "erp_action": state.values.get("erp_action"), | |
| "final_response": state.values.get("final_response"), | |
| "requires_approval": state.values.get("requires_human_approval", False), | |
| "error_messages": state.values.get("error_messages", []), | |
| } | |
| async def approve_action(thread_id: str, approved: bool) -> ApproveResponse: | |
| """ | |
| Called when the Slack Approve / Reject button is clicked. | |
| Flow: | |
| 1. Verify the thread checkpoint exists | |
| 2. Update human_approved in the checkpoint | |
| 3. Resume graph → runs human_loop_node | |
| - approved=True → SQLite UPDATE → erp_action_status=SUCCESS | |
| - approved=False → no DB change → erp_action_status=REJECTED | |
| 4. Return final status (visible in browser after button click) | |
| """ | |
| graph = app.state.graph | |
| config = {"configurable": {"thread_id": thread_id}} | |
| # 1. Verify checkpoint exists | |
| state = await graph.aget_state(config) | |
| if not state or not state.values: | |
| raise HTTPException( | |
| status_code=404, | |
| detail=f"thread_id '{thread_id}' not found.", | |
| ) | |
| current_status = state.values.get("erp_action_status") | |
| if current_status != "PENDING_APPROVAL": | |
| # Already processed | |
| return ApproveResponse( | |
| thread_id=thread_id, | |
| approved=approved, | |
| final_status=current_status or "UNKNOWN", | |
| message=f"This request has already been processed. Current status: {current_status}", | |
| ) | |
| # 2. Resume graph via Command(resume=...) — LangGraph 1.x interrupt() 방식 | |
| # human_loop_node expects {"approved": bool, "reason": str|None}. | |
| from langgraph.types import Command | |
| async for _ in graph.astream( | |
| Command(resume={"approved": approved, "reason": None}), config=config | |
| ): | |
| pass | |
| # 4. Fetch final state | |
| final_state = await graph.aget_state(config) | |
| final_status = "UNKNOWN" | |
| errors: list = [] | |
| if final_state and final_state.values: | |
| final_status = final_state.values.get("erp_action_status", "UNKNOWN") | |
| errors = final_state.values.get("error_messages", []) | |
| action_label = "approved" if approved else "rejected" | |
| logger.info( | |
| "[api/approve] %s — thread_id=%s final_status=%s", | |
| action_label, thread_id, final_status, | |
| ) | |
| return ApproveResponse( | |
| thread_id=thread_id, | |
| approved=approved, | |
| final_status=final_status, | |
| message=f"ERP update {action_label}. Final status: {final_status}", | |
| errors=errors, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Slack Interactivity (interactive buttons + rejection-reason modal) | |
| # --------------------------------------------------------------------------- | |
| async def _resume_graph(thread_id: str, approved: bool, reason: str | None) -> str: | |
| """ | |
| Resume a paused graph with the human decision and return the final status. | |
| Guards against double-processing: if the thread is no longer PENDING_APPROVAL, | |
| the current status is returned without resuming again. | |
| """ | |
| graph = app.state.graph | |
| config = {"configurable": {"thread_id": thread_id}} | |
| state = await graph.aget_state(config) | |
| if not state or not state.values: | |
| logger.warning("[slack/actions] thread_id '%s' not found.", thread_id) | |
| return "NOT_FOUND" | |
| current = state.values.get("erp_action_status") | |
| if current != "PENDING_APPROVAL": | |
| logger.info("[slack/actions] thread_id '%s' already processed (%s).", thread_id, current) | |
| return current or "UNKNOWN" | |
| from langgraph.types import Command | |
| async for _ in graph.astream( | |
| Command(resume={"approved": approved, "reason": reason}), config=config | |
| ): | |
| pass | |
| final = await graph.aget_state(config) | |
| return (final.values.get("erp_action_status") if final and final.values else "UNKNOWN") or "UNKNOWN" | |
| async def _resume_and_notify( | |
| thread_id: str, approved: bool, reason: str | None, response_url: str | |
| ) -> None: | |
| """ | |
| Background task: resume the graph (DB update + email synthesis can take a few | |
| seconds — longer than Slack's 3s ack window) and then update the original Slack | |
| message via response_url. Run via asyncio.create_task so the HTTP handler can | |
| ack Slack immediately. | |
| """ | |
| try: | |
| status = await _resume_graph(thread_id, approved, reason) | |
| if approved: | |
| text = f"✅ *Approved* — final status: *{status}*\n🔑 `{thread_id}`" | |
| else: | |
| reason_line = f"\n> _{reason}_" if reason else " _(no reason provided)_" | |
| text = f"❌ *Rejected* — final status: *{status}*.{reason_line}\n🔑 `{thread_id}`" | |
| await update_message_via_response_url(response_url, text) | |
| except Exception as e: | |
| logger.error("[slack/actions] resume/notify failed for %s: %s", thread_id, e) | |
| await update_message_via_response_url( | |
| response_url, f"⚠️ Processing error for `{thread_id}`: {e}" | |
| ) | |
| async def slack_actions(request: Request): | |
| """ | |
| Slack Interactivity endpoint (Request URL = https://<host>/slack/actions). | |
| Handles two payload types: | |
| • block_actions — Approve/Reject button clicks | |
| - erp_approve → resume graph (approved) in background, ack immediately | |
| - erp_reject → open the rejection-reason modal (views.open) | |
| • view_submission — modal "Submit Rejection" | |
| - resume graph (rejected, with optional reason) in background, close modal | |
| """ | |
| raw_body = await request.body() | |
| timestamp = request.headers.get("X-Slack-Request-Timestamp", "") | |
| signature = request.headers.get("X-Slack-Signature", "") | |
| if not verify_slack_signature(raw_body, timestamp, signature): | |
| raise HTTPException(status_code=401, detail="Invalid Slack signature.") | |
| # Slack sends application/x-www-form-urlencoded with a single `payload` field. | |
| # Parse the raw body directly so we don't depend on python-multipart. | |
| parsed = parse_qs(raw_body.decode("utf-8")) | |
| payload_raw = (parsed.get("payload") or [None])[0] | |
| if not payload_raw: | |
| raise HTTPException(status_code=400, detail="Missing Slack payload.") | |
| try: | |
| payload = json.loads(payload_raw) | |
| except json.JSONDecodeError: | |
| raise HTTPException(status_code=400, detail="Malformed Slack payload.") | |
| ptype = payload.get("type") | |
| # ── Button click ──────────────────────────────────────────────────────── | |
| if ptype == "block_actions": | |
| action = (payload.get("actions") or [{}])[0] | |
| action_id = action.get("action_id", "") | |
| thread_id = action.get("value", "") | |
| response_url = payload.get("response_url", "") | |
| if action_id == "erp_approve": | |
| asyncio.create_task( | |
| _resume_and_notify(thread_id, approved=True, reason=None, response_url=response_url) | |
| ) | |
| await update_message_via_response_url( | |
| response_url, f"⏳ Approving… processing ERP update for `{thread_id}`." | |
| ) | |
| return Response(status_code=200) | |
| if action_id == "erp_reject": | |
| trigger_id = payload.get("trigger_id", "") | |
| meta = json.dumps({"thread_id": thread_id, "response_url": response_url}) | |
| await open_reason_modal(trigger_id, meta) | |
| return Response(status_code=200) | |
| return Response(status_code=200) | |
| # ── Modal submit (rejection reason) ─────────────────────────────────────── | |
| if ptype == "view_submission": | |
| view = payload.get("view", {}) | |
| try: | |
| meta = json.loads(view.get("private_metadata") or "{}") | |
| except json.JSONDecodeError: | |
| meta = {} | |
| thread_id = meta.get("thread_id", "") | |
| response_url = meta.get("response_url", "") | |
| reason = "" | |
| try: | |
| reason = ( | |
| view["state"]["values"]["reason_block"]["reason_input"].get("value") or "" | |
| ) | |
| except (KeyError, TypeError): | |
| reason = "" | |
| reason = reason.strip() | |
| asyncio.create_task( | |
| _resume_and_notify( | |
| thread_id, approved=False, reason=(reason or None), response_url=response_url | |
| ) | |
| ) | |
| # Empty body → Slack closes the modal. | |
| return JSONResponse(content={}) | |
| return Response(status_code=200) | |
| async def ingest_docs() -> dict: | |
| """Trigger re-ingest of RAG documents""" | |
| from src.rag.ingest import ingest_documents | |
| ingest_documents() | |
| return {"status": "ingest_complete"} | |