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}