hindsight-api 0.3.0__py3-none-any.whl → 0.4.1__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.
- hindsight_api/__init__.py +1 -1
- hindsight_api/admin/cli.py +59 -0
- hindsight_api/alembic/versions/h3c4d5e6f7g8_mental_models_v4.py +112 -0
- hindsight_api/alembic/versions/i4d5e6f7g8h9_delete_opinions.py +41 -0
- hindsight_api/alembic/versions/j5e6f7g8h9i0_mental_model_versions.py +95 -0
- hindsight_api/alembic/versions/k6f7g8h9i0j1_add_directive_subtype.py +58 -0
- hindsight_api/alembic/versions/l7g8h9i0j1k2_add_worker_columns.py +109 -0
- hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py +41 -0
- hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py +134 -0
- hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py +113 -0
- hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py +194 -0
- hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py +50 -0
- hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py +47 -0
- hindsight_api/alembic/versions/s4n5o6p7q8r9_add_consolidated_at_to_memory_units.py +53 -0
- hindsight_api/alembic/versions/t5o6p7q8r9s0_rename_mental_models_to_observations.py +134 -0
- hindsight_api/alembic/versions/u6p7q8r9s0t1_mental_models_text_id.py +41 -0
- hindsight_api/alembic/versions/v7q8r9s0t1u2_add_max_tokens_to_mental_models.py +50 -0
- hindsight_api/api/http.py +1120 -93
- hindsight_api/api/mcp.py +11 -191
- hindsight_api/config.py +174 -46
- hindsight_api/engine/consolidation/__init__.py +5 -0
- hindsight_api/engine/consolidation/consolidator.py +926 -0
- hindsight_api/engine/consolidation/prompts.py +77 -0
- hindsight_api/engine/cross_encoder.py +153 -22
- hindsight_api/engine/directives/__init__.py +5 -0
- hindsight_api/engine/directives/models.py +37 -0
- hindsight_api/engine/embeddings.py +136 -13
- hindsight_api/engine/interface.py +32 -13
- hindsight_api/engine/llm_wrapper.py +505 -43
- hindsight_api/engine/memory_engine.py +2101 -1094
- hindsight_api/engine/mental_models/__init__.py +14 -0
- hindsight_api/engine/mental_models/models.py +53 -0
- hindsight_api/engine/reflect/__init__.py +18 -0
- hindsight_api/engine/reflect/agent.py +933 -0
- hindsight_api/engine/reflect/models.py +109 -0
- hindsight_api/engine/reflect/observations.py +186 -0
- hindsight_api/engine/reflect/prompts.py +483 -0
- hindsight_api/engine/reflect/tools.py +437 -0
- hindsight_api/engine/reflect/tools_schema.py +250 -0
- hindsight_api/engine/response_models.py +130 -4
- hindsight_api/engine/retain/bank_utils.py +79 -201
- hindsight_api/engine/retain/fact_extraction.py +81 -48
- hindsight_api/engine/retain/fact_storage.py +5 -8
- hindsight_api/engine/retain/link_utils.py +5 -8
- hindsight_api/engine/retain/orchestrator.py +1 -55
- hindsight_api/engine/retain/types.py +2 -2
- hindsight_api/engine/search/graph_retrieval.py +2 -2
- hindsight_api/engine/search/link_expansion_retrieval.py +164 -29
- hindsight_api/engine/search/mpfp_retrieval.py +1 -1
- hindsight_api/engine/search/retrieval.py +14 -14
- hindsight_api/engine/search/think_utils.py +41 -140
- hindsight_api/engine/search/trace.py +0 -1
- hindsight_api/engine/search/tracer.py +2 -5
- hindsight_api/engine/search/types.py +0 -3
- hindsight_api/engine/task_backend.py +112 -196
- hindsight_api/engine/utils.py +0 -151
- hindsight_api/extensions/__init__.py +10 -1
- hindsight_api/extensions/builtin/tenant.py +11 -4
- hindsight_api/extensions/operation_validator.py +81 -4
- hindsight_api/extensions/tenant.py +26 -0
- hindsight_api/main.py +28 -5
- hindsight_api/mcp_local.py +12 -53
- hindsight_api/mcp_tools.py +494 -0
- hindsight_api/models.py +0 -2
- hindsight_api/worker/__init__.py +11 -0
- hindsight_api/worker/main.py +296 -0
- hindsight_api/worker/poller.py +486 -0
- {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.1.dist-info}/METADATA +12 -6
- hindsight_api-0.4.1.dist-info/RECORD +112 -0
- {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.1.dist-info}/entry_points.txt +1 -0
- hindsight_api/engine/retain/observation_regeneration.py +0 -254
- hindsight_api/engine/search/observation_utils.py +0 -125
- hindsight_api/engine/search/scoring.py +0 -159
- hindsight_api-0.3.0.dist-info/RECORD +0 -82
- {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.1.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
"""Consolidation engine for automatic observation creation from memories.
|
|
2
|
+
|
|
3
|
+
The consolidation engine runs as a background job after retain operations complete.
|
|
4
|
+
It processes new memories and either:
|
|
5
|
+
- Creates new observations from novel facts
|
|
6
|
+
- Updates existing observations when new evidence supports/contradicts/refines them
|
|
7
|
+
|
|
8
|
+
Observations are stored in memory_units with fact_type='observation' and include:
|
|
9
|
+
- proof_count: Number of supporting memories
|
|
10
|
+
- source_memory_ids: Array of memory UUIDs that contribute to this observation
|
|
11
|
+
- history: JSONB tracking changes over time
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from ..memory_engine import fq_table
|
|
22
|
+
from ..retain import embedding_utils
|
|
23
|
+
from .prompts import (
|
|
24
|
+
CONSOLIDATION_SYSTEM_PROMPT,
|
|
25
|
+
CONSOLIDATION_USER_PROMPT,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from asyncpg import Connection
|
|
30
|
+
|
|
31
|
+
from ...api.http import RequestContext
|
|
32
|
+
from ..memory_engine import MemoryEngine
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ConsolidationPerfLog:
|
|
38
|
+
"""Performance logging for consolidation operations."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, bank_id: str):
|
|
41
|
+
self.bank_id = bank_id
|
|
42
|
+
self.start_time = time.time()
|
|
43
|
+
self.lines: list[str] = []
|
|
44
|
+
self.timings: dict[str, float] = {}
|
|
45
|
+
|
|
46
|
+
def log(self, message: str) -> None:
|
|
47
|
+
"""Add a log line."""
|
|
48
|
+
self.lines.append(message)
|
|
49
|
+
|
|
50
|
+
def record_timing(self, key: str, duration: float) -> None:
|
|
51
|
+
"""Record a timing measurement."""
|
|
52
|
+
if key in self.timings:
|
|
53
|
+
self.timings[key] += duration
|
|
54
|
+
else:
|
|
55
|
+
self.timings[key] = duration
|
|
56
|
+
|
|
57
|
+
def flush(self) -> None:
|
|
58
|
+
"""Flush all log lines to the logger."""
|
|
59
|
+
total_time = time.time() - self.start_time
|
|
60
|
+
header = f"\n{'=' * 60}\nCONSOLIDATION for bank {self.bank_id}"
|
|
61
|
+
footer = f"{'=' * 60}\nCONSOLIDATION COMPLETE: {total_time:.3f}s total\n{'=' * 60}"
|
|
62
|
+
|
|
63
|
+
log_output = header + "\n" + "\n".join(self.lines) + "\n" + footer
|
|
64
|
+
logger.info(log_output)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def run_consolidation_job(
|
|
68
|
+
memory_engine: "MemoryEngine",
|
|
69
|
+
bank_id: str,
|
|
70
|
+
request_context: "RequestContext",
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
"""
|
|
73
|
+
Run consolidation job for a bank.
|
|
74
|
+
|
|
75
|
+
This is called after retain operations to consolidate new memories into mental models.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
memory_engine: MemoryEngine instance
|
|
79
|
+
bank_id: Bank identifier
|
|
80
|
+
request_context: Request context for authentication
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Dict with consolidation results
|
|
84
|
+
"""
|
|
85
|
+
from ...config import get_config
|
|
86
|
+
|
|
87
|
+
config = get_config()
|
|
88
|
+
perf = ConsolidationPerfLog(bank_id)
|
|
89
|
+
max_memories_per_batch = config.consolidation_batch_size
|
|
90
|
+
|
|
91
|
+
# Check if consolidation is enabled
|
|
92
|
+
if not config.enable_observations:
|
|
93
|
+
logger.debug(f"Consolidation disabled for bank {bank_id}")
|
|
94
|
+
return {"status": "disabled", "bank_id": bank_id}
|
|
95
|
+
|
|
96
|
+
pool = memory_engine._pool
|
|
97
|
+
|
|
98
|
+
# Get bank profile
|
|
99
|
+
async with pool.acquire() as conn:
|
|
100
|
+
t0 = time.time()
|
|
101
|
+
bank_row = await conn.fetchrow(
|
|
102
|
+
f"""
|
|
103
|
+
SELECT bank_id, name, mission
|
|
104
|
+
FROM {fq_table("banks")}
|
|
105
|
+
WHERE bank_id = $1
|
|
106
|
+
""",
|
|
107
|
+
bank_id,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if not bank_row:
|
|
111
|
+
logger.warning(f"Bank {bank_id} not found for consolidation")
|
|
112
|
+
return {"status": "bank_not_found", "bank_id": bank_id}
|
|
113
|
+
|
|
114
|
+
mission = bank_row["mission"] or "General memory consolidation"
|
|
115
|
+
perf.record_timing("fetch_bank", time.time() - t0)
|
|
116
|
+
|
|
117
|
+
# Count total unconsolidated memories for progress logging
|
|
118
|
+
total_count = await conn.fetchval(
|
|
119
|
+
f"""
|
|
120
|
+
SELECT COUNT(*)
|
|
121
|
+
FROM {fq_table("memory_units")}
|
|
122
|
+
WHERE bank_id = $1
|
|
123
|
+
AND consolidated_at IS NULL
|
|
124
|
+
AND fact_type IN ('experience', 'world')
|
|
125
|
+
""",
|
|
126
|
+
bank_id,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if total_count == 0:
|
|
130
|
+
logger.debug(f"No new memories to consolidate for bank {bank_id}")
|
|
131
|
+
return {"status": "no_new_memories", "bank_id": bank_id, "memories_processed": 0}
|
|
132
|
+
|
|
133
|
+
logger.info(f"[CONSOLIDATION] bank={bank_id} total_unconsolidated={total_count}")
|
|
134
|
+
perf.log(f"[1] Found {total_count} pending memories to consolidate")
|
|
135
|
+
|
|
136
|
+
# Process each memory with individual commits for crash recovery
|
|
137
|
+
stats = {
|
|
138
|
+
"memories_processed": 0,
|
|
139
|
+
"observations_created": 0,
|
|
140
|
+
"observations_updated": 0,
|
|
141
|
+
"observations_merged": 0,
|
|
142
|
+
"actions_executed": 0,
|
|
143
|
+
"skipped": 0,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
batch_num = 0
|
|
147
|
+
while True:
|
|
148
|
+
batch_num += 1
|
|
149
|
+
batch_start = time.time()
|
|
150
|
+
|
|
151
|
+
# Fetch next batch of unconsolidated memories
|
|
152
|
+
async with pool.acquire() as conn:
|
|
153
|
+
t0 = time.time()
|
|
154
|
+
memories = await conn.fetch(
|
|
155
|
+
f"""
|
|
156
|
+
SELECT id, text, fact_type, occurred_start, occurred_end, event_date, tags, mentioned_at
|
|
157
|
+
FROM {fq_table("memory_units")}
|
|
158
|
+
WHERE bank_id = $1
|
|
159
|
+
AND consolidated_at IS NULL
|
|
160
|
+
AND fact_type IN ('experience', 'world')
|
|
161
|
+
ORDER BY created_at ASC
|
|
162
|
+
LIMIT $2
|
|
163
|
+
""",
|
|
164
|
+
bank_id,
|
|
165
|
+
max_memories_per_batch,
|
|
166
|
+
)
|
|
167
|
+
perf.record_timing("fetch_memories", time.time() - t0)
|
|
168
|
+
|
|
169
|
+
if not memories:
|
|
170
|
+
break # No more unconsolidated memories
|
|
171
|
+
|
|
172
|
+
for memory in memories:
|
|
173
|
+
mem_start = time.time()
|
|
174
|
+
|
|
175
|
+
# Process the memory (uses its own connection internally)
|
|
176
|
+
async with pool.acquire() as conn:
|
|
177
|
+
result = await _process_memory(
|
|
178
|
+
conn=conn,
|
|
179
|
+
memory_engine=memory_engine,
|
|
180
|
+
bank_id=bank_id,
|
|
181
|
+
memory=dict(memory),
|
|
182
|
+
mission=mission,
|
|
183
|
+
request_context=request_context,
|
|
184
|
+
perf=perf,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Mark memory as consolidated (committed immediately)
|
|
188
|
+
await conn.execute(
|
|
189
|
+
f"""
|
|
190
|
+
UPDATE {fq_table("memory_units")}
|
|
191
|
+
SET consolidated_at = NOW()
|
|
192
|
+
WHERE id = $1
|
|
193
|
+
""",
|
|
194
|
+
memory["id"],
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
mem_time = time.time() - mem_start
|
|
198
|
+
perf.record_timing("process_memory_total", mem_time)
|
|
199
|
+
|
|
200
|
+
stats["memories_processed"] += 1
|
|
201
|
+
|
|
202
|
+
action = result.get("action")
|
|
203
|
+
if action == "created":
|
|
204
|
+
stats["observations_created"] += 1
|
|
205
|
+
stats["actions_executed"] += 1
|
|
206
|
+
elif action == "updated":
|
|
207
|
+
stats["observations_updated"] += 1
|
|
208
|
+
stats["actions_executed"] += 1
|
|
209
|
+
elif action == "merged":
|
|
210
|
+
stats["observations_merged"] += 1
|
|
211
|
+
stats["actions_executed"] += 1
|
|
212
|
+
elif action == "multiple":
|
|
213
|
+
stats["observations_created"] += result.get("created", 0)
|
|
214
|
+
stats["observations_updated"] += result.get("updated", 0)
|
|
215
|
+
stats["observations_merged"] += result.get("merged", 0)
|
|
216
|
+
stats["actions_executed"] += result.get("total_actions", 0)
|
|
217
|
+
elif action == "skipped":
|
|
218
|
+
stats["skipped"] += 1
|
|
219
|
+
|
|
220
|
+
# Log progress periodically
|
|
221
|
+
if stats["memories_processed"] % 10 == 0:
|
|
222
|
+
logger.info(
|
|
223
|
+
f"[CONSOLIDATION] bank={bank_id} progress: "
|
|
224
|
+
f"{stats['memories_processed']}/{total_count} memories processed"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
batch_time = time.time() - batch_start
|
|
228
|
+
perf.log(
|
|
229
|
+
f"[2] Batch {batch_num}: {len(memories)} memories in {batch_time:.3f}s "
|
|
230
|
+
f"(avg {batch_time / len(memories):.3f}s/memory)"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Build summary
|
|
234
|
+
perf.log(
|
|
235
|
+
f"[3] Results: {stats['memories_processed']} memories -> "
|
|
236
|
+
f"{stats['actions_executed']} actions "
|
|
237
|
+
f"({stats['observations_created']} created, "
|
|
238
|
+
f"{stats['observations_updated']} updated, "
|
|
239
|
+
f"{stats['observations_merged']} merged, "
|
|
240
|
+
f"{stats['skipped']} skipped)"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Add timing breakdown
|
|
244
|
+
timing_parts = []
|
|
245
|
+
if "recall" in perf.timings:
|
|
246
|
+
timing_parts.append(f"recall={perf.timings['recall']:.3f}s")
|
|
247
|
+
if "llm" in perf.timings:
|
|
248
|
+
timing_parts.append(f"llm={perf.timings['llm']:.3f}s")
|
|
249
|
+
if "embedding" in perf.timings:
|
|
250
|
+
timing_parts.append(f"embedding={perf.timings['embedding']:.3f}s")
|
|
251
|
+
if "db_write" in perf.timings:
|
|
252
|
+
timing_parts.append(f"db_write={perf.timings['db_write']:.3f}s")
|
|
253
|
+
|
|
254
|
+
if timing_parts:
|
|
255
|
+
perf.log(f"[4] Timing breakdown: {', '.join(timing_parts)}")
|
|
256
|
+
|
|
257
|
+
# Trigger mental model refreshes for models with refresh_after_consolidation=true
|
|
258
|
+
mental_models_refreshed = await _trigger_mental_model_refreshes(
|
|
259
|
+
memory_engine=memory_engine,
|
|
260
|
+
bank_id=bank_id,
|
|
261
|
+
request_context=request_context,
|
|
262
|
+
perf=perf,
|
|
263
|
+
)
|
|
264
|
+
stats["mental_models_refreshed"] = mental_models_refreshed
|
|
265
|
+
|
|
266
|
+
perf.flush()
|
|
267
|
+
|
|
268
|
+
return {"status": "completed", "bank_id": bank_id, **stats}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def _trigger_mental_model_refreshes(
|
|
272
|
+
memory_engine: "MemoryEngine",
|
|
273
|
+
bank_id: str,
|
|
274
|
+
request_context: "RequestContext",
|
|
275
|
+
perf: ConsolidationPerfLog | None = None,
|
|
276
|
+
) -> int:
|
|
277
|
+
"""
|
|
278
|
+
Trigger refreshes for mental models with refresh_after_consolidation=true.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
memory_engine: MemoryEngine instance
|
|
282
|
+
bank_id: Bank identifier
|
|
283
|
+
request_context: Request context for authentication
|
|
284
|
+
perf: Performance logging
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Number of mental models scheduled for refresh
|
|
288
|
+
"""
|
|
289
|
+
pool = memory_engine._pool
|
|
290
|
+
|
|
291
|
+
# Find mental models with refresh_after_consolidation=true
|
|
292
|
+
async with pool.acquire() as conn:
|
|
293
|
+
rows = await conn.fetch(
|
|
294
|
+
f"""
|
|
295
|
+
SELECT id, name
|
|
296
|
+
FROM {fq_table("mental_models")}
|
|
297
|
+
WHERE bank_id = $1
|
|
298
|
+
AND (trigger->>'refresh_after_consolidation')::boolean = true
|
|
299
|
+
""",
|
|
300
|
+
bank_id,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if not rows:
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
if perf:
|
|
307
|
+
perf.log(f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true")
|
|
308
|
+
|
|
309
|
+
# Submit refresh tasks for each mental model
|
|
310
|
+
refreshed_count = 0
|
|
311
|
+
for row in rows:
|
|
312
|
+
mental_model_id = row["id"]
|
|
313
|
+
try:
|
|
314
|
+
await memory_engine.submit_async_refresh_mental_model(
|
|
315
|
+
bank_id=bank_id,
|
|
316
|
+
mental_model_id=mental_model_id,
|
|
317
|
+
request_context=request_context,
|
|
318
|
+
)
|
|
319
|
+
refreshed_count += 1
|
|
320
|
+
logger.info(
|
|
321
|
+
f"[CONSOLIDATION] Triggered refresh for mental model {mental_model_id} "
|
|
322
|
+
f"(name: {row['name']}) in bank {bank_id}"
|
|
323
|
+
)
|
|
324
|
+
except Exception as e:
|
|
325
|
+
logger.warning(f"[CONSOLIDATION] Failed to trigger refresh for mental model {mental_model_id}: {e}")
|
|
326
|
+
|
|
327
|
+
return refreshed_count
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
async def _process_memory(
|
|
331
|
+
conn: "Connection",
|
|
332
|
+
memory_engine: "MemoryEngine",
|
|
333
|
+
bank_id: str,
|
|
334
|
+
memory: dict[str, Any],
|
|
335
|
+
mission: str,
|
|
336
|
+
request_context: "RequestContext",
|
|
337
|
+
perf: ConsolidationPerfLog | None = None,
|
|
338
|
+
) -> dict[str, Any]:
|
|
339
|
+
"""
|
|
340
|
+
Process a single memory for consolidation using a SINGLE LLM call.
|
|
341
|
+
|
|
342
|
+
This function:
|
|
343
|
+
1. Finds related observations (can be empty)
|
|
344
|
+
2. Uses ONE LLM call to extract durable knowledge AND decide on actions
|
|
345
|
+
3. Executes array of actions (can be multiple creates/updates)
|
|
346
|
+
|
|
347
|
+
The LLM handles all cases:
|
|
348
|
+
- No related observations: returns create action(s) with extracted durable knowledge
|
|
349
|
+
- Related observations exist: returns update/create actions based on tag routing
|
|
350
|
+
- Purely ephemeral fact: returns empty array (skip)
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Dict with action summary: created/updated/merged counts
|
|
354
|
+
"""
|
|
355
|
+
fact_text = memory["text"]
|
|
356
|
+
memory_id = memory["id"]
|
|
357
|
+
fact_tags = memory.get("tags") or []
|
|
358
|
+
|
|
359
|
+
# Find related observations using the full recall system (NO tag filtering)
|
|
360
|
+
t0 = time.time()
|
|
361
|
+
related_observations = await _find_related_observations(
|
|
362
|
+
conn=conn,
|
|
363
|
+
memory_engine=memory_engine,
|
|
364
|
+
bank_id=bank_id,
|
|
365
|
+
query=fact_text,
|
|
366
|
+
request_context=request_context,
|
|
367
|
+
)
|
|
368
|
+
if perf:
|
|
369
|
+
perf.record_timing("recall", time.time() - t0)
|
|
370
|
+
|
|
371
|
+
# Single LLM call handles ALL cases (with or without existing observations)
|
|
372
|
+
# Note: Tags are NOT passed to LLM - they are handled algorithmically
|
|
373
|
+
t0 = time.time()
|
|
374
|
+
actions = await _consolidate_with_llm(
|
|
375
|
+
memory_engine=memory_engine,
|
|
376
|
+
fact_text=fact_text,
|
|
377
|
+
observations=related_observations, # Can be empty list
|
|
378
|
+
mission=mission,
|
|
379
|
+
)
|
|
380
|
+
if perf:
|
|
381
|
+
perf.record_timing("llm", time.time() - t0)
|
|
382
|
+
|
|
383
|
+
if not actions:
|
|
384
|
+
# LLM returned empty array - fact is purely ephemeral, skip
|
|
385
|
+
return {"action": "skipped", "reason": "no_durable_knowledge"}
|
|
386
|
+
|
|
387
|
+
# Execute all actions and collect results
|
|
388
|
+
results = []
|
|
389
|
+
for action in actions:
|
|
390
|
+
action_type = action.get("action")
|
|
391
|
+
if action_type == "update":
|
|
392
|
+
result = await _execute_update_action(
|
|
393
|
+
conn=conn,
|
|
394
|
+
memory_engine=memory_engine,
|
|
395
|
+
bank_id=bank_id,
|
|
396
|
+
memory_id=memory_id,
|
|
397
|
+
action=action,
|
|
398
|
+
observations=related_observations,
|
|
399
|
+
source_fact_tags=fact_tags, # Pass source fact's tags for security
|
|
400
|
+
source_occurred_start=memory.get("occurred_start"),
|
|
401
|
+
source_occurred_end=memory.get("occurred_end"),
|
|
402
|
+
source_mentioned_at=memory.get("mentioned_at"),
|
|
403
|
+
perf=perf,
|
|
404
|
+
)
|
|
405
|
+
results.append(result)
|
|
406
|
+
elif action_type == "create":
|
|
407
|
+
result = await _execute_create_action(
|
|
408
|
+
conn=conn,
|
|
409
|
+
memory_engine=memory_engine,
|
|
410
|
+
bank_id=bank_id,
|
|
411
|
+
memory_id=memory_id,
|
|
412
|
+
action=action,
|
|
413
|
+
source_fact_tags=fact_tags, # Pass source fact's tags for security
|
|
414
|
+
event_date=memory.get("event_date"),
|
|
415
|
+
occurred_start=memory.get("occurred_start"),
|
|
416
|
+
occurred_end=memory.get("occurred_end"),
|
|
417
|
+
mentioned_at=memory.get("mentioned_at"),
|
|
418
|
+
perf=perf,
|
|
419
|
+
)
|
|
420
|
+
results.append(result)
|
|
421
|
+
|
|
422
|
+
if not results:
|
|
423
|
+
# No valid actions executed
|
|
424
|
+
return {"action": "skipped", "reason": "no_valid_actions"}
|
|
425
|
+
|
|
426
|
+
# Summarize results
|
|
427
|
+
created = sum(1 for r in results if r.get("action") == "created")
|
|
428
|
+
updated = sum(1 for r in results if r.get("action") == "updated")
|
|
429
|
+
merged = sum(1 for r in results if r.get("action") == "merged")
|
|
430
|
+
|
|
431
|
+
if len(results) == 1:
|
|
432
|
+
return results[0]
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
"action": "multiple",
|
|
436
|
+
"created": created,
|
|
437
|
+
"updated": updated,
|
|
438
|
+
"merged": merged,
|
|
439
|
+
"total_actions": len(results),
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
async def _execute_update_action(
|
|
444
|
+
conn: "Connection",
|
|
445
|
+
memory_engine: "MemoryEngine",
|
|
446
|
+
bank_id: str,
|
|
447
|
+
memory_id: uuid.UUID,
|
|
448
|
+
action: dict[str, Any],
|
|
449
|
+
observations: list[dict[str, Any]],
|
|
450
|
+
source_fact_tags: list[str] | None = None,
|
|
451
|
+
source_occurred_start: datetime | None = None,
|
|
452
|
+
source_occurred_end: datetime | None = None,
|
|
453
|
+
source_mentioned_at: datetime | None = None,
|
|
454
|
+
perf: ConsolidationPerfLog | None = None,
|
|
455
|
+
) -> dict[str, Any]:
|
|
456
|
+
"""
|
|
457
|
+
Execute an update action on an existing observation.
|
|
458
|
+
|
|
459
|
+
Updates the observation text, adds to history, increments proof_count,
|
|
460
|
+
and updates temporal fields:
|
|
461
|
+
- occurred_start: uses LEAST to keep the earliest start time
|
|
462
|
+
- occurred_end: uses GREATEST to keep the most recent end time
|
|
463
|
+
- mentioned_at: uses GREATEST to keep the most recent mention time
|
|
464
|
+
|
|
465
|
+
SECURITY: Merges source fact's tags into the observation's existing tags.
|
|
466
|
+
This ensures all contributors can see the observation they contributed to.
|
|
467
|
+
For example, if Lisa's observation (tags=['user_lisa']) is updated with
|
|
468
|
+
Mike's fact (tags=['user_mike']), the observation will have both tags.
|
|
469
|
+
"""
|
|
470
|
+
learning_id = action.get("learning_id")
|
|
471
|
+
new_text = action.get("text")
|
|
472
|
+
reason = action.get("reason", "Updated with new fact")
|
|
473
|
+
|
|
474
|
+
if not learning_id or not new_text:
|
|
475
|
+
return {"action": "skipped", "reason": "missing_learning_id_or_text"}
|
|
476
|
+
|
|
477
|
+
# Find the observation
|
|
478
|
+
model = next((m for m in observations if str(m["id"]) == learning_id), None)
|
|
479
|
+
if not model:
|
|
480
|
+
return {"action": "skipped", "reason": "learning_not_found"}
|
|
481
|
+
|
|
482
|
+
# Build history entry
|
|
483
|
+
history = list(model.get("history", []))
|
|
484
|
+
history.append(
|
|
485
|
+
{
|
|
486
|
+
"previous_text": model["text"],
|
|
487
|
+
"changed_at": datetime.now(timezone.utc).isoformat(),
|
|
488
|
+
"reason": reason,
|
|
489
|
+
"source_memory_id": str(memory_id),
|
|
490
|
+
}
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# Update source_memory_ids
|
|
494
|
+
source_ids = list(model.get("source_memory_ids", []))
|
|
495
|
+
source_ids.append(memory_id)
|
|
496
|
+
|
|
497
|
+
# SECURITY: Merge source fact's tags into existing observation tags
|
|
498
|
+
# This ensures all contributors can see the observation they contributed to
|
|
499
|
+
existing_tags = set(model.get("tags", []) or [])
|
|
500
|
+
source_tags = set(source_fact_tags or [])
|
|
501
|
+
merged_tags = list(existing_tags | source_tags) # Union of both tag sets
|
|
502
|
+
if source_tags and source_tags != existing_tags:
|
|
503
|
+
logger.debug(
|
|
504
|
+
f"Security: Merging tags for observation {learning_id}: "
|
|
505
|
+
f"existing={list(existing_tags)}, source={list(source_tags)}, merged={merged_tags}"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# Generate new embedding for updated text
|
|
509
|
+
t0 = time.time()
|
|
510
|
+
embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [new_text])
|
|
511
|
+
embedding_str = str(embeddings[0]) if embeddings else None
|
|
512
|
+
if perf:
|
|
513
|
+
perf.record_timing("embedding", time.time() - t0)
|
|
514
|
+
|
|
515
|
+
# Update the observation
|
|
516
|
+
# - occurred_start: LEAST keeps the earliest start time across all source facts
|
|
517
|
+
# - occurred_end: GREATEST keeps the most recent end time across all source facts
|
|
518
|
+
# - mentioned_at: GREATEST keeps the most recent mention time
|
|
519
|
+
# - tags: merged from existing + source fact (for visibility)
|
|
520
|
+
t0 = time.time()
|
|
521
|
+
await conn.execute(
|
|
522
|
+
f"""
|
|
523
|
+
UPDATE {fq_table("memory_units")}
|
|
524
|
+
SET text = $1,
|
|
525
|
+
embedding = $2::vector,
|
|
526
|
+
history = $3,
|
|
527
|
+
source_memory_ids = $4,
|
|
528
|
+
proof_count = $5,
|
|
529
|
+
tags = $10,
|
|
530
|
+
updated_at = now(),
|
|
531
|
+
occurred_start = LEAST(occurred_start, COALESCE($7, occurred_start)),
|
|
532
|
+
occurred_end = GREATEST(occurred_end, COALESCE($8, occurred_end)),
|
|
533
|
+
mentioned_at = GREATEST(mentioned_at, COALESCE($9, mentioned_at))
|
|
534
|
+
WHERE id = $6
|
|
535
|
+
""",
|
|
536
|
+
new_text,
|
|
537
|
+
embedding_str,
|
|
538
|
+
json.dumps(history),
|
|
539
|
+
source_ids,
|
|
540
|
+
len(source_ids),
|
|
541
|
+
uuid.UUID(learning_id),
|
|
542
|
+
source_occurred_start,
|
|
543
|
+
source_occurred_end,
|
|
544
|
+
source_mentioned_at,
|
|
545
|
+
merged_tags,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Create links from memory to observation
|
|
549
|
+
await _create_memory_links(conn, memory_id, uuid.UUID(learning_id))
|
|
550
|
+
if perf:
|
|
551
|
+
perf.record_timing("db_write", time.time() - t0)
|
|
552
|
+
|
|
553
|
+
logger.debug(f"Updated observation {learning_id} with memory {memory_id}")
|
|
554
|
+
|
|
555
|
+
return {"action": "updated", "observation_id": learning_id}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
async def _execute_create_action(
|
|
559
|
+
conn: "Connection",
|
|
560
|
+
memory_engine: "MemoryEngine",
|
|
561
|
+
bank_id: str,
|
|
562
|
+
memory_id: uuid.UUID,
|
|
563
|
+
action: dict[str, Any],
|
|
564
|
+
source_fact_tags: list[str] | None = None,
|
|
565
|
+
event_date: datetime | None = None,
|
|
566
|
+
occurred_start: datetime | None = None,
|
|
567
|
+
occurred_end: datetime | None = None,
|
|
568
|
+
mentioned_at: datetime | None = None,
|
|
569
|
+
perf: ConsolidationPerfLog | None = None,
|
|
570
|
+
) -> dict[str, Any]:
|
|
571
|
+
"""
|
|
572
|
+
Execute a create action for a new observation.
|
|
573
|
+
|
|
574
|
+
Creates a new observation with the specified text.
|
|
575
|
+
The text comes directly from the classify LLM - no second LLM call needed.
|
|
576
|
+
|
|
577
|
+
Tags are determined algorithmically (not by LLM):
|
|
578
|
+
- Observations always inherit their source fact's tags
|
|
579
|
+
- This ensures visibility scope is maintained (security)
|
|
580
|
+
"""
|
|
581
|
+
text = action.get("text")
|
|
582
|
+
|
|
583
|
+
# Tags are determined algorithmically - always use source fact's tags
|
|
584
|
+
# This ensures private memories create private observations
|
|
585
|
+
tags = source_fact_tags or []
|
|
586
|
+
|
|
587
|
+
if not text:
|
|
588
|
+
return {"action": "skipped", "reason": "missing_text"}
|
|
589
|
+
|
|
590
|
+
# Use text directly from classify - skip the redundant LLM call
|
|
591
|
+
result = await _create_observation_directly(
|
|
592
|
+
conn=conn,
|
|
593
|
+
memory_engine=memory_engine,
|
|
594
|
+
bank_id=bank_id,
|
|
595
|
+
source_memory_id=memory_id,
|
|
596
|
+
observation_text=text, # Text already processed by classify LLM
|
|
597
|
+
tags=tags,
|
|
598
|
+
event_date=event_date,
|
|
599
|
+
occurred_start=occurred_start,
|
|
600
|
+
occurred_end=occurred_end,
|
|
601
|
+
mentioned_at=mentioned_at,
|
|
602
|
+
perf=perf,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
logger.debug(f"Created observation {result.get('observation_id')} from memory {memory_id} (tags: {tags})")
|
|
606
|
+
|
|
607
|
+
return result
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
async def _create_memory_links(
|
|
611
|
+
conn: "Connection",
|
|
612
|
+
memory_id: uuid.UUID,
|
|
613
|
+
observation_id: uuid.UUID,
|
|
614
|
+
) -> None:
|
|
615
|
+
"""
|
|
616
|
+
Placeholder for observation link creation.
|
|
617
|
+
|
|
618
|
+
Observations do NOT get any memory_links copied from their source facts.
|
|
619
|
+
Instead, retrieval uses source_memory_ids to traverse:
|
|
620
|
+
- Entity connections: observation → source_memory_ids → unit_entities
|
|
621
|
+
- Semantic similarity: observations have their own embeddings
|
|
622
|
+
- Temporal proximity: observations have their own temporal fields
|
|
623
|
+
|
|
624
|
+
This avoids data duplication and ensures observations are always
|
|
625
|
+
connected via their source facts' relationships.
|
|
626
|
+
|
|
627
|
+
The memory_id and observation_id parameters are kept for interface
|
|
628
|
+
compatibility but no links are created.
|
|
629
|
+
"""
|
|
630
|
+
# No links are created - observations rely on source_memory_ids for traversal
|
|
631
|
+
pass
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
async def _find_related_observations(
|
|
635
|
+
conn: "Connection",
|
|
636
|
+
memory_engine: "MemoryEngine",
|
|
637
|
+
bank_id: str,
|
|
638
|
+
query: str,
|
|
639
|
+
request_context: "RequestContext",
|
|
640
|
+
) -> list[dict[str, Any]]:
|
|
641
|
+
"""
|
|
642
|
+
Find observations related to the given query using optimized recall.
|
|
643
|
+
|
|
644
|
+
IMPORTANT: We do NOT filter by tags here. Consolidation needs to see ALL
|
|
645
|
+
potentially related observations regardless of scope, so the LLM can
|
|
646
|
+
decide on tag routing (same scope update vs cross-scope create).
|
|
647
|
+
|
|
648
|
+
Uses max_tokens to naturally limit observations (no artificial count limit).
|
|
649
|
+
Includes source memories with dates for LLM context.
|
|
650
|
+
|
|
651
|
+
Returns:
|
|
652
|
+
List of related observations with their tags, source memories, and dates
|
|
653
|
+
"""
|
|
654
|
+
# Use recall to find related observations with token budget
|
|
655
|
+
# max_tokens naturally limits how many observations are returned
|
|
656
|
+
from ...config import get_config
|
|
657
|
+
|
|
658
|
+
config = get_config()
|
|
659
|
+
recall_result = await memory_engine.recall_async(
|
|
660
|
+
bank_id=bank_id,
|
|
661
|
+
query=query,
|
|
662
|
+
max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
|
|
663
|
+
fact_type=["observation"], # Only retrieve observations
|
|
664
|
+
request_context=request_context,
|
|
665
|
+
_quiet=True, # Suppress logging
|
|
666
|
+
# NO tags parameter - intentionally get ALL observations
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
# If no observations returned, return empty list
|
|
670
|
+
if not recall_result.results:
|
|
671
|
+
return []
|
|
672
|
+
|
|
673
|
+
# Batch fetch all observations in a single query (no artificial limit)
|
|
674
|
+
observation_ids = [uuid.UUID(obs.id) for obs in recall_result.results]
|
|
675
|
+
|
|
676
|
+
rows = await conn.fetch(
|
|
677
|
+
f"""
|
|
678
|
+
SELECT id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at,
|
|
679
|
+
occurred_start, occurred_end, mentioned_at
|
|
680
|
+
FROM {fq_table("memory_units")}
|
|
681
|
+
WHERE id = ANY($1) AND bank_id = $2 AND fact_type = 'observation'
|
|
682
|
+
""",
|
|
683
|
+
observation_ids,
|
|
684
|
+
bank_id,
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
# Build results list preserving recall order
|
|
688
|
+
id_to_row = {row["id"]: row for row in rows}
|
|
689
|
+
results = []
|
|
690
|
+
|
|
691
|
+
for obs in recall_result.results:
|
|
692
|
+
obs_id = uuid.UUID(obs.id)
|
|
693
|
+
if obs_id not in id_to_row:
|
|
694
|
+
continue
|
|
695
|
+
|
|
696
|
+
row = id_to_row[obs_id]
|
|
697
|
+
history = row["history"]
|
|
698
|
+
if isinstance(history, str):
|
|
699
|
+
history = json.loads(history)
|
|
700
|
+
elif history is None:
|
|
701
|
+
history = []
|
|
702
|
+
|
|
703
|
+
# Fetch source memories to include their text and dates
|
|
704
|
+
source_memory_ids = row["source_memory_ids"] or []
|
|
705
|
+
source_memories = []
|
|
706
|
+
|
|
707
|
+
if source_memory_ids:
|
|
708
|
+
source_rows = await conn.fetch(
|
|
709
|
+
f"""
|
|
710
|
+
SELECT text, occurred_start, occurred_end, mentioned_at, event_date
|
|
711
|
+
FROM {fq_table("memory_units")}
|
|
712
|
+
WHERE id = ANY($1) AND bank_id = $2
|
|
713
|
+
ORDER BY created_at ASC
|
|
714
|
+
LIMIT 5
|
|
715
|
+
""",
|
|
716
|
+
source_memory_ids[:5], # Limit to first 5 source memories for token efficiency
|
|
717
|
+
bank_id,
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
for src_row in source_rows:
|
|
721
|
+
source_memories.append(
|
|
722
|
+
{
|
|
723
|
+
"text": src_row["text"],
|
|
724
|
+
"occurred_start": src_row["occurred_start"],
|
|
725
|
+
"occurred_end": src_row["occurred_end"],
|
|
726
|
+
"mentioned_at": src_row["mentioned_at"],
|
|
727
|
+
"event_date": src_row["event_date"],
|
|
728
|
+
}
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
results.append(
|
|
732
|
+
{
|
|
733
|
+
"id": row["id"],
|
|
734
|
+
"text": row["text"],
|
|
735
|
+
"proof_count": row["proof_count"] or 1,
|
|
736
|
+
"tags": row["tags"] or [],
|
|
737
|
+
"source_memories": source_memories,
|
|
738
|
+
"occurred_start": row["occurred_start"],
|
|
739
|
+
"occurred_end": row["occurred_end"],
|
|
740
|
+
"mentioned_at": row["mentioned_at"],
|
|
741
|
+
"created_at": row["created_at"],
|
|
742
|
+
"updated_at": row["updated_at"],
|
|
743
|
+
}
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
return results
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
async def _consolidate_with_llm(
|
|
750
|
+
memory_engine: "MemoryEngine",
|
|
751
|
+
fact_text: str,
|
|
752
|
+
observations: list[dict[str, Any]],
|
|
753
|
+
mission: str,
|
|
754
|
+
) -> list[dict[str, Any]]:
|
|
755
|
+
"""
|
|
756
|
+
Single LLM call to extract durable knowledge and decide on consolidation actions.
|
|
757
|
+
|
|
758
|
+
This handles ALL cases:
|
|
759
|
+
- No related observations: extracts durable knowledge, returns create action
|
|
760
|
+
- Related observations exist: compares and returns update/create actions
|
|
761
|
+
- Purely ephemeral fact: returns empty array
|
|
762
|
+
|
|
763
|
+
Note: Tags are NOT handled by the LLM. They are determined algorithmically:
|
|
764
|
+
- CREATE: observation inherits source fact's tags
|
|
765
|
+
- UPDATE: observation merges source fact's tags with existing tags
|
|
766
|
+
|
|
767
|
+
Returns:
|
|
768
|
+
List of actions, each being:
|
|
769
|
+
- {"action": "update", "learning_id": "uuid", "text": "...", "reason": "..."}
|
|
770
|
+
- {"action": "create", "text": "...", "reason": "..."}
|
|
771
|
+
- [] if fact is purely ephemeral (no durable knowledge)
|
|
772
|
+
"""
|
|
773
|
+
# Format observations as JSON with source memories and dates
|
|
774
|
+
if observations:
|
|
775
|
+
obs_list = []
|
|
776
|
+
for obs in observations:
|
|
777
|
+
obs_data = {
|
|
778
|
+
"id": str(obs["id"]),
|
|
779
|
+
"text": obs["text"],
|
|
780
|
+
"proof_count": obs["proof_count"],
|
|
781
|
+
"tags": obs["tags"],
|
|
782
|
+
"created_at": obs["created_at"].isoformat() if obs.get("created_at") else None,
|
|
783
|
+
"updated_at": obs["updated_at"].isoformat() if obs.get("updated_at") else None,
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
# Include temporal info if available
|
|
787
|
+
if obs.get("occurred_start"):
|
|
788
|
+
obs_data["occurred_start"] = obs["occurred_start"].isoformat()
|
|
789
|
+
if obs.get("occurred_end"):
|
|
790
|
+
obs_data["occurred_end"] = obs["occurred_end"].isoformat()
|
|
791
|
+
if obs.get("mentioned_at"):
|
|
792
|
+
obs_data["mentioned_at"] = obs["mentioned_at"].isoformat()
|
|
793
|
+
|
|
794
|
+
# Include source memories (up to 3 for brevity)
|
|
795
|
+
if obs.get("source_memories"):
|
|
796
|
+
obs_data["source_memories"] = [
|
|
797
|
+
{
|
|
798
|
+
"text": sm["text"],
|
|
799
|
+
"event_date": sm["event_date"].isoformat() if sm.get("event_date") else None,
|
|
800
|
+
"occurred_start": sm["occurred_start"].isoformat() if sm.get("occurred_start") else None,
|
|
801
|
+
}
|
|
802
|
+
for sm in obs["source_memories"][:3] # Limit to 3 for token efficiency
|
|
803
|
+
]
|
|
804
|
+
|
|
805
|
+
obs_list.append(obs_data)
|
|
806
|
+
|
|
807
|
+
observations_text = json.dumps(obs_list, indent=2)
|
|
808
|
+
else:
|
|
809
|
+
observations_text = "[]"
|
|
810
|
+
|
|
811
|
+
# Only include mission section if mission is set and not the default
|
|
812
|
+
mission_section = ""
|
|
813
|
+
if mission and mission != "General memory consolidation":
|
|
814
|
+
mission_section = f"""
|
|
815
|
+
MISSION CONTEXT: {mission}
|
|
816
|
+
|
|
817
|
+
Focus on DURABLE knowledge that serves this mission, not ephemeral state.
|
|
818
|
+
"""
|
|
819
|
+
|
|
820
|
+
user_prompt = CONSOLIDATION_USER_PROMPT.format(
|
|
821
|
+
mission_section=mission_section,
|
|
822
|
+
fact_text=fact_text,
|
|
823
|
+
observations_text=observations_text,
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
messages = [
|
|
827
|
+
{"role": "system", "content": CONSOLIDATION_SYSTEM_PROMPT},
|
|
828
|
+
{"role": "user", "content": user_prompt},
|
|
829
|
+
]
|
|
830
|
+
|
|
831
|
+
try:
|
|
832
|
+
result = await memory_engine._consolidation_llm_config.call(
|
|
833
|
+
messages=messages,
|
|
834
|
+
skip_validation=True, # Raw JSON response
|
|
835
|
+
scope="consolidation",
|
|
836
|
+
)
|
|
837
|
+
# Parse JSON response - should be an array
|
|
838
|
+
if isinstance(result, str):
|
|
839
|
+
result = json.loads(result)
|
|
840
|
+
# Ensure result is a list
|
|
841
|
+
if isinstance(result, list):
|
|
842
|
+
return result
|
|
843
|
+
# Handle legacy single-action format for backward compatibility
|
|
844
|
+
if isinstance(result, dict):
|
|
845
|
+
if result.get("related_ids") and result.get("consolidated_text"):
|
|
846
|
+
# Convert old format to new format
|
|
847
|
+
return [
|
|
848
|
+
{
|
|
849
|
+
"action": "update",
|
|
850
|
+
"learning_id": result["related_ids"][0],
|
|
851
|
+
"text": result["consolidated_text"],
|
|
852
|
+
"reason": result.get("reason", ""),
|
|
853
|
+
}
|
|
854
|
+
]
|
|
855
|
+
return []
|
|
856
|
+
return []
|
|
857
|
+
except Exception as e:
|
|
858
|
+
logger.warning(f"Error in consolidation LLM call: {e}")
|
|
859
|
+
return []
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
async def _create_observation_directly(
|
|
863
|
+
conn: "Connection",
|
|
864
|
+
memory_engine: "MemoryEngine",
|
|
865
|
+
bank_id: str,
|
|
866
|
+
source_memory_id: uuid.UUID,
|
|
867
|
+
observation_text: str,
|
|
868
|
+
tags: list[str] | None = None,
|
|
869
|
+
event_date: datetime | None = None,
|
|
870
|
+
occurred_start: datetime | None = None,
|
|
871
|
+
occurred_end: datetime | None = None,
|
|
872
|
+
mentioned_at: datetime | None = None,
|
|
873
|
+
perf: ConsolidationPerfLog | None = None,
|
|
874
|
+
) -> dict[str, Any]:
|
|
875
|
+
"""
|
|
876
|
+
Create an observation directly with pre-processed text (no LLM call).
|
|
877
|
+
|
|
878
|
+
Used when the classify LLM has already provided the learning text.
|
|
879
|
+
This avoids the redundant second LLM call.
|
|
880
|
+
"""
|
|
881
|
+
# Generate embedding for the observation (convert to string for pgvector)
|
|
882
|
+
t0 = time.time()
|
|
883
|
+
embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [observation_text])
|
|
884
|
+
embedding_str = str(embeddings[0]) if embeddings else None
|
|
885
|
+
if perf:
|
|
886
|
+
perf.record_timing("embedding", time.time() - t0)
|
|
887
|
+
|
|
888
|
+
# Create the observation as a memory_unit
|
|
889
|
+
now = datetime.now(timezone.utc)
|
|
890
|
+
obs_event_date = event_date or now
|
|
891
|
+
obs_occurred_start = occurred_start or now
|
|
892
|
+
obs_occurred_end = occurred_end or now
|
|
893
|
+
obs_mentioned_at = mentioned_at or now
|
|
894
|
+
obs_tags = tags or []
|
|
895
|
+
|
|
896
|
+
t0 = time.time()
|
|
897
|
+
observation_id = uuid.uuid4()
|
|
898
|
+
row = await conn.fetchrow(
|
|
899
|
+
f"""
|
|
900
|
+
INSERT INTO {fq_table("memory_units")} (
|
|
901
|
+
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
|
|
902
|
+
tags, event_date, occurred_start, occurred_end, mentioned_at
|
|
903
|
+
)
|
|
904
|
+
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9, $10)
|
|
905
|
+
RETURNING id
|
|
906
|
+
""",
|
|
907
|
+
observation_id,
|
|
908
|
+
bank_id,
|
|
909
|
+
observation_text,
|
|
910
|
+
embedding_str,
|
|
911
|
+
[source_memory_id],
|
|
912
|
+
obs_tags,
|
|
913
|
+
obs_event_date,
|
|
914
|
+
obs_occurred_start,
|
|
915
|
+
obs_occurred_end,
|
|
916
|
+
obs_mentioned_at,
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
# Create links between memory and observation (includes entity links, memory_links)
|
|
920
|
+
await _create_memory_links(conn, source_memory_id, observation_id)
|
|
921
|
+
if perf:
|
|
922
|
+
perf.record_timing("db_write", time.time() - t0)
|
|
923
|
+
|
|
924
|
+
logger.debug(f"Created observation {observation_id} from memory {source_memory_id} (tags: {obs_tags})")
|
|
925
|
+
|
|
926
|
+
return {"action": "created", "observation_id": str(row["id"]), "tags": obs_tags}
|