Spaces:
Sleeping
Sleeping
Reverse glasses pairing: enter code on phone
Browse files
app.py
CHANGED
|
@@ -29,6 +29,7 @@ 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 |
|
| 33 |
logger = logging.getLogger("asq_gateway")
|
| 34 |
|
|
@@ -51,6 +52,8 @@ class SuggestRequest(BaseModel):
|
|
| 51 |
|
| 52 |
sessions: dict[str, dict[str, Any]] = {}
|
| 53 |
interview_codes: dict[str, str] = {}
|
|
|
|
|
|
|
| 54 |
preparation_tasks: set[asyncio.Task[Any]] = set()
|
| 55 |
|
| 56 |
|
|
@@ -75,6 +78,13 @@ def _cleanup_expired() -> None:
|
|
| 75 |
session = sessions.pop(sid, None)
|
| 76 |
if session and session.get("interview_code"):
|
| 77 |
interview_codes.pop(session["interview_code"], None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
|
| 80 |
def _new_interview_code() -> str:
|
|
@@ -86,6 +96,15 @@ def _new_interview_code() -> str:
|
|
| 86 |
raise RuntimeError("Could not allocate an interview code")
|
| 87 |
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
def create_token(session_id: str, role: Literal["owner", "glasses"], expires_at: int) -> str:
|
| 90 |
payload = _b64encode(
|
| 91 |
json.dumps(
|
|
@@ -243,7 +262,7 @@ def session_view(session: dict[str, Any], role: str) -> dict[str, Any]:
|
|
| 243 |
return response
|
| 244 |
|
| 245 |
|
| 246 |
-
api = FastAPI(title="ASQ Rokid Gateway", version="0.
|
| 247 |
api.add_middleware(
|
| 248 |
CORSMiddleware,
|
| 249 |
allow_origins=["*"],
|
|
@@ -263,7 +282,7 @@ def health() -> dict[str, Any]:
|
|
| 263 |
"ok": True,
|
| 264 |
"configured": bool(ACCESS_CODE and SESSION_SECRET and HF_TOKEN),
|
| 265 |
"target": TARGET_SPACE_ID,
|
| 266 |
-
"version": "0.
|
| 267 |
}
|
| 268 |
|
| 269 |
|
|
@@ -321,6 +340,62 @@ async def prepare(
|
|
| 321 |
return session_view(session, "owner")
|
| 322 |
|
| 323 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
@api.post("/join")
|
| 325 |
def join(request: JoinRequest) -> dict[str, Any]:
|
| 326 |
_cleanup_expired()
|
|
@@ -376,20 +451,20 @@ body{margin:0;padding:24px 16px}.card{max-width:760px;margin:auto;background:#ff
|
|
| 376 |
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}
|
| 377 |
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}
|
| 378 |
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}
|
| 379 |
-
.
|
| 380 |
</style></head><body><main class="card">
|
| 381 |
-
<h1>ASQ Interview Setup</h1><p class="muted">
|
| 382 |
<section id="login"><label for="access">Researcher access code</label><input id="access" inputmode="numeric" maxlength="6" placeholder="Six digits"><button id="loginBtn">Continue</button></section>
|
| 383 |
-
<section id="prepare" class="step hidden"><label for="background">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">
|
| 384 |
-
<section id="progress" class="step hidden"><div id="status" class="status">
|
| 385 |
</main><script>
|
| 386 |
let token="", timer=null; const $=id=>document.getElementById(id);
|
| 387 |
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}
|
| 388 |
function errorMessage(e){$("status").textContent=e.message;$("progress").classList.remove("hidden")}
|
| 389 |
$("loginBtn").onclick=async()=>{try{const data=await api("/auth",{method:"POST",body:JSON.stringify({code:$("access").value.trim()})});token=data.token;$("login").classList.add("hidden");$("prepare").classList.remove("hidden")}catch(e){errorMessage(e)}};
|
| 390 |
-
$("prepareBtn").onclick=async()=>{const background=$("background").value.trim();if(background.length<20){errorMessage("Please provide at least 20 characters of background.");return}$("prepareBtn").disabled=true;$("progress").classList.remove("hidden");$("status").textContent="
|
| 391 |
async function poll(){try{show(await api("/session"))}catch(e){clearInterval(timer);errorMessage(e)}}
|
| 392 |
-
function show(data){if(data.
|
| 393 |
</script></body></html>"""
|
| 394 |
|
| 395 |
|
|
|
|
| 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 |
|
|
|
|
| 52 |
|
| 53 |
sessions: dict[str, dict[str, Any]] = {}
|
| 54 |
interview_codes: dict[str, str] = {}
|
| 55 |
+
pairing_codes: dict[str, str] = {}
|
| 56 |
+
pairings_by_token: dict[str, dict[str, Any]] = {}
|
| 57 |
preparation_tasks: set[asyncio.Task[Any]] = set()
|
| 58 |
|
| 59 |
|
|
|
|
| 78 |
session = sessions.pop(sid, None)
|
| 79 |
if session and session.get("interview_code"):
|
| 80 |
interview_codes.pop(session["interview_code"], None)
|
| 81 |
+
expired_pair_tokens = [
|
| 82 |
+
token for token, pairing in pairings_by_token.items() if pairing["expires_at"] <= now
|
| 83 |
+
]
|
| 84 |
+
for pair_token in expired_pair_tokens:
|
| 85 |
+
pairing = pairings_by_token.pop(pair_token, None)
|
| 86 |
+
if pairing:
|
| 87 |
+
pairing_codes.pop(pairing["code"], None)
|
| 88 |
|
| 89 |
|
| 90 |
def _new_interview_code() -> str:
|
|
|
|
| 96 |
raise RuntimeError("Could not allocate an interview code")
|
| 97 |
|
| 98 |
|
| 99 |
+
def _new_pairing_code() -> str:
|
| 100 |
+
_cleanup_expired()
|
| 101 |
+
for _ in range(100):
|
| 102 |
+
code = f"{secrets.randbelow(1_000_000):06d}"
|
| 103 |
+
if code != ACCESS_CODE and code not in pairing_codes and code not in interview_codes:
|
| 104 |
+
return code
|
| 105 |
+
raise RuntimeError("Could not allocate a pairing code")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
def create_token(session_id: str, role: Literal["owner", "glasses"], expires_at: int) -> str:
|
| 109 |
payload = _b64encode(
|
| 110 |
json.dumps(
|
|
|
|
| 262 |
return response
|
| 263 |
|
| 264 |
|
| 265 |
+
api = FastAPI(title="ASQ Rokid Gateway", version="0.3.0")
|
| 266 |
api.add_middleware(
|
| 267 |
CORSMiddleware,
|
| 268 |
allow_origins=["*"],
|
|
|
|
| 282 |
"ok": True,
|
| 283 |
"configured": bool(ACCESS_CODE and SESSION_SECRET and HF_TOKEN),
|
| 284 |
"target": TARGET_SPACE_ID,
|
| 285 |
+
"version": "0.3.0",
|
| 286 |
}
|
| 287 |
|
| 288 |
|
|
|
|
| 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()
|
|
|
|
| 451 |
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}
|
| 452 |
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}
|
| 453 |
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}
|
| 454 |
+
.hypo{white-space:pre-wrap;line-height:1.5;background:#f7faf8;padding:14px;border-radius:12px;max-height:320px;overflow:auto}
|
| 455 |
</style></head><body><main class="card">
|
| 456 |
+
<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>
|
| 457 |
<section id="login"><label for="access">Researcher access code</label><input id="access" inputmode="numeric" maxlength="6" placeholder="Six digits"><button id="loginBtn">Continue</button></section>
|
| 458 |
+
<section id="prepare" class="step hidden"><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">Connect glasses and prepare interview</button></section>
|
| 459 |
+
<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>
|
| 460 |
</main><script>
|
| 461 |
let token="", timer=null; const $=id=>document.getElementById(id);
|
| 462 |
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}
|
| 463 |
function errorMessage(e){$("status").textContent=e.message;$("progress").classList.remove("hidden")}
|
| 464 |
$("loginBtn").onclick=async()=>{try{const data=await api("/auth",{method:"POST",body:JSON.stringify({code:$("access").value.trim()})});token=data.token;$("login").classList.add("hidden");$("prepare").classList.remove("hidden")}catch(e){errorMessage(e)}};
|
| 465 |
+
$("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}};
|
| 466 |
async function poll(){try{show(await api("/session"))}catch(e){clearInterval(timer);errorMessage(e)}}
|
| 467 |
+
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…"}}
|
| 468 |
</script></body></html>"""
|
| 469 |
|
| 470 |
|