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"