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 self.context_fill_pct = 0
async def hud(self, event: str, **data): 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}) await self.send_hud({"node": self.name, "event": event, **data})
def trim_context(self, messages: list[dict]) -> list[dict]: 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 asyncio
import logging import logging
@ -13,52 +17,97 @@ class ErasExpertNode(ExpertNode):
name = "eras_expert" name = "eras_expert"
default_database = "eras2_production" default_database = "eras2_production"
DOMAIN_SYSTEM = """You are the Eras expert — specialist for heating and energy customer data. DOMAIN_SYSTEM = """You are the Eras domain expert — specialist for heating cost billing (Heizkostenabrechnung).
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, BUSINESS CONTEXT:
device counts, consumption analysis, and billing reports.""" 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): SCHEMA = """Known tables (eras2_production):
- kunden — customers - kunden — customers (Hausverwaltungen)
- objekte — properties/objects linked to customers - objekte — properties/buildings (Liegenschaften)
- nutzeinheit — usage units within objects - nutzeinheit — apartments/units within Objekte
- geraete — devices/meters - nutzer — tenants/occupants of Nutzeinheiten
- geraeteverbraeuche — device consumption readings - geraete — measurement devices (Heizkostenverteiler, etc.)
- abrechnungen — billing records - 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. KNOWN PRIMARY KEYS AND FOREIGN KEYS:
Your FIRST tool_sequence step for ANY SELECT query MUST be DESCRIBE on the target table. - kunden: PK = Kundennummer (int), name columns: Name1, Name2, Name3
Then use the actual column names from the DESCRIBE result in your SELECT. - 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 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"}}}}
{{"tool": "query_db", "args": {{"query": "SELECT * FROM kunden LIMIT 5", "database": "eras2_production"}}}}
]""" ]"""
def __init__(self, send_hud, process_manager=None): def __init__(self, send_hud, process_manager=None):
super().__init__(send_hud, process_manager) 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"): async def execute(self, job: str, language: str = "de"):
"""Execute with schema auto-discovery. Caches DESCRIBE results.""" """Execute with schema auto-discovery. Caches DESCRIBE results."""
# Inject cached schema into the job context
if self._schema_cache: if self._schema_cache:
schema_ctx = "Known column names from previous DESCRIBE:\n" schema_ctx = "Known column names from previous DESCRIBE:\n"
for table, desc in self._schema_cache.items(): for table, desc in self._schema_cache.items():
# Just first 5 lines to keep it compact lines = desc.strip().split("\n")[:8]
lines = desc.strip().split("\n")[:6]
schema_ctx += f"\n{table}:\n" + "\n".join(lines) + "\n" schema_ctx += f"\n{table}:\n" + "\n".join(lines) + "\n"
job = job + "\n\n" + schema_ctx job = job + "\n\n" + schema_ctx
result = await super().execute(job, language) result = await super().execute(job, language)
# Cache any DESCRIBE results from this execution # Cache DESCRIBE results
# Parse from tool_output if it looks like a DESCRIBE result
if result.tool_output and "Field\t" in result.tool_output: if result.tool_output and "Field\t" in result.tool_output:
# Try to identify which table was described for table in ["kunden", "objekte", "nutzeinheit", "nutzer", "geraete",
for table in ["kunden", "objekte", "nutzeinheit", "geraete", "geraeteverbraeuche", "abrechnungen", "auftraege"]:
"geraeteverbraeuche", "abrechnungen"]:
if table in job.lower() or table in result.tool_output.lower(): if table in job.lower() or table in result.tool_output.lower():
self._schema_cache[table] = result.tool_output self._schema_cache[table] = result.tool_output
log.info(f"[eras] cached schema for {table}") 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) super().__init__(send_hud)
async def execute(self, job: str, language: str = "de") -> ThoughtResult: 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]}") await self.hud("thinking", detail=f"planning: {job[:80]}")
# Step 1: Plan tool sequence # Step 1: Plan tool sequence
schema_context = self.SCHEMA
plan_messages = [ plan_messages = [
{"role": "system", "content": self.PLAN_SYSTEM.format( {"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)}, database=self.default_database)},
{"role": "user", "content": f"Job: {job}"}, {"role": "user", "content": f"Job: {job}"},
] ]
plan_raw = await llm_call(self.model, plan_messages) plan_raw = await llm_call(self.model, plan_messages)
tool_sequence, response_hint = self._parse_plan(plan_raw) 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]) await self.hud("planned", tools=len(tool_sequence), hint=response_hint[:80])
# Step 2: Execute tools # Step 2: Execute remaining tools
actions = [] actions = []
state_updates = {} state_updates = {}
display_items = [] display_items = []

View File

@ -57,7 +57,7 @@ Rules:
{memory_context}""" {memory_context}"""
EXPERT_DESCRIPTIONS = { 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.", "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" TRACE_FILE = Path(__file__).parent.parent / "trace.jsonl"
# Default graph — can be switched at runtime # Default graph — can be switched at runtime
_active_graph_name = "v1-current" _active_graph_name = "v4-eras"
class OutputSink: class OutputSink:

View File

@ -953,6 +953,24 @@ function send() {
inputEl.value = ''; 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 --- // --- Awareness panel updates ---
let _sensorReadings = {}; let _sensorReadings = {};

View File

@ -16,6 +16,8 @@
<h1>cog</h1> <h1>cog</h1>
<div id="test-status"></div> <div id="test-status"></div>
<div style="flex:1"></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 id="status">disconnected</div>
</div> </div>
@ -27,17 +29,7 @@
</div> </div>
<div class="panel detail-panel"> <div class="panel detail-panel">
<div class="panel-header detail-h">Nodes</div> <div class="panel-header detail-h">Nodes</div>
<div id="node-metrics"> <div id="node-metrics"></div>
<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> </div>
<div class="panel graph-panel"> <div class="panel graph-panel">
<div class="panel-header graph-h">Graph <div class="panel-header graph-h">Graph
@ -58,7 +50,6 @@
<div id="input-bar"> <div id="input-bar">
<input id="input" placeholder="Type a message..." autocomplete="off"> <input id="input" placeholder="Type a message..." autocomplete="off">
<button onclick="send()">Send</button> <button onclick="send()">Send</button>
<button onclick="clearSession()" class="btn-clear" title="Clear session">&#x2715;</button>
</div> </div>
</div> </div>
<div class="panel awareness-panel"> <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'; import { esc, truncate } from './util.js';
let _sensorReadings = {}; 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) { export function updateAwarenessState(state) {
const body = document.getElementById('aw-state-body'); const body = document.getElementById('aw-state-body');
if (!body) return; if (!body) return;
@ -33,6 +157,8 @@ export function updateAwarenessState(state) {
body.innerHTML = html; body.innerHTML = html;
} }
// --- Awareness: sensor readings ---
export function updateAwarenessSensors(tick, deltas) { export function updateAwarenessSensors(tick, deltas) {
const body = document.getElementById('aw-sensor-body'); const body = document.getElementById('aw-sensor-body');
if (!body) return; if (!body) return;
@ -46,12 +172,3 @@ export function updateAwarenessSensors(tick, deltas) {
} }
body.innerHTML = html; 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 { initTrace, addTrace, clearTrace } from './trace.js';
import { initChat, clearChat } from './chat.js'; import { initChat, clearChat } from './chat.js';
import { clearDashboard } from './dashboard.js'; import { clearDashboard } from './dashboard.js';
import { clearNodes } from './awareness.js';
import { initGraph } from './graph.js'; import { initGraph } from './graph.js';
import { connect } from './ws.js'; import { connect } from './ws.js';
@ -12,10 +13,13 @@ window.addEventListener('load', async () => {
initTrace(); initTrace();
initChat(); initChat();
await initGraph(); await initGraph();
await initAuth(() => connect()); await initAuth(() => {
connect();
loadGraphSwitcher();
});
}); });
// Clear session button // Clear session
window.clearSession = async () => { window.clearSession = async () => {
try { try {
const headers = { 'Content-Type': 'application/json' }; const headers = { 'Content-Type': 'application/json' };
@ -24,11 +28,63 @@ window.clearSession = async () => {
clearChat(); clearChat();
clearTrace(); clearTrace();
clearDashboard(); clearDashboard();
clearNodes();
addTrace('runtime', 'cleared', 'session reset'); addTrace('runtime', 'cleared', 'session reset');
} catch (e) { } catch (e) {
addTrace('runtime', 'error', 'clear failed: ' + 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; window.startLogin = startLogin;

View File

@ -5,7 +5,7 @@ import { addTrace } from './trace.js';
import { handleDelta, handleDone, setWs as setChatWs } from './chat.js'; import { handleDelta, handleDone, setWs as setChatWs } from './chat.js';
import { dockControls, setWs as setDashWs } from './dashboard.js'; import { dockControls, setWs as setDashWs } from './dashboard.js';
import { graphAnimate } from './graph.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 { updateTestStatus } from './tests.js';
import { truncate, esc } from './util.js'; import { truncate, esc } from './util.js';
@ -35,7 +35,8 @@ export function connect() {
ws.onerror = () => {}; ws.onerror = () => {};
ws.onclose = (e) => { ws.onclose = (e) => {
if (e.code === 4001 || e.code === 1006) { // 4001 = explicit auth rejection from server
if (e.code === 4001) {
setAuthFailed(true); setAuthFailed(true);
localStorage.removeItem('cog_token'); localStorage.removeItem('cog_token');
localStorage.removeItem('cog_access_token'); localStorage.removeItem('cog_access_token');
@ -44,9 +45,10 @@ export function connect() {
showLogin(); showLogin();
return; return;
} }
document.getElementById('status').textContent = 'disconnected'; // 1006 = abnormal close (deploy, network), just reconnect
document.getElementById('status').style.color = '#666'; document.getElementById('status').textContent = 'reconnecting...';
addTrace('runtime', 'disconnected', 'ws closed'); document.getElementById('status').style.color = '#f59e0b';
addTrace('runtime', 'disconnected', `code ${e.code}, reconnecting...`);
setTimeout(connect, 2000); setTimeout(connect, 2000);
}; };
@ -123,6 +125,7 @@ function handleHud(data) {
const event = data.event || ''; const event = data.event || '';
graphAnimate(event, node); graphAnimate(event, node);
updateNodeFromHud(node, event, data);
if (event === 'context') { if (event === 'context') {
const count = (data.messages || []).length; 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-pass { color: #22c55e; }
#test-status .ts-fail { color: #ef4444; } #test-status .ts-fail { color: #ef4444; }
@keyframes pulse-text { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } } @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 === */ /* === Two-row layout === */
/* Middle row: workspace | node detail | graph */ /* 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: chat | awareness | trace */
#bottom-row { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 1px; background: #222; flex: 1; min-height: 0; } #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 */ /* Node detail / metrics */
.detail-panel { display: flex; flex-direction: column; } .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-metrics { flex: 1; overflow-y: auto; padding: 0.3rem; display: flex; flex-direction: column; gap: 2px; }
.node-meter { display: flex; align-items: center; gap: 0.3rem; padding: 0.2rem 0.4rem; background: #111; border-radius: 2px; } .node-card { background: #111; border-radius: 3px; padding: 0.25rem 0.4rem; border-left: 2px solid #333; }
.nm-label { font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; min-width: 3.5rem; color: #888; } .node-card.nm-active { border-left-color: #f59e0b; background: #1a1408; }
.nm-bar { flex: 1; height: 5px; background: #1a1a1a; border-radius: 3px; overflow: hidden; } .node-card.nm-streaming { border-left-color: #22c55e; background: #0a1e14; }
.nm-fill { height: 100%; width: 0%; border-radius: 3px; transition: width 0.3s; background: #333; } .nc-header { display: flex; align-items: center; gap: 0.3rem; }
.nm-text { font-size: 0.55rem; color: #555; min-width: 3rem; text-align: right; font-family: monospace; } .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 */
.graph-panel { display: flex; flex-direction: column; } .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"