Skip to content

Commit 2a9a72f

Browse files
author
John Major
committed
feat: Enhance GUI for Rank 4/5 testing support
- Fix session_id bug (was hardcoded to 'ui-session') - Add speaker status panel with recognition indicators - Add tool execution status display - Add memory visualization panel with type-colored items - Add /api/memories endpoint for retrieving memories - Add /api/speakers endpoint for listing speakers - Add /api/speakers/{id} endpoint for speaker profiles - Add /api/context endpoint for conversation context - Update broker to return speaker_info and tool_info - Add CSS styling for new UI components GUI Enhancements: - Real-time speaker identification display (new vs recognized) - Tool execution feedback with iteration tracking - Memory panel with filtering by type (FACT, PREFERENCE, etc.) - Speaker badge showing recognition status Also added docs/GUI_ENHANCEMENT_PLAN.md with roadmap for future improvements
1 parent 093ff5c commit 2a9a72f

File tree

4 files changed

+489
-2
lines changed

4 files changed

+489
-2
lines changed

client/gui.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,6 +1070,203 @@ def _effective_region() -> str:
10701070
)
10711071

10721072

1073+
# ─────────────────────────────────────────────────────────────────────────────
1074+
# Memory and Speaker API Endpoints (for Rank 4/5 features)
1075+
# ─────────────────────────────────────────────────────────────────────────────
1076+
1077+
@app.get("/api/memories")
1078+
async def get_memories(
1079+
limit: int = 50,
1080+
speaker_id: Optional[str] = None,
1081+
kind: Optional[str] = None,
1082+
):
1083+
"""Retrieve memories from the agent state table."""
1084+
table = _agent_state_table()
1085+
if not table:
1086+
return {"memories": [], "error": "No table configured"}
1087+
1088+
agent_id = STATE.selected_agent_id or "agent1"
1089+
1090+
try:
1091+
# Query main agent partition
1092+
pk = f"AGENT#{agent_id}"
1093+
resp = table.query(
1094+
KeyConditionExpression=Key("pk").eq(pk),
1095+
ScanIndexForward=False,
1096+
Limit=limit * 2, # Fetch extra for filtering
1097+
)
1098+
items = resp.get("Items", [])
1099+
1100+
# Also query speaker-specific partition if speaker_id provided
1101+
if speaker_id:
1102+
speaker_pk = f"AGENT#{agent_id}#SPEAKER#{speaker_id}"
1103+
speaker_resp = table.query(
1104+
KeyConditionExpression=Key("pk").eq(speaker_pk),
1105+
ScanIndexForward=False,
1106+
Limit=limit,
1107+
)
1108+
items.extend(speaker_resp.get("Items", []))
1109+
1110+
# Filter to memories only
1111+
memories = [i for i in items if "#MEMORY#" in i.get("sk", "") or i.get("item_type") == "MEMORY"]
1112+
1113+
# Filter by kind if specified
1114+
if kind and kind.upper() != "ALL":
1115+
memories = [m for m in memories if m.get("kind") == kind.upper()]
1116+
1117+
# Filter by speaker_id if specified (in meta)
1118+
if speaker_id:
1119+
memories = [
1120+
m for m in memories
1121+
if m.get("speaker_id") == speaker_id or m.get("meta", {}).get("speaker_id") == speaker_id
1122+
]
1123+
1124+
# Sort by timestamp and limit
1125+
memories.sort(key=lambda x: x.get("ts", ""), reverse=True)
1126+
memories = memories[:limit]
1127+
1128+
return {
1129+
"memories": memories,
1130+
"total": len(memories),
1131+
"agent_id": agent_id,
1132+
}
1133+
1134+
except ClientError as e:
1135+
logging.error("get_memories failed: %s", e)
1136+
return {"memories": [], "error": str(e)}
1137+
1138+
1139+
@app.get("/api/speakers")
1140+
async def list_speakers():
1141+
"""List all known speakers for the current agent."""
1142+
table = _agent_state_table()
1143+
if not table:
1144+
return {"speakers": [], "error": "No table configured"}
1145+
1146+
agent_id = STATE.selected_agent_id or "agent1"
1147+
1148+
try:
1149+
# Query VOICE partition for speakers
1150+
pk = f"AGENT#{agent_id}#VOICE"
1151+
resp = table.query(
1152+
KeyConditionExpression=Key("pk").eq(pk),
1153+
Limit=100,
1154+
)
1155+
1156+
speakers = []
1157+
for item in resp.get("Items", []):
1158+
speakers.append({
1159+
"speaker_id": item.get("sk") or item.get("speaker_id"),
1160+
"speaker_name": item.get("speaker_name"),
1161+
"first_seen": item.get("first_seen_ts"),
1162+
"last_seen": item.get("last_seen_ts"),
1163+
"interaction_count": item.get("interaction_count", 0),
1164+
"enrollment_status": item.get("enrollment_status", "unknown"),
1165+
})
1166+
1167+
return {
1168+
"speakers": speakers,
1169+
"total": len(speakers),
1170+
"agent_id": agent_id,
1171+
}
1172+
1173+
except ClientError as e:
1174+
logging.error("list_speakers failed: %s", e)
1175+
return {"speakers": [], "error": str(e)}
1176+
1177+
1178+
@app.get("/api/speakers/{speaker_id}")
1179+
async def get_speaker_profile(speaker_id: str):
1180+
"""Get detailed profile for a specific speaker."""
1181+
table = _agent_state_table()
1182+
if not table:
1183+
return {"error": "No table configured"}
1184+
1185+
agent_id = STATE.selected_agent_id or "agent1"
1186+
1187+
try:
1188+
pk = f"AGENT#{agent_id}#VOICE"
1189+
resp = table.get_item(Key={"pk": pk, "sk": speaker_id})
1190+
item = resp.get("Item")
1191+
1192+
if not item:
1193+
return {"error": "Speaker not found", "speaker_id": speaker_id}
1194+
1195+
# Also fetch speaker-specific memories
1196+
memories_pk = f"AGENT#{agent_id}#SPEAKER#{speaker_id}"
1197+
mem_resp = table.query(
1198+
KeyConditionExpression=Key("pk").eq(memories_pk),
1199+
ScanIndexForward=False,
1200+
Limit=20,
1201+
)
1202+
1203+
return {
1204+
"profile": {
1205+
"speaker_id": item.get("sk") or item.get("speaker_id"),
1206+
"speaker_name": item.get("speaker_name"),
1207+
"first_seen": item.get("first_seen_ts"),
1208+
"last_seen": item.get("last_seen_ts"),
1209+
"interaction_count": item.get("interaction_count", 0),
1210+
"enrollment_status": item.get("enrollment_status", "unknown"),
1211+
"notes": item.get("notes"),
1212+
"preferences": item.get("preferences", {}),
1213+
"voice_samples": item.get("voice_samples", []),
1214+
},
1215+
"memories": mem_resp.get("Items", []),
1216+
"agent_id": agent_id,
1217+
}
1218+
1219+
except ClientError as e:
1220+
logging.error("get_speaker_profile failed: %s", e)
1221+
return {"error": str(e)}
1222+
1223+
1224+
@app.get("/api/context")
1225+
async def get_conversation_context(session_id: Optional[str] = None):
1226+
"""Get full conversation context for a session."""
1227+
table = _agent_state_table()
1228+
if not table:
1229+
return {"context": [], "error": "No table configured"}
1230+
1231+
agent_id = STATE.selected_agent_id or "agent1"
1232+
session_id = session_id or STATE.selected_session
1233+
1234+
try:
1235+
pk = f"AGENT#{agent_id}"
1236+
resp = table.query(
1237+
KeyConditionExpression=Key("pk").eq(pk),
1238+
ScanIndexForward=False,
1239+
Limit=100,
1240+
)
1241+
1242+
items = resp.get("Items", [])
1243+
1244+
# Filter by session if specified
1245+
if session_id:
1246+
items = [
1247+
i for i in items
1248+
if i.get("session_id") == session_id or not i.get("session_id")
1249+
]
1250+
1251+
# Separate events and memories
1252+
events = [i for i in items if "#EVENT#" in i.get("sk", "") or i.get("item_type") == "EVENT"]
1253+
memories = [i for i in items if "#MEMORY#" in i.get("sk", "") or i.get("item_type") == "MEMORY"]
1254+
1255+
return {
1256+
"events": events[:50],
1257+
"memories": memories[:50],
1258+
"agent_id": agent_id,
1259+
"session_id": session_id,
1260+
}
1261+
1262+
except ClientError as e:
1263+
logging.error("get_conversation_context failed: %s", e)
1264+
return {"context": [], "error": str(e)}
1265+
1266+
1267+
# ─────────────────────────────────────────────────────────────────────────────
1268+
1269+
10731270
async def _call_agent_broker(
10741271
*,
10751272
transcript: str,

client/templates/chat.html

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616
button:hover { border-color: #888; }
1717
.row { display:flex; gap: 10px; flex-wrap: wrap; align-items: center; }
1818
select { padding: 6px; border-radius: 8px; border: 1px solid #bbb; max-width: 360px; }
19-
.pill { border: 1px solid #ddd; border-radius: 999px; padding: 4px 9px; }
19+
.pill { border: 1px solid #ddd; border-radius: 999px; padding: 4px 9px; font-size: 0.85em; }
2020
.card { border: 1px solid #ddd; border-radius: 10px; padding: 12px; margin-top: 14px; }
21+
#statusPanel { background: #f8f9fa; }
22+
#statusPanel strong { color: #495057; }
23+
#memoryPanel { background: #fefefe; max-height: 250px; overflow-y: auto; }
24+
#toolDetails { background: #f1f3f4; padding: 6px 10px; border-radius: 6px; font-family: monospace; font-size: 0.85em; }
2125
.debug-log { max-height: 180px; overflow-y: auto; background: #f7f7f7; border: 1px solid #eee; padding: 8px; border-radius: 8px; }
2226
.debug-tools { display: grid; gap: 10px; margin-top: 10px; }
2327
.debug-tool { border: 1px dashed #ccc; padding: 8px; border-radius: 8px; }
@@ -96,6 +100,28 @@ <h1>Chat</h1>
96100
<input type="text" id="speaker_name_input" placeholder="e.g. Major" style="min-width: 260px;" />
97101
</div>
98102

103+
<!-- Speaker & Tool Status Panel -->
104+
<div class="card" id="statusPanel" style="display: none;">
105+
<div class="row" style="gap: 20px;">
106+
<div id="speakerStatus">
107+
<strong>Speaker:</strong> <span id="currentSpeaker">Unknown</span>
108+
<span id="speakerBadge" class="pill" style="display: none;"></span>
109+
</div>
110+
<div id="toolStatus" style="display: none;">
111+
<strong>Tools:</strong> <span id="toolIterations">-</span>
112+
<span id="toolProgress" class="pill"></span>
113+
</div>
114+
</div>
115+
<div id="toolDetails" class="meta" style="margin-top: 8px; display: none;"></div>
116+
</div>
117+
118+
<!-- Memory Context Panel -->
119+
<div class="card" id="memoryPanel" style="display: none;">
120+
<h3 style="margin-top: 0;">Recent Context</h3>
121+
<div id="memoryList" style="max-height: 150px; overflow-y: auto;"></div>
122+
<button onclick="loadMemories()" class="btn" style="margin-top: 8px;">Refresh Memories</button>
123+
</div>
124+
99125
<div class="card" id="debugCard">
100126
<h2 style="margin-top:0">Debug</h2>
101127
<div class="meta">Verbose logging is {{ 'enabled' if state.verbose else 'disabled' }} (toggle in <a href="/settings">Settings</a>).</div>
@@ -158,6 +184,114 @@ <h2 style="margin-top:0">Debug</h2>
158184
debugOutput.textContent = text || '';
159185
}
160186

187+
// Speaker and tool status management
188+
const statusPanel = document.getElementById('statusPanel');
189+
const currentSpeakerEl = document.getElementById('currentSpeaker');
190+
const speakerBadgeEl = document.getElementById('speakerBadge');
191+
const toolStatusEl = document.getElementById('toolStatus');
192+
const toolIterationsEl = document.getElementById('toolIterations');
193+
const toolProgressEl = document.getElementById('toolProgress');
194+
const toolDetailsEl = document.getElementById('toolDetails');
195+
const memoryPanel = document.getElementById('memoryPanel');
196+
const memoryListEl = document.getElementById('memoryList');
197+
198+
function updateSpeakerStatus(speakerInfo) {
199+
if (!speakerInfo) return;
200+
statusPanel.style.display = 'block';
201+
202+
const name = speakerInfo.speaker_name || speakerInfo.current_speaker_name || 'Unknown';
203+
const voiceId = speakerInfo.voice_id || speakerInfo.current_speaker_voice_id || '';
204+
const isNew = speakerInfo.is_new_speaker || speakerInfo.is_new_voice || false;
205+
206+
currentSpeakerEl.textContent = name;
207+
208+
if (isNew) {
209+
speakerBadgeEl.textContent = 'New Speaker';
210+
speakerBadgeEl.style.background = '#ffe066';
211+
speakerBadgeEl.style.display = 'inline-block';
212+
} else if (voiceId) {
213+
speakerBadgeEl.textContent = 'Recognized';
214+
speakerBadgeEl.style.background = '#90EE90';
215+
speakerBadgeEl.style.display = 'inline-block';
216+
} else {
217+
speakerBadgeEl.style.display = 'none';
218+
}
219+
}
220+
221+
function updateToolStatus(toolInfo) {
222+
if (!toolInfo || !toolInfo.tool_calls || toolInfo.tool_calls.length === 0) {
223+
toolStatusEl.style.display = 'none';
224+
toolDetailsEl.style.display = 'none';
225+
return;
226+
}
227+
228+
statusPanel.style.display = 'block';
229+
toolStatusEl.style.display = 'block';
230+
231+
const iterations = toolInfo.iterations || 1;
232+
const calls = toolInfo.tool_calls || [];
233+
234+
toolIterationsEl.textContent = `${iterations} iteration(s)`;
235+
toolProgressEl.textContent = `${calls.length} tool(s) executed`;
236+
237+
// Show tool details
238+
if (calls.length > 0) {
239+
toolDetailsEl.style.display = 'block';
240+
toolDetailsEl.innerHTML = calls.map(c =>
241+
`<div>• ${c.name}: ${JSON.stringify(c.input).substring(0, 50)}...</div>`
242+
).join('');
243+
}
244+
}
245+
246+
function displayMemoryItem(memory) {
247+
const div = document.createElement('div');
248+
div.className = 'msg';
249+
div.style.fontSize = '0.9em';
250+
div.style.padding = '4px 8px';
251+
252+
const kind = memory.kind || 'UNKNOWN';
253+
const text = memory.text || '';
254+
const speaker = memory.speaker_id || memory.meta?.speaker_id || '';
255+
256+
// Color by kind
257+
const kindColors = {
258+
'FACT': '#e3f2fd',
259+
'PREFERENCE': '#fff3e0',
260+
'AI_INSIGHT': '#f3e5f5',
261+
'RELATIONSHIP': '#e8f5e9',
262+
'ACTION': '#fce4ec',
263+
'META': '#f5f5f5'
264+
};
265+
div.style.background = kindColors[kind] || '#fff';
266+
267+
let html = `<strong>[${kind}]</strong> ${text}`;
268+
if (speaker) html += ` <em>(${speaker})</em>`;
269+
div.innerHTML = html;
270+
271+
return div;
272+
}
273+
274+
async function loadMemories() {
275+
try {
276+
const resp = await fetch('/api/memories?limit=20');
277+
if (!resp.ok) throw new Error('Failed to load memories');
278+
const data = await resp.json();
279+
280+
memoryPanel.style.display = 'block';
281+
memoryListEl.innerHTML = '';
282+
283+
(data.memories || []).forEach(m => {
284+
memoryListEl.appendChild(displayMemoryItem(m));
285+
});
286+
287+
if (!data.memories || data.memories.length === 0) {
288+
memoryListEl.innerHTML = '<div class="meta">No memories found</div>';
289+
}
290+
} catch (e) {
291+
appendMeta('Failed to load memories: ' + e);
292+
}
293+
}
294+
161295
function setAsrStatus(t) {
162296
asrStatus.textContent = 'ASR: ' + t;
163297
}
@@ -470,6 +604,21 @@ <h2 style="margin-top:0">Debug</h2>
470604
if (data.reply_text) appendMsg('agent', data.reply_text);
471605
if (data.actions && data.actions.length) appendMeta('actions: ' + JSON.stringify(data.actions));
472606
if (data.audio) playAgentAudio(data.audio);
607+
// Update speaker status from response
608+
if (data.speaker_info || data.speaker_name || data.voice_id) {
609+
updateSpeakerStatus({
610+
speaker_name: data.speaker_name || data.speaker_info?.speaker_name,
611+
voice_id: data.voice_id || data.speaker_info?.voice_id,
612+
is_new_speaker: data.is_new_voice || data.speaker_info?.is_new_speaker
613+
});
614+
}
615+
// Update tool status from response
616+
if (data.tool_info || data.tool_calls) {
617+
updateToolStatus({
618+
iterations: data.tool_info?.iterations || 1,
619+
tool_calls: data.tool_calls || data.tool_info?.tool_calls || []
620+
});
621+
}
473622
} else if (msg.type === 'error') {
474623
appendMeta('ASR error: ' + (msg.error || 'unknown'));
475624
}
@@ -496,11 +645,13 @@ <h2 style="margin-top:0">Debug</h2>
496645
const persona = (document.getElementById('persona_input').value || '').trim();
497646
const speakerName = (document.getElementById('speaker_name_input').value || '').trim();
498647

648+
// Use actual session from state, not hardcoded value
649+
const sessionId = '{{ state.selected_session or "" }}' || 'session-' + Date.now();
499650
ws.send(JSON.stringify({
500651
type: 'start',
501652
language_code: (languageSel.value || 'en-US'),
502653
sample_rate_hz: 16000,
503-
session_id: 'ui-session',
654+
session_id: sessionId,
504655
voice_id: selectedMicDeviceId || null,
505656
speaker_name: speakerName || null,
506657
personality_prompt: persona || null,

0 commit comments

Comments
 (0)