Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/33axx_fmcw-diagnostic-copilot.json

1 line
21 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}