Spaces:
Running
Running
Download src/api/server.py from daisysooyeon/SAP-ERP-AI-Agent: direct link, hf CLI and curl.
- Browser
- Download file 17.1 kB
-
https://huggingface.co/spaces/daisysooyeon/SAP-ERP-AI-Agent/resolve/main/src/api/server.py
- Command line
-
hf download hf://spaces/daisysooyeon/SAP-ERP-AI-Agent/src/api/server.py
-
curl -L -o server.py https://huggingface.co/spaces/daisysooyeon/SAP-ERP-AI-Agent/resolve/main/src/api/server.py
17.1 kB
| """ | |
| 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"} | |