v0.15.3: Domain context, iterative plan-execute, FK mappings, ES6 node inspector

Eras Expert domain context:
- Full Heizkostenabrechnung business model (Kunde>Objekte>Nutzeinheiten>Geraete)
- Known PK/FK mappings: kunden.Kundennummer, objekte.KundenID, etc.
- Correct JOIN example in SCHEMA prompt
- PA knows domain hierarchy for better job formulation

Iterative plan-execute in ExpertNode:
- DESCRIBE queries execute first, results injected into re-plan
- Re-plan uses actual column names from DESCRIBE
- Eliminates "Unknown column" errors on first query

Frontend:
- Node inspector: per-node cards with model, tokens, progress, last event
- Graph switcher buttons in top bar
- Clear button in top bar
- Nodes panel 300px wide
- WS reconnect on 1006 (deploy) without showing login
- Model info emitted on context HUD events

Domain context test: 21/21 (hierarchy, JOINs, FK, PA job quality)
Default graph: v4-eras

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nico 2026-03-29 18:34:42 +02:00
parent 3a9c2795cf
commit 2d649fa448
12 changed files with 403 additions and 68 deletions

View File

@ -18,6 +18,9 @@ class Node:
self.context_fill_pct = 0
async def hud(self, event: str, **data):
# Always include model on context events so frontend knows what model each node uses
if event == "context" and self.model:
data["model"] = self.model
await self.send_hud({"node": self.name, "event": event, **data})
def trim_context(self, messages: list[dict]) -> list[dict]:

View File

@ -1,4 +1,8 @@
"""Eras Expert: heating/energy customer database specialist."""
"""Eras Expert: heating cost billing domain specialist.
Eras is a German software company for Heizkostenabrechnung (heating cost billing).
Users are Hausverwaltungen and Messdienste who manage properties, meters, and billings.
"""
import asyncio
import logging
@ -13,52 +17,97 @@ class ErasExpertNode(ExpertNode):
name = "eras_expert"
default_database = "eras2_production"
DOMAIN_SYSTEM = """You are the Eras expert — specialist for heating and energy customer data.
You work with the eras2_production database containing customer, device, and billing data.
All table and column names are German (lowercase). Common queries involve customer lookups,
device counts, consumption analysis, and billing reports."""
DOMAIN_SYSTEM = """You are the Eras domain expert — specialist for heating cost billing (Heizkostenabrechnung).
BUSINESS CONTEXT:
Eras is a German software company. The software manages Heizkostenabrechnung according to German law (HeizKV).
The USER of this software is a Hausverwaltung (property management) or Messdienst (metering service).
They use Eras to manage their customers' properties, meters, consumption readings, and billings.
DOMAIN MODEL (how the data relates):
- Kunden (customers) = the Hausverwaltungen or property managers that the Eras user serves
Each Kunde has a Kundennummer and contact data (Name, Adresse, etc.)
- Objekte (properties/buildings/Liegenschaften) = physical buildings managed by a Kunde
A Kunde can have many Objekte. Each Objekt has an address and is linked to a Kunde.
- Nutzeinheiten (usage units/apartments) = individual units within an Objekt
An Objekt contains multiple Nutzeinheiten (e.g., Wohnung 1, Wohnung 2).
Each Nutzeinheit has Nutzer (tenants/occupants).
- Geraete (devices/meters) = measurement devices installed in Nutzeinheiten
Heizkostenverteiler, Waermezaehler, Wasserzaehler, etc.
Each Geraet is linked to a Nutzeinheit and has a Geraetetyp.
- Geraeteverbraeuche (consumption readings) = measured values from Geraete
Ablesewerte collected by Monteure or remote reading systems.
- Abrechnungen (billings) = Heizkostenabrechnungen generated per Objekt/period
The core output: distributes heating costs to Nutzeinheiten based on consumption.
- Auftraege (work orders) = tasks for Monteure (technicians)
Device installation, reading collection, maintenance.
HIERARCHY: Kunde Objekte Nutzeinheiten Geraete Verbraeuche
Nutzer
Kunde Abrechnungen
Kunde Auftraege
IMPORTANT NOTES:
- All table/column names are German, lowercase
- Foreign keys often use patterns like KundenID, ObjektID, NutzeinheitID
- The database is eras2_production
- Always DESCRIBE tables before writing JOINs to verify actual column names
- Common user questions: customer overview, device counts, billing status, Objekt details"""
SCHEMA = """Known tables (eras2_production):
- kunden customers
- objekte properties/objects linked to customers
- nutzeinheit usage units within objects
- geraete devices/meters
- geraeteverbraeuche device consumption readings
- abrechnungen billing records
- kunden customers (Hausverwaltungen)
- objekte properties/buildings (Liegenschaften)
- nutzeinheit apartments/units within Objekte
- nutzer tenants/occupants of Nutzeinheiten
- geraete measurement devices (Heizkostenverteiler, etc.)
- geraeteverbraeuche consumption readings
- abrechnungen heating cost billings
- auftraege work orders for Monteure
- auftragspositionen line items within Auftraege
- geraetetypen device type catalog
- geraetekatalog device model catalog
- heizbetriebskosten heating operation costs
- nebenkosten additional costs (Nebenkosten)
CRITICAL: You do NOT know the exact column names. They are German and unpredictable.
Your FIRST tool_sequence step for ANY SELECT query MUST be DESCRIBE on the target table.
Then use the actual column names from the DESCRIBE result in your SELECT.
KNOWN PRIMARY KEYS AND FOREIGN KEYS:
- kunden: PK = Kundennummer (int), name columns: Name1, Name2, Name3
- objekte: PK = ObjektID, FK = KundenID kunden.Kundennummer
- nutzeinheit: FK = ObjektID objekte.ObjektID
- geraete: FK = NutzeinheitID nutzeinheit.NutzeinheitID (verify with DESCRIBE)
Example tool_sequence for "show me 5 customers":
IMPORTANT: Always DESCRIBE tables you haven't seen before to verify column names.
Use the FK mappings above for JOINs. Do NOT guess use exact column names.
Example for "how many Objekte per Kunde":
[
{{"tool": "query_db", "args": {{"query": "DESCRIBE kunden", "database": "eras2_production"}}}},
{{"tool": "query_db", "args": {{"query": "SELECT * FROM kunden LIMIT 5", "database": "eras2_production"}}}}
{{"tool": "query_db", "args": {{"query": "SELECT k.Kundennummer, k.Name1, COUNT(o.ObjektID) as AnzahlObjekte FROM kunden k LEFT JOIN objekte o ON o.KundenID = k.Kundennummer GROUP BY k.Kundennummer, k.Name1 ORDER BY AnzahlObjekte DESC LIMIT 20", "database": "eras2_production"}}}}
]"""
def __init__(self, send_hud, process_manager=None):
super().__init__(send_hud, process_manager)
self._schema_cache: dict[str, str] = {} # table_name -> DESCRIBE result
self._schema_cache: dict[str, str] = {}
async def execute(self, job: str, language: str = "de"):
"""Execute with schema auto-discovery. Caches DESCRIBE results."""
# Inject cached schema into the job context
if self._schema_cache:
schema_ctx = "Known column names from previous DESCRIBE:\n"
for table, desc in self._schema_cache.items():
# Just first 5 lines to keep it compact
lines = desc.strip().split("\n")[:6]
lines = desc.strip().split("\n")[:8]
schema_ctx += f"\n{table}:\n" + "\n".join(lines) + "\n"
job = job + "\n\n" + schema_ctx
result = await super().execute(job, language)
# Cache any DESCRIBE results from this execution
# Parse from tool_output if it looks like a DESCRIBE result
# Cache DESCRIBE results
if result.tool_output and "Field\t" in result.tool_output:
# Try to identify which table was described
for table in ["kunden", "objekte", "nutzeinheit", "geraete",
"geraeteverbraeuche", "abrechnungen"]:
for table in ["kunden", "objekte", "nutzeinheit", "nutzer", "geraete",
"geraeteverbraeuche", "abrechnungen", "auftraege"]:
if table in job.lower() or table in result.tool_output.lower():
self._schema_cache[table] = result.tool_output
log.info(f"[eras] cached schema for {table}")

View File

@ -77,22 +77,67 @@ Write a concise, natural response. 1-3 sentences.
super().__init__(send_hud)
async def execute(self, job: str, language: str = "de") -> ThoughtResult:
"""Execute a self-contained job. Returns ThoughtResult."""
"""Execute a self-contained job. Returns ThoughtResult.
Uses iterative plan-execute: if DESCRIBE queries are in the plan,
execute them first, inject results into a re-plan, then execute the rest."""
await self.hud("thinking", detail=f"planning: {job[:80]}")
# Step 1: Plan tool sequence
schema_context = self.SCHEMA
plan_messages = [
{"role": "system", "content": self.PLAN_SYSTEM.format(
domain=self.DOMAIN_SYSTEM, schema=self.SCHEMA,
domain=self.DOMAIN_SYSTEM, schema=schema_context,
database=self.default_database)},
{"role": "user", "content": f"Job: {job}"},
]
plan_raw = await llm_call(self.model, plan_messages)
tool_sequence, response_hint = self._parse_plan(plan_raw)
# Step 1b: Execute DESCRIBE queries first, then re-plan with actual schema
describe_results = {}
remaining_tools = []
for step in tool_sequence:
if step.get("tool") == "query_db":
query = step.get("args", {}).get("query", "").strip().upper()
if query.startswith("DESCRIBE") or query.startswith("SHOW"):
await self.hud("tool_call", tool="query_db", args=step.get("args", {}))
try:
result = await asyncio.to_thread(
run_db_query, step["args"]["query"],
step["args"].get("database", self.default_database))
describe_results[step["args"]["query"]] = result
await self.hud("tool_result", tool="query_db", output=result[:200])
except Exception as e:
await self.hud("tool_result", tool="query_db", output=str(e)[:200])
else:
remaining_tools.append(step)
else:
remaining_tools.append(step)
# Re-plan if we got DESCRIBE results (now we know actual column names)
if describe_results:
schema_update = "Actual column names from DESCRIBE:\n"
for q, result in describe_results.items():
schema_update += f"\n{q}:\n{result[:500]}\n"
replan_messages = [
{"role": "system", "content": self.PLAN_SYSTEM.format(
domain=self.DOMAIN_SYSTEM,
schema=schema_context + "\n\n" + schema_update,
database=self.default_database)},
{"role": "user", "content": f"Job: {job}\n\nUse ONLY the actual column names from DESCRIBE above. Do NOT include DESCRIBE steps — they are already done."},
]
replan_raw = await llm_call(self.model, replan_messages)
new_tools, new_hint = self._parse_plan(replan_raw)
if new_tools:
remaining_tools = new_tools
if new_hint:
response_hint = new_hint
tool_sequence = remaining_tools
await self.hud("planned", tools=len(tool_sequence), hint=response_hint[:80])
# Step 2: Execute tools
# Step 2: Execute remaining tools
actions = []
state_updates = {}
display_items = []

View File

@ -57,7 +57,7 @@ Rules:
{memory_context}"""
EXPERT_DESCRIPTIONS = {
"eras": "eras — heating/energy domain. Database: eras2_production (customers, devices, billing, consumption). Can also build dashboard UI (buttons, machines, counters, tables) for energy data workflows.",
"eras": "eras — Heizkostenabrechnung (German heating cost billing). Users are Hausverwaltungen managing Kunden, Objekte (buildings), Nutzeinheiten (apartments), Geraete (meters), Verbraeuche (readings), Abrechnungen (billings), Auftraege (work orders). Hierarchy: Kunde > Objekte > Nutzeinheiten > Geraete > Verbraeuche. Database: eras2_production. Can also build dashboard UI.",
"plankiste": "plankiste — Kita planning domain. Database: plankiste_test (children, care schedules, offers, pricing). Can build dashboard UI for education workflows and generate Angebote.",
}

View File

@ -17,7 +17,7 @@ log = logging.getLogger("runtime")
TRACE_FILE = Path(__file__).parent.parent / "trace.jsonl"
# Default graph — can be switched at runtime
_active_graph_name = "v1-current"
_active_graph_name = "v4-eras"
class OutputSink:

View File

@ -953,6 +953,24 @@ function send() {
inputEl.value = '';
}
async function clearSession() {
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['Authorization'] = 'Bearer ' + authToken;
await fetch('/api/clear', { method: 'POST', headers });
// Clear UI
msgs.innerHTML = '';
traceEl.innerHTML = '';
_currentDashboard = [];
currentEl = null;
const dock = document.getElementById('dock');
if (dock) dock.innerHTML = '';
addTrace('runtime', 'cleared', 'session reset');
} catch (e) {
addTrace('runtime', 'error', 'clear failed: ' + e);
}
}
// --- Awareness panel updates ---
let _sensorReadings = {};

View File

@ -16,6 +16,8 @@
<h1>cog</h1>
<div id="test-status"></div>
<div style="flex:1"></div>
<div id="graph-switcher"></div>
<button onclick="clearSession()" class="btn-top" title="Clear session">Clear</button>
<div id="status">disconnected</div>
</div>
@ -27,17 +29,7 @@
</div>
<div class="panel detail-panel">
<div class="panel-header detail-h">Nodes</div>
<div id="node-metrics">
<div class="node-meter" id="meter-input"><span class="nm-label">input</span><div class="nm-bar"><div class="nm-fill"></div></div><span class="nm-text"></span></div>
<div class="node-meter" id="meter-director_v2"><span class="nm-label">director</span><div class="nm-bar"><div class="nm-fill"></div></div><span class="nm-text"></span></div>
<div class="node-meter" id="meter-pa_v1"><span class="nm-label">PA</span><div class="nm-bar"><div class="nm-fill"></div></div><span class="nm-text"></span></div>
<div class="node-meter" id="meter-thinker"><span class="nm-label">thinker</span><div class="nm-bar"><div class="nm-fill"></div></div><span class="nm-text"></span></div>
<div class="node-meter" id="meter-eras_expert"><span class="nm-label">eras</span><div class="nm-bar"><div class="nm-fill"></div></div><span class="nm-text"></span></div>
<div class="node-meter" id="meter-output"><span class="nm-label">output</span><div class="nm-bar"><div class="nm-fill"></div></div><span class="nm-text"></span></div>
<div class="node-meter" id="meter-memorizer"><span class="nm-label">memo</span><div class="nm-bar"><div class="nm-fill"></div></div><span class="nm-text"></span></div>
<div class="node-meter" id="meter-interpreter"><span class="nm-label">interp</span><div class="nm-bar"><div class="nm-fill"></div></div><span class="nm-text"></span></div>
<div class="node-meter" id="meter-sensor"><span class="nm-label">sensor</span><span class="nm-text" style="flex:1"></span></div>
</div>
<div id="node-metrics"></div>
</div>
<div class="panel graph-panel">
<div class="panel-header graph-h">Graph
@ -58,7 +50,6 @@
<div id="input-bar">
<input id="input" placeholder="Type a message..." autocomplete="off">
<button onclick="send()">Send</button>
<button onclick="clearSession()" class="btn-clear" title="Clear session">&#x2715;</button>
</div>
</div>
<div class="panel awareness-panel">

View File

@ -1,9 +1,133 @@
/** Awareness panel: memorizer state, sensor readings, node meters. */
/** Awareness panel: memorizer state, sensor readings.
* Node detail panel: per-node model, tokens, progress, last event.
*/
import { esc, truncate } from './util.js';
let _sensorReadings = {};
// --- Node state tracker ---
const _nodeState = {}; // { nodeName: { model, tokens, maxTokens, fillPct, lastEvent, lastDetail, status, toolCalls, startedAt } }
function _getNode(name) {
if (!_nodeState[name]) {
_nodeState[name] = {
model: '', tokens: 0, maxTokens: 0, fillPct: 0,
lastEvent: '', lastDetail: '', status: 'idle',
toolCalls: 0, lastTool: '',
};
}
return _nodeState[name];
}
export function updateNodeFromHud(node, event, data) {
const n = _getNode(node);
if (event === 'context') {
if (data.model) n.model = data.model.replace('google/', '').replace('anthropic/', '');
if (data.tokens !== undefined) n.tokens = data.tokens;
if (data.max_tokens !== undefined) n.maxTokens = data.max_tokens;
if (data.fill_pct !== undefined) n.fillPct = data.fill_pct;
}
if (event === 'thinking') {
n.status = 'thinking';
n.lastEvent = 'thinking';
n.lastDetail = data.detail || '';
} else if (event === 'perceived') {
n.status = 'done';
n.lastEvent = 'perceived';
const a = data.analysis || {};
n.lastDetail = `${a.intent || '?'}/${a.language || '?'}/${a.tone || '?'}`;
} else if (event === 'decided' || event === 'routed') {
n.status = 'done';
n.lastEvent = event;
n.lastDetail = data.goal || data.instruction || data.job || '';
} else if (event === 'tool_call') {
n.status = 'tool';
n.lastEvent = 'tool_call';
n.lastTool = data.tool || '';
n.lastDetail = data.tool || '';
n.toolCalls++;
} else if (event === 'tool_result') {
n.lastEvent = 'tool_result';
n.lastDetail = truncate(data.output || '', 50);
} else if (event === 'streaming') {
n.status = 'streaming';
n.lastEvent = 'streaming';
} else if (event === 'done') {
n.status = 'done';
n.lastEvent = 'done';
} else if (event === 'updated') {
n.status = 'done';
n.lastEvent = 'updated';
} else if (event === 'planned') {
n.status = 'planned';
n.lastEvent = 'planned';
n.lastDetail = `${data.tools || 0} tools`;
} else if (event === 'interpreted') {
n.status = 'done';
n.lastEvent = 'interpreted';
n.lastDetail = truncate(data.summary || '', 50);
}
renderNodes();
}
function renderNodes() {
const el = document.getElementById('node-metrics');
if (!el) { console.warn('[nodes] #node-metrics not found'); return; }
// Sort: active nodes first, then by name
const statusOrder = { thinking: 0, tool: 0, streaming: 0, planned: 1, done: 2, idle: 3 };
const sorted = Object.entries(_nodeState)
.filter(([name]) => name !== 'runtime' && name !== 'frame_engine')
.sort((a, b) => (statusOrder[a[1].status] || 3) - (statusOrder[b[1].status] || 3));
let html = '';
for (const [name, n] of sorted) {
const statusClass = n.status === 'thinking' || n.status === 'tool' ? 'nm-active'
: n.status === 'streaming' ? 'nm-streaming' : '';
const shortName = name.replace('_v1', '').replace('_v2', '').replace('expert_', '');
const modelShort = n.model ? n.model.split('/').pop().replace('-001', '').replace('-4.5', '4.5') : '';
const tokenStr = n.maxTokens ? `${n.tokens}/${n.maxTokens}t` : '';
const fillW = n.fillPct || 0;
const detail = n.lastDetail ? truncate(n.lastDetail, 45) : '';
const toolStr = n.toolCalls > 0 ? ` [${n.toolCalls} calls]` : '';
html += `<div class="node-card ${statusClass}">
<div class="nc-header">
<span class="nc-name">${esc(shortName)}</span>
<span class="nc-model">${esc(modelShort)}</span>
<span class="nc-tokens">${esc(tokenStr)}</span>
</div>
<div class="nc-bar"><div class="nc-fill" style="width:${fillW}%"></div></div>
<div class="nc-status">
<span class="nc-event">${esc(n.lastEvent)}</span>
<span class="nc-detail">${esc(detail)}${esc(toolStr)}</span>
</div>
</div>`;
}
el.innerHTML = html;
}
export function clearNodes() {
for (const key of Object.keys(_nodeState)) delete _nodeState[key];
const el = document.getElementById('node-metrics');
if (el) el.innerHTML = '';
}
// Keep old meter function for backward compat (called from ws.js)
export function updateMeter(node, tokens, maxTokens, fillPct) {
const n = _getNode(node);
n.tokens = tokens;
n.maxTokens = maxTokens;
n.fillPct = fillPct;
renderNodes();
}
// --- Awareness: memorizer state ---
export function updateAwarenessState(state) {
const body = document.getElementById('aw-state-body');
if (!body) return;
@ -33,6 +157,8 @@ export function updateAwarenessState(state) {
body.innerHTML = html;
}
// --- Awareness: sensor readings ---
export function updateAwarenessSensors(tick, deltas) {
const body = document.getElementById('aw-sensor-body');
if (!body) return;
@ -46,12 +172,3 @@ export function updateAwarenessSensors(tick, deltas) {
}
body.innerHTML = html;
}
export function updateMeter(node, tokens, maxTokens, fillPct) {
const meter = document.getElementById('meter-' + node);
if (!meter) return;
const bar = meter.querySelector('.nm-bar');
const text = meter.querySelector('.nm-text');
if (bar) bar.style.width = fillPct + '%';
if (text) text.textContent = `${tokens}/${maxTokens}t`;
}

View File

@ -4,6 +4,7 @@ import { initAuth, authToken, startLogin } from './auth.js';
import { initTrace, addTrace, clearTrace } from './trace.js';
import { initChat, clearChat } from './chat.js';
import { clearDashboard } from './dashboard.js';
import { clearNodes } from './awareness.js';
import { initGraph } from './graph.js';
import { connect } from './ws.js';
@ -12,10 +13,13 @@ window.addEventListener('load', async () => {
initTrace();
initChat();
await initGraph();
await initAuth(() => connect());
await initAuth(() => {
connect();
loadGraphSwitcher();
});
});
// Clear session button
// Clear session
window.clearSession = async () => {
try {
const headers = { 'Content-Type': 'application/json' };
@ -24,11 +28,63 @@ window.clearSession = async () => {
clearChat();
clearTrace();
clearDashboard();
clearNodes();
addTrace('runtime', 'cleared', 'session reset');
} catch (e) {
addTrace('runtime', 'error', 'clear failed: ' + e);
}
};
// Login button
// Graph switcher — loads available graphs and shows buttons in top bar
async function loadGraphSwitcher() {
const container = document.getElementById('graph-switcher');
if (!container) { console.error('[main] no #graph-switcher'); return; }
try {
const headers = {};
if (authToken) headers['Authorization'] = 'Bearer ' + authToken;
const r = await fetch('/api/graph/list', { headers });
if (!r.ok) { console.error('[main] graph/list failed:', r.status); return; }
const data = await r.json();
const graphs = data.graphs || data || [];
console.log('[main] graphs:', graphs.length);
// Get current active graph
let activeGraph = '';
try {
const ar = await fetch('/api/graph/active', { headers });
if (ar.ok) {
const ag = await ar.json();
activeGraph = ag.name || '';
}
} catch (e) {}
container.innerHTML = graphs.map(g => {
const active = g.name === activeGraph;
return `<button class="btn-graph${active ? ' active' : ''}" onclick="switchGraph('${g.name}')" title="${g.description}">${g.name}</button>`;
}).join('');
} catch (e) {}
}
window.switchGraph = async (name) => {
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['Authorization'] = 'Bearer ' + authToken;
await fetch('/api/graph/switch', {
method: 'POST', headers,
body: JSON.stringify({ name }),
});
addTrace('runtime', 'graph_switch', name);
clearChat();
clearTrace();
clearDashboard();
clearNodes();
addTrace('runtime', 'switched', `graph: ${name}`);
await initGraph();
loadGraphSwitcher();
} catch (e) {
addTrace('runtime', 'error', 'switch failed: ' + e);
}
};
// Login
window.startLogin = startLogin;

View File

@ -5,7 +5,7 @@ import { addTrace } from './trace.js';
import { handleDelta, handleDone, setWs as setChatWs } from './chat.js';
import { dockControls, setWs as setDashWs } from './dashboard.js';
import { graphAnimate } from './graph.js';
import { updateMeter, updateAwarenessState, updateAwarenessSensors } from './awareness.js';
import { updateMeter, updateNodeFromHud, updateAwarenessState, updateAwarenessSensors } from './awareness.js';
import { updateTestStatus } from './tests.js';
import { truncate, esc } from './util.js';
@ -35,7 +35,8 @@ export function connect() {
ws.onerror = () => {};
ws.onclose = (e) => {
if (e.code === 4001 || e.code === 1006) {
// 4001 = explicit auth rejection from server
if (e.code === 4001) {
setAuthFailed(true);
localStorage.removeItem('cog_token');
localStorage.removeItem('cog_access_token');
@ -44,9 +45,10 @@ export function connect() {
showLogin();
return;
}
document.getElementById('status').textContent = 'disconnected';
document.getElementById('status').style.color = '#666';
addTrace('runtime', 'disconnected', 'ws closed');
// 1006 = abnormal close (deploy, network), just reconnect
document.getElementById('status').textContent = 'reconnecting...';
document.getElementById('status').style.color = '#f59e0b';
addTrace('runtime', 'disconnected', `code ${e.code}, reconnecting...`);
setTimeout(connect, 2000);
};
@ -123,6 +125,7 @@ function handleHud(data) {
const event = data.event || '';
graphAnimate(event, node);
updateNodeFromHud(node, event, data);
if (event === 'context') {
const count = (data.messages || []).length;

View File

@ -10,10 +10,16 @@ body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0;
#test-status .ts-pass { color: #22c55e; }
#test-status .ts-fail { color: #ef4444; }
@keyframes pulse-text { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
.btn-top { padding: 0.2rem 0.6rem; font-size: 0.7rem; background: #333; }
.btn-top:hover { background: #ef4444; }
#graph-switcher { display: flex; gap: 3px; }
.btn-graph { padding: 0.2rem 0.5rem; font-size: 0.65rem; font-family: monospace; background: #1a1a1a; color: #888; border: 1px solid #333; border-radius: 3px; cursor: pointer; }
.btn-graph:hover { color: #fff; border-color: #2563eb; }
.btn-graph.active { color: #22c55e; border-color: #22c55e; background: #0a1e14; }
/* === Two-row layout === */
/* Middle row: workspace | node detail | graph */
#middle-row { display: grid; grid-template-columns: 1fr 200px 2fr; gap: 1px; background: #222; flex: 1; min-height: 0; }
#middle-row { display: grid; grid-template-columns: 1fr 300px 2fr; gap: 1px; background: #222; flex: 1; min-height: 0; }
/* Bottom row: chat | awareness | trace */
#bottom-row { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 1px; background: #222; flex: 1; min-height: 0; }
@ -36,12 +42,19 @@ body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0;
/* Node detail / metrics */
.detail-panel { display: flex; flex-direction: column; }
#node-metrics { flex: 1; overflow-y: auto; padding: 0.3rem; display: flex; flex-direction: column; gap: 1px; }
.node-meter { display: flex; align-items: center; gap: 0.3rem; padding: 0.2rem 0.4rem; background: #111; border-radius: 2px; }
.nm-label { font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; min-width: 3.5rem; color: #888; }
.nm-bar { flex: 1; height: 5px; background: #1a1a1a; border-radius: 3px; overflow: hidden; }
.nm-fill { height: 100%; width: 0%; border-radius: 3px; transition: width 0.3s; background: #333; }
.nm-text { font-size: 0.55rem; color: #555; min-width: 3rem; text-align: right; font-family: monospace; }
#node-metrics { flex: 1; overflow-y: auto; padding: 0.3rem; display: flex; flex-direction: column; gap: 2px; }
.node-card { background: #111; border-radius: 3px; padding: 0.25rem 0.4rem; border-left: 2px solid #333; }
.node-card.nm-active { border-left-color: #f59e0b; background: #1a1408; }
.node-card.nm-streaming { border-left-color: #22c55e; background: #0a1e14; }
.nc-header { display: flex; align-items: center; gap: 0.3rem; }
.nc-name { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; color: #e0e0e0; min-width: 3rem; }
.nc-model { font-size: 0.55rem; color: #666; font-family: monospace; }
.nc-tokens { font-size: 0.55rem; color: #555; font-family: monospace; margin-left: auto; }
.nc-bar { height: 3px; background: #1a1a1a; border-radius: 2px; overflow: hidden; margin: 2px 0; }
.nc-fill { height: 100%; border-radius: 2px; background: #333; transition: width 0.3s; }
.nc-status { display: flex; gap: 0.3rem; align-items: baseline; }
.nc-event { font-size: 0.55rem; color: #888; font-family: monospace; }
.nc-detail { font-size: 0.55rem; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Graph panel */
.graph-panel { display: flex; flex-direction: column; }

View File

@ -0,0 +1,40 @@
# Domain Context
Tests that the expert understands the Eras business domain:
Heizkostenabrechnung, Kunde→Objekt→Nutzeinheit→Geraet hierarchy,
and can formulate correct JOINs without guessing column names.
## Setup
- clear history
## Steps
### 1. Expert knows the hierarchy
- send: wie viele Objekte haben Kunden im Durchschnitt?
- expect_trace: has tool_call
- expect_response: not contains "Error" or "error" or "Unknown column"
- expect_response: length > 20
### 2. Expert can JOIN kunden and objekte
- send: zeig mir die Top 5 Kunden mit den meisten Objekten
- expect_trace: has tool_call
- expect_response: not contains "Error" or "error" or "Unknown column"
- expect_response: length > 20
### 3. Expert understands Nutzeinheiten belong to Objekte
- send: how many Nutzeinheiten does the system have total?
- expect_trace: has tool_call
- expect_response: not contains "Error" or "error" or "Unknown column"
- expect_response: length > 10
### 4. Expert understands Geraete belong to Nutzeinheiten
- send: which Objekt has the most Geraete?
- expect_trace: has tool_call
- expect_response: not contains "Error" or "error" or "Unknown column"
- expect_response: length > 20
### 5. PA formulates good job descriptions
- send: gib mir eine Uebersicht ueber Kunde 2
- expect_trace: has routed
- expect_response: length > 20
- expect_response: not contains "clarify" or "specify" or "what kind"