| 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_API_KEY = os.getenv("OPENAI_API_KEY") |
| REALTIME_MODEL = os.getenv("OPENAI_REALTIME_MODEL", "gpt-4o-realtime-preview-2024-12-17") |
|
|
| |
| 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" |
| ) |
|
|
| |
| app = FastAPI() |
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_credentials=True, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| |
| 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 |
| 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 |
|
|
| |
| 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.") |
|
|
| |
| st.info("π **Hugging Face Spaces Version** - Optimized for web deployment") |
|
|
| |
| group = st.selectbox("Select your group:", ["group1", "group2", "group3", "group4", "group5", "group6", "group7"]) |
|
|
| |
| 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}") |
| |
| |
| st.markdown("### Interview Interface") |
| st.markdown("**Note:** Due to WebRTC limitations in Hugging Face Spaces, this is a simplified text-based interface.") |
| |
| |
| 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.") |
| |
| |
| st.markdown("### Voice Interface (Fallback)") |
| st.markdown("Please use the web interface at your Space URL to access the full voice 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") |
|
|
| |
| 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}") |
|
|