SAP-ERP-AI-Agent / src /api /server.py
daisysooyeon's picture
deploy: SAP ERP AI Agent (HF Spaces docker)
50efdc6
Raw
History Blame Contribute Delete
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)
# ---------------------------------------------------------------------------
@asynccontextmanager
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
# ---------------------------------------------------------------------------
@app.get("/", include_in_schema=False)
async def index() -> FileResponse:
"""데모 프론트엔드(web/index.html)를 동일 출처로 서빙 → 단일 배포 링크로 UI+API 제공."""
from pathlib import Path
return FileResponse(Path(__file__).resolve().parents[2] / "web" / "index.html")
@app.get("/api/health")
async def health() -> dict:
"""Health check — server status and current public URL"""
return {
"status": "ok",
"public_url": get_public_url(),
}
@app.post("/api/run", response_model=RunResponse)
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,
)
@app.get("/api/status/{thread_id}")
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", []),
}
@app.get("/api/approve", response_model=ApproveResponse)
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}"
)
@app.post("/slack/actions")
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)
@app.post("/api/ingest")
async def ingest_docs() -> dict:
"""Trigger re-ingest of RAG documents"""
from src.rag.ingest import ingest_documents
ingest_documents()
return {"status": "ingest_complete"}