File size: 10,261 Bytes
93103b0 | 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 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 | 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}")
|