alma-memory 0.5.0__py3-none-any.whl → 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. alma/__init__.py +296 -194
  2. alma/compression/__init__.py +33 -0
  3. alma/compression/pipeline.py +980 -0
  4. alma/confidence/__init__.py +47 -47
  5. alma/confidence/engine.py +540 -540
  6. alma/confidence/types.py +351 -351
  7. alma/config/loader.py +157 -157
  8. alma/consolidation/__init__.py +23 -23
  9. alma/consolidation/engine.py +678 -678
  10. alma/consolidation/prompts.py +84 -84
  11. alma/core.py +1189 -322
  12. alma/domains/__init__.py +30 -30
  13. alma/domains/factory.py +359 -359
  14. alma/domains/schemas.py +448 -448
  15. alma/domains/types.py +272 -272
  16. alma/events/__init__.py +75 -75
  17. alma/events/emitter.py +285 -284
  18. alma/events/storage_mixin.py +246 -246
  19. alma/events/types.py +126 -126
  20. alma/events/webhook.py +425 -425
  21. alma/exceptions.py +49 -49
  22. alma/extraction/__init__.py +31 -31
  23. alma/extraction/auto_learner.py +265 -264
  24. alma/extraction/extractor.py +420 -420
  25. alma/graph/__init__.py +106 -81
  26. alma/graph/backends/__init__.py +32 -18
  27. alma/graph/backends/kuzu.py +624 -0
  28. alma/graph/backends/memgraph.py +432 -0
  29. alma/graph/backends/memory.py +236 -236
  30. alma/graph/backends/neo4j.py +417 -417
  31. alma/graph/base.py +159 -159
  32. alma/graph/extraction.py +198 -198
  33. alma/graph/store.py +860 -860
  34. alma/harness/__init__.py +35 -35
  35. alma/harness/base.py +386 -386
  36. alma/harness/domains.py +705 -705
  37. alma/initializer/__init__.py +37 -37
  38. alma/initializer/initializer.py +418 -418
  39. alma/initializer/types.py +250 -250
  40. alma/integration/__init__.py +62 -62
  41. alma/integration/claude_agents.py +444 -432
  42. alma/integration/helena.py +423 -423
  43. alma/integration/victor.py +471 -471
  44. alma/learning/__init__.py +101 -86
  45. alma/learning/decay.py +878 -0
  46. alma/learning/forgetting.py +1446 -1446
  47. alma/learning/heuristic_extractor.py +390 -390
  48. alma/learning/protocols.py +374 -374
  49. alma/learning/validation.py +346 -346
  50. alma/mcp/__init__.py +123 -45
  51. alma/mcp/__main__.py +156 -156
  52. alma/mcp/resources.py +122 -122
  53. alma/mcp/server.py +955 -591
  54. alma/mcp/tools.py +3254 -511
  55. alma/observability/__init__.py +91 -0
  56. alma/observability/config.py +302 -0
  57. alma/observability/guidelines.py +170 -0
  58. alma/observability/logging.py +424 -0
  59. alma/observability/metrics.py +583 -0
  60. alma/observability/tracing.py +440 -0
  61. alma/progress/__init__.py +21 -21
  62. alma/progress/tracker.py +607 -607
  63. alma/progress/types.py +250 -250
  64. alma/retrieval/__init__.py +134 -53
  65. alma/retrieval/budget.py +525 -0
  66. alma/retrieval/cache.py +1304 -1061
  67. alma/retrieval/embeddings.py +202 -202
  68. alma/retrieval/engine.py +850 -366
  69. alma/retrieval/modes.py +365 -0
  70. alma/retrieval/progressive.py +560 -0
  71. alma/retrieval/scoring.py +344 -344
  72. alma/retrieval/trust_scoring.py +637 -0
  73. alma/retrieval/verification.py +797 -0
  74. alma/session/__init__.py +19 -19
  75. alma/session/manager.py +442 -399
  76. alma/session/types.py +288 -288
  77. alma/storage/__init__.py +101 -61
  78. alma/storage/archive.py +233 -0
  79. alma/storage/azure_cosmos.py +1259 -1048
  80. alma/storage/base.py +1083 -525
  81. alma/storage/chroma.py +1443 -1443
  82. alma/storage/constants.py +103 -0
  83. alma/storage/file_based.py +614 -619
  84. alma/storage/migrations/__init__.py +21 -0
  85. alma/storage/migrations/base.py +321 -0
  86. alma/storage/migrations/runner.py +323 -0
  87. alma/storage/migrations/version_stores.py +337 -0
  88. alma/storage/migrations/versions/__init__.py +11 -0
  89. alma/storage/migrations/versions/v1_0_0.py +373 -0
  90. alma/storage/migrations/versions/v1_1_0_workflow_context.py +551 -0
  91. alma/storage/pinecone.py +1080 -1080
  92. alma/storage/postgresql.py +1948 -1452
  93. alma/storage/qdrant.py +1306 -1306
  94. alma/storage/sqlite_local.py +3041 -1358
  95. alma/testing/__init__.py +46 -0
  96. alma/testing/factories.py +301 -0
  97. alma/testing/mocks.py +389 -0
  98. alma/types.py +292 -264
  99. alma/utils/__init__.py +19 -0
  100. alma/utils/tokenizer.py +521 -0
  101. alma/workflow/__init__.py +83 -0
  102. alma/workflow/artifacts.py +170 -0
  103. alma/workflow/checkpoint.py +311 -0
  104. alma/workflow/context.py +228 -0
  105. alma/workflow/outcomes.py +189 -0
  106. alma/workflow/reducers.py +393 -0
  107. {alma_memory-0.5.0.dist-info → alma_memory-0.7.0.dist-info}/METADATA +244 -72
  108. alma_memory-0.7.0.dist-info/RECORD +112 -0
  109. alma_memory-0.5.0.dist-info/RECORD +0 -76
  110. {alma_memory-0.5.0.dist-info → alma_memory-0.7.0.dist-info}/WHEEL +0 -0
  111. {alma_memory-0.5.0.dist-info → alma_memory-0.7.0.dist-info}/top_level.txt +0 -0
@@ -1,420 +1,420 @@
1
- """
2
- ALMA Fact Extraction Module.
3
-
4
- LLM-powered extraction of facts, preferences, and learnings from conversations.
5
- This bridges the gap between Mem0's automatic extraction and ALMA's explicit learning.
6
- """
7
-
8
- import logging
9
- from abc import ABC, abstractmethod
10
- from dataclasses import dataclass
11
- from enum import Enum
12
- from typing import Any, Dict, List, Optional
13
-
14
- logger = logging.getLogger(__name__)
15
-
16
-
17
- class FactType(Enum):
18
- """Types of facts that can be extracted from conversations."""
19
-
20
- HEURISTIC = "heuristic" # Strategy that worked
21
- ANTI_PATTERN = "anti_pattern" # What NOT to do
22
- PREFERENCE = "preference" # User preference
23
- DOMAIN_KNOWLEDGE = "domain_knowledge" # Factual information
24
- OUTCOME = "outcome" # Task result
25
-
26
-
27
- @dataclass
28
- class ExtractedFact:
29
- """A fact extracted from conversation."""
30
-
31
- fact_type: FactType
32
- content: str
33
- confidence: float # 0.0 to 1.0
34
- source_text: str # Original text this was extracted from
35
- metadata: Dict[str, Any] = None
36
-
37
- # For heuristics/anti-patterns
38
- condition: Optional[str] = None # When does this apply?
39
- strategy: Optional[str] = None # What to do?
40
-
41
- # For preferences
42
- category: Optional[str] = None
43
-
44
- # For domain knowledge
45
- domain: Optional[str] = None
46
-
47
-
48
- @dataclass
49
- class ExtractionResult:
50
- """Result of fact extraction from a conversation."""
51
-
52
- facts: List[ExtractedFact]
53
- raw_response: str # LLM's raw response for debugging
54
- tokens_used: int
55
- extraction_time_ms: int
56
-
57
-
58
- class FactExtractor(ABC):
59
- """Abstract base class for fact extraction."""
60
-
61
- @abstractmethod
62
- def extract(
63
- self,
64
- messages: List[Dict[str, str]],
65
- agent_context: Optional[str] = None,
66
- existing_facts: Optional[List[str]] = None,
67
- ) -> ExtractionResult:
68
- """
69
- Extract facts from a conversation.
70
-
71
- Args:
72
- messages: List of {"role": "user"|"assistant", "content": "..."}
73
- agent_context: Optional context about the agent's domain
74
- existing_facts: Optional list of already-known facts to avoid duplicates
75
-
76
- Returns:
77
- ExtractionResult with extracted facts
78
- """
79
- pass
80
-
81
-
82
- class LLMFactExtractor(FactExtractor):
83
- """
84
- LLM-powered fact extraction.
85
-
86
- Uses structured prompting to extract facts, preferences, and learnings
87
- from conversations. Supports OpenAI, Anthropic, and local models.
88
- """
89
-
90
- EXTRACTION_PROMPT = """You are a fact extraction system for an AI agent memory architecture.
91
-
92
- Analyze the following conversation and extract facts worth remembering.
93
-
94
- IMPORTANT: Only extract facts that are:
95
- 1. Specific and actionable (not vague observations)
96
- 2. Likely to be useful in future similar situations
97
- 3. Not already in the existing facts list
98
-
99
- Categorize each fact as one of:
100
- - HEURISTIC: A strategy or approach that worked well
101
- - ANTI_PATTERN: Something that failed or should be avoided
102
- - PREFERENCE: A user preference or constraint
103
- - DOMAIN_KNOWLEDGE: A factual piece of information about the domain
104
- - OUTCOME: The result of a specific task
105
-
106
- For HEURISTIC and ANTI_PATTERN, also extract:
107
- - condition: When does this apply?
108
- - strategy: What to do (or not do)?
109
-
110
- For PREFERENCE, extract:
111
- - category: What type of preference (communication, code_style, workflow, etc.)
112
-
113
- For DOMAIN_KNOWLEDGE, extract:
114
- - domain: What knowledge domain this belongs to
115
-
116
- {agent_context}
117
-
118
- {existing_facts_section}
119
-
120
- CONVERSATION:
121
- {conversation}
122
-
123
- Respond in JSON format:
124
- ```json
125
- {{
126
- "facts": [
127
- {{
128
- "fact_type": "HEURISTIC|ANTI_PATTERN|PREFERENCE|DOMAIN_KNOWLEDGE|OUTCOME",
129
- "content": "The main fact statement",
130
- "confidence": 0.0-1.0,
131
- "condition": "optional - when this applies",
132
- "strategy": "optional - what to do",
133
- "category": "optional - preference category",
134
- "domain": "optional - knowledge domain"
135
- }}
136
- ]
137
- }}
138
- ```
139
-
140
- If no facts worth extracting, return: {{"facts": []}}
141
- """
142
-
143
- def __init__(
144
- self,
145
- provider: str = "openai",
146
- model: str = "gpt-4o-mini",
147
- api_key: Optional[str] = None,
148
- temperature: float = 0.1,
149
- ):
150
- """
151
- Initialize LLM fact extractor.
152
-
153
- Args:
154
- provider: "openai", "anthropic", or "local"
155
- model: Model name/identifier
156
- api_key: API key (or use environment variable)
157
- temperature: LLM temperature for extraction
158
- """
159
- self.provider = provider
160
- self.model = model
161
- self.api_key = api_key
162
- self.temperature = temperature
163
- self._client = None
164
-
165
- def _get_client(self):
166
- """Lazy initialization of LLM client."""
167
- if self._client is None:
168
- if self.provider == "openai":
169
- from openai import OpenAI
170
-
171
- self._client = OpenAI(api_key=self.api_key)
172
- elif self.provider == "anthropic":
173
- from anthropic import Anthropic
174
-
175
- self._client = Anthropic(api_key=self.api_key)
176
- else:
177
- raise ValueError(f"Unsupported provider: {self.provider}")
178
- return self._client
179
-
180
- def extract(
181
- self,
182
- messages: List[Dict[str, str]],
183
- agent_context: Optional[str] = None,
184
- existing_facts: Optional[List[str]] = None,
185
- ) -> ExtractionResult:
186
- """Extract facts from conversation using LLM."""
187
- import time
188
-
189
- start_time = time.time()
190
-
191
- # Format conversation
192
- conversation = "\n".join(
193
- f"{msg['role'].upper()}: {msg['content']}" for msg in messages
194
- )
195
-
196
- # Build prompt
197
- agent_context_section = ""
198
- if agent_context:
199
- agent_context_section = f"\nAGENT CONTEXT:\n{agent_context}\n"
200
-
201
- existing_facts_section = ""
202
- if existing_facts:
203
- facts_list = "\n".join(f"- {f}" for f in existing_facts)
204
- existing_facts_section = (
205
- f"\nEXISTING FACTS (do not duplicate):\n{facts_list}\n"
206
- )
207
-
208
- prompt = self.EXTRACTION_PROMPT.format(
209
- agent_context=agent_context_section,
210
- existing_facts_section=existing_facts_section,
211
- conversation=conversation,
212
- )
213
-
214
- # Call LLM
215
- client = self._get_client()
216
- tokens_used = 0
217
-
218
- if self.provider == "openai":
219
- response = client.chat.completions.create(
220
- model=self.model,
221
- messages=[{"role": "user", "content": prompt}],
222
- temperature=self.temperature,
223
- )
224
- raw_response = response.choices[0].message.content
225
- tokens_used = response.usage.total_tokens if response.usage else 0
226
-
227
- elif self.provider == "anthropic":
228
- response = client.messages.create(
229
- model=self.model,
230
- max_tokens=2000,
231
- messages=[{"role": "user", "content": prompt}],
232
- )
233
- raw_response = response.content[0].text
234
- tokens_used = response.usage.input_tokens + response.usage.output_tokens
235
-
236
- # Parse response
237
- facts = self._parse_response(raw_response, conversation)
238
-
239
- extraction_time_ms = int((time.time() - start_time) * 1000)
240
-
241
- return ExtractionResult(
242
- facts=facts,
243
- raw_response=raw_response,
244
- tokens_used=tokens_used,
245
- extraction_time_ms=extraction_time_ms,
246
- )
247
-
248
- def _parse_response(
249
- self,
250
- raw_response: str,
251
- source_text: str,
252
- ) -> List[ExtractedFact]:
253
- """Parse LLM response into ExtractedFact objects."""
254
- import json
255
- import re
256
-
257
- # Extract JSON from response (handle markdown code blocks)
258
- json_match = re.search(r"```json\s*(.*?)\s*```", raw_response, re.DOTALL)
259
- if json_match:
260
- json_str = json_match.group(1)
261
- else:
262
- # Try to find raw JSON
263
- json_match = re.search(r"\{.*\}", raw_response, re.DOTALL)
264
- if json_match:
265
- json_str = json_match.group(0)
266
- else:
267
- logger.warning(
268
- f"Could not parse JSON from response: {raw_response[:200]}"
269
- )
270
- return []
271
-
272
- try:
273
- data = json.loads(json_str)
274
- except json.JSONDecodeError as e:
275
- logger.warning(f"JSON parse error: {e}")
276
- return []
277
-
278
- facts = []
279
- for item in data.get("facts", []):
280
- try:
281
- fact_type = FactType[item["fact_type"].upper()]
282
- facts.append(
283
- ExtractedFact(
284
- fact_type=fact_type,
285
- content=item["content"],
286
- confidence=float(item.get("confidence", 0.7)),
287
- source_text=source_text[:500], # Truncate for storage
288
- condition=item.get("condition"),
289
- strategy=item.get("strategy"),
290
- category=item.get("category"),
291
- domain=item.get("domain"),
292
- )
293
- )
294
- except (KeyError, ValueError) as e:
295
- logger.warning(f"Could not parse fact: {item}, error: {e}")
296
- continue
297
-
298
- return facts
299
-
300
-
301
- class RuleBasedExtractor(FactExtractor):
302
- """
303
- Rule-based fact extraction for offline/free usage.
304
-
305
- Uses pattern matching and heuristics instead of LLM calls.
306
- Less accurate but free and fast.
307
- """
308
-
309
- # Patterns that indicate different fact types
310
- HEURISTIC_PATTERNS = [
311
- r"(?:worked|succeeded|fixed|solved|helped).*(?:by|using|with)",
312
- r"(?:better|best|good)\s+(?:to|approach|way|strategy)",
313
- r"(?:should|always|recommend).*(?:use|try|do)",
314
- ]
315
-
316
- ANTI_PATTERN_PATTERNS = [
317
- r"(?:don't|do not|never|avoid).*(?:use|do|try)",
318
- r"(?:failed|broke|caused|error).*(?:because|when|due)",
319
- r"(?:bad|wrong|incorrect)\s+(?:to|approach|way)",
320
- ]
321
-
322
- PREFERENCE_PATTERNS = [
323
- r"(?:i|user)\s+(?:prefer|like|want|need)",
324
- r"(?:always|never).*(?:for me|i want)",
325
- ]
326
-
327
- def extract(
328
- self,
329
- messages: List[Dict[str, str]],
330
- agent_context: Optional[str] = None,
331
- existing_facts: Optional[List[str]] = None,
332
- ) -> ExtractionResult:
333
- """Extract facts using pattern matching."""
334
- import re
335
- import time
336
-
337
- start_time = time.time()
338
- facts = []
339
-
340
- for msg in messages:
341
- content = msg["content"].lower()
342
-
343
- # Check for heuristics
344
- for pattern in self.HEURISTIC_PATTERNS:
345
- if re.search(pattern, content, re.IGNORECASE):
346
- facts.append(
347
- ExtractedFact(
348
- fact_type=FactType.HEURISTIC,
349
- content=msg["content"][:200],
350
- confidence=0.5, # Lower confidence for rule-based
351
- source_text=msg["content"],
352
- )
353
- )
354
- break
355
-
356
- # Check for anti-patterns
357
- for pattern in self.ANTI_PATTERN_PATTERNS:
358
- if re.search(pattern, content, re.IGNORECASE):
359
- facts.append(
360
- ExtractedFact(
361
- fact_type=FactType.ANTI_PATTERN,
362
- content=msg["content"][:200],
363
- confidence=0.5,
364
- source_text=msg["content"],
365
- )
366
- )
367
- break
368
-
369
- # Check for preferences
370
- for pattern in self.PREFERENCE_PATTERNS:
371
- if re.search(pattern, content, re.IGNORECASE):
372
- facts.append(
373
- ExtractedFact(
374
- fact_type=FactType.PREFERENCE,
375
- content=msg["content"][:200],
376
- confidence=0.5,
377
- source_text=msg["content"],
378
- )
379
- )
380
- break
381
-
382
- extraction_time_ms = int((time.time() - start_time) * 1000)
383
-
384
- return ExtractionResult(
385
- facts=facts,
386
- raw_response="rule-based extraction",
387
- tokens_used=0,
388
- extraction_time_ms=extraction_time_ms,
389
- )
390
-
391
-
392
- def create_extractor(
393
- provider: str = "auto",
394
- **kwargs,
395
- ) -> FactExtractor:
396
- """
397
- Factory function to create appropriate extractor.
398
-
399
- Args:
400
- provider: "openai", "anthropic", "local", "rule-based", or "auto"
401
- **kwargs: Additional arguments for the extractor
402
-
403
- Returns:
404
- Configured FactExtractor instance
405
- """
406
- if provider == "auto":
407
- # Try to use LLM if API key is available
408
- import os
409
-
410
- if os.environ.get("OPENAI_API_KEY"):
411
- provider = "openai"
412
- elif os.environ.get("ANTHROPIC_API_KEY"):
413
- provider = "anthropic"
414
- else:
415
- provider = "rule-based"
416
-
417
- if provider == "rule-based":
418
- return RuleBasedExtractor()
419
- else:
420
- return LLMFactExtractor(provider=provider, **kwargs)
1
+ """
2
+ ALMA Fact Extraction Module.
3
+
4
+ LLM-powered extraction of facts, preferences, and learnings from conversations.
5
+ This bridges the gap between Mem0's automatic extraction and ALMA's explicit learning.
6
+ """
7
+
8
+ import logging
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass
11
+ from enum import Enum
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class FactType(Enum):
18
+ """Types of facts that can be extracted from conversations."""
19
+
20
+ HEURISTIC = "heuristic" # Strategy that worked
21
+ ANTI_PATTERN = "anti_pattern" # What NOT to do
22
+ PREFERENCE = "preference" # User preference
23
+ DOMAIN_KNOWLEDGE = "domain_knowledge" # Factual information
24
+ OUTCOME = "outcome" # Task result
25
+
26
+
27
+ @dataclass
28
+ class ExtractedFact:
29
+ """A fact extracted from conversation."""
30
+
31
+ fact_type: FactType
32
+ content: str
33
+ confidence: float # 0.0 to 1.0
34
+ source_text: str # Original text this was extracted from
35
+ metadata: Dict[str, Any] = None
36
+
37
+ # For heuristics/anti-patterns
38
+ condition: Optional[str] = None # When does this apply?
39
+ strategy: Optional[str] = None # What to do?
40
+
41
+ # For preferences
42
+ category: Optional[str] = None
43
+
44
+ # For domain knowledge
45
+ domain: Optional[str] = None
46
+
47
+
48
+ @dataclass
49
+ class ExtractionResult:
50
+ """Result of fact extraction from a conversation."""
51
+
52
+ facts: List[ExtractedFact]
53
+ raw_response: str # LLM's raw response for debugging
54
+ tokens_used: int
55
+ extraction_time_ms: int
56
+
57
+
58
+ class FactExtractor(ABC):
59
+ """Abstract base class for fact extraction."""
60
+
61
+ @abstractmethod
62
+ def extract(
63
+ self,
64
+ messages: List[Dict[str, str]],
65
+ agent_context: Optional[str] = None,
66
+ existing_facts: Optional[List[str]] = None,
67
+ ) -> ExtractionResult:
68
+ """
69
+ Extract facts from a conversation.
70
+
71
+ Args:
72
+ messages: List of {"role": "user"|"assistant", "content": "..."}
73
+ agent_context: Optional context about the agent's domain
74
+ existing_facts: Optional list of already-known facts to avoid duplicates
75
+
76
+ Returns:
77
+ ExtractionResult with extracted facts
78
+ """
79
+ pass
80
+
81
+
82
+ class LLMFactExtractor(FactExtractor):
83
+ """
84
+ LLM-powered fact extraction.
85
+
86
+ Uses structured prompting to extract facts, preferences, and learnings
87
+ from conversations. Supports OpenAI, Anthropic, and local models.
88
+ """
89
+
90
+ EXTRACTION_PROMPT = """You are a fact extraction system for an AI agent memory architecture.
91
+
92
+ Analyze the following conversation and extract facts worth remembering.
93
+
94
+ IMPORTANT: Only extract facts that are:
95
+ 1. Specific and actionable (not vague observations)
96
+ 2. Likely to be useful in future similar situations
97
+ 3. Not already in the existing facts list
98
+
99
+ Categorize each fact as one of:
100
+ - HEURISTIC: A strategy or approach that worked well
101
+ - ANTI_PATTERN: Something that failed or should be avoided
102
+ - PREFERENCE: A user preference or constraint
103
+ - DOMAIN_KNOWLEDGE: A factual piece of information about the domain
104
+ - OUTCOME: The result of a specific task
105
+
106
+ For HEURISTIC and ANTI_PATTERN, also extract:
107
+ - condition: When does this apply?
108
+ - strategy: What to do (or not do)?
109
+
110
+ For PREFERENCE, extract:
111
+ - category: What type of preference (communication, code_style, workflow, etc.)
112
+
113
+ For DOMAIN_KNOWLEDGE, extract:
114
+ - domain: What knowledge domain this belongs to
115
+
116
+ {agent_context}
117
+
118
+ {existing_facts_section}
119
+
120
+ CONVERSATION:
121
+ {conversation}
122
+
123
+ Respond in JSON format:
124
+ ```json
125
+ {{
126
+ "facts": [
127
+ {{
128
+ "fact_type": "HEURISTIC|ANTI_PATTERN|PREFERENCE|DOMAIN_KNOWLEDGE|OUTCOME",
129
+ "content": "The main fact statement",
130
+ "confidence": 0.0-1.0,
131
+ "condition": "optional - when this applies",
132
+ "strategy": "optional - what to do",
133
+ "category": "optional - preference category",
134
+ "domain": "optional - knowledge domain"
135
+ }}
136
+ ]
137
+ }}
138
+ ```
139
+
140
+ If no facts worth extracting, return: {{"facts": []}}
141
+ """
142
+
143
+ def __init__(
144
+ self,
145
+ provider: str = "openai",
146
+ model: str = "gpt-4o-mini",
147
+ api_key: Optional[str] = None,
148
+ temperature: float = 0.1,
149
+ ):
150
+ """
151
+ Initialize LLM fact extractor.
152
+
153
+ Args:
154
+ provider: "openai", "anthropic", or "local"
155
+ model: Model name/identifier
156
+ api_key: API key (or use environment variable)
157
+ temperature: LLM temperature for extraction
158
+ """
159
+ self.provider = provider
160
+ self.model = model
161
+ self.api_key = api_key
162
+ self.temperature = temperature
163
+ self._client = None
164
+
165
+ def _get_client(self):
166
+ """Lazy initialization of LLM client."""
167
+ if self._client is None:
168
+ if self.provider == "openai":
169
+ from openai import OpenAI
170
+
171
+ self._client = OpenAI(api_key=self.api_key)
172
+ elif self.provider == "anthropic":
173
+ from anthropic import Anthropic
174
+
175
+ self._client = Anthropic(api_key=self.api_key)
176
+ else:
177
+ raise ValueError(f"Unsupported provider: {self.provider}")
178
+ return self._client
179
+
180
+ def extract(
181
+ self,
182
+ messages: List[Dict[str, str]],
183
+ agent_context: Optional[str] = None,
184
+ existing_facts: Optional[List[str]] = None,
185
+ ) -> ExtractionResult:
186
+ """Extract facts from conversation using LLM."""
187
+ import time
188
+
189
+ start_time = time.time()
190
+
191
+ # Format conversation
192
+ conversation = "\n".join(
193
+ f"{msg['role'].upper()}: {msg['content']}" for msg in messages
194
+ )
195
+
196
+ # Build prompt
197
+ agent_context_section = ""
198
+ if agent_context:
199
+ agent_context_section = f"\nAGENT CONTEXT:\n{agent_context}\n"
200
+
201
+ existing_facts_section = ""
202
+ if existing_facts:
203
+ facts_list = "\n".join(f"- {f}" for f in existing_facts)
204
+ existing_facts_section = (
205
+ f"\nEXISTING FACTS (do not duplicate):\n{facts_list}\n"
206
+ )
207
+
208
+ prompt = self.EXTRACTION_PROMPT.format(
209
+ agent_context=agent_context_section,
210
+ existing_facts_section=existing_facts_section,
211
+ conversation=conversation,
212
+ )
213
+
214
+ # Call LLM
215
+ client = self._get_client()
216
+ tokens_used = 0
217
+
218
+ if self.provider == "openai":
219
+ response = client.chat.completions.create(
220
+ model=self.model,
221
+ messages=[{"role": "user", "content": prompt}],
222
+ temperature=self.temperature,
223
+ )
224
+ raw_response = response.choices[0].message.content
225
+ tokens_used = response.usage.total_tokens if response.usage else 0
226
+
227
+ elif self.provider == "anthropic":
228
+ response = client.messages.create(
229
+ model=self.model,
230
+ max_tokens=2000,
231
+ messages=[{"role": "user", "content": prompt}],
232
+ )
233
+ raw_response = response.content[0].text
234
+ tokens_used = response.usage.input_tokens + response.usage.output_tokens
235
+
236
+ # Parse response
237
+ facts = self._parse_response(raw_response, conversation)
238
+
239
+ extraction_time_ms = int((time.time() - start_time) * 1000)
240
+
241
+ return ExtractionResult(
242
+ facts=facts,
243
+ raw_response=raw_response,
244
+ tokens_used=tokens_used,
245
+ extraction_time_ms=extraction_time_ms,
246
+ )
247
+
248
+ def _parse_response(
249
+ self,
250
+ raw_response: str,
251
+ source_text: str,
252
+ ) -> List[ExtractedFact]:
253
+ """Parse LLM response into ExtractedFact objects."""
254
+ import json
255
+ import re
256
+
257
+ # Extract JSON from response (handle markdown code blocks)
258
+ json_match = re.search(r"```json\s*(.*?)\s*```", raw_response, re.DOTALL)
259
+ if json_match:
260
+ json_str = json_match.group(1)
261
+ else:
262
+ # Try to find raw JSON
263
+ json_match = re.search(r"\{.*\}", raw_response, re.DOTALL)
264
+ if json_match:
265
+ json_str = json_match.group(0)
266
+ else:
267
+ logger.warning(
268
+ f"Could not parse JSON from response: {raw_response[:200]}"
269
+ )
270
+ return []
271
+
272
+ try:
273
+ data = json.loads(json_str)
274
+ except json.JSONDecodeError as e:
275
+ logger.warning(f"JSON parse error: {e}")
276
+ return []
277
+
278
+ facts = []
279
+ for item in data.get("facts", []):
280
+ try:
281
+ fact_type = FactType[item["fact_type"].upper()]
282
+ facts.append(
283
+ ExtractedFact(
284
+ fact_type=fact_type,
285
+ content=item["content"],
286
+ confidence=float(item.get("confidence", 0.7)),
287
+ source_text=source_text[:500], # Truncate for storage
288
+ condition=item.get("condition"),
289
+ strategy=item.get("strategy"),
290
+ category=item.get("category"),
291
+ domain=item.get("domain"),
292
+ )
293
+ )
294
+ except (KeyError, ValueError) as e:
295
+ logger.warning(f"Could not parse fact: {item}, error: {e}")
296
+ continue
297
+
298
+ return facts
299
+
300
+
301
+ class RuleBasedExtractor(FactExtractor):
302
+ """
303
+ Rule-based fact extraction for offline/free usage.
304
+
305
+ Uses pattern matching and heuristics instead of LLM calls.
306
+ Less accurate but free and fast.
307
+ """
308
+
309
+ # Patterns that indicate different fact types
310
+ HEURISTIC_PATTERNS = [
311
+ r"(?:worked|succeeded|fixed|solved|helped).*(?:by|using|with)",
312
+ r"(?:better|best|good)\s+(?:to|approach|way|strategy)",
313
+ r"(?:should|always|recommend).*(?:use|try|do)",
314
+ ]
315
+
316
+ ANTI_PATTERN_PATTERNS = [
317
+ r"(?:don't|do not|never|avoid).*(?:use|do|try)",
318
+ r"(?:failed|broke|caused|error).*(?:because|when|due)",
319
+ r"(?:bad|wrong|incorrect)\s+(?:to|approach|way)",
320
+ ]
321
+
322
+ PREFERENCE_PATTERNS = [
323
+ r"(?:i|user)\s+(?:prefer|like|want|need)",
324
+ r"(?:always|never).*(?:for me|i want)",
325
+ ]
326
+
327
+ def extract(
328
+ self,
329
+ messages: List[Dict[str, str]],
330
+ agent_context: Optional[str] = None,
331
+ existing_facts: Optional[List[str]] = None,
332
+ ) -> ExtractionResult:
333
+ """Extract facts using pattern matching."""
334
+ import re
335
+ import time
336
+
337
+ start_time = time.time()
338
+ facts = []
339
+
340
+ for msg in messages:
341
+ content = msg["content"].lower()
342
+
343
+ # Check for heuristics
344
+ for pattern in self.HEURISTIC_PATTERNS:
345
+ if re.search(pattern, content, re.IGNORECASE):
346
+ facts.append(
347
+ ExtractedFact(
348
+ fact_type=FactType.HEURISTIC,
349
+ content=msg["content"][:200],
350
+ confidence=0.5, # Lower confidence for rule-based
351
+ source_text=msg["content"],
352
+ )
353
+ )
354
+ break
355
+
356
+ # Check for anti-patterns
357
+ for pattern in self.ANTI_PATTERN_PATTERNS:
358
+ if re.search(pattern, content, re.IGNORECASE):
359
+ facts.append(
360
+ ExtractedFact(
361
+ fact_type=FactType.ANTI_PATTERN,
362
+ content=msg["content"][:200],
363
+ confidence=0.5,
364
+ source_text=msg["content"],
365
+ )
366
+ )
367
+ break
368
+
369
+ # Check for preferences
370
+ for pattern in self.PREFERENCE_PATTERNS:
371
+ if re.search(pattern, content, re.IGNORECASE):
372
+ facts.append(
373
+ ExtractedFact(
374
+ fact_type=FactType.PREFERENCE,
375
+ content=msg["content"][:200],
376
+ confidence=0.5,
377
+ source_text=msg["content"],
378
+ )
379
+ )
380
+ break
381
+
382
+ extraction_time_ms = int((time.time() - start_time) * 1000)
383
+
384
+ return ExtractionResult(
385
+ facts=facts,
386
+ raw_response="rule-based extraction",
387
+ tokens_used=0,
388
+ extraction_time_ms=extraction_time_ms,
389
+ )
390
+
391
+
392
+ def create_extractor(
393
+ provider: str = "auto",
394
+ **kwargs,
395
+ ) -> FactExtractor:
396
+ """
397
+ Factory function to create appropriate extractor.
398
+
399
+ Args:
400
+ provider: "openai", "anthropic", "local", "rule-based", or "auto"
401
+ **kwargs: Additional arguments for the extractor
402
+
403
+ Returns:
404
+ Configured FactExtractor instance
405
+ """
406
+ if provider == "auto":
407
+ # Try to use LLM if API key is available
408
+ import os
409
+
410
+ if os.environ.get("OPENAI_API_KEY"):
411
+ provider = "openai"
412
+ elif os.environ.get("ANTHROPIC_API_KEY"):
413
+ provider = "anthropic"
414
+ else:
415
+ provider = "rule-based"
416
+
417
+ if provider == "rule-based":
418
+ return RuleBasedExtractor()
419
+ else:
420
+ return LLMFactExtractor(provider=provider, **kwargs)