1 line
21 KiB
JSON
1 line
21 KiB
JSON
{"repo": "33axx/fmcw-diagnostic-copilot", "files": {"agents/llm_reasoner.py": {"0da6d0a1838a6d865f5b53b1fd01f7b5efdebf9d": {"t": 1785674306.819304, "n": 20456, "h": "e2b9f545f741c29f", "c": "\"\"\"\nLLM-Powered Reasoning Agent\n============================\nExtends the rule-based EngineeringReasoner with real LLM API calls for\ndeep diagnostic reasoning on anomalous frames.\n\nSupports any OpenAI-compatible API (MiMo, DeepSeek, OpenAI, etc.).\n\nArchitecture\n------------\n Rule Engine (fast filter)\n │\n ├── frame is nominal → skip LLM, return rule result directly\n │\n └── frame has anomalies → serialise features + knowledge base\n │\n ▼\n LLM API call (4-step reasoning prompt)\n │\n ▼\n parse structured JSON response\n │\n ▼\n ReasoningResult with real token usage\n\nUsage::\n\n from agents.llm_reasoner import LLMReasoner\n\n reasoner = LLMReasoner(\n api_key=\"sk-...\",\n base_url=\"https://api.xiaomimimo.com/v1\",\n model=\"mimo-v2.5\",\n )\n result = reasoner.reason(feature_record)\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nimport re\nimport time\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional\n\nfrom .engineering_reasoner import (\n EngineeringReasoner,\n Hypothesis,\n KnowledgeBase,\n ReasoningResult,\n StepResult,\n)\n\nlogger = logging.getLogger(__name__)\n\n# Auto-load .env file if python-dotenv is available\ntry:\n from dotenv import load_dotenv\n\n _env_file = Path(__file__).resolve().parent.parent / \".env\"\n if _env_file.exists():\n load_dotenv(_env_file)\nexcept ImportError:\n pass\n\n# Try to import openai; gracefully degrade if not installed\ntry:\n from openai import OpenAI\n\n _HAS_OPENAI = True\nexcept ImportError:\n _HAS_OPENAI = False\n OpenAI = None # type: ignore\n\n\n# ---------------------------------------------------------------------------\n# Configuration\n# ---------------------------------------------------------------------------\n\n\n@dataclass\nclass LLMConfig:\n \"\"\"Configuration for the LLM reasoning backend.\n\n Attributes\n ----------\n api_key : str\n API key. If empty, reads from ``MIMO_API_KEY``,\n ``DEEPSEEK_API_KEY``, or ``OPENAI_API_KEY`` env vars.\n base_url : str\n OpenAI-compatible base URL.\n model : str\n Model name to use.\n temperature : float\n Sampling temperature (0 = deterministic).\n max_tokens : int\n Maximum output tokens.\n timeout_s : float\n HTTP request timeout in seconds.\n \"\"\"\n\n api_key: str = \"\"\n base_url: str = \"https://api.xiaomimimo.com/v1\"\n model: str = \"mimo-v2.5\"\n temperature: float = 0.0\n max_tokens: int = 4096\n timeout_s: float = 60.0\n\n @property\n def resolved_api_key(self) -> str:\n \"\"\"Resolve API key from config or environment variables.\"\"\"\n if self.api_key:\n return self.api_key\n for env_var in (\"MIMO_API_KEY\", \"DEEPSEEK_API_KEY\", \"OPENAI_API_KEY\"):\n val = os.environ.get(env_var, \"\")\n if val:\n return val\n return \"\"\n\n def auto_detect_provider(self) -> None:\n \"\"\"Auto-configure base_url and model from the resolved API key source.\"\"\"\n if os.environ.get(\"DEEPSEEK_API_KEY\") and self.base_url == \"https://api.xiaomimimo.com/v1\":\n self.base_url = os.environ.get(\"DEEPSEEK_BASE_URL\", \"https://api.deepseek.com/v1\")\n self.model = os.environ.get(\"DEEPSEEK_MODEL\", \"deepseek-chat\")\n elif os.environ.get(\"MIMO_API_KEY\"):\n self.base_url = os.environ.get(\"MIMO_BASE_URL\", self.base_url)\n self.model = os.environ.get(\"MIMO_MODEL\", self.model)\n elif os.environ.get(\"OPENAI_API_KEY\") and self.base_url == \"https://api.xiaomimimo.com/v1\":\n self.base_url = os.environ.get(\"OPENAI_BASE_URL\", \"https://api.openai.com/v1\")\n self.model = os.environ.get(\"OPENAI_MODEL\", \"gpt-4o\")\n\n\n# ---------------------------------------------------------------------------\n# Prompt builder\n# ---------------------------------------------------------------------------\n\n\n_REASONING_SYSTEM_PROMPT = \"\"\"You are an expert FMCW radar signal diagnostic engineer.\nYour task is to analyse structured signal feature data and perform multi-step\ndiagnostic reasoning to identify the root cause of anomalies.\n\nYou have access to a domain knowledge base covering:\n- FMCW radar physics (beat frequency, phase sensitivity, Doppler)\n- Multipath reflection discrimination\n- Mechanical vibration vs. biological micro-Doppler differentiation\n- Systematic diagnostic decision tree\n\nFollow this reasoning chain:\n\nSTEP 1 — Anomaly Classification:\nParse the signal features. Classify each anomaly by type:\n phase_jump, mechanical_vibration, periodic_displacement,\n micro_doppler, narrowband_interference, broadband_noise\n\nSTEP 2 — Physical Modelling:\nFor each anomaly, apply physical models:\n - Phase jump → displacement: Δx = Δφ · λ / (4π)\n - Doppler frequency → velocity: v = f_d · λ / 2\n - Micro-Doppler frequency band check (respiration 0.1–0.5 Hz,\n heartbeat 0.8–3 Hz)\n - Multipath: check phase correlation with direct target\n\nSTEP 3 — Interference Exclusion:\nRule out environmental confounders:\n - Temperature effects on noise floor\n - Nearby machinery (industrial/lab environment?)\n - Multi-target cross-interference\n - AC mains frequency interference (50/60 Hz)\n\nSTEP 4 — Root-Cause Synthesis:\nRank hypotheses by confidence. For each hypothesis provide:\n - Name (e.g., multipath_reflection, mechanical_vibration,\n biosignal_micro_doppler, external_interference,\n receiver_noise_excursion, nominal_operation)\n - Confidence (0–1)\n - Supporting evidence list\n - Recommended action\n\nReturn ONLY valid JSON in this exact format:\n{\n \"primary_diagnosis\": \"hypothesis_name\",\n \"requires_human_review\": true_or_false,\n \"steps\": [\n {\n \"step_name\": \"Anomaly Classification\",\n \"findings\": [\"finding 1\", \"finding 2\"],\n \"active_hypotheses\": [\"h1\", \"h2\"],\n \"eliminated_hypotheses\": []\n },\n {\n \"step_name\": \"Physical Modelling\",\n \"findings\": [\"...\"],\n \"active_hypotheses\": [\"...\"],\n \"eliminated_hypotheses\": [\"...\"]\n },\n {\n \"step_name\": \"Interference Exclusion\",\n \"findings\": [\"...\"],\n \"active_hypotheses\": [\"...\"],\n \"eliminated_hypotheses\": [\"...\"]\n },\n {\n \"step_name\": \"Root-Cause Synthesis\",\n \"findings\": [\"...\"],\n \"active_hypotheses\": [\"primary_hypothesis\"],\n \"eliminated_hypotheses\": [\"...\"]\n }\n ],\n \"hypotheses\": [\n {\n \"name\": \"hypothesis_name\",\n \"confidence\": 0.85,\n \"evidence\": [\"evidence 1\", \"evidence 2\"],\n \"recommendation\": \"actionable recommendation\"\n }\n ]\n}\n\nDo NOT include markdown fences, explanations, or any text outside the JSON.\"\"\"\n\n\ndef _build_user_prompt(\n feature_text: str, knowledge_context: str, metadata: Dict[str, Any]\n) -> str:\n \"\"\"Construct the user prompt combining features, knowledge, and metadata.\"\"\"\n meta_lines = \"\\n\".join(f\" {k}: {v}\" for k, v in metadata.items())\n return f\"\"\"## Domain Knowledge Base\n\n{knowledge_context}\n\n---\n\n## Signal Features (Current Frame)\n\n{feature_text}\n\n---\n\n## Environmental Context\n\n{meta_lines}\n\n---\n\nAnalyse the above signal data using the 4-step diagnostic reasoning chain.\nReturn ONLY the JSON result as specified.\"\"\"\n\n\n# ---------------------------------------------------------------------------\n# Response parser\n# ---------------------------------------------------------------------------\n\n\ndef _extract_json(text: str) -> Dict[str, Any]:\n \"\"\"Robust JSON extraction from LLM output (handles markdown fences).\"\"\"\n # Try direct parse first\n text = text.strip()\n try:\n return json.loads(text)\n except json.JSONDecodeError:\n pass\n\n # Try extracting from ```json ... ``` fences\n fence_match = re.search(r\"```(?:json)?\\s*([\\s\\S]*?)```\", text)\n if fence_match:\n try:\n return json.loads(fence_match.group(1).strip())\n except json.JSONDecodeError:\n pass\n\n # Try finding the first { ... } block\n brace_match = re.search(r\"\\{[\\s\\S]*\\}\", text)\n if brace_match:\n try:\n return json.loads(brace_match.group(0))\n except json.JSONDecodeError:\n pass\n\n raise ValueError(f\"Failed to extract valid JSON from LLM response: {text[:200]}...\")\n\n\ndef _parse_llm_response(raw_json: Dict[str, Any]) -> ReasoningResult:\n \"\"\"Convert the LLM's JSON response into a ReasoningResult.\"\"\"\n # Parse steps\n steps = []\n for s in raw_json.get(\"steps\", []):\n steps.append(\n StepResult(\n step_name=s.get(\"step_name\", \"Unknown\"),\n findings=s.get(\"findings\", []),\n eliminated_hypotheses=s.get(\"eliminated_hypotheses\", []),\n active_hypotheses=s.get(\"active_hypotheses\", []),\n )\n )\n\n # Parse hypotheses\n hypotheses = []\n for h in raw_json.get(\"hypotheses\", []):\n hypotheses.append(\n Hypothesis(\n name=h.get(\"name\", \"unknown\"),\n confidence=float(h.get(\"confidence\", 0.5)),\n evidence=h.get(\"evidence\", []),\n recommendation=h.get(\"recommendation\", \"\"),\n )\n )\n\n # Sort by confidence\n hypotheses.sort(key=lambda h: h.confidence, reverse=True)\n\n primary_name = raw_json.get(\"primary_diagnosis\", \"\")\n primary = None\n if primary_name and hypotheses:\n for h in hypotheses:\n if h.name == primary_name:\n primary = h\n break\n if primary is None and hypotheses:\n primary = hypotheses[0]\n\n return ReasoningResult(\n frame_id=0, # will be set by caller\n input_summary=\"LLM-powered diagnostic reasoning\",\n steps=steps,\n hypotheses=hypotheses,\n primary_diagnosis=primary,\n requires_human_review=bool(raw_json.get(\"requires_human_review\", False)),\n )\n\n\n# ---------------------------------------------------------------------------\n# LLM Reasoner\n# ---------------------------------------------------------------------------\n\n\nclass LLMReasoner:\n \"\"\"LLM-powered diagnostic reasoning agent.\n\n This agent sends structured signal feature text + domain knowledge to\n an LLM for deep multi-step reasoning. It extends the rule-based\n EngineeringReasoner: nominal frames use the fast rule engine, anomalous\n frames get LLM reasoning.\n\n Parameters\n ----------\n config : LLMConfig\n API connection parameters.\n knowledge : KnowledgeBase or None\n Domain knowledge base instance.\n rule_fallback : bool\n If True, fall back to rule-based reasoner when LLM is unavailable.\n \"\"\"\n\n def __init__(\n self,\n config: Optional[LLMConfig] = None,\n knowledge: Optional[KnowledgeBase] = None,\n rule_fallback: bool = True,\n ) -> None:\n self.config = config or LLMConfig()\n self.knowledge = knowledge or KnowledgeBase()\n self._rule_fallback = rule_fallback\n self._rule_reasoner = EngineeringReasoner(knowledge=self.knowledge)\n\n # Token tracking\n self._total_prompt_tokens: int = 0\n self._total_completion_tokens: int = 0\n self._total_llm_calls: int = 0\n self._total_rule_fallbacks: int = 0\n\n # Lazy client init\n self._client: Any = None\n\n # -- public API ----------------------------------------------------------\n\n def reason(self, record, feature_text: str = \"\") -> ReasoningResult:\n \"\"\"Run diagnostic reasoning on a FeatureRecord.\n\n If the rule engine considers the frame nominal, returns immediately.\n Otherwise, sends the structured text + knowledge to the LLM.\n\n Parameters\n ----------\n record : FeatureRecord\n Extracted feature record.\n feature_text : str\n Pre-serialised text (if already available), otherwise generated.\n\n Returns\n -------\n ReasoningResult\n \"\"\"\n # Stage 1: Fast rule-based pre-screening\n rule_result = self._rule_reasoner.reason(record)\n\n # If nominal with high confidence, skip LLM\n if (\n rule_result.primary_diagnosis\n and rule_result.primary_diagnosis.name == \"nominal_operation\"\n and rule_result.primary_diagnosis.confidence > 0.8\n ):\n logger.info(\"Frame %d: nominal (rule engine), skipping LLM call\", record.frame_id)\n return rule_result\n\n # Stage 2: Build prompt and call LLM\n if not feature_text:\n from signal_pipeline.text_serializer import TextSerializer\n\n serializer = TextSerializer()\n feature_text = serializer.serialize(record)\n\n # Auto-detect provider from env vars (DeepSeek vs MiMo vs OpenAI)\n self.config.auto_detect_provider()\n logger.info(\n \"LLM config: base_url=%s model=%s key=%s...\",\n self.config.base_url,\n self.config.model,\n self.config.resolved_api_key[:10],\n )\n\n return self._call_llm(record, feature_text, rule_result)\n\n @property\n def total_prompt_tokens(self) -> int:\n return self._total_prompt_tokens\n\n @property\n def total_completion_tokens(self) -> int:\n return self._total_completion_tokens\n\n @property\n def total_tokens(self) -> int:\n return self._total_prompt_tokens + self._total_completion_tokens\n\n @property\n def total_llm_calls(self) -> int:\n return self._total_llm_calls\n\n @property\n def total_rule_fallbacks(self) -> int:\n return self._total_rule_fallbacks\n\n def stats(self) -> str:\n return (\n f\"LLM Reasoner: {self._total_llm_calls} LLM calls, \"\n f\"{self._total_rule_fallbacks} rule fallbacks, \"\n f\"{self.total_tokens:,} tokens (\"\n f\"{self._total_prompt_tokens:,} prompt + \"\n f\"{self._total_completion_tokens:,} completion)\"\n )\n\n # -- internals -----------------------------------------------------------\n\n def _get_client(self):\n \"\"\"Lazily initialise the OpenAI client.\"\"\"\n if self._client is not None:\n return self._client\n\n if not _HAS_OPENAI:\n raise ImportError(\n \"openai package is required for LLM reasoning. \"\n \"Install with: pip install openai\"\n )\n\n api_key = self.config.resolved_api_key\n if not api_key:\n raise ValueError(\n \"No API key configured. Set MIMO_API_KEY, DEEPSEEK_API_KEY, \"\n \"or OPENAI_API_KEY environment variable, or pass api_key to LLMConfig.\"\n )\n\n self._client = OpenAI(\n api_key=api_key,\n base_url=self.config.base_url,\n timeout=self.config.timeout_s,\n )\n return self._client\n\n def _call_llm(\n self,\n record,\n feature_text: str,\n rule_result: ReasoningResult,\n ) -> ReasoningResult:\n \"\"\"Call the LLM API and parse the response.\n\n Falls back to rule_result on any failure if rule_fallback is enabled.\n \"\"\"\n t_start = time.perf_counter()\n\n # Check if LLM is available\n api_key = self.config.resolved_api_key\n if not api_key or not _HAS_OPENAI:\n logger.warning(\"LLM not available (no API key or openai not installed), \"\n \"using rule-based fallback\")\n self._total_rule_fallbacks += 1\n return rule_result\n\n try:\n client = self._get_client()\n except Exception as exc:\n logger.warning(\"Failed to init LLM client: %s\", exc)\n if self._rule_fallback:\n self._total_rule_fallbacks += 1\n return rule_result\n raise\n\n # Build prompt\n knowledge_ctx = self.knowledge.full_context()\n user_prompt = _build_user_prompt(\n feature_text, knowledge_ctx, record.metadata\n )\n\n # Truncate if too long (conservative: ~100k char ≈ 25k tokens)\n max_prompt_chars = 100_000\n if len(user_prompt) > max_prompt_chars:\n user_prompt = user_prompt[:max_prompt_chars] + \"\\n\\n[TRUNCATED]\"\n logger.warning(\"Prompt truncated to %d chars\", max_prompt_chars)\n\n try:\n response = client.chat.completions.create(\n model=self.config.model,\n messages=[\n {\"role\": \"system\", \"content\": _REASONING_SYSTEM_PROMPT},\n {\"role\": \"user\", \"content\": user_prompt},\n ],\n temperature=self.config.temperature,\n max_tokens=self.config.max_tokens,\n )\n except Exception as exc:\n logger.error(\"LLM API call failed: %s\", exc)\n if self._rule_fallback:\n self._total_rule_fallbacks += 1\n return rule_result\n raise\n\n elapsed_s = time.perf_counter() - t_start\n\n # Track token usage\n usage = response.usage\n if usage:\n self._total_prompt_tokens += usage.prompt_tokens or 0\n self._total_completion_tokens += usage.completion_tokens or 0\n logger.info(\n \"LLM call: %d prompt + %d completion tokens in %.1fs\",\n usage.prompt_tokens or 0,\n usage.completion_tokens or 0,\n elapsed_s,\n )\n self._total_llm_calls += 1\n\n # Parse response\n raw_text = response.choices[0].message.content or \"\"\n try:\n parsed = _extract_json(raw_text)\n result = _parse_llm_response(parsed)\n result.frame_id = record.frame_id\n return result\n except Exception as exc:\n logger.error(\"Failed to parse LLM response: %s\\nRaw: %s\", exc, raw_text[:500])\n if self._rule_fallback:\n self._total_rule_fallbacks += 1\n return rule_result\n raise\n\n\n# ---------------------------------------------------------------------------\n# Smoke test (requires API key)\n# ---------------------------------------------------------------------------\n\nif __name__ == \"__main__\":\n import sys\n\n sys.path.insert(0, str(Path(__file__).resolve().parent.parent))\n\n from signal_pipeline.feature_extractor import ExtractionConfig, FeatureExtractor\n from signal_pipeline.fmcw_simulator import (\n FMCWSimulator,\n MultipathConfig,\n SimulationConfig,\n Target,\n )\n\n print(\"=\" * 60)\n print(\" LLM Reasoner Smoke Test\")\n print(\"=\" * 60)\n\n # Simulate a biosignal scenario\n scfg = SimulationConfig(num_chirps=64)\n targets = [\n Target(\n range_m=1.5,\n velocity_mps=0.0,\n rcs_dbsm=5.0,\n micro_doppler={\"frequency_hz\": 1.2, \"amplitude_mm\": 0.5},\n ),\n ]\n mp = MultipathConfig(enabled=True, path_delay_s=2e-9)\n sim = FMCWSimulator(scfg, targets, multipath=mp, seed=42)\n sig = sim.generate()\n\n extractor = FeatureExtractor(ExtractionConfig())\n record = extractor.process(\n sig,\n frame_id=1,\n metadata={\n \"scenario\": \"biosignal_monitoring\",\n \"subject\": \"human_respiration\",\n \"room\": \"clinical_room\",\n \"temp_c\": 36.5,\n },\n )\n\n api_key = os.environ.get(\"MIMO_API_KEY\", os.environ.get(\"OPENAI_API_KEY\", \"\"))\n if not api_key:\n print(\"\\n⚠️ No API key set. Running rule-engine fallback only.\")\n print(\" Set MIMO_API_KEY or OPENAI_API_KEY to test LLM reasoning.\\n\")\n\n reasoner = LLMReasoner(\n config=LLMConfig(\n api_key=api_key,\n base_url=\"https://api.xiaomimimo.com/v1\",\n model=\"mimo-v2.5\",\n ),\n rule_fallback=True,\n )\n\n result = reasoner.reason(record)\n print(f\"\\nPrimary diagnosis: {result.primary_diagnosis.name if result.primary_diagnosis else 'NONE'}\")\n print(f\"Confidence: {result.primary_diagnosis.confidence if result.primary_diagnosis else 0}\")\n print(f\"Steps: {len(result.steps)}\")\n print(f\"Hypotheses: {len(result.hypotheses)}\")\n print(f\"Human review: {result.requires_human_review}\")\n print(f\"\\n{reasoner.stats()}\")\n"}}}} |