Architecture: - Graph engine (engine.py) loads graph definitions, instantiates nodes - Versioned nodes: input_v1, thinker_v1, output_v1, memorizer_v1, director_v1 - NODE_REGISTRY for dynamic node lookup by name - Graph API: /api/graph/active, /api/graph/list, /api/graph/switch - Graph definition: graphs/v1_current.py (7 nodes, 13 edges, 3 edge types) S3* Audit system: - Workspace mismatch detection (server vs browser controls) - Code-without-tools retry (Thinker wrote code but no tool calls) - Intent-without-action retry (request intent but Thinker only produced text) - Dashboard feedback: browser sends workspace state on every message - Sensor continuous comparison on 5s tick State machines: - create_machine / add_state / reset_machine / destroy_machine via function calling - Local transitions (go:) resolve without LLM round-trip - Button persistence across turns Database tools: - query_db tool via pymysql to MariaDB K3s pod (eras2_production) - Table rendering in workspace (tab-separated parsing) - Director pre-planning with Opus for complex data requests - Error retry with corrected SQL Frontend: - Cytoscape.js pipeline graph with real-time node animations - Overlay scrollbars (CSS-only, no reflow) - Tool call/result trace events - S3* audit events in trace Testing: - 167 integration tests (11 test suites) - 22 node-level unit tests (test_nodes/) - Three test levels: node unit, graph integration, scenario Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
106 lines
4.2 KiB
Python
106 lines
4.2 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 — the analyst of this cognitive runtime.
|
|
|
|
Listener: {identity} on {channel}
|
|
|
|
YOUR ONLY JOB: Analyze the user's message and return a JSON classification.
|
|
Output ONLY valid JSON, nothing else. No markdown fences, 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 situational note or empty string"
|
|
}}
|
|
|
|
Classification guide:
|
|
- intent "social": greetings, thanks, goodbye, acknowledgments (hi, ok, thanks, bye, cool)
|
|
- intent "question": asking for information (what, how, when, why, who)
|
|
- intent "request": asking to do/create/build something
|
|
- intent "action": clicking a button or triggering a UI action
|
|
- intent "feedback": commenting on results, correcting, expressing satisfaction/dissatisfaction
|
|
- complexity "trivial": one-word or very short social messages that need no reasoning
|
|
- complexity "simple": clear single-step requests or questions
|
|
- complexity "complex": multi-step, ambiguous, or requires deep reasoning
|
|
- tone "frustrated": complaints, anger, exasperation
|
|
- tone "urgent": time pressure, critical issues
|
|
- tone "playful": jokes, teasing, lighthearted
|
|
- tone "casual": neutral everyday conversation
|
|
|
|
{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}")
|
|
|
|
messages = [
|
|
{"role": "system", "content": self.SYSTEM.format(
|
|
memory_context=memory_context, identity=identity, channel=channel)},
|
|
]
|
|
for msg in history[-8:]:
|
|
messages.append(msg)
|
|
messages.append({"role": "user", "content": f"Classify this message: {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)
|