pdm-memory 0.1.2__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.
- pdm_memory/__init__.py +22 -0
- pdm_memory/auth/__init__.py +4 -0
- pdm_memory/auth/jwt_handler.py +119 -0
- pdm_memory/bench.py +5 -0
- pdm_memory/core/__init__.py +1 -0
- pdm_memory/core/math.py +329 -0
- pdm_memory/core/retrieval.py +362 -0
- pdm_memory/core/signature.py +257 -0
- pdm_memory/ingest/__init__.py +6 -0
- pdm_memory/ingest/auto_signature.py +202 -0
- pdm_memory/ingest/batch.py +164 -0
- pdm_memory/ingest/ingester.py +272 -0
- pdm_memory/integrations/__init__.py +21 -0
- pdm_memory/integrations/anthropic_adapter.py +179 -0
- pdm_memory/integrations/context_manager.py +134 -0
- pdm_memory/integrations/gemini_adapter.py +215 -0
- pdm_memory/integrations/groq_adapter.py +171 -0
- pdm_memory/integrations/ollama_adapter.py +184 -0
- pdm_memory/integrations/openai_adapter.py +191 -0
- pdm_memory/memory.py +552 -0
- pdm_memory/storage/__init__.py +5 -0
- pdm_memory/storage/base.py +123 -0
- pdm_memory/storage/cloud_driver.py +271 -0
- pdm_memory/storage/sqlite_driver.py +331 -0
- pdm_memory/sync.py +145 -0
- pdm_memory/tools/__init__.py +1 -0
- pdm_memory/tools/bench.py +294 -0
- pdm_memory/tools/cli.py +207 -0
- pdm_memory-0.1.2.dist-info/METADATA +485 -0
- pdm_memory-0.1.2.dist-info/RECORD +34 -0
- pdm_memory-0.1.2.dist-info/WHEEL +5 -0
- pdm_memory-0.1.2.dist-info/entry_points.txt +2 -0
- pdm_memory-0.1.2.dist-info/licenses/LICENSE +50 -0
- pdm_memory-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-Signature Generator — Task 3.2
|
|
3
|
+
|
|
4
|
+
Uses an LLM to auto-generate:
|
|
5
|
+
1. compressed_fact — ≤500 char compressed memory from raw text
|
|
6
|
+
2. intent_tags — exactly 3 mandatory tags (+ up to 2 optional)
|
|
7
|
+
3. p_magnitude — suggested importance score (0–100)
|
|
8
|
+
|
|
9
|
+
Works with both OpenAI and Anthropic clients (duck-typed).
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
from pdm_memory.ingest.auto_signature import AutoSignatureGenerator
|
|
13
|
+
gen = AutoSignatureGenerator(openai_client)
|
|
14
|
+
result = gen.generate("User said they prefer dark mode and smaller fonts.")
|
|
15
|
+
print(result.compressed_fact)
|
|
16
|
+
print(result.intent_tags)
|
|
17
|
+
print(result.p_magnitude)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any, List, Optional
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
GENERATION_PROMPT = """You are a memory compression engine for an AI assistant.
|
|
31
|
+
|
|
32
|
+
Your job: compress the raw input into a PDM (Pressure-Driven Memory) signature.
|
|
33
|
+
|
|
34
|
+
Rules:
|
|
35
|
+
1. compressed_fact: A concise factual statement (max 500 characters).
|
|
36
|
+
Strip conversational filler. Keep only the durable, important information.
|
|
37
|
+
2. intent_tags: Exactly 3 to 5 lowercase tags (strings) that categorise this memory.
|
|
38
|
+
Tags must be specific and searchable (e.g. ["units", "formatting", "preferences"]).
|
|
39
|
+
The first 3 are mandatory.
|
|
40
|
+
3. p_magnitude: An integer 0-100 representing importance.
|
|
41
|
+
- 80-100: Critical, user-defining preference or fact
|
|
42
|
+
- 60-79: Important, frequently relevant
|
|
43
|
+
- 40-59: Useful, occasionally relevant
|
|
44
|
+
- 20-39: Low signal, rarely matters
|
|
45
|
+
- 0-19: Near-trivial
|
|
46
|
+
|
|
47
|
+
Return ONLY valid JSON in this exact format (no markdown):
|
|
48
|
+
{
|
|
49
|
+
"compressed_fact": "...",
|
|
50
|
+
"intent_tags": ["tag1", "tag2", "tag3"],
|
|
51
|
+
"p_magnitude": 65
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Raw input to compress:
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class AutoSignatureResult:
|
|
60
|
+
compressed_fact: str
|
|
61
|
+
intent_tags: List[str]
|
|
62
|
+
p_magnitude: float
|
|
63
|
+
raw_response: str = ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AutoSignatureGenerator:
|
|
67
|
+
"""
|
|
68
|
+
Generates PDM signatures from raw text using an LLM.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
llm_client: An OpenAI or Anthropic client instance.
|
|
72
|
+
The generator will duck-type to detect which one.
|
|
73
|
+
model: Model name override (defaults per provider).
|
|
74
|
+
max_tokens: Max tokens for the LLM response.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
OPENAI_DEFAULT_MODEL = "gpt-4o-mini"
|
|
78
|
+
ANTHROPIC_DEFAULT_MODEL = "claude-3-haiku-20240307"
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
llm_client: Any,
|
|
83
|
+
model: Optional[str] = None,
|
|
84
|
+
max_tokens: int = 256,
|
|
85
|
+
) -> None:
|
|
86
|
+
self._client = llm_client
|
|
87
|
+
self._model = model
|
|
88
|
+
self._max_tokens = max_tokens
|
|
89
|
+
self._provider = self._detect_provider()
|
|
90
|
+
|
|
91
|
+
def generate(self, raw_text: str) -> Optional[AutoSignatureResult]:
|
|
92
|
+
"""
|
|
93
|
+
Generate a PDM signature for raw_text.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
raw_text: The text to compress into a memory.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
AutoSignatureResult or None on failure.
|
|
100
|
+
"""
|
|
101
|
+
prompt = GENERATION_PROMPT + raw_text.strip()[:2000] # Cap input length
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
if self._provider == "openai":
|
|
105
|
+
return self._call_openai(prompt)
|
|
106
|
+
elif self._provider == "anthropic":
|
|
107
|
+
return self._call_anthropic(prompt)
|
|
108
|
+
else:
|
|
109
|
+
logger.warning("[PDM-AutoSig] Unknown LLM provider; trying OpenAI API shape")
|
|
110
|
+
return self._call_openai(prompt)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
logger.warning("[PDM-AutoSig] Generation failed: %s", e)
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
# Provider-specific calls
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def _call_openai(self, prompt: str) -> Optional[AutoSignatureResult]:
|
|
120
|
+
model = self._model or self.OPENAI_DEFAULT_MODEL
|
|
121
|
+
resp = self._client.chat.completions.create(
|
|
122
|
+
model=model,
|
|
123
|
+
messages=[{"role": "user", "content": prompt}],
|
|
124
|
+
max_tokens=self._max_tokens,
|
|
125
|
+
temperature=0.2,
|
|
126
|
+
)
|
|
127
|
+
raw = resp.choices[0].message.content or ""
|
|
128
|
+
return self._parse_response(raw)
|
|
129
|
+
|
|
130
|
+
def _call_anthropic(self, prompt: str) -> Optional[AutoSignatureResult]:
|
|
131
|
+
model = self._model or self.ANTHROPIC_DEFAULT_MODEL
|
|
132
|
+
resp = self._client.messages.create(
|
|
133
|
+
model=model,
|
|
134
|
+
max_tokens=self._max_tokens,
|
|
135
|
+
messages=[{"role": "user", "content": prompt}],
|
|
136
|
+
)
|
|
137
|
+
raw = resp.content[0].text if resp.content else ""
|
|
138
|
+
return self._parse_response(raw)
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
# Parsing
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def _parse_response(self, raw: str) -> Optional[AutoSignatureResult]:
|
|
145
|
+
raw = raw.strip()
|
|
146
|
+
# Strip markdown code fences if present
|
|
147
|
+
if raw.startswith("```"):
|
|
148
|
+
lines = raw.split("\n")
|
|
149
|
+
raw = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
data = json.loads(raw)
|
|
153
|
+
except json.JSONDecodeError:
|
|
154
|
+
# Try to extract JSON from mixed content
|
|
155
|
+
import re
|
|
156
|
+
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
|
157
|
+
if not match:
|
|
158
|
+
logger.warning("[PDM-AutoSig] Could not parse JSON from: %s", raw[:200])
|
|
159
|
+
return None
|
|
160
|
+
try:
|
|
161
|
+
data = json.loads(match.group(0))
|
|
162
|
+
except json.JSONDecodeError:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
fact = str(data.get("compressed_fact", "")).strip()[:500]
|
|
166
|
+
tags = data.get("intent_tags", [])
|
|
167
|
+
if isinstance(tags, list):
|
|
168
|
+
tags = [str(t).lower().strip() for t in tags if t][:5]
|
|
169
|
+
else:
|
|
170
|
+
tags = []
|
|
171
|
+
|
|
172
|
+
if len(tags) < 3:
|
|
173
|
+
logger.warning("[PDM-AutoSig] LLM returned fewer than 3 tags: %s", tags)
|
|
174
|
+
|
|
175
|
+
p_mag = float(data.get("p_magnitude", 50))
|
|
176
|
+
p_mag = max(0.0, min(100.0, p_mag))
|
|
177
|
+
|
|
178
|
+
if not fact:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
return AutoSignatureResult(
|
|
182
|
+
compressed_fact=fact,
|
|
183
|
+
intent_tags=tags,
|
|
184
|
+
p_magnitude=p_mag,
|
|
185
|
+
raw_response=raw,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _detect_provider(self) -> str:
|
|
189
|
+
"""Duck-type detection of OpenAI vs Anthropic client."""
|
|
190
|
+
client_type = type(self._client).__name__.lower()
|
|
191
|
+
module = getattr(type(self._client), "__module__", "") or ""
|
|
192
|
+
|
|
193
|
+
if "anthropic" in module or "anthropic" in client_type:
|
|
194
|
+
return "anthropic"
|
|
195
|
+
if "openai" in module or "openai" in client_type:
|
|
196
|
+
return "openai"
|
|
197
|
+
# Check for common attribute shapes
|
|
198
|
+
if hasattr(self._client, "messages") and hasattr(self._client.messages, "create"):
|
|
199
|
+
return "anthropic"
|
|
200
|
+
if hasattr(self._client, "chat") and hasattr(self._client.chat, "completions"):
|
|
201
|
+
return "openai"
|
|
202
|
+
return "openai" # default fallback
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Batch Processing Handler — Task 3.3
|
|
3
|
+
|
|
4
|
+
Splits large data sources into chunks and processes them with rate limiting.
|
|
5
|
+
Prevents OOM and API overload when ingesting large datasets (10,000+ rows).
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
processor = BatchProcessor(batch_size=50, delay_seconds=0.5)
|
|
9
|
+
counts = processor.process(data_source, ingester, on_progress=print_progress)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BatchProcessor:
|
|
22
|
+
"""
|
|
23
|
+
Processes large data sources in batches.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
batch_size: Number of records per batch (default 50).
|
|
27
|
+
delay_seconds: Pause between batches in seconds (default 0.2).
|
|
28
|
+
max_retries: Retries per batch on transient errors (default 3).
|
|
29
|
+
retry_delay: Wait before retry, doubles each attempt (default 1.0s).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
batch_size: int = 50,
|
|
35
|
+
delay_seconds: float = 0.2,
|
|
36
|
+
max_retries: int = 3,
|
|
37
|
+
retry_delay: float = 1.0,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.batch_size = max(1, batch_size)
|
|
40
|
+
self.delay_seconds = max(0.0, delay_seconds)
|
|
41
|
+
self.max_retries = max(0, max_retries)
|
|
42
|
+
self.retry_delay = max(0.0, retry_delay)
|
|
43
|
+
|
|
44
|
+
def process(
|
|
45
|
+
self,
|
|
46
|
+
data_source: Any,
|
|
47
|
+
ingester: Any, # DataIngester instance
|
|
48
|
+
on_progress: Optional[Callable[[int, int], None]] = None,
|
|
49
|
+
) -> Dict[str, int]:
|
|
50
|
+
"""
|
|
51
|
+
Process a data source through the ingester in batches.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
data_source: Any of:
|
|
55
|
+
- list[dict]
|
|
56
|
+
- str (CSV file path or raw CSV content)
|
|
57
|
+
- list[str] (plain text lines → auto-converted to dicts)
|
|
58
|
+
ingester: DataIngester instance.
|
|
59
|
+
on_progress: Optional callback(processed: int, total: int).
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Aggregated counts: {saved, skipped, errors}.
|
|
63
|
+
"""
|
|
64
|
+
rows = self._normalise_source(data_source)
|
|
65
|
+
total = len(rows)
|
|
66
|
+
counts = {"saved": 0, "skipped": 0, "errors": 0}
|
|
67
|
+
processed = 0
|
|
68
|
+
|
|
69
|
+
logger.info("[PDM-Batch] Starting: %d records, batch_size=%d", total, self.batch_size)
|
|
70
|
+
|
|
71
|
+
for i in range(0, total, self.batch_size):
|
|
72
|
+
batch = rows[i : i + self.batch_size]
|
|
73
|
+
batch_counts = self._process_batch(batch, ingester)
|
|
74
|
+
|
|
75
|
+
counts["saved"] += batch_counts["saved"]
|
|
76
|
+
counts["skipped"] += batch_counts["skipped"]
|
|
77
|
+
counts["errors"] += batch_counts["errors"]
|
|
78
|
+
processed += len(batch)
|
|
79
|
+
|
|
80
|
+
if on_progress:
|
|
81
|
+
try:
|
|
82
|
+
on_progress(processed, total)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
logger.debug(
|
|
87
|
+
"[PDM-Batch] Batch %d/%d processed | saved=%d skipped=%d errors=%d",
|
|
88
|
+
processed, total,
|
|
89
|
+
batch_counts["saved"], batch_counts["skipped"], batch_counts["errors"],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Rate-limit between batches (not after the last one)
|
|
93
|
+
if i + self.batch_size < total and self.delay_seconds > 0:
|
|
94
|
+
time.sleep(self.delay_seconds)
|
|
95
|
+
|
|
96
|
+
logger.info(
|
|
97
|
+
"[PDM-Batch] Done: saved=%d skipped=%d errors=%d",
|
|
98
|
+
counts["saved"], counts["skipped"], counts["errors"],
|
|
99
|
+
)
|
|
100
|
+
return counts
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Private
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def _process_batch(
|
|
107
|
+
self,
|
|
108
|
+
batch: List[Dict[str, Any]],
|
|
109
|
+
ingester: Any,
|
|
110
|
+
) -> Dict[str, int]:
|
|
111
|
+
"""Process one batch with retry logic."""
|
|
112
|
+
last_error = None
|
|
113
|
+
delay = self.retry_delay
|
|
114
|
+
|
|
115
|
+
for attempt in range(self.max_retries + 1):
|
|
116
|
+
try:
|
|
117
|
+
return ingester.ingest_rows(batch)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
last_error = e
|
|
120
|
+
if attempt < self.max_retries:
|
|
121
|
+
logger.warning(
|
|
122
|
+
"[PDM-Batch] Batch attempt %d failed (%s). Retrying in %.1fs…",
|
|
123
|
+
attempt + 1, e, delay,
|
|
124
|
+
)
|
|
125
|
+
time.sleep(delay)
|
|
126
|
+
delay *= 2 # Exponential backoff
|
|
127
|
+
|
|
128
|
+
logger.error("[PDM-Batch] Batch failed after %d attempts: %s", self.max_retries + 1, last_error)
|
|
129
|
+
return {"saved": 0, "skipped": 0, "errors": len(batch)}
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _normalise_source(data_source: Any) -> List[Dict[str, Any]]:
|
|
133
|
+
"""Convert any supported source type to list[dict]."""
|
|
134
|
+
if isinstance(data_source, list):
|
|
135
|
+
if not data_source:
|
|
136
|
+
return []
|
|
137
|
+
if isinstance(data_source[0], dict):
|
|
138
|
+
return data_source
|
|
139
|
+
# Assume list of strings
|
|
140
|
+
return [{"text": str(item)} for item in data_source]
|
|
141
|
+
|
|
142
|
+
if isinstance(data_source, str):
|
|
143
|
+
# Try as CSV file path or CSV content
|
|
144
|
+
import csv
|
|
145
|
+
from io import StringIO
|
|
146
|
+
try:
|
|
147
|
+
with open(data_source, newline="", encoding="utf-8") as f:
|
|
148
|
+
content = f.read()
|
|
149
|
+
except (FileNotFoundError, OSError):
|
|
150
|
+
content = data_source # Treat as raw CSV string
|
|
151
|
+
reader = csv.DictReader(StringIO(content))
|
|
152
|
+
return list(reader)
|
|
153
|
+
|
|
154
|
+
# Try iterator/generator
|
|
155
|
+
try:
|
|
156
|
+
rows = list(data_source)
|
|
157
|
+
if rows and isinstance(rows[0], dict):
|
|
158
|
+
return rows
|
|
159
|
+
return [{"text": str(r)} for r in rows]
|
|
160
|
+
except Exception:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"Unsupported data_source type: {type(data_source)}. "
|
|
163
|
+
"Expected list[dict], list[str], or a CSV file path/string."
|
|
164
|
+
)
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data Ingester — Task 3.1
|
|
3
|
+
|
|
4
|
+
Convert any legacy data source (list[dict], CSV, raw messages) into
|
|
5
|
+
PDM SignatureRecords.
|
|
6
|
+
|
|
7
|
+
Usage (via Memory):
|
|
8
|
+
mem.ingest(
|
|
9
|
+
data_source=[
|
|
10
|
+
{"text": "User dislikes long responses", "importance": 70},
|
|
11
|
+
{"message": "Prefers Python over JS"},
|
|
12
|
+
],
|
|
13
|
+
mapping={"text": "compressed_fact", "importance": "p_magnitude"},
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
Usage (standalone):
|
|
17
|
+
from pdm_memory.ingest.ingester import DataIngester
|
|
18
|
+
ingester = DataIngester(storage=driver, user="alice")
|
|
19
|
+
count = ingester.ingest_rows(rows)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import csv
|
|
25
|
+
import logging
|
|
26
|
+
from io import StringIO
|
|
27
|
+
from typing import Any, Dict, List, Optional
|
|
28
|
+
|
|
29
|
+
from pdm_memory.core.math import calculate_effective_spike, infer_domain
|
|
30
|
+
from pdm_memory.core.signature import SignatureRecord
|
|
31
|
+
from pdm_memory.storage.base import BaseStorage
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Auto-detect common field name aliases
|
|
37
|
+
_FIELD_ALIASES: Dict[str, str] = {
|
|
38
|
+
# compressed_fact aliases
|
|
39
|
+
"text": "compressed_fact",
|
|
40
|
+
"content": "compressed_fact",
|
|
41
|
+
"message": "compressed_fact",
|
|
42
|
+
"body": "compressed_fact",
|
|
43
|
+
"fact": "compressed_fact",
|
|
44
|
+
"memory": "compressed_fact",
|
|
45
|
+
"summary": "compressed_fact",
|
|
46
|
+
# p_magnitude aliases
|
|
47
|
+
"importance": "p_magnitude",
|
|
48
|
+
"priority": "p_magnitude",
|
|
49
|
+
"pressure": "p_magnitude",
|
|
50
|
+
"score": "p_magnitude",
|
|
51
|
+
"weight": "p_magnitude",
|
|
52
|
+
# intent_tags aliases
|
|
53
|
+
"tags": "intent_tags",
|
|
54
|
+
"labels": "intent_tags",
|
|
55
|
+
"categories": "intent_tags",
|
|
56
|
+
"keywords": "intent_tags",
|
|
57
|
+
# drawer aliases
|
|
58
|
+
"category": "drawer_domain",
|
|
59
|
+
"drawer": "drawer_domain",
|
|
60
|
+
"topic": "drawer_domain",
|
|
61
|
+
"domain": "drawer_domain",
|
|
62
|
+
# source aliases
|
|
63
|
+
"origin": "source",
|
|
64
|
+
"channel": "source",
|
|
65
|
+
# regime aliases
|
|
66
|
+
"context": "question_regime",
|
|
67
|
+
"regime": "question_regime",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DataIngester:
|
|
72
|
+
"""
|
|
73
|
+
Converts structured rows into SignatureRecords and saves them.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
storage: The active BaseStorage backend.
|
|
77
|
+
user: User identifier.
|
|
78
|
+
mapping: Optional explicit field mapping (source_key → pdm_field).
|
|
79
|
+
If None, auto-detect via _FIELD_ALIASES.
|
|
80
|
+
llm_client: Optional LLM client for auto-signature generation.
|
|
81
|
+
If provided, missing fields are filled via the LLM.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
storage: BaseStorage,
|
|
87
|
+
user: str = "default",
|
|
88
|
+
mapping: Optional[Dict[str, str]] = None,
|
|
89
|
+
llm_client: Optional[Any] = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
self._storage = storage
|
|
92
|
+
self._user = user
|
|
93
|
+
self._mapping = mapping
|
|
94
|
+
self._llm_client = llm_client
|
|
95
|
+
|
|
96
|
+
def ingest_rows(self, rows: List[Dict[str, Any]]) -> Dict[str, int]:
|
|
97
|
+
"""
|
|
98
|
+
Ingest a list of dicts. Returns {'saved': N, 'skipped': N, 'errors': N}.
|
|
99
|
+
"""
|
|
100
|
+
counts = {"saved": 0, "skipped": 0, "errors": 0}
|
|
101
|
+
|
|
102
|
+
for i, row in enumerate(rows):
|
|
103
|
+
try:
|
|
104
|
+
sig = self._row_to_signature(row)
|
|
105
|
+
if sig is None:
|
|
106
|
+
counts["skipped"] += 1
|
|
107
|
+
continue
|
|
108
|
+
self._storage.save(sig)
|
|
109
|
+
counts["saved"] += 1
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.warning("[PDM-Ingest] Row %d failed: %s", i, e)
|
|
112
|
+
counts["errors"] += 1
|
|
113
|
+
|
|
114
|
+
return counts
|
|
115
|
+
|
|
116
|
+
def ingest_csv(self, csv_path_or_content: str) -> Dict[str, int]:
|
|
117
|
+
"""
|
|
118
|
+
Ingest from a CSV file path or raw CSV string.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
with open(csv_path_or_content, newline="", encoding="utf-8") as f:
|
|
122
|
+
content = f.read()
|
|
123
|
+
except (FileNotFoundError, OSError):
|
|
124
|
+
content = csv_path_or_content # Treat as raw CSV string
|
|
125
|
+
|
|
126
|
+
reader = csv.DictReader(StringIO(content))
|
|
127
|
+
return self.ingest_rows(list(reader))
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Private
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def _row_to_signature(self, row: Dict[str, Any]) -> Optional[SignatureRecord]:
|
|
134
|
+
"""Map one row to a SignatureRecord using auto-detect or explicit mapping."""
|
|
135
|
+
# Build effective mapping
|
|
136
|
+
field_map = self._build_field_map(row)
|
|
137
|
+
|
|
138
|
+
# Extract compressed_fact (required)
|
|
139
|
+
text = field_map.get("compressed_fact", "")
|
|
140
|
+
if not text:
|
|
141
|
+
# Try LLM fallback
|
|
142
|
+
if self._llm_client:
|
|
143
|
+
raw_data = " | ".join(f"{k}: {v}" for k, v in row.items())
|
|
144
|
+
text = self._generate_fact_via_llm(raw_data)
|
|
145
|
+
if not text:
|
|
146
|
+
logger.debug("[PDM-Ingest] Skipping row with no text: %s", row)
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
text = str(text).strip()[:500]
|
|
150
|
+
|
|
151
|
+
# Extract other fields with defaults
|
|
152
|
+
p_mag = _safe_float(field_map.get("p_magnitude"), default=50.0, min_v=0, max_v=100)
|
|
153
|
+
t_pers = _safe_float(field_map.get("t_persistence"), default=30.0, min_v=1, max_v=365)
|
|
154
|
+
phase = _safe_float(field_map.get("phase_privilege"), default=1.0)
|
|
155
|
+
tags = _parse_tags(field_map.get("intent_tags"))
|
|
156
|
+
drawer = str(field_map.get("drawer_domain") or "general").strip()
|
|
157
|
+
source = str(field_map.get("source") or "csv").strip()
|
|
158
|
+
regime = str(field_map.get("question_regime") or "neutral").strip()
|
|
159
|
+
|
|
160
|
+
# Auto-generate tags via LLM if none found
|
|
161
|
+
if not tags and self._llm_client:
|
|
162
|
+
tags = self._generate_tags_via_llm(text)
|
|
163
|
+
|
|
164
|
+
# Auto-generate tags (minimum 3 recommended)
|
|
165
|
+
if not tags:
|
|
166
|
+
tags = self._auto_tags_from_text(text)
|
|
167
|
+
|
|
168
|
+
domain = infer_domain(tags)
|
|
169
|
+
eff_spike = calculate_effective_spike(p_mag, t_pers, phase)
|
|
170
|
+
|
|
171
|
+
return SignatureRecord(
|
|
172
|
+
user=self._user,
|
|
173
|
+
compressed_fact=text,
|
|
174
|
+
source=source,
|
|
175
|
+
p_magnitude=p_mag,
|
|
176
|
+
t_persistence=t_pers,
|
|
177
|
+
phase_privilege=phase,
|
|
178
|
+
effective_spike=eff_spike,
|
|
179
|
+
intent_tags=tags,
|
|
180
|
+
question_regime=regime,
|
|
181
|
+
domain=domain,
|
|
182
|
+
drawer_domain=drawer,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def _build_field_map(self, row: Dict[str, Any]) -> Dict[str, Any]:
|
|
186
|
+
"""Apply explicit or auto mapping to a row."""
|
|
187
|
+
result: Dict[str, Any] = {}
|
|
188
|
+
|
|
189
|
+
if self._mapping:
|
|
190
|
+
# Explicit mapping
|
|
191
|
+
for src_key, pdm_key in self._mapping.items():
|
|
192
|
+
if src_key in row:
|
|
193
|
+
result[pdm_key] = row[src_key]
|
|
194
|
+
# Also pass through keys that directly match PDM fields
|
|
195
|
+
for k, v in row.items():
|
|
196
|
+
if k not in result:
|
|
197
|
+
result[k] = v
|
|
198
|
+
else:
|
|
199
|
+
# Auto-detect
|
|
200
|
+
for k, v in row.items():
|
|
201
|
+
canonical = _FIELD_ALIASES.get(k.lower(), k.lower())
|
|
202
|
+
result[canonical] = v
|
|
203
|
+
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
def _generate_fact_via_llm(self, raw: str) -> str:
|
|
207
|
+
"""Ask the LLM to compress raw data into a ≤500 char fact."""
|
|
208
|
+
from pdm_memory.ingest.auto_signature import AutoSignatureGenerator
|
|
209
|
+
gen = AutoSignatureGenerator(self._llm_client)
|
|
210
|
+
result = gen.generate(raw)
|
|
211
|
+
return result.compressed_fact if result else ""
|
|
212
|
+
|
|
213
|
+
def _generate_tags_via_llm(self, text: str) -> List[str]:
|
|
214
|
+
"""Ask the LLM to generate 3+ tags for a text."""
|
|
215
|
+
from pdm_memory.ingest.auto_signature import AutoSignatureGenerator
|
|
216
|
+
gen = AutoSignatureGenerator(self._llm_client)
|
|
217
|
+
result = gen.generate(text)
|
|
218
|
+
return result.intent_tags if result else []
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _auto_tags_from_text(text: str) -> List[str]:
|
|
222
|
+
"""Extract simple keyword tags from text without an LLM."""
|
|
223
|
+
import re
|
|
224
|
+
words = re.findall(r"\b[a-zA-Z]{4,}\b", text.lower())
|
|
225
|
+
stopwords = {
|
|
226
|
+
"this", "that", "with", "from", "have", "been", "will",
|
|
227
|
+
"which", "when", "they", "their", "there", "about",
|
|
228
|
+
}
|
|
229
|
+
seen = set()
|
|
230
|
+
tags = []
|
|
231
|
+
for w in words:
|
|
232
|
+
if w not in stopwords and w not in seen:
|
|
233
|
+
seen.add(w)
|
|
234
|
+
tags.append(w)
|
|
235
|
+
if len(tags) >= 5:
|
|
236
|
+
break
|
|
237
|
+
return tags[:5]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# Helpers
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _safe_float(val: Any, default: float = 0.0, min_v: float = None, max_v: float = None) -> float:
|
|
246
|
+
try:
|
|
247
|
+
v = float(val)
|
|
248
|
+
if min_v is not None:
|
|
249
|
+
v = max(min_v, v)
|
|
250
|
+
if max_v is not None:
|
|
251
|
+
v = min(max_v, v)
|
|
252
|
+
return v
|
|
253
|
+
except (TypeError, ValueError):
|
|
254
|
+
return default
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _parse_tags(val: Any) -> List[str]:
|
|
258
|
+
if val is None:
|
|
259
|
+
return []
|
|
260
|
+
if isinstance(val, list):
|
|
261
|
+
return [str(t).strip() for t in val if t]
|
|
262
|
+
if isinstance(val, str):
|
|
263
|
+
import json
|
|
264
|
+
try:
|
|
265
|
+
parsed = json.loads(val)
|
|
266
|
+
if isinstance(parsed, list):
|
|
267
|
+
return [str(t).strip() for t in parsed if t]
|
|
268
|
+
except (json.JSONDecodeError, ValueError):
|
|
269
|
+
pass
|
|
270
|
+
# Comma or space separated
|
|
271
|
+
return [t.strip() for t in val.replace(",", " ").split() if t.strip()]
|
|
272
|
+
return []
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""pdm_memory.integrations package."""
|
|
2
|
+
from pdm_memory.integrations.openai_adapter import wrap_openai, PDMOpenAIClient
|
|
3
|
+
from pdm_memory.integrations.anthropic_adapter import wrap_anthropic, PDMAnthropicClient
|
|
4
|
+
from pdm_memory.integrations.context_manager import ContextWindowManager
|
|
5
|
+
from pdm_memory.integrations.gemini_adapter import wrap_gemini, PDMGeminiClient
|
|
6
|
+
from pdm_memory.integrations.ollama_adapter import wrap_ollama, PDMOllamaClient
|
|
7
|
+
from pdm_memory.integrations.groq_adapter import wrap_groq, PDMGroqClient
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"wrap_openai",
|
|
11
|
+
"wrap_anthropic",
|
|
12
|
+
"wrap_gemini",
|
|
13
|
+
"wrap_ollama",
|
|
14
|
+
"wrap_groq",
|
|
15
|
+
"PDMOpenAIClient",
|
|
16
|
+
"PDMAnthropicClient",
|
|
17
|
+
"PDMGeminiClient",
|
|
18
|
+
"PDMOllamaClient",
|
|
19
|
+
"PDMGroqClient",
|
|
20
|
+
"ContextWindowManager",
|
|
21
|
+
]
|