import os import time from typing import Any, Dict import requests import streamlit as st from fastapi import FastAPI, HTTPException, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.security import HTTPBasic, HTTPBasicCredentials import uvicorn # OpenAI Configuration OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") REALTIME_MODEL = os.getenv("OPENAI_REALTIME_MODEL", "gpt-4o-realtime-preview-2024-12-17") # System prompt for Catherine SYSTEM_PROMPT = ( "You are Catherine, owner of Catherine's Catering. You role-play as a non-technical small-business owner being interviewed by student consultants.\n\n" "BACKGROUND STORY\n" "• Catherine's Catering provides food for luncheons, weddings, and banquets. Started small; reputation and event count grew after a new convention center opened. Catherine still uses spreadsheets/Word. Phone calls about menus, guest-count changes, and special diets now overwhelm her. Part-time cooks/servers are used; HR manager struggles with scheduling.\n\n" "KNOWN PAIN POINTS (do NOT dump all at once; reveal naturally when asked well):\n" "1) Master chef orders ingredients event-by-event; suppliers offer discounts for bulk orders across a time window.\n" "2) Clients frequently change guest counts, sometimes 1–2 days before the event.\n" "3) Inquiry→contract process is phone-heavy and slow; only ~60% of calls convert to contracts.\n" "4) Staff schedule conflicts cause understaffed events and punctuality complaints.\n" "5) No summary trends on event counts or menu popularity to guide client choices.\n" "6) Formal sit-down events are especially sensitive to sudden guest-count changes and server scheduling.\n\n" "OPERATIONS SNAPSHOT (use when students ask for processes/details):\n" "• Ordering: chef compiles per-event lists; no consolidated purchase orders; deliveries 2–3x/week.\n" "• Sales: inquiries via phone/email/social; menu PDFs; manual quote → manual contract.\n" "• Dietary requests: vegan/vegetarian/low-fat/low-carb/gluten-free tracked in notes.\n" "• Scheduling: part-timers choose shifts; conflicts tracked in spreadsheets; last-minute swaps happen via WhatsApp.\n" "• Event types: buffet, sit-down plated, coffee breaks; sit-down needs server:guest ≈ 1:12 (adjust if students ask).\n" "• KPIs (approx): 25–40 inquiries/month; ~60% conversion; 4–10 events/week in peak; food waste occurs when guest changes are late.\n\n" "ROLE-PLAY RULES\n" "• Stay friendly, busy, practical; avoid IT jargon; answer from lived operations.\n" "• If students are vague, ask clarifying questions. Praise precise, grounded questions.\n" "• Offer concrete anecdotes (e.g., last Friday's wedding changed from 150→185 guests 36 hours before).\n" "• Never list 'requirements' proactively—describe frustrations; let students infer.\n" "• If asked for numbers, give rough, believable ranges.\n" "• If students ask you to output a structured summary, use the JSON schema below.\n\n" "ON DEMAND OUTPUT (when students explicitly ask for a structured summary, respond with ONLY valid JSON):\n" "{ \"summary\": {\n" " \"top_problems\": [\"string\"],\n" " \"current_process_gaps\": [\"string\"],\n" " \"risks\": [\"string\"],\n" " \"metrics_shared\": {\"monthly_inquiries\": \"~25-40\", \"conversion_rate\": \"~60%\", \"events_per_week_peak\": \"4-10\"}\n" "},\n" "\"hints_for_students\": [\"Ask about supplier terms & lead times\", \"Ask how last-minute changes propagate to kitchen & staffing\", \"Ask what a 'good day' looks like vs 'bad day'\"] }\n\n" "SESSION OPENING (first message you send):\n" "\"Hi, I'm Catherine. Thanks for taking the time—things have been hectic lately! How would you like to start?\"\n" ) # FastAPI app for token management app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Serve the web client statically WEB_DIR = os.path.join(os.path.dirname(__file__), "web") if os.path.isdir(WEB_DIR): app.mount("/static", StaticFiles(directory=WEB_DIR, html=True), name="static") security = HTTPBasic() VALID_GROUPS = {f"group{i}": f"group{i}" for i in range(1, 8)} LIMIT_MS = 5 * 60 * 1000 # 5 minutes START_TIMES: Dict[str, int] = {} def _require_group_auth(credentials: HTTPBasicCredentials) -> None: user = credentials.username or "" pw = credentials.password or "" if user not in VALID_GROUPS or VALID_GROUPS[user] != pw: raise HTTPException(status_code=401, detail="Unauthorized", headers={"WWW-Authenticate": "Basic"}) def _ensure_start_and_remaining_ms(user: str) -> int: now_ms = int(time.time() * 1000) if user not in START_TIMES: START_TIMES[user] = now_ms elapsed = now_ms - START_TIMES[user] remaining = max(0, LIMIT_MS - elapsed) return remaining @app.get("/remaining") def get_remaining(credentials: HTTPBasicCredentials = Depends(security)) -> Dict[str, Any]: _require_group_auth(credentials) remaining = _ensure_start_and_remaining_ms(credentials.username) return {"remaining_ms": remaining} @app.get("/health") def health() -> Dict[str, Any]: return {"ok": True, "time": int(time.time())} @app.get("/session") def create_ephemeral_session(credentials: HTTPBasicCredentials = Depends(security)) -> Dict[str, Any]: """Create an ephemeral Realtime session token for the browser client.""" _require_group_auth(credentials) if not OPENAI_API_KEY: raise HTTPException(status_code=500, detail="OPENAI_API_KEY is not set") remaining = _ensure_start_and_remaining_ms(credentials.username) if remaining <= 0: raise HTTPException(status_code=403, detail="Time limit reached for this group") try: resp = requests.post( "https://api.openai.com/v1/realtime/sessions", headers={ "Authorization": f"Bearer {OPENAI_API_KEY}", "Content-Type": "application/json", }, json={ "model": REALTIME_MODEL, "voice": os.getenv("OPENAI_VOICE", "shimmer"), "modalities": ["text", "audio"], "turn_detection": {"type": "server_vad"}, "instructions": SYSTEM_PROMPT, }, timeout=15, ) resp.raise_for_status() except requests.HTTPError as e: raise HTTPException(status_code=resp.status_code, detail=resp.text) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) data = resp.json() if isinstance(data, dict): data["remaining_ms"] = remaining return data # Streamlit UI st.set_page_config(page_title="Catherine – Role-Play Voice Simulator", page_icon="🎤", layout="centered") st.title("Client Role-Play Voice Simulator (Catherine)") st.markdown( "- Use your mic to interview Catherine (voice only).\n" "- Catherine will answer when asked clearly and stay in role.\n" ) missing_key = os.getenv("OPENAI_API_KEY") is None if missing_key: st.warning("Set environment variable OPENAI_API_KEY before starting.") # Hugging Face Spaces optimized interface st.info("🌐 **Hugging Face Spaces Version** - Optimized for web deployment") # Group selection group = st.selectbox("Select your group:", ["group1", "group2", "group3", "group4", "group5", "group6", "group7"]) # Time remaining display if "start_time" not in st.session_state: st.session_state.start_time = None if st.session_state.start_time: elapsed = (time.time() * 1000) - st.session_state.start_time remaining = max(0, LIMIT_MS - elapsed) minutes = int(remaining // 60000) seconds = int((remaining % 60000) // 1000) st.metric("Time Remaining", f"{minutes:02d}:{seconds:02d}") if st.button("🎤 Start Voice Interview"): if not OPENAI_API_KEY: st.error("❌ OPENAI_API_KEY is not set. Please set it in the Space settings.") else: st.session_state.start_time = time.time() * 1000 st.success(f"✅ Ready to start interview as {group}") # Create a simple text-based interface for Hugging Face Spaces st.markdown("### Interview Interface") st.markdown("**Note:** Due to WebRTC limitations in Hugging Face Spaces, this is a simplified text-based interface.") # Show the web client in an iframe space_id = os.getenv("SPACE_ID", "") if space_id: client_url = f"https://{space_id}.hf.space/static/index.html" st.components.v1.iframe(client_url, height=600) else: st.warning("Could not determine Space URL. Please check your Space configuration.") # Fallback: show the web client content directly st.markdown("### Voice Interface (Fallback)") st.markdown("Please use the web interface at your Space URL to access the full voice functionality.") # Instructions for full functionality st.markdown("---") st.markdown("### For Full Voice Functionality") st.markdown("To use the complete voice interface with WebRTC support:") st.markdown("1. **Download and run locally:** `streamlit run app.py`") st.markdown("2. **Or use the web client directly** at your Space URL") st.markdown("3. **Set your OpenAI API key** in the Space settings") # Show API endpoints for debugging with st.expander("🔧 API Endpoints (for debugging)"): st.code(f""" Health: GET /health Session: GET /session (requires Basic Auth) Remaining: GET /remaining (requires Basic Auth) Static files: GET /static/index.html """) if st.button("Test Health Endpoint"): try: response = requests.get(f"https://{os.getenv('SPACE_ID', '')}.hf.space/health", timeout=5) if response.status_code == 200: st.success("✅ Health endpoint working") st.json(response.json()) else: st.error(f"❌ Health endpoint failed: {response.status_code}") except Exception as e: st.error(f"❌ Error: {e}")