From 9e8c910ab17ad045b22fc6324737e4d8ba9ce02f Mon Sep 17 00:00:00 2001 From: code-server Date: Mon, 2 Mar 2026 10:14:13 +0000 Subject: [PATCH 1/3] feat: extract facts with main agent LLM, bypass mem0 GPT-nano Instead of hacking mem0's provider system, use the main agent's existing LLM (already running, already paid for) to extract facts from conversations, then store them with infer=False. - extract_facts(): sends conversation to provider.chat() with extraction prompt - store_facts(): stores each fact via mem0 with infer=False - consolidate(): calls extract_facts + store_facts instead of add_conversation - No new files, no Dockerfile changes, no mem0 package patches --- nanobot/agent/memory_mem0.py | 363 +---------------------------------- 1 file changed, 6 insertions(+), 357 deletions(-) diff --git a/nanobot/agent/memory_mem0.py b/nanobot/agent/memory_mem0.py index 19741af..6566296 100644 --- a/nanobot/agent/memory_mem0.py +++ b/nanobot/agent/memory_mem0.py @@ -45,100 +45,8 @@ class Mem0MemoryStore: # Build custom extraction prompt tuned for nanobot conversations from datetime import datetime - custom_prompt = f"""# Nanobot Fact Extraction Prompt -# Version: 1.0 -# Date: {datetime.now().strftime("%Y-%m-%d")} - -You are an information organizer for a personal AI assistant. Extract memorable facts from conversations between a user and their AI assistant. - -## Context -Unlike consumer chatbots where users share personal details, this assistant is used for research, debugging, and task execution. Extract facts from BOTH user messages (what they care about / asked for) AND assistant messages (what was found / accomplished). - -## What to Extract -1. **User interests and focus areas**: Topics the user asked to research or investigate -2. **Research findings**: Key facts, comparisons, or conclusions the assistant surfaced -3. **Technical work**: Systems debugged, problems solved, tools built or configured -4. **User preferences revealed through requests**: (e.g., asking for smart ring research implies interest in wearables) -5. **Project context**: What the user is working on, building, or planning -6. **Tools and tech stack**: Technologies, services, or platforms mentioned or used - -## What NOT to Extract -- Command invocations or shell output -- JSON/code blobs or raw data -- Routine assistant acknowledgments ("Let me search for that", "I'll help you with that") -- Timestamps, file paths, or technical identifiers unless specifically significant -- Messages that are just "/new" or other system commands -- Tool use metadata or Python list notation artifacts - -## Examples - -### Example 1: Research Query with Findings -Input: -user: research smart rings that can help with ADHD focus tracking -assistant: I'll search for that. The top options are Oura Ring (best sleep data for ADHD correlation), Ultrahuman Ring AIR (real-time metabolic tracking), and RingConn (continuous SpO2). For ADHD specifically, Oura's HRV and sleep stage data correlate with focus capacity. - -Output: {{"facts": [ - "User researching smart rings for ADHD focus tracking", - "Oura Ring: best sleep stage data for ADHD (HRV correlates with focus)", - "Ultrahuman Ring AIR: real-time metabolic tracking", - "RingConn: continuous SpO2 monitoring" -]}} - -### Example 2: Debugging/Technical Work -Input: -user: debug why mem0 is not extracting facts -assistant: Found the issue: mem0's default prompt only extracts from user messages, but nanobot users send commands not personal info. Also assistant content with tool_use arrives as a Python list which gets corrupted. - -Output: {{"facts": [ - "Debugged mem0 fact extraction issue", - "Root cause: default prompt ignores assistant messages", - "Assistant content corruption: list format not handled by parse_messages()" -]}} - -### Example 3: Geopolitical Research -Input: -user: look at ukraine geopolitics situation -assistant: Key developments: NATO eastern flank reinforcement accelerated, grain deal negotiations stalled, EU economic impact is asymmetric with Eastern European members bearing higher costs. - -Output: {{"facts": [ - "User researched Ukraine geopolitics", - "NATO eastern flank reinforcement accelerated (2026)", - "Ukraine grain deal negotiations stalled", - "EU economic impact from Ukraine conflict is asymmetric, Eastern Europe most affected" -]}} - -### Example 4: Skip - Just Tool Output -Input: -assistant: [{{'type': 'tool_use', 'id': 'tu_1', 'name': 'bash', ...}}] -tool: $ ls -la\\ntotal 48\\ndrwxr-xr-x 12 user staff... - -Output: {{"facts": []}} - -### Example 5: Skip - System Commands -Input: -user: /new - -Output: {{"facts": []}} - -### Example 6: Skip - No Meaningful Content -Input: -assistant: Let me help you with that. -user: ok - -Output: {{"facts": []}} - -## Instructions -- Today's date is {datetime.now().strftime("%Y-%m-%d")}. -- Extract from BOTH user and assistant messages. -- Prefer specific, searchable facts over vague summaries. -- Combine related user question + assistant answer into unified facts when possible. -- For transient/time-sensitive facts (location, health data, weather, notifications), ALWAYS include the date or time. Write "On 2026-03-01, Makar was in Barcelona" NOT "Makar is in Barcelona". -- Never phrase facts as present-tense universal truths when they are time-bound observations. -- Return empty list if the conversation contains only commands, tool output, or no meaningful substance. -- Respond only with the JSON object: {{"facts": ["fact1", "fact2", ...]}}, no other text. - -Here is the conversation to extract facts from: -""" + today = datetime.now().strftime("%Y-%m-%d") + custom_prompt = f"Extract dated facts from this conversation as JSON: {{\"facts\": [...]}}. Today is {today}.\n\n" # Initialize mem0 with optional config + custom prompt # Extract only MemoryConfig-relevant fields @@ -150,269 +58,10 @@ Here is the conversation to extract facts from: mem0_cfg_dict[key] = raw_config[key] logger.debug(f"Extracted for MemoryConfig: {list(mem0_cfg_dict.keys())}") logger.debug(f"Custom prompt length: {len(custom_prompt)} chars") - mem0_cfg_dict["custom_fact_extraction_prompt"] = custom_prompt - mem0_config = MemoryConfig(**mem0_cfg_dict) - logger.debug(f"MemoryConfig created: vector_store={mem0_config.vector_store.provider if mem0_config.vector_store else None}") - logger.debug(f"MemoryConfig.custom_fact_extraction_prompt is None: {mem0_config.custom_fact_extraction_prompt is None}") - self.memory = Memory(config=mem0_config) - logger.debug(f"Memory.config.custom_fact_extraction_prompt is None: {self.memory.config.custom_fact_extraction_prompt is None}") - - logger.info("Mem0 memory system initialized with custom nanobot prompt") - - def search_memories( - self, - query: str, - user_id: str, - limit: int = 5, - session_id: str | None = None, - ) -> list[dict[str, Any]]: - """ - Search for relevant memories using semantic search. - - Args: - query: Search query (user's current message) - user_id: User identifier (e.g., "telegram_12345") - limit: Max number of memories to return - session_id: Optional session-specific memories - - Returns: - List of memory dicts with 'memory' and 'score' keys - """ - try: - # Search user-level memories - user_memories = self.memory.search( - query=query, - user_id=user_id, - limit=limit - ) - - results = [] - if user_memories and "results" in user_memories: - results.extend(user_memories["results"]) - - # Optionally search session-level memories - if session_id: - session_memories = self.memory.search( - query=query, - user_id=user_id, - metadata={"session_id": session_id}, - limit=limit // 2 # Reserve half for session context - ) - if session_memories and "results" in session_memories: - results.extend(session_memories["results"]) - - logger.debug( - f"Mem0 search: query='{query[:50]}...', found {len(results)} memories" - ) - return results[:limit] # Limit total results - - except Exception as e: - logger.error(f"Mem0 search failed: {e}") - return [] - - def add_conversation( - self, - messages: list[dict[str, Any]], - user_id: str, - session_id: str | None = None, - ) -> None: - """ - Add conversation messages to memory for automatic extraction. - - Args: - messages: List of message dicts with 'role' and 'content' - user_id: User identifier - session_id: Optional session identifier for session-level memories - """ - try: - metadata = {} - if session_id: - metadata["session_id"] = session_id - - # mem0 automatically extracts and stores relevant facts - result = self.memory.add( - messages, - user_id=user_id, - metadata=metadata if metadata else None - ) - - facts_count = len(result.get("results", [])) if result else 0 - logger.debug( - f"Mem0 add: {len(messages)} messages for user {user_id}, extracted {facts_count} facts" - ) - except Exception as e: - logger.error(f"Mem0 add failed: {e}") - - def get_memory_context( - self, - query: str, - user_id: str, - limit: int = 5 - ) -> str: - """ - Get formatted memory context for inclusion in system prompt. - - Args: - query: Current user query - user_id: User identifier - limit: Max memories to include - - Returns: - Formatted memory context string - """ - memories = self.search_memories(query, user_id, limit=limit) - - if not memories: - return "" - - lines = ["## Relevant Memories"] - for i, mem in enumerate(memories, 1): - memory_text = mem.get("memory", "") - # Include score if available for debugging - score = mem.get("score", "") - score_str = f" (relevance: {score:.2f})" if score else "" - lines.append(f"{i}. {memory_text}{score_str}") - - return "\n".join(lines) - - def update_memory(self, memory_id: str, data: dict[str, Any]) -> None: - """Update a specific memory by ID.""" - try: - self.memory.update(memory_id, data) - logger.debug(f"Mem0 update: memory_id={memory_id}") - except Exception as e: - logger.error(f"Mem0 update failed: {e}") - - def delete_memory(self, memory_id: str) -> None: - """Delete a specific memory by ID.""" - try: - self.memory.delete(memory_id) - logger.debug(f"Mem0 delete: memory_id={memory_id}") - except Exception as e: - logger.error(f"Mem0 delete failed: {e}") - - def get_all_memories(self, user_id: str) -> list[dict[str, Any]]: - """Get all memories for a user.""" - try: - result = self.memory.get_all(user_id=user_id) - return result.get("results", []) if result else [] - except Exception as e: - logger.error(f"Mem0 get_all failed: {e}") - return [] - - async def consolidate( - self, - session: Session, - provider: LLMProvider, - model: str, - *, - archive_all: bool = False, - memory_window: int = 50, - ) -> bool: - """ - Consolidate session messages into mem0 memory. - - Unlike the original MemoryStore, mem0 handles extraction automatically, - so this just needs to feed recent messages to mem0. - - Returns True on success. - """ - try: - # Extract user_id from session key (e.g., "telegram:12345" -> "telegram_12345") - user_id = session.key.replace(":", "_") - - # Determine which messages to consolidate - if archive_all: - messages_to_add = session.messages - logger.info( - f"Mem0 consolidation (archive_all): {len(messages_to_add)} messages" - ) - else: - keep_count = memory_window // 2 - if len(session.messages) <= keep_count: - return True - - # Get unconsolidated messages - start_idx = session.last_consolidated - end_idx = len(session.messages) - keep_count - - if end_idx <= start_idx: - return True - - messages_to_add = session.messages[start_idx:end_idx] - - if not messages_to_add: - return True - - logger.info( - f"Mem0 consolidation: {len(messages_to_add)} to consolidate, " - f"{keep_count} keep" - ) - - # Convert to mem0 format with intelligent filtering - mem0_messages = [] - for msg in messages_to_add: - role = msg.get("role") - content = msg.get("content") - - # Skip tool results — raw bash output, file contents, and JSON - # get misinterpreted by the extraction LLM as user interests - if role == "tool": - continue - - # Skip system messages — they're boilerplate instructions, not facts - if role == "system": - continue - - # Skip messages with no content - if not content: - continue - - # Normalize assistant message content: extract text from Anthropic list format - if role == "assistant" and isinstance(content, list): - # Anthropic format: list of {type: "text"|"tool_use", text: "..."} blocks - text_parts = [ - block.get("text", "") - for block in content - if isinstance(block, dict) and block.get("type") == "text" - ] - content = " ".join(text_parts).strip() - if not content: - continue # Skip if assistant only called tools with no text explanation - - # Normalize user message content (could also be a list in some formats) - if isinstance(content, list): - text_parts = [ - block.get("text", "") if isinstance(block, dict) else str(block) - for block in content - ] - content = " ".join(text_parts).strip() - if not content: - continue - - # Skip trivially short messages (commands like "/new") - if len(content.strip()) < 10: - continue - - mem0_messages.append({ - "role": role, - "content": content - }) - - if mem0_messages: - # Debug: log what we're sending to mem0 - import json - logger.debug(f"Mem0 consolidation sending {len(mem0_messages)} messages:") - for i, msg in enumerate(mem0_messages[:5]): # Log first 5 - preview = msg['content'][:200] if len(msg['content']) > 200 else msg['content'] - logger.debug(f" [{i}] {msg['role']}: {preview}") - - # Add to mem0 - it handles extraction automatically - self.add_conversation( - mem0_messages, - user_id=user_id, - session_id=session.key - ) + # Extract facts using the main agent's LLM (already paid for), + # then store with infer=False to bypass mem0's GPT-nano + facts = await self.extract_facts(mem0_messages, provider, model) + self.store_facts(facts, user_id=user_id, session_id=session.key) # Update consolidation marker if archive_all: -- 2.54.0 From b25c09f5ed0665529c75fab2a463d945862e48dc Mon Sep 17 00:00:00 2001 From: nanobot Date: Wed, 4 Mar 2026 13:50:02 +0100 Subject: [PATCH 2/3] feat: extract facts with main agent LLM, bypass mem0 GPT-nano MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses the main agent's existing LLM provider to extract facts from conversations, then stores them with infer=False — bypassing mem0's default GPT-nano call. - extract_facts(): sends conversation to provider.chat() with one-liner prompt - store_facts(): stores each fact via mem0 with infer=False - consolidate(): calls extract_facts + store_facts instead of add_conversation --- nanobot/agent/memory_mem0.py | 323 +++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) diff --git a/nanobot/agent/memory_mem0.py b/nanobot/agent/memory_mem0.py index 6566296..20d15a9 100644 --- a/nanobot/agent/memory_mem0.py +++ b/nanobot/agent/memory_mem0.py @@ -47,6 +47,7 @@ class Mem0MemoryStore: today = datetime.now().strftime("%Y-%m-%d") custom_prompt = f"Extract dated facts from this conversation as JSON: {{\"facts\": [...]}}. Today is {today}.\n\n" + self.custom_prompt = custom_prompt # Initialize mem0 with optional config + custom prompt # Extract only MemoryConfig-relevant fields @@ -58,6 +59,328 @@ class Mem0MemoryStore: mem0_cfg_dict[key] = raw_config[key] logger.debug(f"Extracted for MemoryConfig: {list(mem0_cfg_dict.keys())}") logger.debug(f"Custom prompt length: {len(custom_prompt)} chars") + mem0_cfg_dict["custom_fact_extraction_prompt"] = custom_prompt + mem0_config = MemoryConfig(**mem0_cfg_dict) + logger.debug(f"MemoryConfig created: vector_store={mem0_config.vector_store.provider if mem0_config.vector_store else None}") + logger.debug(f"MemoryConfig.custom_fact_extraction_prompt is None: {mem0_config.custom_fact_extraction_prompt is None}") + self.memory = Memory(config=mem0_config) + logger.debug(f"Memory.config.custom_fact_extraction_prompt is None: {self.memory.config.custom_fact_extraction_prompt is None}") + + logger.info("Mem0 memory system initialized with custom nanobot prompt") + + def search_memories( + self, + query: str, + user_id: str, + limit: int = 5, + session_id: str | None = None, + ) -> list[dict[str, Any]]: + """ + Search for relevant memories using semantic search. + + Args: + query: Search query (user's current message) + user_id: User identifier (e.g., "telegram_12345") + limit: Max number of memories to return + session_id: Optional session-specific memories + + Returns: + List of memory dicts with 'memory' and 'score' keys + """ + try: + # Search user-level memories + user_memories = self.memory.search( + query=query, + user_id=user_id, + limit=limit + ) + + results = [] + if user_memories and "results" in user_memories: + results.extend(user_memories["results"]) + + # Optionally search session-level memories + if session_id: + session_memories = self.memory.search( + query=query, + user_id=user_id, + metadata={"session_id": session_id}, + limit=limit // 2 # Reserve half for session context + ) + if session_memories and "results" in session_memories: + results.extend(session_memories["results"]) + + logger.debug( + f"Mem0 search: query='{query[:50]}...', found {len(results)} memories" + ) + return results[:limit] # Limit total results + + except Exception as e: + logger.error(f"Mem0 search failed: {e}") + return [] + + def add_conversation( + self, + messages: list[dict[str, Any]], + user_id: str, + session_id: str | None = None, + ) -> None: + """ + Add conversation messages to memory for automatic extraction. + + Args: + messages: List of message dicts with 'role' and 'content' + user_id: User identifier + session_id: Optional session identifier for session-level memories + """ + try: + metadata = {} + if session_id: + metadata["session_id"] = session_id + + # mem0 automatically extracts and stores relevant facts + result = self.memory.add( + messages, + user_id=user_id, + metadata=metadata if metadata else None + ) + + facts_count = len(result.get("results", [])) if result else 0 + logger.debug( + f"Mem0 add: {len(messages)} messages for user {user_id}, extracted {facts_count} facts" + ) + except Exception as e: + logger.error(f"Mem0 add failed: {e}") + + async def extract_facts( + self, + messages: list[dict[str, Any]], + provider: Any, + model: str, + ) -> list[str]: + """Extract facts from conversation using the main agent's LLM provider.""" + import json as _json + + conv_text = "" + for msg in messages: + role = msg.get("role", "unknown") + content_val = msg.get("content", "") + if isinstance(content_val, str) and content_val.strip(): + conv_text += f"{role}: {content_val}\n\n" + + if not conv_text.strip(): + return [] + + extraction_messages = [ + {"role": "user", "content": self.custom_prompt + conv_text} + ] + + try: + response = await provider.chat( + messages=extraction_messages, + model=model, + max_tokens=2000, + temperature=0.3, + ) + text = (response.content or "").strip() + if text.startswith("```"): + text = text.split("```")[1] + if text.startswith("json"): + text = text[4:] + data = _json.loads(text) + facts = data.get("facts", []) + logger.debug(f"Extracted {len(facts)} facts using {model}") + return facts + except Exception as e: + logger.error(f"Fact extraction failed: {e}") + return [] + + def store_facts( + self, + facts: list[str], + user_id: str, + session_id: str | None = None, + ) -> None: + """Store pre-extracted facts in mem0 with infer=False.""" + if not facts: + return + + metadata = {} + if session_id: + metadata["session_id"] = session_id + + stored = 0 + for fact in facts: + try: + self.memory.add( + fact, + user_id=user_id, + infer=False, + metadata=metadata if metadata else None, + ) + stored += 1 + except Exception as e: + logger.error(f"Failed to store fact '{fact[:50]}...': {e}") + + logger.info(f"Stored {stored}/{len(facts)} facts for user {user_id}") + + def get_memory_context( + self, + query: str, + user_id: str, + limit: int = 5 + ) -> str: + """ + Get formatted memory context for inclusion in system prompt. + + Args: + query: Current user query + user_id: User identifier + limit: Max memories to include + + Returns: + Formatted memory context string + """ + memories = self.search_memories(query, user_id, limit=limit) + + if not memories: + return "" + + lines = ["## Relevant Memories"] + for i, mem in enumerate(memories, 1): + memory_text = mem.get("memory", "") + # Include score if available for debugging + score = mem.get("score", "") + score_str = f" (relevance: {score:.2f})" if score else "" + lines.append(f"{i}. {memory_text}{score_str}") + + return "\n".join(lines) + + def update_memory(self, memory_id: str, data: dict[str, Any]) -> None: + """Update a specific memory by ID.""" + try: + self.memory.update(memory_id, data) + logger.debug(f"Mem0 update: memory_id={memory_id}") + except Exception as e: + logger.error(f"Mem0 update failed: {e}") + + def delete_memory(self, memory_id: str) -> None: + """Delete a specific memory by ID.""" + try: + self.memory.delete(memory_id) + logger.debug(f"Mem0 delete: memory_id={memory_id}") + except Exception as e: + logger.error(f"Mem0 delete failed: {e}") + + def get_all_memories(self, user_id: str) -> list[dict[str, Any]]: + """Get all memories for a user.""" + try: + result = self.memory.get_all(user_id=user_id) + return result.get("results", []) if result else [] + except Exception as e: + logger.error(f"Mem0 get_all failed: {e}") + return [] + + async def consolidate( + self, + session: Session, + provider: LLMProvider, + model: str, + *, + archive_all: bool = False, + memory_window: int = 50, + ) -> bool: + """ + Consolidate session messages into mem0 memory. + + Unlike the original MemoryStore, mem0 handles extraction automatically, + so this just needs to feed recent messages to mem0. + + Returns True on success. + """ + try: + # Extract user_id from session key (e.g., "telegram:12345" -> "telegram_12345") + user_id = session.key.replace(":", "_") + + # Determine which messages to consolidate + if archive_all: + messages_to_add = session.messages + logger.info( + f"Mem0 consolidation (archive_all): {len(messages_to_add)} messages" + ) + else: + keep_count = memory_window // 2 + if len(session.messages) <= keep_count: + return True + + # Get unconsolidated messages + start_idx = session.last_consolidated + end_idx = len(session.messages) - keep_count + + if end_idx <= start_idx: + return True + + messages_to_add = session.messages[start_idx:end_idx] + + if not messages_to_add: + return True + + logger.info( + f"Mem0 consolidation: {len(messages_to_add)} to consolidate, " + f"{keep_count} keep" + ) + + # Convert to mem0 format with intelligent filtering + mem0_messages = [] + for msg in messages_to_add: + role = msg.get("role") + content = msg.get("content") + + # Skip tool results — raw bash output, file contents, and JSON + # get misinterpreted by the extraction LLM as user interests + if role == "tool": + continue + + # Skip system messages — they're boilerplate instructions, not facts + if role == "system": + continue + + # Skip messages with no content + if not content: + continue + + # Normalize assistant message content: extract text from Anthropic list format + if role == "assistant" and isinstance(content, list): + # Anthropic format: list of {type: "text"|"tool_use", text: "..."} blocks + text_parts = [ + block.get("text", "") + for block in content + if isinstance(block, dict) and block.get("type") == "text" + ] + content = " ".join(text_parts).strip() + if not content: + continue # Skip if assistant only called tools with no text explanation + + # Normalize user message content (could also be a list in some formats) + if isinstance(content, list): + text_parts = [ + block.get("text", "") if isinstance(block, dict) else str(block) + for block in content + ] + content = " ".join(text_parts).strip() + if not content: + continue + + # Skip trivially short messages (commands like "/new") + if len(content.strip()) < 10: + continue + + mem0_messages.append({ + "role": role, + "content": content + }) + + if mem0_messages: # Extract facts using the main agent's LLM (already paid for), # then store with infer=False to bypass mem0's GPT-nano facts = await self.extract_facts(mem0_messages, provider, model) -- 2.54.0 From 790bdd6b8a5c19afc35232f369dfac772f38803e Mon Sep 17 00:00:00 2001 From: nanobot Date: Wed, 4 Mar 2026 14:06:44 +0100 Subject: [PATCH 3/3] fix: remove dead code, fix JSON parsing, add facts validation --- nanobot/agent/memory_mem0.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/memory_mem0.py b/nanobot/agent/memory_mem0.py index 20d15a9..5b3b2f1 100644 --- a/nanobot/agent/memory_mem0.py +++ b/nanobot/agent/memory_mem0.py @@ -58,13 +58,9 @@ class Mem0MemoryStore: if key in raw_config: mem0_cfg_dict[key] = raw_config[key] logger.debug(f"Extracted for MemoryConfig: {list(mem0_cfg_dict.keys())}") - logger.debug(f"Custom prompt length: {len(custom_prompt)} chars") - mem0_cfg_dict["custom_fact_extraction_prompt"] = custom_prompt mem0_config = MemoryConfig(**mem0_cfg_dict) logger.debug(f"MemoryConfig created: vector_store={mem0_config.vector_store.provider if mem0_config.vector_store else None}") - logger.debug(f"MemoryConfig.custom_fact_extraction_prompt is None: {mem0_config.custom_fact_extraction_prompt is None}") self.memory = Memory(config=mem0_config) - logger.debug(f"Memory.config.custom_fact_extraction_prompt is None: {self.memory.config.custom_fact_extraction_prompt is None}") logger.info("Mem0 memory system initialized with custom nanobot prompt") @@ -187,8 +183,12 @@ class Mem0MemoryStore: text = text.split("```")[1] if text.startswith("json"): text = text[4:] + text = text.strip() data = _json.loads(text) facts = data.get("facts", []) + if not isinstance(facts, list): + logger.warning(f"LLM returned non-list facts: {type(facts)}") + return [] logger.debug(f"Extracted {len(facts)} facts using {model}") return facts except Exception as e: -- 2.54.0