"""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)