File size: 4,603 Bytes
20e9e63 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | """
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}
|