curieous's picture
Fix Venue Manager hosted startup and Modal preload config
eb44c6f verified
Raw
History Blame Contribute Delete
16.9 kB
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
def load_local_modal_env() -> None:
current = Path(__file__).resolve()
env_path = next(
(parent / ".env.modal.local" for parent in current.parents if (parent / ".env.modal.local").exists()),
None,
)
if env_path is None:
return
for raw_line in env_path.read_text().splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if key.startswith("APP_MODAL_"):
os.environ.setdefault(key, value.strip().strip("'\""))
load_local_modal_env()
from fastapi import Body, Header
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from gradio import Server
from starlette.concurrency import run_in_threadpool
from floodlight_space.booking_flow import approve_booking_flow
from floodlight_space.conversation_service import (
process_baileys_message,
process_simulated_message,
process_simulated_model_message,
)
from floodlight_space.conversation_store import InMemoryConversationStore
from floodlight_space.fixtures import build_bootstrap
from floodlight_space.modal_client import (
SAMPLE_USER_MESSAGE,
call_booking_extractor,
modal_status as get_modal_status,
)
from floodlight_space.owner_review import apply_owner_review
from floodlight_space.whatsapp_adapter import normalize_baileys_envelope, normalize_phone
ROOT = Path(__file__).resolve().parent
FRONTEND_ROOT = ROOT / "frontend"
ASSETS_ROOT = FRONTEND_ROOT / "assets"
CONVERSATION_STORE = InMemoryConversationStore()
def build_server() -> Server:
app = Server(
title="Floodlight Venue Ops Agent",
description="A custom Floodlight venue-ops UI served by a Gradio Server.",
docs_url=None,
redoc_url=None,
)
app.mount("/frontend", StaticFiles(directory=FRONTEND_ROOT), name="frontend")
app.mount("/assets", StaticFiles(directory=ASSETS_ROOT), name="assets")
@app.get("/")
async def homepage() -> FileResponse:
return FileResponse(FRONTEND_ROOT / "index.html")
@app.get("/health")
async def health() -> dict[str, str]:
return {
"status": "ok",
"app": "floodlight-venue-ops-agent",
"frontend": "fully-custom-ui",
"version": "v2-active-demo",
"model_runtime": "modal",
"model_id": get_modal_status()["model_id"],
}
@app.get("/api/model-status")
async def model_status_endpoint() -> JSONResponse:
return JSONResponse(get_modal_status())
@app.get("/api/bootstrap")
async def bootstrap(scenario: str = "normal") -> JSONResponse:
return JSONResponse(build_bootstrap(scenario))
@app.post("/api/reload-demo")
async def reload_demo() -> JSONResponse:
CONVERSATION_STORE.reset()
data = build_bootstrap("normal")
data["demo_reset"] = {
"state": "reloaded",
"source": data.get("state_snapshot", {}).get("snapshot_id", "state_snapshot.v1"),
"temporary_sessions_cleared": True,
}
return JSONResponse(data)
@app.post("/api/decision")
async def decision(payload: dict[str, Any] = Body(default_factory=dict)) -> JSONResponse:
scenario = str(payload.get("scenario") or "normal")
data = build_bootstrap(scenario)
action = str(payload.get("action") or "approve")
if action == "reject":
data["decision"]["state"] = "rejected"
data["trace"].append({"id": "rejected", "label": "Owner rejected the reply", "tone": "error", "timestamp_label": "now"})
elif action == "clarify":
data["decision"]["state"] = "ready"
data["trace"].append({"id": "clarify", "label": "Owner asked for clarification", "tone": "warning", "timestamp_label": "now"})
else:
data["decision"]["state"] = "sent"
data["trace"].append({"id": "sent", "label": "Reply marked sent via simulator", "tone": "success", "timestamp_label": "now"})
return JSONResponse(data)
@app.post("/api/extract-booking")
async def extract_booking(payload: dict[str, Any] = Body(default_factory=dict)) -> JSONResponse:
scenario = str(payload.get("scenario") or "normal")
message = str(payload.get("message") or SAMPLE_USER_MESSAGE)
trace_id = str(payload.get("trace_id") or "space-venue-sample-001")
context = build_bootstrap(scenario)
result = await run_in_threadpool(
call_booking_extractor,
message=message,
venue_context=_model_context(context),
trace_id=trace_id,
)
return JSONResponse(result, status_code=200 if result.get("ok") else 502)
@app.post("/api/player-message")
async def player_message(payload: dict[str, Any] = Body(default_factory=dict)) -> JSONResponse:
trace_id = str(payload.get("trace_id") or "space-venue-flow-sample-001")
return JSONResponse(
{
"ok": False,
"trace_id": trace_id,
"error": "endpoint_retired",
"replacement": "/api/whatsapp/simulated-message",
"message": "The legacy /api/player-message spike endpoint is retired. Use the deterministic conversation API.",
},
status_code=410,
)
@app.post("/api/booking-decision")
async def booking_decision(payload: dict[str, Any] = Body(default_factory=dict)) -> JSONResponse:
flow = payload.get("flow") if isinstance(payload.get("flow"), dict) else {}
decision = str(payload.get("decision") or "approve")
return JSONResponse(approve_booking_flow(flow, decision=decision))
@app.post("/api/whatsapp/simulated-message")
async def whatsapp_simulated_message(payload: dict[str, Any] = Body(default_factory=dict)) -> JSONResponse:
scenario = str(payload.get("scenario") or "normal")
context = _model_context(build_bootstrap(scenario))
session = process_simulated_message(
payload=payload,
venue_context=context,
store=CONVERSATION_STORE,
)
return JSONResponse({"ok": session["terminal_state"] != "failed_model", "conversation": session})
@app.post("/api/whatsapp/simulated-model-message")
async def whatsapp_simulated_model_message(payload: dict[str, Any] = Body(default_factory=dict)) -> JSONResponse:
scenario = str(payload.get("scenario") or "normal")
context = _model_context(build_bootstrap(scenario))
session = await run_in_threadpool(
process_simulated_model_message,
payload=payload,
venue_context=context,
store=CONVERSATION_STORE,
extraction_provider=call_booking_extractor,
)
ok = session["terminal_state"] != "failed_model"
return JSONResponse(
{
"ok": ok,
"conversation": session,
"proof_boundary": {
"adapter": "simulator",
"extraction_mode": "modal",
"live_whatsapp_inbound_proven": False,
"live_outbound_proven": False,
},
},
status_code=200 if ok else 502,
)
@app.post("/api/whatsapp/baileys-message")
async def whatsapp_baileys_message(
payload: dict[str, Any] = Body(default_factory=dict),
adapter_token: str | None = Header(default=None, alias="X-Floodlight-Adapter-Token"),
) -> JSONResponse:
if not _adapter_token_valid(adapter_token):
return JSONResponse({"ok": False, "error": "adapter_token_missing_or_invalid", "conversation": None}, status_code=401)
try:
envelope = normalize_baileys_envelope(payload)
except ValueError as exc:
return JSONResponse({"ok": False, "error": str(exc), "conversation": None}, status_code=400)
if not _sender_allowed(envelope.get("sender_phone")):
return JSONResponse({"ok": False, "error": "sender_not_allowed", "conversation": None}, status_code=403)
scenario = str(payload.get("scenario") or "normal")
context = _model_context(build_bootstrap(scenario))
mode = _live_adapter_mode()
try:
if mode == "modal":
session = await run_in_threadpool(
process_baileys_message,
payload=payload,
venue_context=context,
store=CONVERSATION_STORE,
extraction_mode=mode,
extraction_provider=call_booking_extractor,
)
else:
session = process_baileys_message(
payload=payload,
venue_context=context,
store=CONVERSATION_STORE,
extraction_mode=mode,
extraction_provider=call_booking_extractor,
)
except ValueError as exc:
return JSONResponse({"ok": False, "error": str(exc), "conversation": None}, status_code=400)
ok = session["terminal_state"] != "failed_model"
status_code = 200
if not ok and mode == "modal":
status_code = 502
return JSONResponse(
{
"ok": ok,
"conversation": session,
"proof_boundary": {
"adapter": "baileys",
"extraction_mode": mode,
"live_whatsapp_inbound_proven": False,
"live_outbound_proven": False,
},
},
status_code=status_code,
)
@app.post("/api/conversations/{session_id}/owner-review")
async def conversation_owner_review(session_id: str, payload: dict[str, Any] = Body(default_factory=dict)) -> JSONResponse:
session = CONVERSATION_STORE.get_mutable(session_id)
if session is None:
return JSONResponse({"ok": False, "error": "conversation_not_found", "session_id": session_id}, status_code=404)
scenario = str(payload.get("scenario") or "normal")
context = _model_context(build_bootstrap(scenario))
reviewed = apply_owner_review(session=session, payload=payload, venue_context=context)
saved = CONVERSATION_STORE.save(reviewed)
return JSONResponse({"ok": True, "conversation": saved})
@app.get("/api/conversations/{session_id}")
async def conversation_get(session_id: str) -> JSONResponse:
session = CONVERSATION_STORE.get(session_id)
if session is None:
return JSONResponse({"ok": False, "error": "conversation_not_found", "session_id": session_id}, status_code=404)
return JSONResponse({"ok": True, "conversation": session})
@app.get("/api/conversations/{session_id}/trace")
async def conversation_trace(session_id: str) -> JSONResponse:
session = CONVERSATION_STORE.get(session_id)
if session is None:
return JSONResponse({"ok": False, "error": "conversation_not_found", "session_id": session_id}, status_code=404)
trace = {
"session_id": session_id,
"trace_id": session.get("trace_id"),
"raw_message": ((session.get("turns") or [{}])[-1].get("raw_message", "")),
"inbound_turns": session.get("turns", []),
"adapter_envelope": (session.get("turns") or [{}])[-1].get("adapter_envelope", {}),
"model_extraction": session.get("model_extraction"),
"model_validation": session.get("model_validation"),
"truth_check_initial": session.get("truth_check_initial"),
"owner_review_action": session.get("owner_review_action"),
"owner_correction_diff": session.get("owner_correction_diff"),
"approved_extraction": session.get("approved_extraction"),
"truth_check_final": session.get("truth_check_final"),
"backend_action": session.get("status"),
"outbound_message": session.get("outbound_message"),
"send_adapter": session.get("send_adapter"),
"terminal_state": session.get("terminal_state"),
"runtime_axes": _conversation_runtime_axes(session),
"failure_reason": _failure_reason(session),
"agent_trace": session.get("agent_trace", []),
}
return JSONResponse({"ok": True, "trace": trace})
@app.api(
name="get-floodlight-fixture",
description="Return the static Floodlight venue-ops fixture with explicit proof boundaries.",
queue=False,
api_visibility="public",
)
def get_floodlight_fixture(scenario: str = "normal") -> dict[str, Any]:
return build_bootstrap(scenario)
@app.api(
name="extract-venue-booking-sample",
description="Send one booking message to the Modal Nemotron Cascade extractor and return schema-checked JSON.",
queue=False,
api_visibility="public",
)
def extract_venue_booking_sample(message: str = SAMPLE_USER_MESSAGE) -> dict[str, Any]:
context = build_bootstrap("normal")
return call_booking_extractor(
message=message,
venue_context=_model_context(context),
trace_id="gradio-api-venue-sample-001",
)
return app
def _model_context(context: dict[str, Any]) -> dict[str, Any]:
return {
"slots": context["slots"],
"venues": context["venues"],
"bookings": context.get("bookings", []),
"registered_teams": context.get("registered_teams", []),
"players": context.get("players", []),
"decision": context["decision"],
}
def _runtime_axes(extraction: dict[str, Any]) -> dict[str, Any]:
return {
"lifecycle_stage": "testing",
"app_host": "hf_space" if os.environ.get("SPACE_ID") else "local",
"model_runtime": "modal",
"model_backend": extraction.get("backend", "modal_http"),
"inference_engine": extraction.get("inference_engine", "unknown"),
"model_artifact_format": extraction.get("model_artifact_format", "unknown"),
"quantization": extraction.get("quantization", "unknown"),
"model_id": extraction.get("model_id", get_modal_status()["model_id"]),
"fallback_used": extraction.get("fallback_used", False),
}
def _conversation_runtime_axes(session: dict[str, Any]) -> dict[str, Any]:
extraction = session.get("model_extraction") if isinstance(session.get("model_extraction"), dict) else {}
is_modal = extraction.get("backend") == "modal_http"
return {
"lifecycle_stage": "testing",
"app_host": "hf_space" if os.environ.get("SPACE_ID") else "local",
"model_runtime": "modal" if is_modal else "fixture_model_shaped_input",
"model_backend": extraction.get("backend", "fixture_extractor"),
"model_id": extraction.get("model_id", get_modal_status()["model_id"] if is_modal else "fixture-model-shaped-json"),
"fallback_used": extraction.get("fallback_used", False),
"send_adapter": "simulator",
"live_whatsapp_inbound": False,
"live_whatsapp_outbound": False,
}
def _failure_reason(session: dict[str, Any]) -> str | None:
if session.get("terminal_state") == "failed_model":
extraction = session.get("model_extraction") if isinstance(session.get("model_extraction"), dict) else {}
return str(extraction.get("blocker") or "failed_model")
send_adapter = session.get("send_adapter") if isinstance(session.get("send_adapter"), dict) else {}
if send_adapter.get("delivery_state") == "not_sent" and send_adapter.get("reason"):
return str(send_adapter.get("reason"))
return None
def _adapter_token_valid(adapter_token: str | None) -> bool:
expected = os.environ.get("FLOODLIGHT_ADAPTER_TOKEN", "")
return bool(expected) and adapter_token == expected
def _sender_allowed(sender_phone: Any) -> bool:
allowed = {
normalize_phone(item)
for item in os.environ.get("FLOODLIGHT_ALLOWED_PHONES", "").split(",")
if normalize_phone(item)
}
if not allowed:
return True
return normalize_phone(sender_phone) in allowed
def _live_adapter_mode() -> str:
mode = os.environ.get("FLOODLIGHT_LIVE_ADAPTER_MODE", "fixture_dry_run")
if mode not in {"fixture_dry_run", "modal"}:
return "fixture_dry_run"
return mode
app = build_server()
if __name__ == "__main__":
app.launch(
server_name=os.environ.get("HOST", "0.0.0.0"),
server_port=int(os.environ.get("PORT", "7860")),
footer_links=[],
show_error=True,
)