alma-memory 0.5.1__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.
- alma/__init__.py +296 -226
- alma/compression/__init__.py +33 -0
- alma/compression/pipeline.py +980 -0
- alma/confidence/__init__.py +47 -47
- alma/confidence/engine.py +540 -540
- alma/confidence/types.py +351 -351
- alma/config/loader.py +157 -157
- alma/consolidation/__init__.py +23 -23
- alma/consolidation/engine.py +678 -678
- alma/consolidation/prompts.py +84 -84
- alma/core.py +1189 -430
- alma/domains/__init__.py +30 -30
- alma/domains/factory.py +359 -359
- alma/domains/schemas.py +448 -448
- alma/domains/types.py +272 -272
- alma/events/__init__.py +75 -75
- alma/events/emitter.py +285 -284
- alma/events/storage_mixin.py +246 -246
- alma/events/types.py +126 -126
- alma/events/webhook.py +425 -425
- alma/exceptions.py +49 -49
- alma/extraction/__init__.py +31 -31
- alma/extraction/auto_learner.py +265 -265
- alma/extraction/extractor.py +420 -420
- alma/graph/__init__.py +106 -106
- alma/graph/backends/__init__.py +32 -32
- alma/graph/backends/kuzu.py +624 -624
- alma/graph/backends/memgraph.py +432 -432
- alma/graph/backends/memory.py +236 -236
- alma/graph/backends/neo4j.py +417 -417
- alma/graph/base.py +159 -159
- alma/graph/extraction.py +198 -198
- alma/graph/store.py +860 -860
- alma/harness/__init__.py +35 -35
- alma/harness/base.py +386 -386
- alma/harness/domains.py +705 -705
- alma/initializer/__init__.py +37 -37
- alma/initializer/initializer.py +418 -418
- alma/initializer/types.py +250 -250
- alma/integration/__init__.py +62 -62
- alma/integration/claude_agents.py +444 -444
- alma/integration/helena.py +423 -423
- alma/integration/victor.py +471 -471
- alma/learning/__init__.py +101 -86
- alma/learning/decay.py +878 -0
- alma/learning/forgetting.py +1446 -1446
- alma/learning/heuristic_extractor.py +390 -390
- alma/learning/protocols.py +374 -374
- alma/learning/validation.py +346 -346
- alma/mcp/__init__.py +123 -45
- alma/mcp/__main__.py +156 -156
- alma/mcp/resources.py +122 -122
- alma/mcp/server.py +955 -591
- alma/mcp/tools.py +3254 -509
- alma/observability/__init__.py +91 -84
- alma/observability/config.py +302 -302
- alma/observability/guidelines.py +170 -0
- alma/observability/logging.py +424 -424
- alma/observability/metrics.py +583 -583
- alma/observability/tracing.py +440 -440
- alma/progress/__init__.py +21 -21
- alma/progress/tracker.py +607 -607
- alma/progress/types.py +250 -250
- alma/retrieval/__init__.py +134 -53
- alma/retrieval/budget.py +525 -0
- alma/retrieval/cache.py +1304 -1061
- alma/retrieval/embeddings.py +202 -202
- alma/retrieval/engine.py +850 -427
- alma/retrieval/modes.py +365 -0
- alma/retrieval/progressive.py +560 -0
- alma/retrieval/scoring.py +344 -344
- alma/retrieval/trust_scoring.py +637 -0
- alma/retrieval/verification.py +797 -0
- alma/session/__init__.py +19 -19
- alma/session/manager.py +442 -399
- alma/session/types.py +288 -288
- alma/storage/__init__.py +101 -90
- alma/storage/archive.py +233 -0
- alma/storage/azure_cosmos.py +1259 -1259
- alma/storage/base.py +1083 -583
- alma/storage/chroma.py +1443 -1443
- alma/storage/constants.py +103 -103
- alma/storage/file_based.py +614 -614
- alma/storage/migrations/__init__.py +21 -21
- alma/storage/migrations/base.py +321 -321
- alma/storage/migrations/runner.py +323 -323
- alma/storage/migrations/version_stores.py +337 -337
- alma/storage/migrations/versions/__init__.py +11 -11
- alma/storage/migrations/versions/v1_0_0.py +373 -373
- alma/storage/migrations/versions/v1_1_0_workflow_context.py +551 -0
- alma/storage/pinecone.py +1080 -1080
- alma/storage/postgresql.py +1948 -1559
- alma/storage/qdrant.py +1306 -1306
- alma/storage/sqlite_local.py +3041 -1457
- alma/testing/__init__.py +46 -46
- alma/testing/factories.py +301 -301
- alma/testing/mocks.py +389 -389
- alma/types.py +292 -264
- alma/utils/__init__.py +19 -0
- alma/utils/tokenizer.py +521 -0
- alma/workflow/__init__.py +83 -0
- alma/workflow/artifacts.py +170 -0
- alma/workflow/checkpoint.py +311 -0
- alma/workflow/context.py +228 -0
- alma/workflow/outcomes.py +189 -0
- alma/workflow/reducers.py +393 -0
- {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/METADATA +210 -72
- alma_memory-0.7.0.dist-info/RECORD +112 -0
- alma_memory-0.5.1.dist-info/RECORD +0 -93
- {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/WHEEL +0 -0
- {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/top_level.txt +0 -0
alma/extraction/extractor.py
CHANGED
|
@@ -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)
|