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>
130 lines
5.3 KiB
Python
130 lines
5.3 KiB
Python
"""Input Node: structured analyst — classifies user input."""
|
|
|
|
import json
|
|
import logging
|
|
|
|
from .base import Node
|
|
from ..llm import llm_call
|
|
from ..types import Envelope, Command, InputAnalysis
|
|
|
|
log = logging.getLogger("runtime")
|
|
|
|
|
|
class InputNode(Node):
|
|
name = "input"
|
|
model = "google/gemini-2.0-flash-001"
|
|
max_context_tokens = 2000
|
|
|
|
SYSTEM = """You are the Input node — classify ONLY the current message.
|
|
|
|
Listener: {identity} on {channel}
|
|
|
|
Return ONLY valid JSON. No markdown, no explanation.
|
|
|
|
Schema:
|
|
{{
|
|
"who": "name or unknown",
|
|
"language": "en | de | mixed",
|
|
"intent": "question | request | social | action | feedback",
|
|
"topic": "short topic string",
|
|
"tone": "casual | frustrated | playful | urgent",
|
|
"complexity": "trivial | simple | complex",
|
|
"context": "brief note or empty"
|
|
}}
|
|
|
|
Rules:
|
|
- Classify the CURRENT message only. Previous messages are context, not the target.
|
|
- language: detect from the CURRENT message text, not the conversation language.
|
|
"Wie spaet ist es?" = de. "hello" = en. "Hallo, how are you" = mixed.
|
|
- intent: what does THIS message ask for?
|
|
social = greetings, thanks, goodbye, ok, bye, cool
|
|
question = asking for info (what, how, when, why, wieviel, was, wie)
|
|
request = asking to create/build/do something
|
|
action = clicking a button or UI trigger
|
|
feedback = commenting on results, correcting, satisfaction/dissatisfaction
|
|
- complexity: how much reasoning does THIS message need?
|
|
trivial = one-word social (hi, ok, thanks, bye)
|
|
simple = clear single-step
|
|
complex = multi-step, ambiguous, deep reasoning
|
|
- tone: emotional register of THIS message
|
|
frustrated = complaints, anger, "broken", "nothing works", "sick of"
|
|
urgent = time pressure, critical
|
|
playful = jokes, teasing
|
|
casual = neutral
|
|
|
|
Examples:
|
|
"hi there!" -> {{"language":"en","intent":"social","tone":"casual","complexity":"trivial"}}
|
|
"Wie spaet ist es?" -> {{"language":"de","intent":"question","tone":"casual","complexity":"simple"}}
|
|
"this is broken, nothing works" -> {{"language":"en","intent":"feedback","tone":"frustrated","complexity":"simple"}}
|
|
"create two buttons" -> {{"language":"en","intent":"request","tone":"casual","complexity":"simple"}}
|
|
"ok thanks bye" -> {{"language":"en","intent":"social","tone":"casual","complexity":"trivial"}}
|
|
|
|
{memory_context}"""
|
|
|
|
async def process(self, envelope: Envelope, history: list[dict], memory_context: str = "",
|
|
identity: str = "unknown", channel: str = "unknown") -> Command:
|
|
await self.hud("thinking", detail="analyzing input")
|
|
log.info(f"[input] user said: {envelope.text}")
|
|
|
|
# Build context summary from recent history (not raw chat messages)
|
|
history_summary = ""
|
|
recent = history[-8:]
|
|
if recent:
|
|
lines = []
|
|
for msg in recent:
|
|
role = msg.get("role", "?")
|
|
content = msg.get("content", "")[:80]
|
|
lines.append(f" {role}: {content}")
|
|
history_summary = "Recent conversation:\n" + "\n".join(lines)
|
|
|
|
messages = [
|
|
{"role": "system", "content": self.SYSTEM.format(
|
|
memory_context=memory_context, identity=identity, channel=channel)},
|
|
]
|
|
if history_summary:
|
|
messages.append({"role": "user", "content": history_summary})
|
|
messages.append({"role": "assistant", "content": "OK, I have the context. Send the message to classify."})
|
|
messages.append({"role": "user", "content": f"Classify: {envelope.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"[input] raw: {raw[:300]}")
|
|
|
|
analysis = self._parse_analysis(raw, identity)
|
|
log.info(f"[input] analysis: {analysis}")
|
|
await self.hud("perceived", analysis=self._to_dict(analysis))
|
|
return Command(analysis=analysis, source_text=envelope.text)
|
|
|
|
def _parse_analysis(self, raw: str, identity: str = "unknown") -> InputAnalysis:
|
|
"""Parse LLM JSON response into InputAnalysis, with fallback defaults."""
|
|
text = raw.strip()
|
|
# Strip markdown fences if present
|
|
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)
|
|
return InputAnalysis(
|
|
who=data.get("who", identity) or identity,
|
|
language=data.get("language", "en"),
|
|
intent=data.get("intent", "request"),
|
|
topic=data.get("topic", ""),
|
|
tone=data.get("tone", "casual"),
|
|
complexity=data.get("complexity", "simple"),
|
|
context=data.get("context", ""),
|
|
)
|
|
except (json.JSONDecodeError, Exception) as e:
|
|
log.error(f"[input] JSON parse failed: {e}, raw: {text[:200]}")
|
|
# Fallback: best-effort from raw text
|
|
return InputAnalysis(who=identity, topic=text[:50])
|
|
|
|
@staticmethod
|
|
def _to_dict(analysis: InputAnalysis) -> dict:
|
|
from dataclasses import asdict
|
|
return asdict(analysis)
|