""" main.py ------- FastAPI application serving the PayParse multi-turn NLU assistant. Endpoints --------- POST /api/chat : one dialogue turn (NLU -> slot filling -> reply) DELETE /api/session/{id} : reset a conversation GET /health : liveness probe + engine mode Run locally: uvicorn main:app --reload Run on Hugging Face Spaces: The Dockerfile sets PORT=7860. GEMINI_API_KEY is injected as a HF Secret. PAYPARSE_SESSION_BACKEND=memory (HF filesystem is ephemeral). """ from __future__ import annotations import logging import os from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from nlu_engine import MockNLUEngine, create_engine from schema import ChatRequest, ChatResponse from session_store import SessionStore from state_manager import StateManager STATIC_DIR = Path(__file__).parent / "static" logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): """Initialize shared singletons once at startup.""" offline = os.getenv("PAYPARSE_OFFLINE", "0") == "1" use_paraphrase = os.getenv("PAYPARSE_LLM_FOLLOWUP", "0") == "1" engine = create_engine(offline=offline) app.state.engine = engine app.state.store = SessionStore() app.state.manager = StateManager( nlu_engine=engine, use_llm_paraphrase=use_paraphrase ) logger.info( "PayParse started (engine=%s, llm_followup=%s)", type(engine).__name__, use_paraphrase, ) yield app.state.store.close() app = FastAPI( title="PayParse — Indonesian NLU Voice Assistant", description=( "Zero-shot LLM NLU (Gemini Flash) + slot-filling state machine " "for Indonesian financial voice queries." ), version="1.0.0", lifespan=lifespan, ) # CORS — allow the Android app (and any local dev client) to call the API. app.add_middleware( CORSMiddleware, allow_origins=["*"], # portfolio project; tighten for production allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_headers=["*"], ) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") @app.get("/") def serve_ui(): """Serve the simple chat UI, or API info if static dir is missing.""" index = STATIC_DIR / "index.html" if index.exists(): return FileResponse(index) return { "name": "PayParse — Indonesian NLU Voice Assistant", "endpoints": { "chat": "POST /api/chat", "reset": "DELETE /api/session/{id}", "health": "GET /health", }, "docs": "/docs", } @app.get("/health") def health() -> dict: return { "status": "ok", "engine": type(app.state.engine).__name__, "offline_mode": isinstance(app.state.engine, MockNLUEngine), } @app.post("/api/chat", response_model=ChatResponse) def chat(request: ChatRequest) -> ChatResponse: """Process one user turn in a (possibly multi-turn) conversation.""" store: SessionStore = app.state.store state = store.get_or_create(request.session_id) nlu_result = app.state.engine.extract(request.message, state=state) logger.info( "session=%s intent=%s entities=%s", request.session_id, nlu_result.intent.value, nlu_result.entities.model_dump(exclude_none=True), ) result = app.state.manager.process( state, nlu_result, request.message, request.resolve_contacts_locally ) store.save(result.state) # A completed transaction ends the session; a fresh one starts next turn. if result.is_complete: store.delete(request.session_id) return ChatResponse( session_id=request.session_id, reply=result.reply, intent=result.state.intent, is_complete=result.is_complete, missing_slots=result.state.missing_slots, payload=result.payload, needs_contact_resolution=result.needs_contact_resolution, target_kontak=result.target_kontak, ) @app.delete("/api/session/{session_id}") def reset_session(session_id: str) -> dict: """Discard a conversation so the user can start over.""" deleted = app.state.store.delete(session_id) if not deleted: raise HTTPException(status_code=404, detail="Session not found.") return {"status": "reset", "session_id": session_id}