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}")