infinicode 2.8.66 → 2.8.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -14,7 +14,7 @@ import hashlib
|
|
|
14
14
|
import hmac
|
|
15
15
|
import time
|
|
16
16
|
from datetime import datetime, timedelta
|
|
17
|
-
from typing import Optional, List
|
|
17
|
+
from typing import Optional, List
|
|
18
18
|
from contextlib import asynccontextmanager
|
|
19
19
|
|
|
20
20
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Query, Header
|
|
@@ -230,6 +230,20 @@ class PipelineEventPayload(BaseModel):
|
|
|
230
230
|
session_id: Optional[str] = None
|
|
231
231
|
source: str = "robot"
|
|
232
232
|
details: Optional[dict] = None
|
|
233
|
+
|
|
234
|
+
class TranscriptTurnPayload(BaseModel):
|
|
235
|
+
role: str
|
|
236
|
+
text: str
|
|
237
|
+
speaker: Optional[str] = None
|
|
238
|
+
is_final: bool = True
|
|
239
|
+
sequence: Optional[int] = None
|
|
240
|
+
source: str = "voice_agent"
|
|
241
|
+
metadata: Optional[dict] = None
|
|
242
|
+
|
|
243
|
+
class VoiceCallRequest(BaseModel):
|
|
244
|
+
character_preset_id: str
|
|
245
|
+
voice_stack_id: Optional[str] = None
|
|
246
|
+
client_name: str = "RoboPark Operator"
|
|
233
247
|
|
|
234
248
|
class SupervisorStatusReport(BaseModel):
|
|
235
249
|
services: List[ServiceStatus] = []
|
|
@@ -471,6 +485,23 @@ async def init_db():
|
|
|
471
485
|
);
|
|
472
486
|
CREATE INDEX IF NOT EXISTS idx_pipeline_robot_time
|
|
473
487
|
ON pipeline_events(robot_id, timestamp DESC);
|
|
488
|
+
|
|
489
|
+
CREATE TABLE IF NOT EXISTS session_transcripts (
|
|
490
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
491
|
+
session_id TEXT NOT NULL,
|
|
492
|
+
robot_id TEXT,
|
|
493
|
+
role TEXT NOT NULL,
|
|
494
|
+
speaker TEXT,
|
|
495
|
+
text TEXT NOT NULL,
|
|
496
|
+
is_final INTEGER NOT NULL DEFAULT 1,
|
|
497
|
+
sequence INTEGER,
|
|
498
|
+
source TEXT NOT NULL DEFAULT 'voice_agent',
|
|
499
|
+
metadata TEXT,
|
|
500
|
+
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
501
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
502
|
+
);
|
|
503
|
+
CREATE INDEX IF NOT EXISTS idx_transcript_session_time
|
|
504
|
+
ON session_transcripts(session_id, timestamp, id);
|
|
474
505
|
|
|
475
506
|
-- Scheduler-managed voice stacks (STT/LLM/TTS configuration)
|
|
476
507
|
CREATE TABLE IF NOT EXISTS voice_stacks (
|
|
@@ -558,7 +589,11 @@ async def init_db():
|
|
|
558
589
|
# started_at at INSERT time; existing rows on an upgraded DB will
|
|
559
590
|
# have NULL here until touched, so readers should fall back to
|
|
560
591
|
# started_at (see the /status endpoint below).
|
|
561
|
-
("sessions", "last_activity_at", "TEXT"),
|
|
592
|
+
("sessions", "last_activity_at", "TEXT"),
|
|
593
|
+
("sessions", "character_id", "TEXT"),
|
|
594
|
+
("sessions", "voice_stack_id", "TEXT"),
|
|
595
|
+
("sessions", "initiated_by", "TEXT"),
|
|
596
|
+
("sessions", "event_token_hash", "TEXT"),
|
|
562
597
|
):
|
|
563
598
|
try:
|
|
564
599
|
await db.execute(f"ALTER TABLE {_table} ADD COLUMN {_column} {_coldef}")
|
|
@@ -1238,14 +1273,104 @@ class WebClientSessionRequest(BaseModel):
|
|
|
1238
1273
|
"""Request body for web client session"""
|
|
1239
1274
|
client_name: Optional[str] = "Web User"
|
|
1240
1275
|
|
|
1241
|
-
class WebClientSessionResponse(BaseModel):
|
|
1276
|
+
class WebClientSessionResponse(BaseModel):
|
|
1242
1277
|
"""Response for web client session"""
|
|
1243
1278
|
session_id: str
|
|
1244
1279
|
server_id: str
|
|
1245
1280
|
server_url: str
|
|
1246
1281
|
room_name: str
|
|
1247
1282
|
api_key: str
|
|
1248
|
-
api_secret: str
|
|
1283
|
+
api_secret: str
|
|
1284
|
+
|
|
1285
|
+
async def _select_available_server(db):
|
|
1286
|
+
async with db.execute("""
|
|
1287
|
+
SELECT s.*,
|
|
1288
|
+
(SELECT COUNT(*) FROM sessions WHERE server_id = s.id AND ended_at IS NULL) AS active
|
|
1289
|
+
FROM livekit_servers s
|
|
1290
|
+
WHERE s.status = 'online'
|
|
1291
|
+
ORDER BY active ASC
|
|
1292
|
+
LIMIT 1
|
|
1293
|
+
""") as cursor:
|
|
1294
|
+
server = await cursor.fetchone()
|
|
1295
|
+
if not server:
|
|
1296
|
+
raise HTTPException(503, "No available servers")
|
|
1297
|
+
if server["active"] >= server["max_sessions"]:
|
|
1298
|
+
raise HTTPException(503, "All servers at capacity")
|
|
1299
|
+
return server
|
|
1300
|
+
|
|
1301
|
+
@app.post("/api/sessions/voice-call")
|
|
1302
|
+
async def create_voice_call(payload: VoiceCallRequest):
|
|
1303
|
+
"""Create a browser voice call using a frozen character and voice stack.
|
|
1304
|
+
|
|
1305
|
+
Unlike the legacy web-client endpoint this never exposes LiveKit API
|
|
1306
|
+
credentials. The event token only authorizes transcript writes for this
|
|
1307
|
+
session and is stored as a hash.
|
|
1308
|
+
"""
|
|
1309
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
1310
|
+
db.row_factory = aiosqlite.Row
|
|
1311
|
+
async with db.execute(
|
|
1312
|
+
"SELECT * FROM character_presets WHERE id = ?", (payload.character_preset_id,)
|
|
1313
|
+
) as cursor:
|
|
1314
|
+
character = await cursor.fetchone()
|
|
1315
|
+
if not character:
|
|
1316
|
+
raise HTTPException(404, "Character preset not found")
|
|
1317
|
+
stack_id = payload.voice_stack_id or character["voice_stack_id"]
|
|
1318
|
+
if not stack_id:
|
|
1319
|
+
raise HTTPException(422, "Character has no voice stack")
|
|
1320
|
+
async with db.execute("SELECT id FROM voice_stacks WHERE id = ?", (stack_id,)) as cursor:
|
|
1321
|
+
if not await cursor.fetchone():
|
|
1322
|
+
raise HTTPException(404, "Voice stack not found")
|
|
1323
|
+
server = await _select_available_server(db)
|
|
1324
|
+
now = datetime.utcnow()
|
|
1325
|
+
nonce = secrets.token_urlsafe(8).replace("-", "").replace("_", "")
|
|
1326
|
+
session_id = f"voice_{now.strftime('%Y%m%d%H%M%S')}_{nonce}"
|
|
1327
|
+
room_name = f"robopark-voice-{nonce}"
|
|
1328
|
+
event_token = secrets.token_urlsafe(32)
|
|
1329
|
+
await db.execute("""
|
|
1330
|
+
INSERT INTO sessions
|
|
1331
|
+
(id, robot_id, server_id, room_name, started_at, last_activity_at,
|
|
1332
|
+
character_id, voice_stack_id, initiated_by, event_token_hash)
|
|
1333
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1334
|
+
""", (
|
|
1335
|
+
session_id, "operator-call", server["id"], room_name,
|
|
1336
|
+
now.isoformat(), now.isoformat(), character["id"], stack_id,
|
|
1337
|
+
(payload.client_name or "RoboPark Operator")[:80], _hash(event_token),
|
|
1338
|
+
))
|
|
1339
|
+
await db.commit()
|
|
1340
|
+
|
|
1341
|
+
try:
|
|
1342
|
+
from livekit.api import AccessToken, VideoGrants
|
|
1343
|
+
except ImportError:
|
|
1344
|
+
raise HTTPException(503, "livekit-api not installed on scheduler")
|
|
1345
|
+
identity = f"operator:{nonce}"
|
|
1346
|
+
token = (
|
|
1347
|
+
AccessToken(server["api_key"] or "devkey", server["api_secret"] or "secret")
|
|
1348
|
+
.with_identity(identity)
|
|
1349
|
+
.with_name((payload.client_name or "RoboPark Operator")[:80])
|
|
1350
|
+
.with_ttl(timedelta(hours=2))
|
|
1351
|
+
.with_grants(VideoGrants(
|
|
1352
|
+
room=room_name, room_join=True, can_publish=True,
|
|
1353
|
+
can_subscribe=True, can_publish_data=True,
|
|
1354
|
+
))
|
|
1355
|
+
.to_jwt()
|
|
1356
|
+
)
|
|
1357
|
+
await _log_history(
|
|
1358
|
+
"session", "session", session_id, "voice_call_started",
|
|
1359
|
+
(payload.client_name or "operator")[:64],
|
|
1360
|
+
f"character={character['id']}, voice_stack={stack_id}",
|
|
1361
|
+
)
|
|
1362
|
+
await broadcast("voice_call_started", {"session_id": session_id, "character_id": character["id"]})
|
|
1363
|
+
return {
|
|
1364
|
+
"session_id": session_id,
|
|
1365
|
+
"server_id": server["id"],
|
|
1366
|
+
"server_url": server["url"],
|
|
1367
|
+
"room_name": room_name,
|
|
1368
|
+
"identity": identity,
|
|
1369
|
+
"token": token,
|
|
1370
|
+
"event_token": event_token,
|
|
1371
|
+
"character": {"id": character["id"], "name": character["name"], "img": character["img"]},
|
|
1372
|
+
"voice_stack_id": stack_id,
|
|
1373
|
+
}
|
|
1249
1374
|
|
|
1250
1375
|
@app.post("/api/sessions/web-client", response_model=WebClientSessionResponse)
|
|
1251
1376
|
async def create_web_client_session(req: Optional[WebClientSessionRequest] = None):
|
|
@@ -1333,17 +1458,18 @@ def _session_latency_ms(started_at: Optional[str], joined_at: Optional[str]) ->
|
|
|
1333
1458
|
except Exception:
|
|
1334
1459
|
return None
|
|
1335
1460
|
|
|
1336
|
-
@app.post("/api/sessions/{session_id}/joined")
|
|
1337
|
-
async def session_joined(session_id: str,
|
|
1338
|
-
authorization: Optional[str] = Header(default=None)
|
|
1461
|
+
@app.post("/api/sessions/{session_id}/joined")
|
|
1462
|
+
async def session_joined(session_id: str,
|
|
1463
|
+
authorization: Optional[str] = Header(default=None),
|
|
1464
|
+
session_token: Optional[str] = Header(default=None, alias="X-RoboPark-Session-Token")):
|
|
1339
1465
|
"""Mark the moment a robot successfully joined the LiveKit room for a session.
|
|
1340
1466
|
|
|
1341
1467
|
Used to compute session latency = joined_at - started_at (the session row's
|
|
1342
1468
|
started_at is set when the session is requested). Requires a valid
|
|
1343
1469
|
enrolled-device Bearer token. Idempotent: the first join wins; later calls
|
|
1344
1470
|
leave the original join time in place."""
|
|
1345
|
-
if not await
|
|
1346
|
-
raise HTTPException(401, "Invalid or missing device token")
|
|
1471
|
+
if not await _session_event_authorized(session_id, session_token, authorization, None):
|
|
1472
|
+
raise HTTPException(401, "Invalid or missing device token")
|
|
1347
1473
|
now = datetime.utcnow().isoformat()
|
|
1348
1474
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
1349
1475
|
db.row_factory = aiosqlite.Row
|
|
@@ -1497,17 +1623,160 @@ async def get_session_token(session_id: str):
|
|
|
1497
1623
|
|
|
1498
1624
|
return {"token": token, "room_name": room_name}
|
|
1499
1625
|
|
|
1500
|
-
@app.get("/api/sessions")
|
|
1501
|
-
async def list_sessions(active_only: bool = False, limit: int = 50):
|
|
1502
|
-
"""List sessions"""
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1626
|
+
@app.get("/api/sessions")
|
|
1627
|
+
async def list_sessions(active_only: bool = False, limit: int = 50):
|
|
1628
|
+
"""List enriched sessions for the operator history and live views."""
|
|
1629
|
+
limit = max(1, min(limit, 1000))
|
|
1630
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
1631
|
+
db.row_factory = aiosqlite.Row
|
|
1632
|
+
base = """SELECT s.*, s.id AS session_id,
|
|
1633
|
+
cp.name AS character_name, cp.img AS character_img,
|
|
1634
|
+
CASE WHEN s.joined_at IS NOT NULL THEN
|
|
1635
|
+
CAST((julianday(s.joined_at) - julianday(s.started_at)) * 86400000 AS INTEGER)
|
|
1636
|
+
END AS avg_latency_ms,
|
|
1637
|
+
(SELECT COUNT(*) FROM session_transcripts t
|
|
1638
|
+
WHERE t.session_id = s.id AND t.is_final = 1) AS transcript_count,
|
|
1639
|
+
(SELECT COUNT(*) FROM pipeline_events p
|
|
1640
|
+
WHERE p.session_id = s.id AND p.status IN ('failed','blocked')) AS error_count,
|
|
1641
|
+
(SELECT COUNT(*) FROM pipeline_events p
|
|
1642
|
+
WHERE p.session_id = s.id) AS pipeline_event_count
|
|
1643
|
+
FROM sessions s
|
|
1644
|
+
LEFT JOIN character_presets cp ON cp.id = COALESCE(
|
|
1645
|
+
s.character_id,
|
|
1646
|
+
(SELECT character_id FROM devices d WHERE d.id = s.robot_id),
|
|
1647
|
+
(SELECT character_id FROM robots r WHERE r.id = s.robot_id)
|
|
1648
|
+
)"""
|
|
1649
|
+
if active_only:
|
|
1650
|
+
query = base + " WHERE s.ended_at IS NULL ORDER BY s.started_at DESC"
|
|
1651
|
+
else:
|
|
1652
|
+
query = base + " ORDER BY s.started_at DESC LIMIT ?"
|
|
1653
|
+
async with db.execute(query, () if active_only else (limit,)) as cursor:
|
|
1654
|
+
return [dict(row) for row in await cursor.fetchall()]
|
|
1655
|
+
|
|
1656
|
+
async def _session_event_authorized(session_id: str, session_token: Optional[str],
|
|
1657
|
+
authorization: Optional[str], agent_token: Optional[str]) -> bool:
|
|
1658
|
+
if await _authorize_fleet(authorization):
|
|
1659
|
+
return True
|
|
1660
|
+
if ROBOPARK_AGENT_TOKEN and agent_token and hmac.compare_digest(agent_token.strip(), ROBOPARK_AGENT_TOKEN):
|
|
1661
|
+
return True
|
|
1662
|
+
if not session_token:
|
|
1663
|
+
return False
|
|
1664
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
1665
|
+
async with db.execute("SELECT event_token_hash FROM sessions WHERE id = ?", (session_id,)) as cursor:
|
|
1666
|
+
row = await cursor.fetchone()
|
|
1667
|
+
return bool(row and row[0] and hmac.compare_digest(row[0], _hash(session_token.strip())))
|
|
1668
|
+
|
|
1669
|
+
@app.post("/api/sessions/{session_id}/transcript")
|
|
1670
|
+
async def append_session_transcript(
|
|
1671
|
+
session_id: str,
|
|
1672
|
+
payload: TranscriptTurnPayload,
|
|
1673
|
+
authorization: Optional[str] = Header(default=None),
|
|
1674
|
+
agent_token: Optional[str] = Header(default=None, alias="X-RoboPark-Agent-Token"),
|
|
1675
|
+
session_token: Optional[str] = Header(default=None, alias="X-RoboPark-Session-Token"),
|
|
1676
|
+
):
|
|
1677
|
+
if not await _session_event_authorized(session_id, session_token, authorization, agent_token):
|
|
1678
|
+
raise HTTPException(401, "Invalid or missing session event token")
|
|
1679
|
+
role = (payload.role or "").strip().lower()
|
|
1680
|
+
if role not in {"user", "assistant", "system", "tool"}:
|
|
1681
|
+
raise HTTPException(422, "role must be user, assistant, system, or tool")
|
|
1682
|
+
text = (payload.text or "").strip()
|
|
1683
|
+
if not text:
|
|
1684
|
+
raise HTTPException(422, "text is required")
|
|
1685
|
+
if len(text) > 20000:
|
|
1686
|
+
raise HTTPException(422, "text exceeds 20000 characters")
|
|
1687
|
+
now = datetime.utcnow().isoformat()
|
|
1688
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
1689
|
+
db.row_factory = aiosqlite.Row
|
|
1690
|
+
async with db.execute("SELECT robot_id, ended_at FROM sessions WHERE id = ?", (session_id,)) as cursor:
|
|
1691
|
+
session = await cursor.fetchone()
|
|
1692
|
+
if not session:
|
|
1693
|
+
raise HTTPException(404, "Session not found")
|
|
1694
|
+
cur = await db.execute("""
|
|
1695
|
+
INSERT INTO session_transcripts
|
|
1696
|
+
(session_id, robot_id, role, speaker, text, is_final, sequence, source, metadata, timestamp)
|
|
1697
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1698
|
+
""", (
|
|
1699
|
+
session_id, session["robot_id"], role, (payload.speaker or "")[:120] or None,
|
|
1700
|
+
text, int(payload.is_final), payload.sequence, (payload.source or "unknown")[:40],
|
|
1701
|
+
json.dumps(payload.metadata or {}, separators=(",", ":"))[:8000], now,
|
|
1702
|
+
))
|
|
1703
|
+
if not session["ended_at"]:
|
|
1704
|
+
await db.execute("UPDATE sessions SET last_activity_at = ? WHERE id = ?", (now, session_id))
|
|
1705
|
+
await db.commit()
|
|
1706
|
+
await broadcast("transcript_turn", {"session_id": session_id, "role": role, "is_final": payload.is_final})
|
|
1707
|
+
return {"status": "ok", "id": cur.lastrowid, "timestamp": now}
|
|
1708
|
+
|
|
1709
|
+
@app.post("/api/sessions/by-room/{room_name}/transcript")
|
|
1710
|
+
async def append_room_transcript(
|
|
1711
|
+
room_name: str,
|
|
1712
|
+
payload: TranscriptTurnPayload,
|
|
1713
|
+
authorization: Optional[str] = Header(default=None),
|
|
1714
|
+
agent_token: Optional[str] = Header(default=None, alias="X-RoboPark-Agent-Token"),
|
|
1715
|
+
):
|
|
1716
|
+
"""Persist a ROBOVOICE turn without coupling the worker to session ID formats."""
|
|
1717
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
1718
|
+
async with db.execute(
|
|
1719
|
+
"SELECT id FROM sessions WHERE room_name = ? ORDER BY started_at DESC LIMIT 1",
|
|
1720
|
+
(room_name,),
|
|
1721
|
+
) as cursor:
|
|
1722
|
+
row = await cursor.fetchone()
|
|
1723
|
+
if not row:
|
|
1724
|
+
raise HTTPException(404, "No session bound to this room")
|
|
1725
|
+
return await append_session_transcript(
|
|
1726
|
+
row[0], payload, authorization=authorization, agent_token=agent_token, session_token=None
|
|
1727
|
+
)
|
|
1728
|
+
|
|
1729
|
+
@app.get("/api/sessions/{session_id}/detail")
|
|
1730
|
+
async def session_detail(session_id: str):
|
|
1731
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
1732
|
+
db.row_factory = aiosqlite.Row
|
|
1733
|
+
async with db.execute("""
|
|
1734
|
+
SELECT s.*, s.id AS session_id, cp.name AS character_name, cp.img AS character_img,
|
|
1735
|
+
vs.name AS voice_stack_name
|
|
1736
|
+
FROM sessions s
|
|
1737
|
+
LEFT JOIN character_presets cp ON cp.id = COALESCE(
|
|
1738
|
+
s.character_id,
|
|
1739
|
+
(SELECT character_id FROM devices d WHERE d.id = s.robot_id),
|
|
1740
|
+
(SELECT character_id FROM robots r WHERE r.id = s.robot_id)
|
|
1741
|
+
)
|
|
1742
|
+
LEFT JOIN voice_stacks vs ON vs.id = COALESCE(s.voice_stack_id, cp.voice_stack_id)
|
|
1743
|
+
WHERE s.id = ?
|
|
1744
|
+
""", (session_id,)) as cursor:
|
|
1745
|
+
session = await cursor.fetchone()
|
|
1746
|
+
if not session:
|
|
1747
|
+
raise HTTPException(404, "Session not found")
|
|
1748
|
+
async with db.execute(
|
|
1749
|
+
"SELECT * FROM session_transcripts WHERE session_id = ? ORDER BY COALESCE(sequence, id), timestamp, id",
|
|
1750
|
+
(session_id,),
|
|
1751
|
+
) as cursor:
|
|
1752
|
+
transcript = [dict(r) for r in await cursor.fetchall()]
|
|
1753
|
+
async with db.execute(
|
|
1754
|
+
"SELECT * FROM pipeline_events WHERE session_id = ? ORDER BY timestamp, id", (session_id,)
|
|
1755
|
+
) as cursor:
|
|
1756
|
+
pipeline = [dict(r) for r in await cursor.fetchall()]
|
|
1757
|
+
for turn in transcript:
|
|
1758
|
+
try:
|
|
1759
|
+
turn["metadata"] = json.loads(turn.get("metadata") or "{}")
|
|
1760
|
+
except Exception:
|
|
1761
|
+
turn["metadata"] = {}
|
|
1762
|
+
turn["is_final"] = bool(turn.get("is_final"))
|
|
1763
|
+
for event in pipeline:
|
|
1764
|
+
try:
|
|
1765
|
+
event["details"] = json.loads(event.get("details") or "{}")
|
|
1766
|
+
except Exception:
|
|
1767
|
+
event["details"] = {}
|
|
1768
|
+
data = dict(session)
|
|
1769
|
+
data.pop("event_token_hash", None)
|
|
1770
|
+
data["latency_ms"] = _session_latency_ms(data.get("started_at"), data.get("joined_at"))
|
|
1771
|
+
data["transcript"] = transcript
|
|
1772
|
+
data["pipeline_events"] = pipeline
|
|
1773
|
+
data["summary"] = {
|
|
1774
|
+
"turns": len([t for t in transcript if t["is_final"]]),
|
|
1775
|
+
"user_turns": len([t for t in transcript if t["is_final"] and t["role"] == "user"]),
|
|
1776
|
+
"assistant_turns": len([t for t in transcript if t["is_final"] and t["role"] == "assistant"]),
|
|
1777
|
+
"pipeline_errors": len([e for e in pipeline if e["status"] in {"failed", "blocked"}]),
|
|
1778
|
+
}
|
|
1779
|
+
return data
|
|
1511
1780
|
|
|
1512
1781
|
@app.get("/api/sessions/stats")
|
|
1513
1782
|
async def session_stats():
|
|
@@ -2168,7 +2437,17 @@ async def preview_agent(robot_id: str, authorization: Optional[str] = Header(def
|
|
|
2168
2437
|
"""The ROBOT polls this. While an operator preview is active it returns a
|
|
2169
2438
|
PUBLISH token + room so the Pi opens its cam and joins; otherwise
|
|
2170
2439
|
{active:false} so the Pi stops publishing. Enrolled-device auth."""
|
|
2171
|
-
|
|
2440
|
+
fleet_authorized = await _authorize_fleet(authorization)
|
|
2441
|
+
event_authorized = False
|
|
2442
|
+
if session_token:
|
|
2443
|
+
async with aiosqlite.connect(DB_PATH) as auth_db:
|
|
2444
|
+
async with auth_db.execute("SELECT event_token_hash FROM sessions WHERE id = ?", (session_id,)) as c:
|
|
2445
|
+
auth_row = await c.fetchone()
|
|
2446
|
+
event_authorized = bool(
|
|
2447
|
+
auth_row and auth_row[0]
|
|
2448
|
+
and hmac.compare_digest(auth_row[0], _hash(session_token.strip()))
|
|
2449
|
+
)
|
|
2450
|
+
if not fleet_authorized and not event_authorized:
|
|
2172
2451
|
raise HTTPException(401, "Invalid or missing device token")
|
|
2173
2452
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
2174
2453
|
db.row_factory = aiosqlite.Row
|
|
@@ -3640,7 +3919,7 @@ async def _auto_elevenlabs_voice(robot_name: Optional[str], current: Optional[st
|
|
|
3640
3919
|
return current
|
|
3641
3920
|
|
|
3642
3921
|
|
|
3643
|
-
async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
|
|
3922
|
+
async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
|
|
3644
3923
|
"""Public helper to resolve the effective voice config for a robot id."""
|
|
3645
3924
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
3646
3925
|
db.row_factory = aiosqlite.Row
|
|
@@ -3692,14 +3971,48 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
|
|
|
3692
3971
|
if stack_dict:
|
|
3693
3972
|
stack_dict["tts_voice"] = await _auto_elevenlabs_voice(character_name or character_id, stack_dict.get("tts_voice"))
|
|
3694
3973
|
voice_stack = VoiceStack(**stack_dict) if stack_dict else None
|
|
3695
|
-
return RobotVoiceConfig(
|
|
3974
|
+
return RobotVoiceConfig(
|
|
3696
3975
|
character_id=character_id,
|
|
3697
3976
|
character_name=character_name,
|
|
3698
3977
|
system_prompt=system_prompt,
|
|
3699
3978
|
voice_stack_id=voice_stack.id if voice_stack else None,
|
|
3700
3979
|
voice_stack=voice_stack,
|
|
3701
|
-
greeting_phrases=greeting_phrases,
|
|
3702
|
-
)
|
|
3980
|
+
greeting_phrases=greeting_phrases,
|
|
3981
|
+
)
|
|
3982
|
+
|
|
3983
|
+
async def _resolve_session_voice_config(session_row) -> RobotVoiceConfig:
|
|
3984
|
+
"""Resolve the immutable character/stack snapshot selected for a web call."""
|
|
3985
|
+
character_id = session_row["character_id"]
|
|
3986
|
+
stack_id = session_row["voice_stack_id"]
|
|
3987
|
+
if not character_id:
|
|
3988
|
+
return await _resolve_robot_voice_config(session_row["robot_id"])
|
|
3989
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
3990
|
+
db.row_factory = aiosqlite.Row
|
|
3991
|
+
async with db.execute(
|
|
3992
|
+
"SELECT name, system_prompt, voice_stack_id FROM character_presets WHERE id = ?",
|
|
3993
|
+
(character_id,),
|
|
3994
|
+
) as cursor:
|
|
3995
|
+
character = await cursor.fetchone()
|
|
3996
|
+
stack_id = stack_id or (character["voice_stack_id"] if character else None)
|
|
3997
|
+
stack = None
|
|
3998
|
+
if stack_id:
|
|
3999
|
+
async with db.execute("SELECT * FROM voice_stacks WHERE id = ?", (stack_id,)) as cursor:
|
|
4000
|
+
stack_row = await cursor.fetchone()
|
|
4001
|
+
if stack_row:
|
|
4002
|
+
stack_data = dict(stack_row)
|
|
4003
|
+
stack_data["tts_voice"] = await _auto_elevenlabs_voice(
|
|
4004
|
+
character["name"] if character else character_id,
|
|
4005
|
+
stack_data.get("tts_voice"),
|
|
4006
|
+
)
|
|
4007
|
+
stack = VoiceStack(**stack_data)
|
|
4008
|
+
return RobotVoiceConfig(
|
|
4009
|
+
character_id=character_id,
|
|
4010
|
+
character_name=character["name"] if character else character_id,
|
|
4011
|
+
system_prompt=character["system_prompt"] if character else None,
|
|
4012
|
+
voice_stack_id=stack.id if stack else stack_id,
|
|
4013
|
+
voice_stack=stack,
|
|
4014
|
+
greeting_phrases=[],
|
|
4015
|
+
)
|
|
3703
4016
|
|
|
3704
4017
|
|
|
3705
4018
|
@app.get("/api/voice-stacks", response_model=List[VoiceStack])
|
|
@@ -4186,22 +4499,24 @@ async def update_robot_voice_config(robot_id: str, payload: RobotVoiceConfigUpda
|
|
|
4186
4499
|
|
|
4187
4500
|
|
|
4188
4501
|
@app.get("/api/devices/by-room/{room_name}/voice-config", response_model=RobotVoiceConfig)
|
|
4189
|
-
async def get_device_voice_config_by_room(room_name: str):
|
|
4502
|
+
async def get_device_voice_config_by_room(room_name: str):
|
|
4190
4503
|
"""ROBOVOICE agent calls this when it joins a room to fetch per-robot config."""
|
|
4191
4504
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
4192
4505
|
db.row_factory = aiosqlite.Row
|
|
4193
|
-
async with db.execute(
|
|
4194
|
-
"SELECT robot_id FROM sessions WHERE room_name = ? ORDER BY started_at DESC LIMIT 1",
|
|
4195
|
-
(room_name,),
|
|
4196
|
-
) as c:
|
|
4197
|
-
session = await c.fetchone()
|
|
4506
|
+
async with db.execute(
|
|
4507
|
+
"SELECT robot_id, character_id, voice_stack_id FROM sessions WHERE room_name = ? ORDER BY started_at DESC LIMIT 1",
|
|
4508
|
+
(room_name,),
|
|
4509
|
+
) as c:
|
|
4510
|
+
session = await c.fetchone()
|
|
4198
4511
|
if not session:
|
|
4199
4512
|
if room_name.startswith("robopark-"):
|
|
4200
4513
|
device_id = room_name[len("robopark-"):]
|
|
4201
4514
|
if device_id:
|
|
4202
4515
|
return await _resolve_robot_voice_config(device_id)
|
|
4203
|
-
raise HTTPException(404, "No session bound to this room")
|
|
4204
|
-
|
|
4516
|
+
raise HTTPException(404, "No session bound to this room")
|
|
4517
|
+
if session["character_id"]:
|
|
4518
|
+
return await _resolve_session_voice_config(session)
|
|
4519
|
+
return await _resolve_robot_voice_config(session["robot_id"])
|
|
4205
4520
|
|
|
4206
4521
|
|
|
4207
4522
|
# =============================================================================
|
|
@@ -4213,18 +4528,45 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
|
|
|
4213
4528
|
|
|
4214
4529
|
Returns trigger_count, sessions_total, avg_latency_ms, drop_rate, drops and
|
|
4215
4530
|
an end_reason breakdown; None if the robot does not exist."""
|
|
4216
|
-
async with db.execute(
|
|
4217
|
-
"SELECT trigger_count, name, status, current_session_id, connected_server_id FROM robots WHERE id = ?", (robot_id,)
|
|
4218
|
-
) as c:
|
|
4531
|
+
async with db.execute(
|
|
4532
|
+
"SELECT trigger_count, name, status, current_session_id, connected_server_id, character_id FROM robots WHERE id = ?", (robot_id,)
|
|
4533
|
+
) as c:
|
|
4219
4534
|
robot = await c.fetchone()
|
|
4220
4535
|
if not robot:
|
|
4221
4536
|
return None
|
|
4222
4537
|
trigger_count = robot["trigger_count"] or 0
|
|
4223
4538
|
|
|
4224
|
-
async with db.execute(
|
|
4225
|
-
"SELECT status, production_mode, last_heartbeat
|
|
4226
|
-
|
|
4227
|
-
|
|
4539
|
+
async with db.execute(
|
|
4540
|
+
"""SELECT status, production_mode, last_heartbeat, character_id,
|
|
4541
|
+
video_device, audio_device, audio_output_device,
|
|
4542
|
+
device_inventory, supervisor_status, supervisor_status_at
|
|
4543
|
+
FROM devices WHERE id = ?""",
|
|
4544
|
+
(robot_id,),
|
|
4545
|
+
) as c:
|
|
4546
|
+
device = await c.fetchone()
|
|
4547
|
+
|
|
4548
|
+
character_id = (device["character_id"] if device else None) or robot["character_id"]
|
|
4549
|
+
character_name = None
|
|
4550
|
+
character_img = None
|
|
4551
|
+
if character_id:
|
|
4552
|
+
async with db.execute(
|
|
4553
|
+
"SELECT name, img FROM character_presets WHERE id = ?", (character_id,)
|
|
4554
|
+
) as c:
|
|
4555
|
+
character = await c.fetchone()
|
|
4556
|
+
if character:
|
|
4557
|
+
character_name = character["name"]
|
|
4558
|
+
character_img = character["img"]
|
|
4559
|
+
|
|
4560
|
+
def parse_json_field(value, fallback):
|
|
4561
|
+
if not value:
|
|
4562
|
+
return fallback
|
|
4563
|
+
try:
|
|
4564
|
+
return json.loads(value)
|
|
4565
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
4566
|
+
return fallback
|
|
4567
|
+
|
|
4568
|
+
device_inventory = parse_json_field(device["device_inventory"], {}) if device else {}
|
|
4569
|
+
supervisor_status = parse_json_field(device["supervisor_status"], []) if device else []
|
|
4228
4570
|
session = None
|
|
4229
4571
|
if robot["current_session_id"]:
|
|
4230
4572
|
async with db.execute(
|
|
@@ -4319,7 +4661,10 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
|
|
|
4319
4661
|
|
|
4320
4662
|
return {
|
|
4321
4663
|
"robot_id": robot_id,
|
|
4322
|
-
"name": robot["name"],
|
|
4664
|
+
"name": robot["name"],
|
|
4665
|
+
"character_id": character_id,
|
|
4666
|
+
"character_name": character_name,
|
|
4667
|
+
"character_img": character_img,
|
|
4323
4668
|
"lan_ip": lan_ip,
|
|
4324
4669
|
"vision_port": DEFAULT_VISION_PORT,
|
|
4325
4670
|
"trigger_count": trigger_count,
|
|
@@ -4335,7 +4680,13 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
|
|
|
4335
4680
|
"session_age_seconds": session_age,
|
|
4336
4681
|
"session_activity_age_seconds": activity_age,
|
|
4337
4682
|
"heartbeat_age_seconds": heartbeat_age,
|
|
4338
|
-
"device_status": device["status"] if device else None,
|
|
4683
|
+
"device_status": device["status"] if device else None,
|
|
4684
|
+
"video_device": device["video_device"] if device else None,
|
|
4685
|
+
"audio_device": device["audio_device"] if device else None,
|
|
4686
|
+
"audio_output_device": device["audio_output_device"] if device else None,
|
|
4687
|
+
"device_inventory": device_inventory,
|
|
4688
|
+
"supervisor_status": supervisor_status,
|
|
4689
|
+
"supervisor_status_at": device["supervisor_status_at"] if device else None,
|
|
4339
4690
|
"production_mode": bool(device["production_mode"]) if device else False,
|
|
4340
4691
|
"readiness_code": readiness_code,
|
|
4341
4692
|
"readiness_message": readiness_message,
|