LucasUn commited on
Commit
30c384e
·
verified ·
1 Parent(s): cdfb14d

Fix automatic question synchronization

Browse files
Files changed (1) hide show
  1. app.py +501 -476
app.py CHANGED
@@ -1,476 +1,501 @@
1
- import asyncio
2
- import base64
3
- import hashlib
4
- import hmac
5
- import json
6
- import logging
7
- import os
8
- import re
9
- import secrets
10
- import time
11
- from typing import Any, Literal
12
-
13
- import gradio as gr
14
- import uvicorn
15
- from fastapi import FastAPI, Header, HTTPException
16
- from fastapi.middleware.cors import CORSMiddleware
17
- from fastapi.responses import HTMLResponse
18
- from gradio_client import Client
19
- from pydantic import BaseModel, Field
20
-
21
-
22
- SESSION_SECRET = os.getenv("SESSION_SECRET", "")
23
- HF_TOKEN = os.getenv("HF_TOKEN", "")
24
- TARGET_SPACE_ID = os.getenv("TARGET_SPACE_ID", "ASQ2/ASQ")
25
- TARGET_CHAT_API_NAME = os.getenv("TARGET_CHAT_API_NAME", "/chat_flow")
26
- TARGET_INITIALIZE_API_NAME = os.getenv("TARGET_INITIALIZE_API_NAME", "/initialize_flow")
27
- SESSION_TTL_SECONDS = int(os.getenv("SESSION_TTL_SECONDS", "14400"))
28
- MAX_BACKGROUND_CHARS = int(os.getenv("MAX_BACKGROUND_CHARS", "20000"))
29
- PREPARE_TIMEOUT_SECONDS = int(os.getenv("PREPARE_TIMEOUT_SECONDS", "600"))
30
- PREPARE_RETRY_SECONDS = int(os.getenv("PREPARE_RETRY_SECONDS", "15"))
31
- PAIRING_TTL_SECONDS = int(os.getenv("PAIRING_TTL_SECONDS", "600"))
32
-
33
- logger = logging.getLogger("asq_gateway")
34
-
35
-
36
- class PrepareRequest(BaseModel):
37
- background: str = Field(min_length=20, max_length=MAX_BACKGROUND_CHARS)
38
-
39
-
40
- class JoinRequest(BaseModel):
41
- code: str = Field(min_length=6, max_length=6, pattern=r"^\d{6}$")
42
-
43
-
44
- class SuggestRequest(BaseModel):
45
- transcript: str = Field(min_length=1, max_length=4000)
46
-
47
-
48
- sessions: dict[str, dict[str, Any]] = {}
49
- interview_codes: dict[str, str] = {}
50
- pairing_codes: dict[str, str] = {}
51
- pairings_by_token: dict[str, dict[str, Any]] = {}
52
- preparation_tasks: set[asyncio.Task[Any]] = set()
53
-
54
-
55
- def _b64encode(data: bytes) -> str:
56
- return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
57
-
58
-
59
- def _b64decode(data: str) -> bytes:
60
- return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
61
-
62
-
63
- def _sign(payload: str) -> str:
64
- return _b64encode(
65
- hmac.new(SESSION_SECRET.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).digest()
66
- )
67
-
68
-
69
- def _cleanup_expired() -> None:
70
- now = int(time.time())
71
- expired_ids = [sid for sid, session in sessions.items() if session["expires_at"] <= now]
72
- for sid in expired_ids:
73
- session = sessions.pop(sid, None)
74
- if session and session.get("interview_code"):
75
- interview_codes.pop(session["interview_code"], None)
76
- expired_pair_tokens = [
77
- token for token, pairing in pairings_by_token.items() if pairing["expires_at"] <= now
78
- ]
79
- for pair_token in expired_pair_tokens:
80
- pairing = pairings_by_token.pop(pair_token, None)
81
- if pairing:
82
- pairing_codes.pop(pairing["code"], None)
83
-
84
-
85
- def _new_interview_code() -> str:
86
- _cleanup_expired()
87
- for _ in range(100):
88
- code = f"{secrets.randbelow(1_000_000):06d}"
89
- if code not in interview_codes:
90
- return code
91
- raise RuntimeError("Could not allocate an interview code")
92
-
93
-
94
- def _new_pairing_code() -> str:
95
- _cleanup_expired()
96
- for _ in range(100):
97
- code = f"{secrets.randbelow(1_000_000):06d}"
98
- if code not in pairing_codes and code not in interview_codes:
99
- return code
100
- raise RuntimeError("Could not allocate a pairing code")
101
-
102
-
103
- def create_token(session_id: str, role: Literal["owner", "glasses"], expires_at: int) -> str:
104
- payload = _b64encode(
105
- json.dumps(
106
- {"sid": session_id, "role": role, "exp": expires_at}, separators=(",", ":")
107
- ).encode("utf-8")
108
- )
109
- return f"{payload}.{_sign(payload)}"
110
-
111
-
112
- def verify_token(token: str, roles: set[str] | None = None) -> tuple[dict[str, Any], str]:
113
- if not SESSION_SECRET:
114
- raise HTTPException(status_code=503, detail="Gateway is not configured")
115
- try:
116
- payload, signature = token.split(".", 1)
117
- if not hmac.compare_digest(signature, _sign(payload)):
118
- raise ValueError("bad signature")
119
- data = json.loads(_b64decode(payload))
120
- if int(data["exp"]) <= int(time.time()):
121
- raise ValueError("expired")
122
- role = str(data["role"])
123
- session = sessions.get(str(data["sid"]))
124
- if not session:
125
- raise ValueError("unknown session")
126
- if roles is not None and role not in roles:
127
- raise HTTPException(status_code=403, detail="This session cannot perform that action")
128
- return session, role
129
- except HTTPException:
130
- raise
131
- except (ValueError, KeyError, json.JSONDecodeError):
132
- raise HTTPException(status_code=401, detail="Invalid or expired session")
133
-
134
-
135
- def _bearer(authorization: str | None, roles: set[str] | None = None) -> tuple[dict[str, Any], str]:
136
- if not authorization or not authorization.startswith("Bearer "):
137
- raise HTTPException(status_code=401, detail="Missing session token")
138
- return verify_token(authorization[7:], roles)
139
-
140
-
141
- def _extract_turn_count(value: Any) -> int:
142
- if isinstance(value, (int, float)):
143
- return int(value)
144
- match = re.search(r"\d+", str(value or ""))
145
- return int(match.group(0)) if match else 0
146
-
147
-
148
- def _message_text(message: Any) -> str:
149
- if isinstance(message, str):
150
- return message.strip()
151
- if isinstance(message, dict):
152
- content = message.get("content", message.get("text", ""))
153
- return _message_text(content)
154
- if isinstance(message, (list, tuple)):
155
- parts = [_message_text(item) for item in message]
156
- return "\n".join(part for part in parts if part).strip()
157
- return str(message or "").strip()
158
-
159
-
160
- def extract_initial_state(result: Any) -> tuple[str, list[Any], int]:
161
- # /initialize_flow returns:
162
- # turn markdown, interview history, hypotheses, summary, strategy, status.
163
- if not isinstance(result, (list, tuple)) or len(result) < 6:
164
- raise RuntimeError("Unexpected initialization response from the ASQ Space")
165
- hypotheses = str(result[2] or "").strip()
166
- history = result[1] if isinstance(result[1], list) else []
167
- turns = _extract_turn_count(result[0])
168
- if not hypotheses:
169
- raise RuntimeError("ASQ returned no initial hypotheses")
170
- return hypotheses, history, turns
171
-
172
-
173
- def extract_question(result: Any) -> tuple[str, list[Any], int, str | None]:
174
- # /chat_flow returns:
175
- # history, cleared input, turn markdown, hypotheses, summary, strategy, status.
176
- if not isinstance(result, (list, tuple)) or len(result) < 7:
177
- raise RuntimeError("Unexpected response from the ASQ Space")
178
- history = result[0]
179
- turns = _extract_turn_count(result[2])
180
- hypotheses = str(result[3]).strip() if result[3] is not None else None
181
- if not isinstance(history, list) or not history:
182
- raise RuntimeError("ASQ returned no conversation history")
183
- assistant_messages = [
184
- message
185
- for message in history
186
- if isinstance(message, dict) and message.get("role") == "assistant"
187
- ]
188
- question = _message_text((assistant_messages or history)[-1])
189
- if not question:
190
- raise RuntimeError("ASQ returned an empty question")
191
- return question, history, turns, hypotheses
192
-
193
-
194
- def initialize_asq(background: str) -> tuple[str, list[Any], int]:
195
- if not HF_TOKEN:
196
- raise RuntimeError("HF_TOKEN is not configured")
197
- client = Client(TARGET_SPACE_ID, hf_token=HF_TOKEN, verbose=False)
198
- result = client.predict(background, api_name=TARGET_INITIALIZE_API_NAME)
199
- return extract_initial_state(result)
200
-
201
-
202
- def call_asq(transcript: str, session: dict[str, Any]) -> tuple[str, list[Any], int, str | None]:
203
- if not HF_TOKEN:
204
- raise RuntimeError("HF_TOKEN is not configured")
205
- client = Client(TARGET_SPACE_ID, hf_token=HF_TOKEN, verbose=False)
206
- result = client.predict(
207
- transcript,
208
- session["history"],
209
- session["background"],
210
- api_name=TARGET_CHAT_API_NAME,
211
- )
212
- return extract_question(result)
213
-
214
-
215
- async def prepare_session(session_id: str, background: str) -> None:
216
- session = sessions.get(session_id)
217
- if not session:
218
- return
219
- deadline = time.monotonic() + PREPARE_TIMEOUT_SECONDS
220
- attempt = 0
221
- while True:
222
- attempt += 1
223
- try:
224
- hypotheses, history, turns = await asyncio.to_thread(initialize_asq, background)
225
- break
226
- except Exception as error:
227
- remaining = deadline - time.monotonic()
228
- logger.warning("ASQ preparation attempt %s failed: %s", attempt, type(error).__name__)
229
- if remaining <= 0:
230
- if session_id in sessions:
231
- session["status"] = "error"
232
- session["error"] = "Unable to start the model. Please try again."
233
- return
234
- await asyncio.sleep(min(PREPARE_RETRY_SECONDS, remaining))
235
- if session_id in sessions:
236
- session.update(
237
- status="ready",
238
- hypotheses=hypotheses,
239
- history=history,
240
- turns=turns,
241
- error=None,
242
- )
243
-
244
-
245
- def session_view(session: dict[str, Any], role: str) -> dict[str, Any]:
246
- response: dict[str, Any] = {
247
- "status": session["status"],
248
- "ready": session["status"] == "ready",
249
- "expiresAt": session["expires_at"],
250
- }
251
- if session.get("error"):
252
- response["error"] = session["error"]
253
- if role == "owner":
254
- response["interviewCode"] = session.get("interview_code")
255
- response["hypotheses"] = session.get("hypotheses", "")
256
- response["turns"] = session.get("turns", 0)
257
- return response
258
-
259
-
260
- api = FastAPI(title="ASQ Rokid Gateway", version="0.4.0")
261
- api.add_middleware(
262
- CORSMiddleware,
263
- allow_origins=["*"],
264
- allow_methods=["GET", "POST", "OPTIONS"],
265
- allow_headers=["Authorization", "Content-Type"],
266
- )
267
-
268
-
269
- @api.get("/", response_class=HTMLResponse)
270
- def mobile_app() -> str:
271
- return MOBILE_APP_HTML
272
-
273
-
274
- @api.get("/health")
275
- def health() -> dict[str, Any]:
276
- return {
277
- "ok": True,
278
- "configured": bool(SESSION_SECRET and HF_TOKEN),
279
- "target": TARGET_SPACE_ID,
280
- "version": "0.4.0",
281
- }
282
-
283
-
284
- @api.post("/auth")
285
- def create_owner_session() -> dict[str, Any]:
286
- if not SESSION_SECRET:
287
- raise HTTPException(status_code=503, detail="Gateway is not configured")
288
- _cleanup_expired()
289
- session_id = secrets.token_urlsafe(18)
290
- expires_at = int(time.time()) + SESSION_TTL_SECONDS
291
- sessions[session_id] = {
292
- "background": "",
293
- "hypotheses": "",
294
- "history": [],
295
- "turns": 0,
296
- "status": "draft",
297
- "error": None,
298
- "interview_code": None,
299
- "expires_at": expires_at,
300
- }
301
- return {
302
- "token": create_token(session_id, "owner", expires_at),
303
- "expiresIn": SESSION_TTL_SECONDS,
304
- }
305
-
306
-
307
- @api.post("/prepare", status_code=202)
308
- async def prepare(
309
- request: PrepareRequest,
310
- authorization: str | None = Header(default=None),
311
- ) -> dict[str, Any]:
312
- session, _ = _bearer(authorization, {"owner"})
313
- if session["status"] == "starting":
314
- return session_view(session, "owner")
315
- old_code = session.get("interview_code")
316
- if old_code:
317
- interview_codes.pop(old_code, None)
318
- code = _new_interview_code()
319
- session_id = next(sid for sid, value in sessions.items() if value is session)
320
- interview_codes[code] = session_id
321
- session.update(
322
- background=request.background.strip(),
323
- hypotheses="",
324
- history=[],
325
- turns=0,
326
- status="starting",
327
- error=None,
328
- interview_code=code,
329
- )
330
- task = asyncio.create_task(prepare_session(session_id, session["background"]))
331
- preparation_tasks.add(task)
332
- task.add_done_callback(preparation_tasks.discard)
333
- return session_view(session, "owner")
334
-
335
-
336
- @api.post("/pair")
337
- def create_pairing() -> dict[str, Any]:
338
- _cleanup_expired()
339
- code = _new_pairing_code()
340
- pair_token = secrets.token_urlsafe(24)
341
- expires_at = int(time.time()) + PAIRING_TTL_SECONDS
342
- pairings_by_token[pair_token] = {
343
- "code": code,
344
- "session_id": None,
345
- "expires_at": expires_at,
346
- }
347
- pairing_codes[code] = pair_token
348
- return {
349
- "pairingCode": code,
350
- "pairingToken": pair_token,
351
- "expiresIn": PAIRING_TTL_SECONDS,
352
- }
353
-
354
-
355
- @api.get("/pair/status")
356
- def pairing_status(
357
- x_pairing_token: str | None = Header(default=None, alias="X-Pairing-Token"),
358
- ) -> dict[str, Any]:
359
- _cleanup_expired()
360
- pairing = pairings_by_token.get(x_pairing_token or "")
361
- if not pairing:
362
- raise HTTPException(status_code=401, detail="Invalid or expired pairing")
363
- session_id = pairing.get("session_id")
364
- if not session_id:
365
- return {"paired": False, "expiresAt": pairing["expires_at"]}
366
- session = sessions.get(session_id)
367
- if not session:
368
- raise HTTPException(status_code=410, detail="Interview session expired")
369
- return {
370
- "paired": True,
371
- "token": create_token(session_id, "glasses", session["expires_at"]),
372
- **session_view(session, "glasses"),
373
- }
374
-
375
-
376
- @api.post("/pair/claim")
377
- def claim_pairing(
378
- request: JoinRequest,
379
- authorization: str | None = Header(default=None),
380
- ) -> dict[str, bool]:
381
- session, _ = _bearer(authorization, {"owner"})
382
- _cleanup_expired()
383
- pair_token = pairing_codes.get(request.code)
384
- pairing = pairings_by_token.get(pair_token or "")
385
- if not pairing or not hmac.compare_digest(request.code, pairing.get("code") or ""):
386
- raise HTTPException(status_code=401, detail="Incorrect or expired glasses pairing code")
387
- session_id = next(sid for sid, value in sessions.items() if value is session)
388
- pairing["session_id"] = session_id
389
- return {"paired": True}
390
-
391
-
392
- @api.post("/join")
393
- def join(request: JoinRequest) -> dict[str, Any]:
394
- _cleanup_expired()
395
- session_id = interview_codes.get(request.code)
396
- session = sessions.get(session_id or "")
397
- if not session or not hmac.compare_digest(request.code, session.get("interview_code") or ""):
398
- raise HTTPException(status_code=401, detail="Incorrect or expired interview code")
399
- return {
400
- "token": create_token(session_id, "glasses", session["expires_at"]),
401
- **session_view(session, "glasses"),
402
- }
403
-
404
-
405
- @api.get("/session")
406
- def get_session(authorization: str | None = Header(default=None)) -> dict[str, Any]:
407
- session, role = _bearer(authorization)
408
- return session_view(session, role)
409
-
410
-
411
- @api.post("/start")
412
- def start(authorization: str | None = Header(default=None)) -> dict[str, Any]:
413
- session, role = _bearer(authorization)
414
- return session_view(session, role)
415
-
416
-
417
- @api.post("/suggest")
418
- async def suggest(
419
- request: SuggestRequest,
420
- authorization: str | None = Header(default=None),
421
- ) -> dict[str, str]:
422
- session, _ = _bearer(authorization, {"owner", "glasses"})
423
- if session["status"] != "ready":
424
- raise HTTPException(status_code=409, detail="Interview is not ready")
425
- try:
426
- question, history, turns, hypotheses = await asyncio.to_thread(
427
- call_asq, request.transcript.strip(), session
428
- )
429
- except Exception as error:
430
- raise HTTPException(status_code=502, detail=f"ASQ inference failed: {error}")
431
- session["history"] = history
432
- session["turns"] = turns
433
- if hypotheses:
434
- session["hypotheses"] = hypotheses
435
- return {"question": question}
436
-
437
-
438
- MOBILE_APP_HTML = """<!doctype html>
439
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
440
- <title>ASQ Interview Setup</title>
441
- <style>
442
- :root{font-family:Inter,system-ui,sans-serif;color:#16201d;background:#eef5f1}*{box-sizing:border-box}
443
- body{margin:0;padding:24px 16px}.card{max-width:760px;margin:auto;background:#fff;border-radius:20px;padding:24px;box-shadow:0 12px 36px #173b2b1a}
444
- h1{margin:0 0 6px;font-size:1.55rem}.muted{color:#65716d;margin:0 0 24px}.step{border-top:1px solid #e4ebe7;padding-top:20px;margin-top:20px}
445
- label{display:block;font-weight:650;margin-bottom:8px}input,textarea,button{font:inherit;width:100%;border-radius:12px}input,textarea{border:1px solid #b9c9c1;padding:13px}textarea{min-height:220px;resize:vertical}
446
- button{border:0;background:#087a54;color:#fff;padding:14px;font-weight:700;margin-top:12px}button:disabled{background:#9cada5}.hidden{display:none}.status{padding:14px;border-radius:12px;background:#edf7f2;margin-top:14px;white-space:pre-wrap}
447
- .hypo{white-space:pre-wrap;line-height:1.5;background:#f7faf8;padding:14px;border-radius:12px;max-height:320px;overflow:auto}
448
- </style></head><body><main class="card">
449
- <h1>ASQ Interview Setup</h1><p class="muted">Open ASQ on the Rokid glasses first. The glasses will display a six-digit pairing code; enter that code on this phone.</p>
450
- <section id="prepare"><label for="pairingCode">Code shown on the glasses</label><input id="pairingCode" inputmode="numeric" maxlength="6" placeholder="Six digits"><label for="background" style="margin-top:16px">Case and interview background</label><textarea id="background" placeholder="Enter the case facts, context, existing evidence, interview goals, and any constraints..."></textarea><button id="prepareBtn" disabled>Connect glasses and prepare interview</button></section>
451
- <section id="progress" class="step hidden"><div id="status" class="status">Connecting the glasses…</div><div id="hypoWrap" class="hidden"><h2>Initial hypotheses</h2><div id="hypotheses" class="hypo"></div></div></section>
452
- </main><script>
453
- let token="", timer=null; const $=id=>document.getElementById(id);
454
- async function api(path, options={}){const headers={"Content-Type":"application/json",...(options.headers||{})};if(token)headers.Authorization=`Bearer ${token}`;const r=await fetch(path,{...options,headers});let data={};try{data=await r.json()}catch{}if(!r.ok)throw new Error(data.detail||`Request failed (${r.status})`);return data}
455
- function errorMessage(e){$("status").textContent=e.message;$("progress").classList.remove("hidden")}
456
- async function begin(){try{const data=await api("/auth",{method:"POST"});token=data.token;$("prepareBtn").disabled=false}catch(e){errorMessage(e)}}
457
- $("prepareBtn").onclick=async()=>{const pairingCode=$("pairingCode").value.trim();const background=$("background").value.trim();if(!/^\d{6}$/.test(pairingCode)){errorMessage("Enter the six-digit code shown on the glasses.");return}if(background.length<20){errorMessage("Please provide at least 20 characters of background.");return}$("prepareBtn").disabled=true;$("progress").classList.remove("hidden");$("status").textContent="Connecting the glasses and generating initial hypotheses…";try{await api("/pair/claim",{method:"POST",body:JSON.stringify({code:pairingCode})});const data=await api("/prepare",{method:"POST",body:JSON.stringify({background})});show(data);timer=setInterval(poll,3000)}catch(e){errorMessage(e);$("prepareBtn").disabled=false}};
458
- async function poll(){try{show(await api("/session"))}catch(e){clearInterval(timer);errorMessage(e)}}
459
- function show(data){if(data.status==="ready"){clearInterval(timer);$("status").textContent="Ready. The glasses are connected and can begin the interview.";$("hypotheses").textContent=data.hypotheses||"No hypotheses returned.";$("hypoWrap").classList.remove("hidden")}else if(data.status==="error"){clearInterval(timer);$("status").textContent=data.error||"Model startup failed.";$("prepareBtn").disabled=false}else{$("status").textContent="Glasses connected. Starting the model and generating initial hypotheses…"}}
460
- begin();
461
- </script></body></html>"""
462
-
463
-
464
- with gr.Blocks(title="ASQ Rokid Gateway") as status_page:
465
- gr.Markdown(
466
- "# ASQ Rokid Gateway\n"
467
- "This service provides phone preparation and Rokid interview pairing APIs.\n\n"
468
- "Open the site root on a phone to prepare an interview. Use `GET /health` for service status."
469
- )
470
-
471
-
472
- api = gr.mount_gradio_app(api, status_page, path="/ui")
473
-
474
-
475
- if __name__ == "__main__":
476
- uvicorn.run(api, host="0.0.0.0", port=7860)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import base64
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import logging
7
+ import os
8
+ import re
9
+ import secrets
10
+ import time
11
+ from typing import Any, Literal
12
+
13
+ import gradio as gr
14
+ import uvicorn
15
+ from fastapi import FastAPI, Header, HTTPException
16
+ from fastapi.middleware.cors import CORSMiddleware
17
+ from fastapi.responses import HTMLResponse
18
+ from gradio_client import Client
19
+ from pydantic import BaseModel, Field
20
+
21
+
22
+ SESSION_SECRET = os.getenv("SESSION_SECRET", "")
23
+ HF_TOKEN = os.getenv("HF_TOKEN", "")
24
+ TARGET_SPACE_ID = os.getenv("TARGET_SPACE_ID", "ASQ2/ASQ")
25
+ INITIAL_PROMPT = os.getenv("ASQ_INITIAL_PROMPT", "Hello")
26
+ TARGET_CHAT_API_NAME = os.getenv("TARGET_CHAT_API_NAME", "/chat_flow")
27
+ TARGET_INITIALIZE_API_NAME = os.getenv("TARGET_INITIALIZE_API_NAME", "/initialize_flow")
28
+ SESSION_TTL_SECONDS = int(os.getenv("SESSION_TTL_SECONDS", "14400"))
29
+ MAX_BACKGROUND_CHARS = int(os.getenv("MAX_BACKGROUND_CHARS", "20000"))
30
+ PREPARE_TIMEOUT_SECONDS = int(os.getenv("PREPARE_TIMEOUT_SECONDS", "600"))
31
+ PREPARE_RETRY_SECONDS = int(os.getenv("PREPARE_RETRY_SECONDS", "15"))
32
+ PAIRING_TTL_SECONDS = int(os.getenv("PAIRING_TTL_SECONDS", "600"))
33
+
34
+ logger = logging.getLogger("asq_gateway")
35
+
36
+
37
+ class PrepareRequest(BaseModel):
38
+ background: str = Field(min_length=20, max_length=MAX_BACKGROUND_CHARS)
39
+
40
+
41
+ class JoinRequest(BaseModel):
42
+ code: str = Field(min_length=6, max_length=6, pattern=r"^\d{6}$")
43
+
44
+
45
+ class SuggestRequest(BaseModel):
46
+ transcript: str = Field(min_length=1, max_length=4000)
47
+
48
+
49
+ sessions: dict[str, dict[str, Any]] = {}
50
+ interview_codes: dict[str, str] = {}
51
+ pairing_codes: dict[str, str] = {}
52
+ pairings_by_token: dict[str, dict[str, Any]] = {}
53
+ preparation_tasks: set[asyncio.Task[Any]] = set()
54
+
55
+
56
+ def _b64encode(data: bytes) -> str:
57
+ return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
58
+
59
+
60
+ def _b64decode(data: str) -> bytes:
61
+ return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
62
+
63
+
64
+ def _sign(payload: str) -> str:
65
+ return _b64encode(
66
+ hmac.new(SESSION_SECRET.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).digest()
67
+ )
68
+
69
+
70
+ def _cleanup_expired() -> None:
71
+ now = int(time.time())
72
+ expired_ids = [sid for sid, session in sessions.items() if session["expires_at"] <= now]
73
+ for sid in expired_ids:
74
+ session = sessions.pop(sid, None)
75
+ if session and session.get("interview_code"):
76
+ interview_codes.pop(session["interview_code"], None)
77
+ expired_pair_tokens = [
78
+ token for token, pairing in pairings_by_token.items() if pairing["expires_at"] <= now
79
+ ]
80
+ for pair_token in expired_pair_tokens:
81
+ pairing = pairings_by_token.pop(pair_token, None)
82
+ if pairing:
83
+ pairing_codes.pop(pairing["code"], None)
84
+
85
+
86
+ def _new_interview_code() -> str:
87
+ _cleanup_expired()
88
+ for _ in range(100):
89
+ code = f"{secrets.randbelow(1_000_000):06d}"
90
+ if code not in interview_codes:
91
+ return code
92
+ raise RuntimeError("Could not allocate an interview code")
93
+
94
+
95
+ def _new_pairing_code() -> str:
96
+ _cleanup_expired()
97
+ for _ in range(100):
98
+ code = f"{secrets.randbelow(1_000_000):06d}"
99
+ if code not in pairing_codes and code not in interview_codes:
100
+ return code
101
+ raise RuntimeError("Could not allocate a pairing code")
102
+
103
+
104
+ def create_token(session_id: str, role: Literal["owner", "glasses"], expires_at: int) -> str:
105
+ payload = _b64encode(
106
+ json.dumps(
107
+ {"sid": session_id, "role": role, "exp": expires_at}, separators=(",", ":")
108
+ ).encode("utf-8")
109
+ )
110
+ return f"{payload}.{_sign(payload)}"
111
+
112
+
113
+ def verify_token(token: str, roles: set[str] | None = None) -> tuple[dict[str, Any], str]:
114
+ if not SESSION_SECRET:
115
+ raise HTTPException(status_code=503, detail="Gateway is not configured")
116
+ try:
117
+ payload, signature = token.split(".", 1)
118
+ if not hmac.compare_digest(signature, _sign(payload)):
119
+ raise ValueError("bad signature")
120
+ data = json.loads(_b64decode(payload))
121
+ if int(data["exp"]) <= int(time.time()):
122
+ raise ValueError("expired")
123
+ role = str(data["role"])
124
+ session = sessions.get(str(data["sid"]))
125
+ if not session:
126
+ raise ValueError("unknown session")
127
+ if roles is not None and role not in roles:
128
+ raise HTTPException(status_code=403, detail="This session cannot perform that action")
129
+ return session, role
130
+ except HTTPException:
131
+ raise
132
+ except (ValueError, KeyError, json.JSONDecodeError):
133
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
134
+
135
+
136
+ def _bearer(authorization: str | None, roles: set[str] | None = None) -> tuple[dict[str, Any], str]:
137
+ if not authorization or not authorization.startswith("Bearer "):
138
+ raise HTTPException(status_code=401, detail="Missing session token")
139
+ return verify_token(authorization[7:], roles)
140
+
141
+
142
+ def _extract_turn_count(value: Any) -> int:
143
+ if isinstance(value, (int, float)):
144
+ return int(value)
145
+ match = re.search(r"\d+", str(value or ""))
146
+ return int(match.group(0)) if match else 0
147
+
148
+
149
+ def _message_text(message: Any) -> str:
150
+ if isinstance(message, str):
151
+ return message.strip()
152
+ if isinstance(message, dict):
153
+ content = message.get("content", message.get("text", ""))
154
+ return _message_text(content)
155
+ if isinstance(message, (list, tuple)):
156
+ parts = [_message_text(item) for item in message]
157
+ return "\n".join(part for part in parts if part).strip()
158
+ return str(message or "").strip()
159
+
160
+
161
+ def extract_initial_state(result: Any) -> tuple[str, list[Any], int]:
162
+ # /initialize_flow returns:
163
+ # turn markdown, interview history, hypotheses, summary, strategy, status.
164
+ if not isinstance(result, (list, tuple)) or len(result) < 6:
165
+ raise RuntimeError("Unexpected initialization response from the ASQ Space")
166
+ hypotheses = str(result[2] or "").strip()
167
+ history = result[1] if isinstance(result[1], list) else []
168
+ turns = _extract_turn_count(result[0])
169
+ if not hypotheses:
170
+ raise RuntimeError("ASQ returned no initial hypotheses")
171
+ return hypotheses, history, turns
172
+
173
+
174
+ def extract_question(result: Any) -> tuple[str, list[Any], int, str | None]:
175
+ # /chat_flow returns:
176
+ # history, cleared input, turn markdown, hypotheses, summary, strategy, status.
177
+ if not isinstance(result, (list, tuple)) or len(result) < 7:
178
+ raise RuntimeError("Unexpected response from the ASQ Space")
179
+ history = result[0]
180
+ turns = _extract_turn_count(result[2])
181
+ hypotheses = str(result[3]).strip() if result[3] is not None else None
182
+ if not isinstance(history, list) or not history:
183
+ raise RuntimeError("ASQ returned no conversation history")
184
+ assistant_messages = [
185
+ message
186
+ for message in history
187
+ if isinstance(message, dict) and message.get("role") == "assistant"
188
+ ]
189
+ question = _message_text((assistant_messages or history)[-1])
190
+ if not question:
191
+ raise RuntimeError("ASQ returned an empty question")
192
+ return question, history, turns, hypotheses
193
+
194
+
195
+ def initialize_asq(background: str) -> tuple[str, list[Any], int]:
196
+ if not HF_TOKEN:
197
+ raise RuntimeError("HF_TOKEN is not configured")
198
+ client = Client(TARGET_SPACE_ID, hf_token=HF_TOKEN, verbose=False)
199
+ result = client.predict(background, api_name=TARGET_INITIALIZE_API_NAME)
200
+ return extract_initial_state(result)
201
+
202
+
203
+ def call_asq(transcript: str, session: dict[str, Any]) -> tuple[str, list[Any], int, str | None]:
204
+ if not HF_TOKEN:
205
+ raise RuntimeError("HF_TOKEN is not configured")
206
+ client = Client(TARGET_SPACE_ID, hf_token=HF_TOKEN, verbose=False)
207
+ result = client.predict(
208
+ transcript,
209
+ session["history"],
210
+ session["background"],
211
+ api_name=TARGET_CHAT_API_NAME,
212
+ )
213
+ return extract_question(result)
214
+
215
+
216
+ async def prepare_session(session_id: str, background: str) -> None:
217
+ session = sessions.get(session_id)
218
+ if not session:
219
+ return
220
+ deadline = time.monotonic() + PREPARE_TIMEOUT_SECONDS
221
+ attempt = 0
222
+ while True:
223
+ attempt += 1
224
+ try:
225
+ hypotheses, history, turns = await asyncio.to_thread(initialize_asq, background)
226
+ break
227
+ except Exception as error:
228
+ remaining = deadline - time.monotonic()
229
+ logger.warning("ASQ preparation attempt %s failed: %s", attempt, type(error).__name__)
230
+ if remaining <= 0:
231
+ if session_id in sessions:
232
+ session["status"] = "error"
233
+ session["error"] = "Unable to start the model. Please try again."
234
+ return
235
+ await asyncio.sleep(min(PREPARE_RETRY_SECONDS, remaining))
236
+ if session_id in sessions:
237
+ session.update(
238
+ status="ready",
239
+ hypotheses=hypotheses,
240
+ history=history,
241
+ turns=turns,
242
+ error=None,
243
+ )
244
+
245
+
246
+ def session_view(session: dict[str, Any], role: str) -> dict[str, Any]:
247
+ response: dict[str, Any] = {
248
+ "status": session["status"],
249
+ "ready": session["status"] == "ready",
250
+ "expiresAt": session["expires_at"],
251
+ "question": session.get("current_question", ""),
252
+ "questionRevision": session.get("question_revision", 0),
253
+ "turns": session.get("turns", 0),
254
+ }
255
+ if session.get("error"):
256
+ response["error"] = session["error"]
257
+ if role == "owner":
258
+ response["interviewCode"] = session.get("interview_code")
259
+ response["hypotheses"] = session.get("hypotheses", "")
260
+ return response
261
+
262
+
263
+ api = FastAPI(title="ASQ Rokid Gateway", version="0.5.0")
264
+ api.add_middleware(
265
+ CORSMiddleware,
266
+ allow_origins=["*"],
267
+ allow_methods=["GET", "POST", "OPTIONS"],
268
+ allow_headers=["Authorization", "Content-Type"],
269
+ )
270
+
271
+
272
+ @api.get("/", response_class=HTMLResponse)
273
+ def mobile_app() -> str:
274
+ return MOBILE_APP_HTML
275
+
276
+
277
+ @api.get("/health")
278
+ def health() -> dict[str, Any]:
279
+ return {
280
+ "ok": True,
281
+ "configured": bool(SESSION_SECRET and HF_TOKEN),
282
+ "target": TARGET_SPACE_ID,
283
+ "version": "0.5.0",
284
+ }
285
+
286
+
287
+ @api.post("/auth")
288
+ def create_owner_session() -> dict[str, Any]:
289
+ if not SESSION_SECRET:
290
+ raise HTTPException(status_code=503, detail="Gateway is not configured")
291
+ _cleanup_expired()
292
+ session_id = secrets.token_urlsafe(18)
293
+ expires_at = int(time.time()) + SESSION_TTL_SECONDS
294
+ sessions[session_id] = {
295
+ "background": "",
296
+ "hypotheses": "",
297
+ "history": [],
298
+ "turns": 0,
299
+ "current_question": "",
300
+ "question_revision": 0,
301
+ "status": "draft",
302
+ "error": None,
303
+ "interview_code": None,
304
+ "expires_at": expires_at,
305
+ }
306
+ return {
307
+ "token": create_token(session_id, "owner", expires_at),
308
+ "expiresIn": SESSION_TTL_SECONDS,
309
+ }
310
+
311
+
312
+ @api.post("/prepare", status_code=202)
313
+ async def prepare(
314
+ request: PrepareRequest,
315
+ authorization: str | None = Header(default=None),
316
+ ) -> dict[str, Any]:
317
+ session, _ = _bearer(authorization, {"owner"})
318
+ if session["status"] == "starting":
319
+ return session_view(session, "owner")
320
+ old_code = session.get("interview_code")
321
+ if old_code:
322
+ interview_codes.pop(old_code, None)
323
+ code = _new_interview_code()
324
+ session_id = next(sid for sid, value in sessions.items() if value is session)
325
+ interview_codes[code] = session_id
326
+ session.update(
327
+ background=request.background.strip(),
328
+ hypotheses="",
329
+ history=[],
330
+ turns=0,
331
+ current_question="",
332
+ question_revision=0,
333
+ status="starting",
334
+ error=None,
335
+ interview_code=code,
336
+ )
337
+ task = asyncio.create_task(prepare_session(session_id, session["background"]))
338
+ preparation_tasks.add(task)
339
+ task.add_done_callback(preparation_tasks.discard)
340
+ return session_view(session, "owner")
341
+
342
+
343
+ @api.post("/pair")
344
+ def create_pairing() -> dict[str, Any]:
345
+ _cleanup_expired()
346
+ code = _new_pairing_code()
347
+ pair_token = secrets.token_urlsafe(24)
348
+ expires_at = int(time.time()) + PAIRING_TTL_SECONDS
349
+ pairings_by_token[pair_token] = {
350
+ "code": code,
351
+ "session_id": None,
352
+ "expires_at": expires_at,
353
+ }
354
+ pairing_codes[code] = pair_token
355
+ return {
356
+ "pairingCode": code,
357
+ "pairingToken": pair_token,
358
+ "expiresIn": PAIRING_TTL_SECONDS,
359
+ }
360
+
361
+
362
+ @api.get("/pair/status")
363
+ def pairing_status(
364
+ x_pairing_token: str | None = Header(default=None, alias="X-Pairing-Token"),
365
+ ) -> dict[str, Any]:
366
+ _cleanup_expired()
367
+ pairing = pairings_by_token.get(x_pairing_token or "")
368
+ if not pairing:
369
+ raise HTTPException(status_code=401, detail="Invalid or expired pairing")
370
+ session_id = pairing.get("session_id")
371
+ if not session_id:
372
+ return {"paired": False, "expiresAt": pairing["expires_at"]}
373
+ session = sessions.get(session_id)
374
+ if not session:
375
+ raise HTTPException(status_code=410, detail="Interview session expired")
376
+ return {
377
+ "paired": True,
378
+ "token": create_token(session_id, "glasses", session["expires_at"]),
379
+ **session_view(session, "glasses"),
380
+ }
381
+
382
+
383
+ @api.post("/pair/claim")
384
+ def claim_pairing(
385
+ request: JoinRequest,
386
+ authorization: str | None = Header(default=None),
387
+ ) -> dict[str, bool]:
388
+ session, _ = _bearer(authorization, {"owner"})
389
+ _cleanup_expired()
390
+ pair_token = pairing_codes.get(request.code)
391
+ pairing = pairings_by_token.get(pair_token or "")
392
+ if not pairing or not hmac.compare_digest(request.code, pairing.get("code") or ""):
393
+ raise HTTPException(status_code=401, detail="Incorrect or expired glasses pairing code")
394
+ session_id = next(sid for sid, value in sessions.items() if value is session)
395
+ pairing["session_id"] = session_id
396
+ return {"paired": True}
397
+
398
+
399
+ @api.post("/join")
400
+ def join(request: JoinRequest) -> dict[str, Any]:
401
+ _cleanup_expired()
402
+ session_id = interview_codes.get(request.code)
403
+ session = sessions.get(session_id or "")
404
+ if not session or not hmac.compare_digest(request.code, session.get("interview_code") or ""):
405
+ raise HTTPException(status_code=401, detail="Incorrect or expired interview code")
406
+ return {
407
+ "token": create_token(session_id, "glasses", session["expires_at"]),
408
+ **session_view(session, "glasses"),
409
+ }
410
+
411
+
412
+ @api.get("/session")
413
+ def get_session(authorization: str | None = Header(default=None)) -> dict[str, Any]:
414
+ session, role = _bearer(authorization)
415
+ return session_view(session, role)
416
+
417
+
418
+ @api.post("/start")
419
+ async def start(authorization: str | None = Header(default=None)) -> dict[str, Any]:
420
+ session, role = _bearer(authorization, {"owner", "glasses"})
421
+ if session["status"] != "ready":
422
+ raise HTTPException(status_code=409, detail="Interview is not ready")
423
+ if session.get("current_question"):
424
+ return session_view(session, role)
425
+ try:
426
+ question, history, turns, hypotheses = await asyncio.to_thread(
427
+ call_asq, INITIAL_PROMPT, session
428
+ )
429
+ except Exception as error:
430
+ raise HTTPException(status_code=502, detail=f"ASQ inference failed: {error}")
431
+ session["history"] = history
432
+ session["turns"] = turns
433
+ session["current_question"] = question
434
+ session["question_revision"] = session.get("question_revision", 0) + 1
435
+ if hypotheses:
436
+ session["hypotheses"] = hypotheses
437
+ return session_view(session, role)
438
+
439
+
440
+ @api.post("/suggest")
441
+ async def suggest(
442
+ request: SuggestRequest,
443
+ authorization: str | None = Header(default=None),
444
+ ) -> dict[str, str]:
445
+ session, _ = _bearer(authorization, {"owner", "glasses"})
446
+ if session["status"] != "ready":
447
+ raise HTTPException(status_code=409, detail="Interview is not ready")
448
+ try:
449
+ question, history, turns, hypotheses = await asyncio.to_thread(
450
+ call_asq, request.transcript.strip(), session
451
+ )
452
+ except Exception as error:
453
+ raise HTTPException(status_code=502, detail=f"ASQ inference failed: {error}")
454
+ session["history"] = history
455
+ session["turns"] = turns
456
+ session["current_question"] = question
457
+ session["question_revision"] = session.get("question_revision", 0) + 1
458
+ if hypotheses:
459
+ session["hypotheses"] = hypotheses
460
+ return {"question": question}
461
+
462
+
463
+ MOBILE_APP_HTML = """<!doctype html>
464
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
465
+ <title>ASQ Interview Setup</title>
466
+ <style>
467
+ :root{font-family:Inter,system-ui,sans-serif;color:#16201d;background:#eef5f1}*{box-sizing:border-box}
468
+ body{margin:0;padding:24px 16px}.card{max-width:760px;margin:auto;background:#fff;border-radius:20px;padding:24px;box-shadow:0 12px 36px #173b2b1a}
469
+ h1{margin:0 0 6px;font-size:1.55rem}.muted{color:#65716d;margin:0 0 24px}.step{border-top:1px solid #e4ebe7;padding-top:20px;margin-top:20px}
470
+ label{display:block;font-weight:650;margin-bottom:8px}input,textarea,button{font:inherit;width:100%;border-radius:12px}input,textarea{border:1px solid #b9c9c1;padding:13px}textarea{min-height:220px;resize:vertical}
471
+ button{border:0;background:#087a54;color:#fff;padding:14px;font-weight:700;margin-top:12px}button:disabled{background:#9cada5}.hidden{display:none}.status{padding:14px;border-radius:12px;background:#edf7f2;margin-top:14px;white-space:pre-wrap}
472
+ .hypo{white-space:pre-wrap;line-height:1.5;background:#f7faf8;padding:14px;border-radius:12px;max-height:320px;overflow:auto}
473
+ </style></head><body><main class="card">
474
+ <h1>ASQ Interview Setup</h1><p class="muted">Open ASQ on the Rokid glasses first. The glasses will display a six-digit pairing code; enter that code on this phone.</p>
475
+ <section id="prepare"><label for="pairingCode">Code shown on the glasses</label><input id="pairingCode" inputmode="numeric" maxlength="6" placeholder="Six digits"><label for="background" style="margin-top:16px">Case and interview background</label><textarea id="background" placeholder="Enter the case facts, context, existing evidence, interview goals, and any constraints..."></textarea><button id="prepareBtn" disabled>Connect glasses and prepare interview</button></section>
476
+ <section id="progress" class="step hidden"><div id="status" class="status">Connecting the glasses…</div><div id="hypoWrap" class="hidden"><h2>Initial hypotheses</h2><div id="hypotheses" class="hypo"></div></div></section>
477
+ </main><script>
478
+ let token="", timer=null; const $=id=>document.getElementById(id);
479
+ async function api(path, options={}){const headers={"Content-Type":"application/json",...(options.headers||{})};if(token)headers.Authorization=`Bearer ${token}`;const r=await fetch(path,{...options,headers});let data={};try{data=await r.json()}catch{}if(!r.ok)throw new Error(data.detail||`Request failed (${r.status})`);return data}
480
+ function errorMessage(e){$("status").textContent=e.message;$("progress").classList.remove("hidden")}
481
+ async function begin(){try{const data=await api("/auth",{method:"POST"});token=data.token;$("prepareBtn").disabled=false}catch(e){errorMessage(e)}}
482
+ $("prepareBtn").onclick=async()=>{const pairingCode=$("pairingCode").value.trim();const background=$("background").value.trim();if(!/^\d{6}$/.test(pairingCode)){errorMessage("Enter the six-digit code shown on the glasses.");return}if(background.length<20){errorMessage("Please provide at least 20 characters of background.");return}$("prepareBtn").disabled=true;$("progress").classList.remove("hidden");$("status").textContent="Connecting the glasses and generating initial hypotheses…";try{await api("/pair/claim",{method:"POST",body:JSON.stringify({code:pairingCode})});const data=await api("/prepare",{method:"POST",body:JSON.stringify({background})});show(data);timer=setInterval(poll,3000)}catch(e){errorMessage(e);$("prepareBtn").disabled=false}};
483
+ async function poll(){try{show(await api("/session"))}catch(e){clearInterval(timer);errorMessage(e)}}
484
+ function show(data){if(data.status==="ready"){clearInterval(timer);$("status").textContent="Ready. The glasses are connected and can begin the interview.";$("hypotheses").textContent=data.hypotheses||"No hypotheses returned.";$("hypoWrap").classList.remove("hidden")}else if(data.status==="error"){clearInterval(timer);$("status").textContent=data.error||"Model startup failed.";$("prepareBtn").disabled=false}else{$("status").textContent="Glasses connected. Starting the model and generating initial hypotheses…"}}
485
+ begin();
486
+ </script></body></html>"""
487
+
488
+
489
+ with gr.Blocks(title="ASQ Rokid Gateway") as status_page:
490
+ gr.Markdown(
491
+ "# ASQ Rokid Gateway\n"
492
+ "This service provides phone preparation and Rokid interview pairing APIs.\n\n"
493
+ "Open the site root on a phone to prepare an interview. Use `GET /health` for service status."
494
+ )
495
+
496
+
497
+ api = gr.mount_gradio_app(api, status_page, path="/ui")
498
+
499
+
500
+ if __name__ == "__main__":
501
+ uvicorn.run(api, host="0.0.0.0", port=7860)