runtime-memory 3.0.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.
- runtime_memory/__init__.py +28 -0
- runtime_memory/claude_code/__init__.py +48 -0
- runtime_memory/claude_code/commands.py +698 -0
- runtime_memory/claude_code/daemon.py +852 -0
- runtime_memory/claude_code/hooks.py +722 -0
- runtime_memory/cli/__init__.py +8 -0
- runtime_memory/cli/main.py +1936 -0
- runtime_memory/core/__init__.py +216 -0
- runtime_memory/core/config.py +473 -0
- runtime_memory/core/embeddings.py +908 -0
- runtime_memory/core/engine.py +1007 -0
- runtime_memory/core/exceptions.py +547 -0
- runtime_memory/core/legacy_env.py +39 -0
- runtime_memory/core/logging.py +160 -0
- runtime_memory/core/models.py +1051 -0
- runtime_memory/core/observability.py +725 -0
- runtime_memory/core/paths.py +30 -0
- runtime_memory/core/resilience.py +511 -0
- runtime_memory/core/retrieval.py +819 -0
- runtime_memory/core/storage.py +1105 -0
- runtime_memory/extraction/__init__.py +36 -0
- runtime_memory/extraction/extractor.py +1143 -0
- runtime_memory/hermes/__init__.py +39 -0
- runtime_memory/hermes/_base.py +154 -0
- runtime_memory/hermes/bridge.py +119 -0
- runtime_memory/hermes/plugin.yaml +13 -0
- runtime_memory/hermes/provider.py +536 -0
- runtime_memory/hermes/tools.py +230 -0
- runtime_memory/hermes/trace.py +177 -0
- runtime_memory/plugin/__init__.py +646 -0
- runtime_memory/sdk/__init__.py +97 -0
- runtime_memory/sdk/client.py +1577 -0
- runtime_memory/server/__init__.py +75 -0
- runtime_memory/server/api.py +1665 -0
- runtime_memory/server/mcp.py +1574 -0
- runtime_memory/server/static/css/styles.css +1110 -0
- runtime_memory/server/static/index.html +264 -0
- runtime_memory/server/static/js/api.js +294 -0
- runtime_memory/server/static/js/app.js +771 -0
- runtime_memory/tasks/__init__.py +114 -0
- runtime_memory/tasks/adapter.py +501 -0
- runtime_memory/tasks/claude_code_adapter.py +495 -0
- runtime_memory/tasks/claude_code_parser.py +339 -0
- runtime_memory/tasks/cli_bridge.py +415 -0
- runtime_memory/tasks/linking.py +397 -0
- runtime_memory/tasks/models.py +520 -0
- runtime_memory/tasks/outcomes.py +320 -0
- runtime_memory/tasks/parser.py +305 -0
- runtime_memory/tasks/unified_adapter.py +661 -0
- runtime_memory-3.0.0.dist-info/METADATA +497 -0
- runtime_memory-3.0.0.dist-info/RECORD +54 -0
- runtime_memory-3.0.0.dist-info/WHEEL +4 -0
- runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
- runtime_memory-3.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
"""Extraction pipeline for Runtime Memory.
|
|
2
|
+
|
|
3
|
+
Extracts actionable memories from conversation transcripts using LLM analysis.
|
|
4
|
+
|
|
5
|
+
Features:
|
|
6
|
+
- Structured extraction via LLM prompts
|
|
7
|
+
- Category auto-detection
|
|
8
|
+
- Confidence and importance scoring
|
|
9
|
+
- Entity detection (files, functions, errors)
|
|
10
|
+
- Conflict detection with existing memories
|
|
11
|
+
- Rate limiting for API calls
|
|
12
|
+
- PII detection and filtering
|
|
13
|
+
- Prompt injection prevention
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import UTC, datetime
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
26
|
+
|
|
27
|
+
from runtime_memory.core.logging import get_logger
|
|
28
|
+
from runtime_memory.core.models import Memory, MemoryCategory, MemorySource
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from runtime_memory.core.engine import MemoryEngine
|
|
32
|
+
|
|
33
|
+
logger = get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# =============================================================================
|
|
37
|
+
# Extraction Prompts
|
|
38
|
+
# =============================================================================
|
|
39
|
+
|
|
40
|
+
EXTRACTION_SYSTEM_PROMPT = """You are a memory extraction assistant. Your job is to extract actionable, reusable knowledge from coding conversation transcripts.
|
|
41
|
+
|
|
42
|
+
Focus on extracting:
|
|
43
|
+
1. **Decisions made and their rationale** - WHY something was chosen, not just WHAT
|
|
44
|
+
2. **Patterns discovered or established** - Reusable approaches
|
|
45
|
+
3. **Gotchas and pitfalls encountered** - Things that caused problems
|
|
46
|
+
4. **Solutions to problems** - Error messages → fixes (troubleshooting)
|
|
47
|
+
5. **User preferences expressed** - Coding style, tool preferences
|
|
48
|
+
6. **Useful commands** - Shell, npm, docker commands that worked
|
|
49
|
+
7. **Architecture insights** - System design decisions
|
|
50
|
+
8. **Conventions** - Project-specific coding standards
|
|
51
|
+
|
|
52
|
+
Skip:
|
|
53
|
+
- Generic coding advice the AI would already know
|
|
54
|
+
- One-time specific fixes unlikely to recur
|
|
55
|
+
- Incomplete or abandoned approaches
|
|
56
|
+
- Information that's too vague to be actionable
|
|
57
|
+
- Personal information (names, emails, API keys, passwords)
|
|
58
|
+
|
|
59
|
+
Output JSON only. No markdown, no explanations."""
|
|
60
|
+
|
|
61
|
+
EXTRACTION_USER_PROMPT = """Extract actionable memories from this conversation transcript.
|
|
62
|
+
|
|
63
|
+
<transcript>
|
|
64
|
+
{transcript}
|
|
65
|
+
</transcript>
|
|
66
|
+
|
|
67
|
+
Return a JSON object with this exact structure:
|
|
68
|
+
{{
|
|
69
|
+
"memories": [
|
|
70
|
+
{{
|
|
71
|
+
"content": "Clear, actionable statement of what was learned",
|
|
72
|
+
"category": "one of: architecture, convention, decision, pattern, gotcha, workaround, troubleshooting, command, preference",
|
|
73
|
+
"importance": 0.0 to 1.0 (how important/reusable is this),
|
|
74
|
+
"confidence": 0.0 to 1.0 (how certain are we this is correct),
|
|
75
|
+
"entities": ["list", "of", "relevant", "entities"],
|
|
76
|
+
"tags": ["optional", "tags"],
|
|
77
|
+
"rationale": "Brief explanation of why this is worth remembering"
|
|
78
|
+
}}
|
|
79
|
+
],
|
|
80
|
+
"summary": "One sentence summary of the conversation"
|
|
81
|
+
}}
|
|
82
|
+
|
|
83
|
+
Guidelines:
|
|
84
|
+
- importance: 0.9+ for critical gotchas/decisions, 0.5-0.8 for useful patterns, 0.3-0.5 for minor preferences
|
|
85
|
+
- confidence: 0.9+ if explicitly stated and verified, 0.6-0.8 if implied, 0.3-0.5 if inferred
|
|
86
|
+
- entities: Include file names, function names, package names, error types
|
|
87
|
+
- Each memory should be self-contained and understandable without context
|
|
88
|
+
|
|
89
|
+
Return ONLY the JSON object, no other text."""
|
|
90
|
+
|
|
91
|
+
CONFLICT_DETECTION_PROMPT = """Compare these two memories and determine their relationship.
|
|
92
|
+
|
|
93
|
+
EXISTING MEMORY:
|
|
94
|
+
Content: {existing_content}
|
|
95
|
+
Category: {existing_category}
|
|
96
|
+
|
|
97
|
+
NEW MEMORY:
|
|
98
|
+
Content: {new_content}
|
|
99
|
+
Category: {new_category}
|
|
100
|
+
|
|
101
|
+
What is the relationship between these memories?
|
|
102
|
+
|
|
103
|
+
Return a JSON object:
|
|
104
|
+
{{
|
|
105
|
+
"relationship": "one of: updates, extends, conflicts, unrelated",
|
|
106
|
+
"confidence": 0.0 to 1.0,
|
|
107
|
+
"explanation": "Brief explanation of your reasoning",
|
|
108
|
+
"should_supersede": true or false (should new memory replace existing?)
|
|
109
|
+
}}
|
|
110
|
+
|
|
111
|
+
Definitions:
|
|
112
|
+
- updates: New memory provides updated information on the same topic (supersedes old)
|
|
113
|
+
- extends: New memory adds complementary information (both should exist)
|
|
114
|
+
- conflicts: Memories contradict each other (needs resolution)
|
|
115
|
+
- unrelated: Different topics entirely
|
|
116
|
+
|
|
117
|
+
Return ONLY the JSON object."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# =============================================================================
|
|
121
|
+
# Entity Detection Patterns
|
|
122
|
+
# =============================================================================
|
|
123
|
+
|
|
124
|
+
ENTITY_PATTERNS: dict[str, list[str]] = {
|
|
125
|
+
"file": [
|
|
126
|
+
r'\b([\w/-]+\.(?:py|js|ts|tsx|jsx|go|rs|java|cpp|c|h|rb|php|swift|kt|scala|vue|svelte|md|json|yaml|yml|toml|sql|sh|bash|zsh))\b',
|
|
127
|
+
r'`([^`]+\.(?:py|js|ts|tsx|jsx|go|rs|java|cpp|c|h|rb|php))`',
|
|
128
|
+
],
|
|
129
|
+
"module": [
|
|
130
|
+
r'\bfrom\s+([\w.]+)\s+import\b',
|
|
131
|
+
r'\bimport\s+([\w.]+)',
|
|
132
|
+
r'\brequire\([\'"]([^\'"]+)[\'"]\)',
|
|
133
|
+
],
|
|
134
|
+
"error": [
|
|
135
|
+
r'\b([A-Z][a-zA-Z]*Error)\b',
|
|
136
|
+
r'\b([A-Z][a-zA-Z]*Exception)\b',
|
|
137
|
+
r'\b([A-Z][a-zA-Z]*Warning)\b',
|
|
138
|
+
],
|
|
139
|
+
"function": [
|
|
140
|
+
r'\bdef\s+(\w+)\s*\(',
|
|
141
|
+
r'\bfunction\s+(\w+)\s*\(',
|
|
142
|
+
r'\bconst\s+(\w+)\s*=\s*(?:async\s*)?\(',
|
|
143
|
+
r'\b(\w+)\s*=\s*(?:async\s+)?function',
|
|
144
|
+
],
|
|
145
|
+
"class": [
|
|
146
|
+
r'\bclass\s+(\w+)',
|
|
147
|
+
],
|
|
148
|
+
"command": [
|
|
149
|
+
r'\$\s*([^\n]+)',
|
|
150
|
+
r'```(?:bash|sh|shell|zsh)\n([^`]+)```',
|
|
151
|
+
r'`(npm\s+\w+[^`]*)`',
|
|
152
|
+
r'`(pip\s+\w+[^`]*)`',
|
|
153
|
+
r'`(docker\s+\w+[^`]*)`',
|
|
154
|
+
r'`(git\s+\w+[^`]*)`',
|
|
155
|
+
],
|
|
156
|
+
"package": [
|
|
157
|
+
r'\b(npm|pip|cargo|go)\s+install\s+([\w@/-]+)',
|
|
158
|
+
r'"([\w@/-]+)":\s*"[\d^~]',
|
|
159
|
+
],
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# PII detection patterns
|
|
163
|
+
PII_PATTERNS: dict[str, re.Pattern[str]] = {
|
|
164
|
+
"email": re.compile(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'),
|
|
165
|
+
"api_key": re.compile(r'\b(?:sk-|pk-|api[_-]?key[_-]?)[a-zA-Z0-9]{20,}\b', re.IGNORECASE),
|
|
166
|
+
"password": re.compile(r'(?:password|passwd|pwd)\s*[=:]\s*[\'"]?([^\s\'"]+)', re.IGNORECASE),
|
|
167
|
+
"token": re.compile(r'\b(?:token|secret)[_-]?[a-zA-Z0-9]{20,}\b', re.IGNORECASE),
|
|
168
|
+
"ip_address": re.compile(r'\b(?:\d{1,3}\.){3}\d{1,3}\b'),
|
|
169
|
+
"phone": re.compile(r'\b(?:\+\d{1,3}[-.]?)?\(?\d{3}\)?[-.]?\d{3}[-.]?\d{4}\b'),
|
|
170
|
+
"ssn": re.compile(r'\b\d{3}-\d{2}-\d{4}\b'),
|
|
171
|
+
"credit_card": re.compile(r'\b(?:\d{4}[-\s]?){3}\d{4}\b'),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# Prompt injection patterns to detect and filter
|
|
175
|
+
INJECTION_PATTERNS: list[re.Pattern[str]] = [
|
|
176
|
+
re.compile(r'ignore\s+(?:all\s+)?(?:previous|above)\s+instructions?', re.IGNORECASE),
|
|
177
|
+
re.compile(r'disregard\s+(?:all\s+)?(?:previous|above)', re.IGNORECASE),
|
|
178
|
+
re.compile(r'you\s+are\s+now\s+(?:a|an|in)', re.IGNORECASE),
|
|
179
|
+
re.compile(r'new\s+instructions?\s*:', re.IGNORECASE),
|
|
180
|
+
re.compile(r'system\s*:\s*you', re.IGNORECASE),
|
|
181
|
+
re.compile(r'<\|(?:im_start|im_end|system|user|assistant)\|>', re.IGNORECASE),
|
|
182
|
+
re.compile(r'\[INST\]|\[/INST\]', re.IGNORECASE),
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# =============================================================================
|
|
187
|
+
# Data Classes
|
|
188
|
+
# =============================================================================
|
|
189
|
+
|
|
190
|
+
class ConflictRelationship(str, Enum):
|
|
191
|
+
"""Relationship between conflicting memories."""
|
|
192
|
+
|
|
193
|
+
UPDATES = "updates"
|
|
194
|
+
"""New memory updates/supersedes old memory."""
|
|
195
|
+
|
|
196
|
+
EXTENDS = "extends"
|
|
197
|
+
"""New memory extends/complements old memory."""
|
|
198
|
+
|
|
199
|
+
CONFLICTS = "conflicts"
|
|
200
|
+
"""Memories conflict with each other."""
|
|
201
|
+
|
|
202
|
+
UNRELATED = "unrelated"
|
|
203
|
+
"""Memories are about different topics."""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass
|
|
207
|
+
class ExtractedMemory:
|
|
208
|
+
"""A memory extracted from a transcript."""
|
|
209
|
+
|
|
210
|
+
content: str
|
|
211
|
+
"""The extracted memory content."""
|
|
212
|
+
|
|
213
|
+
category: MemoryCategory
|
|
214
|
+
"""Detected category."""
|
|
215
|
+
|
|
216
|
+
importance: float = 0.5
|
|
217
|
+
"""Importance score (0.0 to 1.0)."""
|
|
218
|
+
|
|
219
|
+
confidence: float = 0.5
|
|
220
|
+
"""Confidence score (0.0 to 1.0)."""
|
|
221
|
+
|
|
222
|
+
entities: list[str] = field(default_factory=list)
|
|
223
|
+
"""Detected entities (files, functions, errors)."""
|
|
224
|
+
|
|
225
|
+
tags: list[str] = field(default_factory=list)
|
|
226
|
+
"""Optional tags."""
|
|
227
|
+
|
|
228
|
+
rationale: str = ""
|
|
229
|
+
"""Why this memory is worth remembering."""
|
|
230
|
+
|
|
231
|
+
def to_memory(self, project: str | None = None) -> Memory:
|
|
232
|
+
"""Convert to Memory dataclass.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
project: Project scope.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Memory instance.
|
|
239
|
+
"""
|
|
240
|
+
return Memory(
|
|
241
|
+
content=self.content,
|
|
242
|
+
category=self.category,
|
|
243
|
+
source=MemorySource.EXTRACTED,
|
|
244
|
+
confidence=self.confidence,
|
|
245
|
+
importance=self.importance,
|
|
246
|
+
entities=self.entities,
|
|
247
|
+
tags=self.tags,
|
|
248
|
+
project=project,
|
|
249
|
+
metadata={"rationale": self.rationale} if self.rationale else {},
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass
|
|
254
|
+
class ConflictResult:
|
|
255
|
+
"""Result of conflict detection between memories."""
|
|
256
|
+
|
|
257
|
+
existing_id: str
|
|
258
|
+
"""ID of existing memory."""
|
|
259
|
+
|
|
260
|
+
relationship: ConflictRelationship
|
|
261
|
+
"""Detected relationship."""
|
|
262
|
+
|
|
263
|
+
confidence: float
|
|
264
|
+
"""Confidence in the relationship detection."""
|
|
265
|
+
|
|
266
|
+
explanation: str
|
|
267
|
+
"""Explanation of the relationship."""
|
|
268
|
+
|
|
269
|
+
should_supersede: bool
|
|
270
|
+
"""Whether new memory should supersede existing."""
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass
|
|
274
|
+
class ExtractionResult:
|
|
275
|
+
"""Result of extracting memories from a transcript."""
|
|
276
|
+
|
|
277
|
+
memories: list[ExtractedMemory]
|
|
278
|
+
"""Extracted memories."""
|
|
279
|
+
|
|
280
|
+
summary: str
|
|
281
|
+
"""Summary of the conversation."""
|
|
282
|
+
|
|
283
|
+
transcript_length: int
|
|
284
|
+
"""Length of the original transcript."""
|
|
285
|
+
|
|
286
|
+
extraction_time_ms: float
|
|
287
|
+
"""Time taken for extraction."""
|
|
288
|
+
|
|
289
|
+
conflicts: list[ConflictResult] = field(default_factory=list)
|
|
290
|
+
"""Detected conflicts with existing memories."""
|
|
291
|
+
|
|
292
|
+
pii_removed: int = 0
|
|
293
|
+
"""Number of PII instances removed."""
|
|
294
|
+
|
|
295
|
+
injection_attempts: int = 0
|
|
296
|
+
"""Number of potential injection attempts detected."""
|
|
297
|
+
|
|
298
|
+
raw_response: str = ""
|
|
299
|
+
"""Raw LLM response for debugging."""
|
|
300
|
+
|
|
301
|
+
error: str | None = None
|
|
302
|
+
"""Error message if extraction failed."""
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def success(self) -> bool:
|
|
306
|
+
"""Whether extraction was successful."""
|
|
307
|
+
return self.error is None
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def memory_count(self) -> int:
|
|
311
|
+
"""Number of memories extracted."""
|
|
312
|
+
return len(self.memories)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@dataclass
|
|
316
|
+
class ExtractionConfig:
|
|
317
|
+
"""Configuration for the extraction pipeline."""
|
|
318
|
+
|
|
319
|
+
# LLM settings
|
|
320
|
+
model: str = "claude-sonnet-4-20250514"
|
|
321
|
+
"""Model to use for extraction."""
|
|
322
|
+
|
|
323
|
+
max_tokens: int = 4096
|
|
324
|
+
"""Maximum tokens in response."""
|
|
325
|
+
|
|
326
|
+
temperature: float = 0.1
|
|
327
|
+
"""Temperature for generation (low for consistency)."""
|
|
328
|
+
|
|
329
|
+
# Rate limiting
|
|
330
|
+
rate_limit_rpm: int = 50
|
|
331
|
+
"""Rate limit: requests per minute."""
|
|
332
|
+
|
|
333
|
+
rate_limit_tpm: int = 100000
|
|
334
|
+
"""Rate limit: tokens per minute."""
|
|
335
|
+
|
|
336
|
+
# Content limits
|
|
337
|
+
max_transcript_length: int = 100000
|
|
338
|
+
"""Maximum transcript length in characters."""
|
|
339
|
+
|
|
340
|
+
min_transcript_length: int = 100
|
|
341
|
+
"""Minimum transcript length to process."""
|
|
342
|
+
|
|
343
|
+
# Filtering
|
|
344
|
+
min_confidence: float = 0.3
|
|
345
|
+
"""Minimum confidence to keep a memory."""
|
|
346
|
+
|
|
347
|
+
min_importance: float = 0.2
|
|
348
|
+
"""Minimum importance to keep a memory."""
|
|
349
|
+
|
|
350
|
+
# Security
|
|
351
|
+
enable_pii_filtering: bool = True
|
|
352
|
+
"""Whether to filter PII from transcripts."""
|
|
353
|
+
|
|
354
|
+
enable_injection_detection: bool = True
|
|
355
|
+
"""Whether to detect prompt injection attempts."""
|
|
356
|
+
|
|
357
|
+
# Conflict detection
|
|
358
|
+
enable_conflict_detection: bool = True
|
|
359
|
+
"""Whether to detect conflicts with existing memories."""
|
|
360
|
+
|
|
361
|
+
conflict_similarity_threshold: float = 0.7
|
|
362
|
+
"""Similarity threshold for potential conflicts."""
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# =============================================================================
|
|
366
|
+
# Rate Limiter
|
|
367
|
+
# =============================================================================
|
|
368
|
+
|
|
369
|
+
class RateLimiter:
|
|
370
|
+
"""Simple rate limiter using token bucket algorithm."""
|
|
371
|
+
|
|
372
|
+
def __init__(self, requests_per_minute: int, tokens_per_minute: int) -> None:
|
|
373
|
+
"""Initialize rate limiter.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
requests_per_minute: Maximum requests per minute.
|
|
377
|
+
tokens_per_minute: Maximum tokens per minute.
|
|
378
|
+
"""
|
|
379
|
+
self.rpm = requests_per_minute
|
|
380
|
+
self.tpm = tokens_per_minute
|
|
381
|
+
self._request_times: list[float] = []
|
|
382
|
+
self._token_counts: list[tuple[float, int]] = []
|
|
383
|
+
self._lock = asyncio.Lock()
|
|
384
|
+
|
|
385
|
+
async def acquire(self, tokens: int = 0) -> None:
|
|
386
|
+
"""Wait until request can proceed.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
tokens: Estimated tokens for this request.
|
|
390
|
+
"""
|
|
391
|
+
async with self._lock:
|
|
392
|
+
now = time.time()
|
|
393
|
+
minute_ago = now - 60
|
|
394
|
+
|
|
395
|
+
# Clean old entries
|
|
396
|
+
self._request_times = [t for t in self._request_times if t > minute_ago]
|
|
397
|
+
self._token_counts = [(t, c) for t, c in self._token_counts if t > minute_ago]
|
|
398
|
+
|
|
399
|
+
# Check request limit
|
|
400
|
+
while len(self._request_times) >= self.rpm:
|
|
401
|
+
sleep_time = self._request_times[0] - minute_ago
|
|
402
|
+
if sleep_time > 0:
|
|
403
|
+
await asyncio.sleep(sleep_time)
|
|
404
|
+
now = time.time()
|
|
405
|
+
minute_ago = now - 60
|
|
406
|
+
self._request_times = [t for t in self._request_times if t > minute_ago]
|
|
407
|
+
|
|
408
|
+
# Check token limit
|
|
409
|
+
current_tokens = sum(c for _, c in self._token_counts)
|
|
410
|
+
while current_tokens + tokens > self.tpm:
|
|
411
|
+
if self._token_counts:
|
|
412
|
+
sleep_time = self._token_counts[0][0] - minute_ago
|
|
413
|
+
if sleep_time > 0:
|
|
414
|
+
await asyncio.sleep(sleep_time)
|
|
415
|
+
now = time.time()
|
|
416
|
+
minute_ago = now - 60
|
|
417
|
+
self._token_counts = [(t, c) for t, c in self._token_counts if t > minute_ago]
|
|
418
|
+
current_tokens = sum(c for _, c in self._token_counts)
|
|
419
|
+
|
|
420
|
+
# Record this request
|
|
421
|
+
self._request_times.append(now)
|
|
422
|
+
if tokens > 0:
|
|
423
|
+
self._token_counts.append((now, tokens))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# =============================================================================
|
|
427
|
+
# Main Extractor Class
|
|
428
|
+
# =============================================================================
|
|
429
|
+
|
|
430
|
+
class MemoryExtractor:
|
|
431
|
+
"""Extracts memories from conversation transcripts using LLM analysis."""
|
|
432
|
+
|
|
433
|
+
# Category keywords for auto-detection fallback
|
|
434
|
+
CATEGORY_KEYWORDS: ClassVar[dict[MemoryCategory, set[str]]] = {
|
|
435
|
+
MemoryCategory.ARCHITECTURE: {
|
|
436
|
+
"architecture", "design", "microservice", "monolith", "database",
|
|
437
|
+
"system", "component", "layer", "api", "service",
|
|
438
|
+
},
|
|
439
|
+
MemoryCategory.CONVENTION: {
|
|
440
|
+
"convention", "standard", "naming", "format", "style", "lint",
|
|
441
|
+
"rule", "guideline", "best practice",
|
|
442
|
+
},
|
|
443
|
+
MemoryCategory.DECISION: {
|
|
444
|
+
"decided", "chose", "choice", "decision", "why", "because",
|
|
445
|
+
"rationale", "trade-off", "alternative",
|
|
446
|
+
},
|
|
447
|
+
MemoryCategory.PATTERN: {
|
|
448
|
+
"pattern", "approach", "technique", "method", "way to",
|
|
449
|
+
"how to", "idiom", "recipe",
|
|
450
|
+
},
|
|
451
|
+
MemoryCategory.GOTCHA: {
|
|
452
|
+
"gotcha", "watch out", "careful", "warning", "trap", "pitfall",
|
|
453
|
+
"don't", "avoid", "never", "beware", "caution", "caveat",
|
|
454
|
+
},
|
|
455
|
+
MemoryCategory.WORKAROUND: {
|
|
456
|
+
"workaround", "hack", "temporary", "quick fix", "bypass",
|
|
457
|
+
"until", "for now", "interim",
|
|
458
|
+
},
|
|
459
|
+
MemoryCategory.TROUBLESHOOTING: {
|
|
460
|
+
"error", "exception", "bug", "fix", "crash", "fail", "issue",
|
|
461
|
+
"traceback", "debug", "solve", "solution",
|
|
462
|
+
},
|
|
463
|
+
MemoryCategory.COMMAND: {
|
|
464
|
+
"command", "cli", "terminal", "shell", "npm", "pip", "docker",
|
|
465
|
+
"git", "run", "execute", "script",
|
|
466
|
+
},
|
|
467
|
+
MemoryCategory.PREFERENCE: {
|
|
468
|
+
"prefer", "like", "want", "favorite", "always", "usually",
|
|
469
|
+
"habit", "style",
|
|
470
|
+
},
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
def __init__(
|
|
474
|
+
self,
|
|
475
|
+
config: ExtractionConfig | None = None,
|
|
476
|
+
api_key: str | None = None,
|
|
477
|
+
) -> None:
|
|
478
|
+
"""Initialize the extractor.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
config: Extraction configuration.
|
|
482
|
+
api_key: Anthropic API key (or use ANTHROPIC_API_KEY env var).
|
|
483
|
+
"""
|
|
484
|
+
self.config = config or ExtractionConfig()
|
|
485
|
+
self._api_key = api_key
|
|
486
|
+
self._client: Any = None
|
|
487
|
+
self._rate_limiter = RateLimiter(
|
|
488
|
+
self.config.rate_limit_rpm,
|
|
489
|
+
self.config.rate_limit_tpm,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def _get_client(self) -> Any:
|
|
493
|
+
"""Get or create the Anthropic client."""
|
|
494
|
+
if self._client is None:
|
|
495
|
+
try:
|
|
496
|
+
import anthropic
|
|
497
|
+
except ImportError as e:
|
|
498
|
+
raise ImportError(
|
|
499
|
+
"anthropic package required for extraction. "
|
|
500
|
+
"Install with: pip install 'runtime-memory[phase1]'"
|
|
501
|
+
) from e
|
|
502
|
+
|
|
503
|
+
self._client = anthropic.AsyncAnthropic(api_key=self._api_key)
|
|
504
|
+
|
|
505
|
+
return self._client
|
|
506
|
+
|
|
507
|
+
# =========================================================================
|
|
508
|
+
# Security: PII and Injection Detection
|
|
509
|
+
# =========================================================================
|
|
510
|
+
|
|
511
|
+
def detect_pii(self, text: str) -> list[tuple[str, str, int, int]]:
|
|
512
|
+
"""Detect PII in text.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
text: Text to scan.
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
List of (pii_type, matched_text, start, end) tuples.
|
|
519
|
+
"""
|
|
520
|
+
findings: list[tuple[str, str, int, int]] = []
|
|
521
|
+
for pii_type, pattern in PII_PATTERNS.items():
|
|
522
|
+
for match in pattern.finditer(text):
|
|
523
|
+
findings.append((pii_type, match.group(), match.start(), match.end()))
|
|
524
|
+
return findings
|
|
525
|
+
|
|
526
|
+
def filter_pii(self, text: str) -> tuple[str, int]:
|
|
527
|
+
"""Remove PII from text.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
text: Text to filter.
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
Tuple of (filtered_text, count_removed).
|
|
534
|
+
"""
|
|
535
|
+
findings = self.detect_pii(text)
|
|
536
|
+
if not findings:
|
|
537
|
+
return text, 0
|
|
538
|
+
|
|
539
|
+
# Sort by position descending to replace from end
|
|
540
|
+
findings.sort(key=lambda x: x[2], reverse=True)
|
|
541
|
+
|
|
542
|
+
result = text
|
|
543
|
+
for pii_type, _, start, end in findings:
|
|
544
|
+
placeholder = f"[{pii_type.upper()}_REDACTED]"
|
|
545
|
+
result = result[:start] + placeholder + result[end:]
|
|
546
|
+
|
|
547
|
+
return result, len(findings)
|
|
548
|
+
|
|
549
|
+
def detect_injection_attempts(self, text: str) -> list[str]:
|
|
550
|
+
"""Detect potential prompt injection attempts.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
text: Text to scan.
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
List of detected injection patterns.
|
|
557
|
+
"""
|
|
558
|
+
attempts = []
|
|
559
|
+
for pattern in INJECTION_PATTERNS:
|
|
560
|
+
matches = pattern.findall(text)
|
|
561
|
+
if matches:
|
|
562
|
+
attempts.extend(matches if isinstance(matches[0], str) else [m[0] for m in matches])
|
|
563
|
+
return attempts
|
|
564
|
+
|
|
565
|
+
def sanitize_for_prompt(self, text: str) -> str:
|
|
566
|
+
"""Sanitize text for use in prompts.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
text: Text to sanitize.
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
Sanitized text.
|
|
573
|
+
"""
|
|
574
|
+
# Escape any prompt-like patterns
|
|
575
|
+
text = re.sub(r'<\|', '<|', text) # Use fullwidth vertical line
|
|
576
|
+
text = re.sub(r'\|>', '|>', text)
|
|
577
|
+
text = re.sub(r'\[INST\]', '[inst]', text, flags=re.IGNORECASE)
|
|
578
|
+
text = re.sub(r'\[/INST\]', '[/inst]', text, flags=re.IGNORECASE)
|
|
579
|
+
return text
|
|
580
|
+
|
|
581
|
+
# =========================================================================
|
|
582
|
+
# Entity Detection
|
|
583
|
+
# =========================================================================
|
|
584
|
+
|
|
585
|
+
def extract_entities(self, text: str) -> dict[str, list[str]]:
|
|
586
|
+
"""Extract entities from text.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
text: Text to analyze.
|
|
590
|
+
|
|
591
|
+
Returns:
|
|
592
|
+
Dictionary mapping entity type to list of entities.
|
|
593
|
+
"""
|
|
594
|
+
entities: dict[str, list[str]] = {}
|
|
595
|
+
|
|
596
|
+
for entity_type, patterns in ENTITY_PATTERNS.items():
|
|
597
|
+
found: set[str] = set()
|
|
598
|
+
for pattern in patterns:
|
|
599
|
+
matches = re.findall(pattern, text)
|
|
600
|
+
for match in matches:
|
|
601
|
+
if isinstance(match, tuple):
|
|
602
|
+
# Multi-group pattern, take first non-empty
|
|
603
|
+
for group in match:
|
|
604
|
+
if group:
|
|
605
|
+
found.add(group.strip())
|
|
606
|
+
break
|
|
607
|
+
else:
|
|
608
|
+
found.add(match.strip())
|
|
609
|
+
|
|
610
|
+
if found:
|
|
611
|
+
entities[entity_type] = sorted(found)
|
|
612
|
+
|
|
613
|
+
return entities
|
|
614
|
+
|
|
615
|
+
def flatten_entities(self, entities: dict[str, list[str]]) -> list[str]:
|
|
616
|
+
"""Flatten entity dictionary to list.
|
|
617
|
+
|
|
618
|
+
Args:
|
|
619
|
+
entities: Entity dictionary.
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
Flat list of unique entities.
|
|
623
|
+
"""
|
|
624
|
+
all_entities: set[str] = set()
|
|
625
|
+
for entity_list in entities.values():
|
|
626
|
+
all_entities.update(entity_list)
|
|
627
|
+
return sorted(all_entities)
|
|
628
|
+
|
|
629
|
+
# =========================================================================
|
|
630
|
+
# Category Detection
|
|
631
|
+
# =========================================================================
|
|
632
|
+
|
|
633
|
+
def detect_category(self, content: str) -> tuple[MemoryCategory, float]:
|
|
634
|
+
"""Detect memory category from content.
|
|
635
|
+
|
|
636
|
+
Args:
|
|
637
|
+
content: Memory content.
|
|
638
|
+
|
|
639
|
+
Returns:
|
|
640
|
+
Tuple of (category, confidence).
|
|
641
|
+
"""
|
|
642
|
+
content_lower = content.lower()
|
|
643
|
+
scores: dict[MemoryCategory, int] = {}
|
|
644
|
+
|
|
645
|
+
for category, keywords in self.CATEGORY_KEYWORDS.items():
|
|
646
|
+
score = sum(1 for keyword in keywords if keyword in content_lower)
|
|
647
|
+
if score > 0:
|
|
648
|
+
scores[category] = score
|
|
649
|
+
|
|
650
|
+
if not scores:
|
|
651
|
+
# Default to DECISION if no keywords match
|
|
652
|
+
return MemoryCategory.DECISION, 0.3
|
|
653
|
+
|
|
654
|
+
best_category = max(scores, key=lambda k: scores[k])
|
|
655
|
+
max_score = scores[best_category]
|
|
656
|
+
|
|
657
|
+
# Calculate confidence based on number of keyword matches
|
|
658
|
+
confidence = min(0.4 + (max_score * 0.15), 0.9)
|
|
659
|
+
|
|
660
|
+
return best_category, confidence
|
|
661
|
+
|
|
662
|
+
def parse_category(self, category_str: str) -> MemoryCategory:
|
|
663
|
+
"""Parse category string to enum.
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
category_str: Category string from LLM.
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
MemoryCategory enum.
|
|
670
|
+
"""
|
|
671
|
+
category_str = category_str.lower().strip()
|
|
672
|
+
try:
|
|
673
|
+
return MemoryCategory(category_str)
|
|
674
|
+
except ValueError:
|
|
675
|
+
# Try to match partial
|
|
676
|
+
for category in MemoryCategory:
|
|
677
|
+
if category.value in category_str or category_str in category.value:
|
|
678
|
+
return category
|
|
679
|
+
return MemoryCategory.DECISION
|
|
680
|
+
|
|
681
|
+
# =========================================================================
|
|
682
|
+
# LLM Extraction
|
|
683
|
+
# =========================================================================
|
|
684
|
+
|
|
685
|
+
async def _call_llm(
|
|
686
|
+
self,
|
|
687
|
+
system_prompt: str,
|
|
688
|
+
user_prompt: str,
|
|
689
|
+
estimated_tokens: int = 1000,
|
|
690
|
+
) -> str:
|
|
691
|
+
"""Call the LLM with rate limiting.
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
system_prompt: System prompt.
|
|
695
|
+
user_prompt: User prompt.
|
|
696
|
+
estimated_tokens: Estimated tokens for rate limiting.
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
LLM response text.
|
|
700
|
+
"""
|
|
701
|
+
await self._rate_limiter.acquire(estimated_tokens)
|
|
702
|
+
|
|
703
|
+
client = self._get_client()
|
|
704
|
+
response = await client.messages.create(
|
|
705
|
+
model=self.config.model,
|
|
706
|
+
max_tokens=self.config.max_tokens,
|
|
707
|
+
temperature=self.config.temperature,
|
|
708
|
+
system=system_prompt,
|
|
709
|
+
messages=[{"role": "user", "content": user_prompt}],
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
return response.content[0].text
|
|
713
|
+
|
|
714
|
+
def _parse_extraction_response(self, response: str) -> tuple[list[ExtractedMemory], str]:
|
|
715
|
+
"""Parse LLM extraction response.
|
|
716
|
+
|
|
717
|
+
Args:
|
|
718
|
+
response: Raw LLM response.
|
|
719
|
+
|
|
720
|
+
Returns:
|
|
721
|
+
Tuple of (memories, summary).
|
|
722
|
+
"""
|
|
723
|
+
# Try to extract JSON from response
|
|
724
|
+
json_match = re.search(r'\{[\s\S]*\}', response)
|
|
725
|
+
if not json_match:
|
|
726
|
+
raise ValueError("No JSON found in response")
|
|
727
|
+
|
|
728
|
+
try:
|
|
729
|
+
data = json.loads(json_match.group())
|
|
730
|
+
except json.JSONDecodeError as e:
|
|
731
|
+
raise ValueError(f"Invalid JSON: {e}") from e
|
|
732
|
+
|
|
733
|
+
memories: list[ExtractedMemory] = []
|
|
734
|
+
raw_memories = data.get("memories", [])
|
|
735
|
+
|
|
736
|
+
for raw in raw_memories:
|
|
737
|
+
if not isinstance(raw, dict):
|
|
738
|
+
continue
|
|
739
|
+
|
|
740
|
+
content = raw.get("content", "").strip()
|
|
741
|
+
if not content:
|
|
742
|
+
continue
|
|
743
|
+
|
|
744
|
+
# Parse category
|
|
745
|
+
category_str = raw.get("category", "decision")
|
|
746
|
+
category = self.parse_category(category_str)
|
|
747
|
+
|
|
748
|
+
# Parse scores with validation
|
|
749
|
+
importance = float(raw.get("importance", 0.5))
|
|
750
|
+
importance = max(0.0, min(1.0, importance))
|
|
751
|
+
|
|
752
|
+
confidence = float(raw.get("confidence", 0.5))
|
|
753
|
+
confidence = max(0.0, min(1.0, confidence))
|
|
754
|
+
|
|
755
|
+
# Parse lists
|
|
756
|
+
entities = raw.get("entities", [])
|
|
757
|
+
if not isinstance(entities, list):
|
|
758
|
+
entities = []
|
|
759
|
+
entities = [str(e).strip() for e in entities if e]
|
|
760
|
+
|
|
761
|
+
tags = raw.get("tags", [])
|
|
762
|
+
if not isinstance(tags, list):
|
|
763
|
+
tags = []
|
|
764
|
+
tags = [str(t).strip().lower() for t in tags if t]
|
|
765
|
+
|
|
766
|
+
rationale = str(raw.get("rationale", "")).strip()
|
|
767
|
+
|
|
768
|
+
memories.append(ExtractedMemory(
|
|
769
|
+
content=content,
|
|
770
|
+
category=category,
|
|
771
|
+
importance=importance,
|
|
772
|
+
confidence=confidence,
|
|
773
|
+
entities=entities,
|
|
774
|
+
tags=tags,
|
|
775
|
+
rationale=rationale,
|
|
776
|
+
))
|
|
777
|
+
|
|
778
|
+
summary = str(data.get("summary", "")).strip()
|
|
779
|
+
return memories, summary
|
|
780
|
+
|
|
781
|
+
async def extract_from_transcript(
|
|
782
|
+
self,
|
|
783
|
+
transcript: str,
|
|
784
|
+
project: str | None = None,
|
|
785
|
+
existing_memories: list[Memory] | None = None,
|
|
786
|
+
) -> ExtractionResult:
|
|
787
|
+
"""Extract memories from a conversation transcript.
|
|
788
|
+
|
|
789
|
+
Args:
|
|
790
|
+
transcript: The conversation transcript.
|
|
791
|
+
project: Optional project context.
|
|
792
|
+
existing_memories: Optional list of existing memories for conflict detection.
|
|
793
|
+
|
|
794
|
+
Returns:
|
|
795
|
+
ExtractionResult with extracted memories.
|
|
796
|
+
"""
|
|
797
|
+
start_time = time.time()
|
|
798
|
+
transcript_length = len(transcript)
|
|
799
|
+
|
|
800
|
+
# Validate transcript length
|
|
801
|
+
if transcript_length < self.config.min_transcript_length:
|
|
802
|
+
return ExtractionResult(
|
|
803
|
+
memories=[],
|
|
804
|
+
summary="Transcript too short for extraction",
|
|
805
|
+
transcript_length=transcript_length,
|
|
806
|
+
extraction_time_ms=0,
|
|
807
|
+
error="Transcript too short",
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
if transcript_length > self.config.max_transcript_length:
|
|
811
|
+
# Truncate to max length
|
|
812
|
+
transcript = transcript[:self.config.max_transcript_length]
|
|
813
|
+
logger.warning(f"Transcript truncated from {transcript_length} to {self.config.max_transcript_length}")
|
|
814
|
+
|
|
815
|
+
# Security: Detect injection attempts
|
|
816
|
+
injection_attempts = 0
|
|
817
|
+
if self.config.enable_injection_detection:
|
|
818
|
+
attempts = self.detect_injection_attempts(transcript)
|
|
819
|
+
injection_attempts = len(attempts)
|
|
820
|
+
if injection_attempts > 0:
|
|
821
|
+
logger.warning(f"Detected {injection_attempts} potential injection attempts")
|
|
822
|
+
|
|
823
|
+
# Security: Filter PII
|
|
824
|
+
pii_removed = 0
|
|
825
|
+
if self.config.enable_pii_filtering:
|
|
826
|
+
transcript, pii_removed = self.filter_pii(transcript)
|
|
827
|
+
if pii_removed > 0:
|
|
828
|
+
logger.info(f"Removed {pii_removed} PII instances")
|
|
829
|
+
|
|
830
|
+
# Sanitize transcript for prompt
|
|
831
|
+
transcript = self.sanitize_for_prompt(transcript)
|
|
832
|
+
|
|
833
|
+
# Build prompt
|
|
834
|
+
user_prompt = EXTRACTION_USER_PROMPT.format(transcript=transcript)
|
|
835
|
+
estimated_tokens = len(transcript) // 4 + 1000
|
|
836
|
+
|
|
837
|
+
try:
|
|
838
|
+
# Call LLM
|
|
839
|
+
response = await self._call_llm(
|
|
840
|
+
EXTRACTION_SYSTEM_PROMPT,
|
|
841
|
+
user_prompt,
|
|
842
|
+
estimated_tokens,
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
# Parse response
|
|
846
|
+
memories, summary = self._parse_extraction_response(response)
|
|
847
|
+
|
|
848
|
+
# Filter by minimum thresholds
|
|
849
|
+
memories = [
|
|
850
|
+
m for m in memories
|
|
851
|
+
if m.confidence >= self.config.min_confidence
|
|
852
|
+
and m.importance >= self.config.min_importance
|
|
853
|
+
]
|
|
854
|
+
|
|
855
|
+
# Enhance with additional entity detection
|
|
856
|
+
for memory in memories:
|
|
857
|
+
detected = self.extract_entities(memory.content)
|
|
858
|
+
existing_entities = set(memory.entities)
|
|
859
|
+
for entity_list in detected.values():
|
|
860
|
+
for entity in entity_list:
|
|
861
|
+
if entity not in existing_entities:
|
|
862
|
+
memory.entities.append(entity)
|
|
863
|
+
|
|
864
|
+
# Conflict detection
|
|
865
|
+
conflicts: list[ConflictResult] = []
|
|
866
|
+
if self.config.enable_conflict_detection and existing_memories:
|
|
867
|
+
conflicts = await self._detect_conflicts(memories, existing_memories)
|
|
868
|
+
|
|
869
|
+
extraction_time = (time.time() - start_time) * 1000
|
|
870
|
+
|
|
871
|
+
return ExtractionResult(
|
|
872
|
+
memories=memories,
|
|
873
|
+
summary=summary,
|
|
874
|
+
transcript_length=transcript_length,
|
|
875
|
+
extraction_time_ms=extraction_time,
|
|
876
|
+
conflicts=conflicts,
|
|
877
|
+
pii_removed=pii_removed,
|
|
878
|
+
injection_attempts=injection_attempts,
|
|
879
|
+
raw_response=response,
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
except Exception as e:
|
|
883
|
+
extraction_time = (time.time() - start_time) * 1000
|
|
884
|
+
logger.error(f"Extraction failed: {e}")
|
|
885
|
+
return ExtractionResult(
|
|
886
|
+
memories=[],
|
|
887
|
+
summary="",
|
|
888
|
+
transcript_length=transcript_length,
|
|
889
|
+
extraction_time_ms=extraction_time,
|
|
890
|
+
pii_removed=pii_removed,
|
|
891
|
+
injection_attempts=injection_attempts,
|
|
892
|
+
error=str(e),
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
# =========================================================================
|
|
896
|
+
# Conflict Detection
|
|
897
|
+
# =========================================================================
|
|
898
|
+
|
|
899
|
+
async def _detect_conflicts(
|
|
900
|
+
self,
|
|
901
|
+
new_memories: list[ExtractedMemory],
|
|
902
|
+
existing_memories: list[Memory],
|
|
903
|
+
) -> list[ConflictResult]:
|
|
904
|
+
"""Detect conflicts between new and existing memories.
|
|
905
|
+
|
|
906
|
+
Args:
|
|
907
|
+
new_memories: Newly extracted memories.
|
|
908
|
+
existing_memories: Existing memories to check against.
|
|
909
|
+
|
|
910
|
+
Returns:
|
|
911
|
+
List of conflict results.
|
|
912
|
+
"""
|
|
913
|
+
conflicts: list[ConflictResult] = []
|
|
914
|
+
|
|
915
|
+
for new_mem in new_memories:
|
|
916
|
+
# Find potentially related existing memories
|
|
917
|
+
for existing in existing_memories:
|
|
918
|
+
# Quick category match check
|
|
919
|
+
if new_mem.category != existing.category:
|
|
920
|
+
continue
|
|
921
|
+
|
|
922
|
+
# Check for entity overlap
|
|
923
|
+
new_entities = set(new_mem.entities)
|
|
924
|
+
existing_entities = set(existing.entities)
|
|
925
|
+
if not new_entities.intersection(existing_entities):
|
|
926
|
+
# No entity overlap, check content similarity heuristic
|
|
927
|
+
if not self._content_similar(new_mem.content, existing.content):
|
|
928
|
+
continue
|
|
929
|
+
|
|
930
|
+
# Potential conflict found, use LLM to classify
|
|
931
|
+
try:
|
|
932
|
+
conflict = await self._classify_conflict(new_mem, existing)
|
|
933
|
+
if conflict.relationship != ConflictRelationship.UNRELATED:
|
|
934
|
+
conflicts.append(conflict)
|
|
935
|
+
except Exception as e:
|
|
936
|
+
logger.warning(f"Conflict classification failed: {e}")
|
|
937
|
+
|
|
938
|
+
return conflicts
|
|
939
|
+
|
|
940
|
+
def _content_similar(self, content1: str, content2: str) -> bool:
|
|
941
|
+
"""Quick heuristic check for content similarity.
|
|
942
|
+
|
|
943
|
+
Args:
|
|
944
|
+
content1: First content.
|
|
945
|
+
content2: Second content.
|
|
946
|
+
|
|
947
|
+
Returns:
|
|
948
|
+
True if contents appear similar.
|
|
949
|
+
"""
|
|
950
|
+
# Simple word overlap check
|
|
951
|
+
words1 = set(content1.lower().split())
|
|
952
|
+
words2 = set(content2.lower().split())
|
|
953
|
+
|
|
954
|
+
if not words1 or not words2:
|
|
955
|
+
return False
|
|
956
|
+
|
|
957
|
+
overlap = len(words1.intersection(words2))
|
|
958
|
+
min_len = min(len(words1), len(words2))
|
|
959
|
+
|
|
960
|
+
return overlap / min_len >= 0.3
|
|
961
|
+
|
|
962
|
+
async def _classify_conflict(
|
|
963
|
+
self,
|
|
964
|
+
new_memory: ExtractedMemory,
|
|
965
|
+
existing: Memory,
|
|
966
|
+
) -> ConflictResult:
|
|
967
|
+
"""Classify the relationship between new and existing memory.
|
|
968
|
+
|
|
969
|
+
Args:
|
|
970
|
+
new_memory: New extracted memory.
|
|
971
|
+
existing: Existing memory.
|
|
972
|
+
|
|
973
|
+
Returns:
|
|
974
|
+
ConflictResult with classification.
|
|
975
|
+
"""
|
|
976
|
+
prompt = CONFLICT_DETECTION_PROMPT.format(
|
|
977
|
+
existing_content=existing.content,
|
|
978
|
+
existing_category=existing.category.value,
|
|
979
|
+
new_content=new_memory.content,
|
|
980
|
+
new_category=new_memory.category.value,
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
response = await self._call_llm(
|
|
984
|
+
"You are a memory conflict analyzer. Classify relationships between memories.",
|
|
985
|
+
prompt,
|
|
986
|
+
500,
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
# Parse response
|
|
990
|
+
json_match = re.search(r'\{[\s\S]*\}', response)
|
|
991
|
+
if not json_match:
|
|
992
|
+
raise ValueError("No JSON in conflict response")
|
|
993
|
+
|
|
994
|
+
data = json.loads(json_match.group())
|
|
995
|
+
|
|
996
|
+
relationship_str = data.get("relationship", "unrelated").lower()
|
|
997
|
+
try:
|
|
998
|
+
relationship = ConflictRelationship(relationship_str)
|
|
999
|
+
except ValueError:
|
|
1000
|
+
relationship = ConflictRelationship.UNRELATED
|
|
1001
|
+
|
|
1002
|
+
return ConflictResult(
|
|
1003
|
+
existing_id=existing.id,
|
|
1004
|
+
relationship=relationship,
|
|
1005
|
+
confidence=float(data.get("confidence", 0.5)),
|
|
1006
|
+
explanation=str(data.get("explanation", "")),
|
|
1007
|
+
should_supersede=bool(data.get("should_supersede", False)),
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
# =========================================================================
|
|
1011
|
+
# High-Level API
|
|
1012
|
+
# =========================================================================
|
|
1013
|
+
|
|
1014
|
+
async def extract_and_store(
|
|
1015
|
+
self,
|
|
1016
|
+
transcript: str,
|
|
1017
|
+
engine: MemoryEngine,
|
|
1018
|
+
project: str | None = None,
|
|
1019
|
+
) -> ExtractionResult:
|
|
1020
|
+
"""Extract memories and store them in the engine.
|
|
1021
|
+
|
|
1022
|
+
Args:
|
|
1023
|
+
transcript: Conversation transcript.
|
|
1024
|
+
engine: Memory engine to store in.
|
|
1025
|
+
project: Project scope.
|
|
1026
|
+
|
|
1027
|
+
Returns:
|
|
1028
|
+
ExtractionResult with stored memories.
|
|
1029
|
+
"""
|
|
1030
|
+
# Get existing memories for conflict detection
|
|
1031
|
+
existing_memories: list[Memory] = []
|
|
1032
|
+
if self.config.enable_conflict_detection:
|
|
1033
|
+
existing_memories = await engine.list(project=project, limit=100)
|
|
1034
|
+
|
|
1035
|
+
# Extract
|
|
1036
|
+
result = await self.extract_from_transcript(
|
|
1037
|
+
transcript=transcript,
|
|
1038
|
+
project=project,
|
|
1039
|
+
existing_memories=existing_memories,
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
if not result.success or not result.memories:
|
|
1043
|
+
return result
|
|
1044
|
+
|
|
1045
|
+
# Handle conflicts and store
|
|
1046
|
+
for memory in result.memories:
|
|
1047
|
+
# Check if this memory has conflicts that require superseding
|
|
1048
|
+
supersedes_id: str | None = None
|
|
1049
|
+
for conflict in result.conflicts:
|
|
1050
|
+
if conflict.should_supersede and conflict.relationship == ConflictRelationship.UPDATES:
|
|
1051
|
+
# Find if this conflict is for the current memory
|
|
1052
|
+
# (simplified: just use the first supersede candidate)
|
|
1053
|
+
supersedes_id = conflict.existing_id
|
|
1054
|
+
break
|
|
1055
|
+
|
|
1056
|
+
# Store the memory
|
|
1057
|
+
await engine.add(
|
|
1058
|
+
content=memory.content,
|
|
1059
|
+
category=memory.category,
|
|
1060
|
+
project=project,
|
|
1061
|
+
source=MemorySource.EXTRACTED,
|
|
1062
|
+
confidence=memory.confidence,
|
|
1063
|
+
importance=memory.importance,
|
|
1064
|
+
entities=memory.entities,
|
|
1065
|
+
tags=memory.tags,
|
|
1066
|
+
supersedes=supersedes_id,
|
|
1067
|
+
metadata={"rationale": memory.rationale} if memory.rationale else {},
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
logger.info(f"Stored {len(result.memories)} memories from extraction")
|
|
1071
|
+
return result
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
# =============================================================================
|
|
1075
|
+
# Convenience Functions
|
|
1076
|
+
# =============================================================================
|
|
1077
|
+
|
|
1078
|
+
async def extract_from_transcript(
|
|
1079
|
+
transcript: str,
|
|
1080
|
+
project: str | None = None,
|
|
1081
|
+
config: ExtractionConfig | None = None,
|
|
1082
|
+
api_key: str | None = None,
|
|
1083
|
+
) -> ExtractionResult:
|
|
1084
|
+
"""Extract memories from a transcript.
|
|
1085
|
+
|
|
1086
|
+
Convenience function for one-off extraction.
|
|
1087
|
+
|
|
1088
|
+
Args:
|
|
1089
|
+
transcript: Conversation transcript.
|
|
1090
|
+
project: Project scope.
|
|
1091
|
+
config: Extraction configuration.
|
|
1092
|
+
api_key: Anthropic API key.
|
|
1093
|
+
|
|
1094
|
+
Returns:
|
|
1095
|
+
ExtractionResult.
|
|
1096
|
+
"""
|
|
1097
|
+
extractor = MemoryExtractor(config=config, api_key=api_key)
|
|
1098
|
+
return await extractor.extract_from_transcript(transcript, project=project)
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def detect_entities(text: str) -> dict[str, list[str]]:
|
|
1102
|
+
"""Detect entities in text.
|
|
1103
|
+
|
|
1104
|
+
Convenience function for entity detection without LLM.
|
|
1105
|
+
|
|
1106
|
+
Args:
|
|
1107
|
+
text: Text to analyze.
|
|
1108
|
+
|
|
1109
|
+
Returns:
|
|
1110
|
+
Dictionary of entity types to entity lists.
|
|
1111
|
+
"""
|
|
1112
|
+
extractor = MemoryExtractor()
|
|
1113
|
+
return extractor.extract_entities(text)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def detect_pii(text: str) -> list[tuple[str, str, int, int]]:
|
|
1117
|
+
"""Detect PII in text.
|
|
1118
|
+
|
|
1119
|
+
Convenience function for PII detection.
|
|
1120
|
+
|
|
1121
|
+
Args:
|
|
1122
|
+
text: Text to scan.
|
|
1123
|
+
|
|
1124
|
+
Returns:
|
|
1125
|
+
List of (pii_type, matched_text, start, end) tuples.
|
|
1126
|
+
"""
|
|
1127
|
+
extractor = MemoryExtractor()
|
|
1128
|
+
return extractor.detect_pii(text)
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def filter_pii(text: str) -> tuple[str, int]:
|
|
1132
|
+
"""Filter PII from text.
|
|
1133
|
+
|
|
1134
|
+
Convenience function for PII filtering.
|
|
1135
|
+
|
|
1136
|
+
Args:
|
|
1137
|
+
text: Text to filter.
|
|
1138
|
+
|
|
1139
|
+
Returns:
|
|
1140
|
+
Tuple of (filtered_text, count_removed).
|
|
1141
|
+
"""
|
|
1142
|
+
extractor = MemoryExtractor()
|
|
1143
|
+
return extractor.filter_pii(text)
|