Frame Engine (v3-framed): - Tick-based deterministic pipeline: frames advance on completion, not timers - FrameRecord/FrameTrace dataclasses for structured per-message tracing - /api/frames endpoint: queryable frame trace history (last 20 messages) - frame_trace HUD event with full pipeline visibility - Reflex=2F, Director=4F, Director+Interpreter=5F deterministic frame counts Expert Architecture (v4-eras): - PA node (pa_v1): routes to domain experts, holds user context - ExpertNode base: stateless executor with plan+execute two-LLM-call pattern - ErasExpertNode: eras2_production DB specialist with DESCRIBE-first discipline - Schema caching: DESCRIBE results reused across queries within session - Progress streaming: PA streams thinking message, expert streams per-tool progress - PARouting type for structured routing decisions UI Controls Split: - Separate thinker_controls from machine controls (current_controls is now a property) - Machine buttons persist across Thinker responses - Machine state parser handles both dict and list formats from Director - Normalized button format with go/payload field mapping WebSocket Architecture: - /ws/test: dedicated debug socket for test runner progress - /ws/trace: dedicated debug socket for HUD/frame trace events - /ws (chat): cleaned up, only deltas/controls/done/cleared - WS survives graph switch (re-attaches to new runtime) - Pipeline result reset on clear Test Infrastructure: - Live test streaming: on_result callback fires per check during execution - Frontend polling fallback (500ms) for proxy-buffered WS - frame_trace-first trace assertion (fixes stale perceived event bug) - action_match supports "or" patterns and multi-pattern matching - Trace window increased to 40 events - Graph-agnostic assertions (has X or Y) Test Suites: - smoketest.md: 12 steps covering all categories (~2min) - fast.md: 10 quick checks (~1min) - fast_v4.md: 10 v4-eras specific checks - expert_eras.md: eras domain tests (routing, DB, schema, errors) - expert_progress.md: progress streaming tests Other: - Shared db.py extracted from thinker_v2 (reused by experts) - InputNode prompt: few-shot examples, history as context summary - Director prompt: full tool signatures for add_state/reset_machine/destroy_machine - nginx no-cache headers for static files during development - Cache-busted static file references Scores: v3 smoketest 39/40, v4-eras fast 28/28, expert_eras 23/23 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
154 lines
6.2 KiB
Python
154 lines
6.2 KiB
Python
"""Personal Assistant Node: routes to domain experts, holds user context."""
|
|
|
|
import json
|
|
import logging
|
|
|
|
from .base import Node
|
|
from ..llm import llm_call
|
|
from ..types import Command, PARouting
|
|
|
|
log = logging.getLogger("runtime")
|
|
|
|
|
|
class PANode(Node):
|
|
name = "pa_v1"
|
|
model = "anthropic/claude-haiku-4.5"
|
|
max_context_tokens = 4000
|
|
|
|
SYSTEM = """You are the Personal Assistant (PA) — the user's companion in this cognitive runtime.
|
|
You manage the conversation and route domain-specific work to the right expert.
|
|
|
|
Listener: {identity} on {channel}
|
|
|
|
Available experts:
|
|
{experts}
|
|
|
|
YOUR JOB:
|
|
1. Understand what the user wants
|
|
2. If it's a domain task: route to the right expert with a clear, self-contained job description
|
|
3. If it's social/general: respond directly (no expert needed)
|
|
|
|
Output ONLY valid JSON:
|
|
{{
|
|
"expert": "eras | plankiste | none",
|
|
"job": "Self-contained task description for the expert. Include all context the expert needs — it has NO conversation history.",
|
|
"thinking_message": "Short message shown to user while expert works (in user's language). e.g. 'Moment, ich schaue in der Datenbank nach...'",
|
|
"response_hint": "If expert=none, your direct response to the user.",
|
|
"language": "de | en | mixed"
|
|
}}
|
|
|
|
Rules:
|
|
- The expert has NO history. The job must be fully self-contained.
|
|
- Include relevant facts from memory in the job (e.g. "customer Kathrin Jager, ID 2").
|
|
- thinking_message should be natural and in the user's language.
|
|
- For greetings, thanks, general chat: expert=none, write response_hint directly.
|
|
- For DB queries, reports, data analysis: route to the domain expert.
|
|
- When unsure which expert: expert=none, ask the user to clarify.
|
|
|
|
{memory_context}"""
|
|
|
|
EXPERT_DESCRIPTIONS = {
|
|
"eras": "eras — heating/energy customer database (eras2_production). Customers, devices, billing, consumption data.",
|
|
"plankiste": "plankiste — Kita planning database (plankiste_test). Children, care schedules, offers, pricing.",
|
|
}
|
|
|
|
def __init__(self, send_hud):
|
|
super().__init__(send_hud)
|
|
self.directive: dict = {"mode": "assistant", "style": "helpful and concise"}
|
|
self._available_experts: list[str] = []
|
|
|
|
def set_available_experts(self, experts: list[str]):
|
|
"""Called by frame engine to tell PA which experts are in this graph."""
|
|
self._available_experts = experts
|
|
|
|
def get_context_line(self) -> str:
|
|
d = self.directive
|
|
return f"PA: {d['mode']} mode. {d['style']}."
|
|
|
|
async def route(self, command: Command, history: list[dict],
|
|
memory_context: str = "", identity: str = "unknown",
|
|
channel: str = "unknown") -> PARouting:
|
|
"""Decide which expert handles this request."""
|
|
await self.hud("thinking", detail="routing request")
|
|
|
|
# Build expert list for prompt
|
|
expert_lines = []
|
|
for name in self._available_experts:
|
|
desc = self.EXPERT_DESCRIPTIONS.get(name, f"{name} — domain expert")
|
|
expert_lines.append(f"- {desc}")
|
|
if not expert_lines:
|
|
expert_lines.append("- (no experts available — handle everything directly)")
|
|
|
|
messages = [
|
|
{"role": "system", "content": self.SYSTEM.format(
|
|
memory_context=memory_context, identity=identity, channel=channel,
|
|
experts="\n".join(expert_lines))},
|
|
]
|
|
|
|
# Summarize recent history (PA sees full context)
|
|
recent = history[-12:]
|
|
if recent:
|
|
lines = []
|
|
for msg in recent:
|
|
role = msg.get("role", "?")
|
|
content = msg.get("content", "")[:100]
|
|
lines.append(f" {role}: {content}")
|
|
messages.append({"role": "user", "content": "Recent conversation:\n" + "\n".join(lines)})
|
|
messages.append({"role": "assistant", "content": "OK, I have the context."})
|
|
|
|
a = command.analysis
|
|
messages.append({"role": "user",
|
|
"content": f"Route this message (intent={a.intent}, lang={a.language}, tone={a.tone}):\n{command.source_text}"})
|
|
messages = self.trim_context(messages)
|
|
|
|
await self.hud("context", messages=messages, tokens=self.last_context_tokens,
|
|
max_tokens=self.max_context_tokens, fill_pct=self.context_fill_pct)
|
|
|
|
raw = await llm_call(self.model, messages)
|
|
log.info(f"[pa] raw: {raw[:300]}")
|
|
|
|
routing = self._parse_routing(raw, command)
|
|
await self.hud("routed", expert=routing.expert, job=routing.job[:100],
|
|
direct=routing.expert == "none")
|
|
|
|
# Update directive style based on tone
|
|
if command.analysis.tone == "frustrated":
|
|
self.directive["style"] = "patient and empathetic"
|
|
elif command.analysis.tone == "playful":
|
|
self.directive["style"] = "light and fun"
|
|
else:
|
|
self.directive["style"] = "helpful and concise"
|
|
|
|
return routing
|
|
|
|
def _parse_routing(self, raw: str, command: Command) -> PARouting:
|
|
"""Parse LLM JSON into PARouting with fallback."""
|
|
text = raw.strip()
|
|
if text.startswith("```"):
|
|
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
|
|
if text.endswith("```"):
|
|
text = text[:-3]
|
|
text = text.strip()
|
|
|
|
try:
|
|
data = json.loads(text)
|
|
expert = data.get("expert", "none")
|
|
# Validate expert is available
|
|
if expert != "none" and expert not in self._available_experts:
|
|
log.warning(f"[pa] expert '{expert}' not available, falling back to none")
|
|
expert = "none"
|
|
return PARouting(
|
|
expert=expert,
|
|
job=data.get("job", ""),
|
|
thinking_message=data.get("thinking_message", ""),
|
|
response_hint=data.get("response_hint", ""),
|
|
language=data.get("language", command.analysis.language),
|
|
)
|
|
except (json.JSONDecodeError, Exception) as e:
|
|
log.error(f"[pa] parse failed: {e}, raw: {text[:200]}")
|
|
return PARouting(
|
|
expert="none",
|
|
response_hint=command.source_text,
|
|
language=command.analysis.language,
|
|
)
|