hindsight-api 0.0.13__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 +38 -0
- hindsight_api/api/__init__.py +105 -0
- hindsight_api/api/http.py +1872 -0
- hindsight_api/api/mcp.py +157 -0
- hindsight_api/engine/__init__.py +47 -0
- hindsight_api/engine/cross_encoder.py +97 -0
- hindsight_api/engine/db_utils.py +93 -0
- hindsight_api/engine/embeddings.py +113 -0
- hindsight_api/engine/entity_resolver.py +575 -0
- hindsight_api/engine/llm_wrapper.py +269 -0
- hindsight_api/engine/memory_engine.py +3095 -0
- hindsight_api/engine/query_analyzer.py +519 -0
- hindsight_api/engine/response_models.py +222 -0
- hindsight_api/engine/retain/__init__.py +50 -0
- hindsight_api/engine/retain/bank_utils.py +423 -0
- hindsight_api/engine/retain/chunk_storage.py +82 -0
- hindsight_api/engine/retain/deduplication.py +104 -0
- hindsight_api/engine/retain/embedding_processing.py +62 -0
- hindsight_api/engine/retain/embedding_utils.py +54 -0
- hindsight_api/engine/retain/entity_processing.py +90 -0
- hindsight_api/engine/retain/fact_extraction.py +1027 -0
- hindsight_api/engine/retain/fact_storage.py +176 -0
- hindsight_api/engine/retain/link_creation.py +121 -0
- hindsight_api/engine/retain/link_utils.py +651 -0
- hindsight_api/engine/retain/orchestrator.py +405 -0
- hindsight_api/engine/retain/types.py +206 -0
- hindsight_api/engine/search/__init__.py +15 -0
- hindsight_api/engine/search/fusion.py +122 -0
- hindsight_api/engine/search/observation_utils.py +132 -0
- hindsight_api/engine/search/reranking.py +103 -0
- hindsight_api/engine/search/retrieval.py +503 -0
- hindsight_api/engine/search/scoring.py +161 -0
- hindsight_api/engine/search/temporal_extraction.py +64 -0
- hindsight_api/engine/search/think_utils.py +255 -0
- hindsight_api/engine/search/trace.py +215 -0
- hindsight_api/engine/search/tracer.py +447 -0
- hindsight_api/engine/search/types.py +160 -0
- hindsight_api/engine/task_backend.py +223 -0
- hindsight_api/engine/utils.py +203 -0
- hindsight_api/metrics.py +227 -0
- hindsight_api/migrations.py +163 -0
- hindsight_api/models.py +309 -0
- hindsight_api/pg0.py +425 -0
- hindsight_api/web/__init__.py +12 -0
- hindsight_api/web/server.py +143 -0
- hindsight_api-0.0.13.dist-info/METADATA +41 -0
- hindsight_api-0.0.13.dist-info/RECORD +48 -0
- hindsight_api-0.0.13.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1872 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI application factory and API routes for memory system.
|
|
3
|
+
|
|
4
|
+
This module provides the create_app function to create and configure
|
|
5
|
+
the FastAPI application with all API endpoints.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import uuid
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional, List, Dict, Any, Union
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
|
|
15
|
+
from fastapi import FastAPI, HTTPException, Query
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_metadata(metadata: Any) -> Dict[str, Any]:
|
|
19
|
+
"""Parse metadata that may be a dict, JSON string, or None."""
|
|
20
|
+
if metadata is None:
|
|
21
|
+
return {}
|
|
22
|
+
if isinstance(metadata, dict):
|
|
23
|
+
return metadata
|
|
24
|
+
if isinstance(metadata, str):
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(metadata)
|
|
27
|
+
except json.JSONDecodeError:
|
|
28
|
+
return {}
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
from fastapi.staticfiles import StaticFiles
|
|
33
|
+
from fastapi.responses import FileResponse
|
|
34
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
35
|
+
|
|
36
|
+
from hindsight_api import MemoryEngine
|
|
37
|
+
from hindsight_api.engine.memory_engine import Budget
|
|
38
|
+
from hindsight_api.engine.db_utils import acquire_with_retry
|
|
39
|
+
from hindsight_api.metrics import get_metrics_collector, initialize_metrics, create_metrics_collector
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MetadataFilter(BaseModel):
|
|
46
|
+
"""Filter for metadata fields. Matches records where (key=value) OR (key not set) when match_unset=True."""
|
|
47
|
+
model_config = ConfigDict(json_schema_extra={
|
|
48
|
+
"example": {
|
|
49
|
+
"key": "source",
|
|
50
|
+
"value": "slack",
|
|
51
|
+
"match_unset": True
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
key: str = Field(description="Metadata key to filter on")
|
|
56
|
+
value: Optional[str] = Field(default=None, description="Value to match. If None with match_unset=True, matches any record where key is not set.")
|
|
57
|
+
match_unset: bool = Field(default=True, description="If True, also match records where this metadata key is not set")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class EntityIncludeOptions(BaseModel):
|
|
61
|
+
"""Options for including entity observations in recall results."""
|
|
62
|
+
max_tokens: int = Field(default=500, description="Maximum tokens for entity observations")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ChunkIncludeOptions(BaseModel):
|
|
66
|
+
"""Options for including chunks in recall results."""
|
|
67
|
+
max_tokens: int = Field(default=8192, description="Maximum tokens for chunks (chunks may be truncated)")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class IncludeOptions(BaseModel):
|
|
71
|
+
"""Options for including additional data in recall results."""
|
|
72
|
+
entities: Optional[EntityIncludeOptions] = Field(
|
|
73
|
+
default=EntityIncludeOptions(),
|
|
74
|
+
description="Include entity observations. Set to null to disable entity inclusion."
|
|
75
|
+
)
|
|
76
|
+
chunks: Optional[ChunkIncludeOptions] = Field(
|
|
77
|
+
default=None,
|
|
78
|
+
description="Include raw chunks. Set to {} to enable, null to disable (default: disabled)."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RecallRequest(BaseModel):
|
|
83
|
+
"""Request model for recall endpoint."""
|
|
84
|
+
model_config = ConfigDict(json_schema_extra={
|
|
85
|
+
"example": {
|
|
86
|
+
"query": "What did Alice say about machine learning?",
|
|
87
|
+
"types": ["world", "bank"],
|
|
88
|
+
"budget": "mid",
|
|
89
|
+
"max_tokens": 4096,
|
|
90
|
+
"trace": True,
|
|
91
|
+
"query_timestamp": "2023-05-30T23:40:00",
|
|
92
|
+
"filters": [{"key": "source", "value": "slack", "match_unset": True}],
|
|
93
|
+
"include": {
|
|
94
|
+
"entities": {
|
|
95
|
+
"max_tokens": 500
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
query: str
|
|
102
|
+
types: Optional[List[str]] = Field(default=None, description="List of fact types to recall (defaults to all if not specified)")
|
|
103
|
+
budget: Budget = Budget.MID
|
|
104
|
+
max_tokens: int = 4096
|
|
105
|
+
trace: bool = False
|
|
106
|
+
query_timestamp: Optional[str] = Field(default=None, description="ISO format date string (e.g., '2023-05-30T23:40:00')")
|
|
107
|
+
filters: Optional[List[MetadataFilter]] = Field(default=None, description="Filter by metadata. Multiple filters are ANDed together.")
|
|
108
|
+
include: IncludeOptions = Field(default_factory=IncludeOptions, description="Options for including additional data (entities are included by default)")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class RecallResult(BaseModel):
|
|
112
|
+
"""Single recall result item."""
|
|
113
|
+
model_config = {
|
|
114
|
+
"populate_by_name": True,
|
|
115
|
+
"json_schema_extra": {
|
|
116
|
+
"example": {
|
|
117
|
+
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
118
|
+
"text": "Alice works at Google on the AI team",
|
|
119
|
+
"type": "world",
|
|
120
|
+
"entities": ["Alice", "Google"],
|
|
121
|
+
"context": "work info",
|
|
122
|
+
"occurred_start": "2024-01-15T10:30:00Z",
|
|
123
|
+
"occurred_end": "2024-01-15T10:30:00Z",
|
|
124
|
+
"mentioned_at": "2024-01-15T10:30:00Z",
|
|
125
|
+
"document_id": "session_abc123",
|
|
126
|
+
"metadata": {"source": "slack"},
|
|
127
|
+
"chunk_id": "456e7890-e12b-34d5-a678-901234567890"
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
id: str
|
|
133
|
+
text: str
|
|
134
|
+
type: Optional[str] = None # fact type: world, agent, opinion, observation
|
|
135
|
+
entities: Optional[List[str]] = None # Entity names mentioned in this fact
|
|
136
|
+
context: Optional[str] = None
|
|
137
|
+
occurred_start: Optional[str] = None # ISO format date when the event started
|
|
138
|
+
occurred_end: Optional[str] = None # ISO format date when the event ended
|
|
139
|
+
mentioned_at: Optional[str] = None # ISO format date when the fact was mentioned
|
|
140
|
+
document_id: Optional[str] = None # Document this memory belongs to
|
|
141
|
+
metadata: Optional[Dict[str, str]] = None # User-defined metadata
|
|
142
|
+
chunk_id: Optional[str] = None # Chunk this fact was extracted from
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class EntityObservationResponse(BaseModel):
|
|
146
|
+
"""An observation about an entity."""
|
|
147
|
+
text: str
|
|
148
|
+
mentioned_at: Optional[str] = None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class EntityStateResponse(BaseModel):
|
|
152
|
+
"""Current mental model of an entity."""
|
|
153
|
+
entity_id: str
|
|
154
|
+
canonical_name: str
|
|
155
|
+
observations: List[EntityObservationResponse]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class EntityListItem(BaseModel):
|
|
159
|
+
"""Entity list item with summary."""
|
|
160
|
+
model_config = ConfigDict(json_schema_extra={
|
|
161
|
+
"example": {
|
|
162
|
+
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
163
|
+
"canonical_name": "John",
|
|
164
|
+
"mention_count": 15,
|
|
165
|
+
"first_seen": "2024-01-15T10:30:00Z",
|
|
166
|
+
"last_seen": "2024-02-01T14:00:00Z"
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
id: str
|
|
171
|
+
canonical_name: str
|
|
172
|
+
mention_count: int
|
|
173
|
+
first_seen: Optional[str] = None
|
|
174
|
+
last_seen: Optional[str] = None
|
|
175
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class EntityListResponse(BaseModel):
|
|
179
|
+
"""Response model for entity list endpoint."""
|
|
180
|
+
model_config = ConfigDict(json_schema_extra={
|
|
181
|
+
"example": {
|
|
182
|
+
"items": [
|
|
183
|
+
{
|
|
184
|
+
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
185
|
+
"canonical_name": "John",
|
|
186
|
+
"mention_count": 15,
|
|
187
|
+
"first_seen": "2024-01-15T10:30:00Z",
|
|
188
|
+
"last_seen": "2024-02-01T14:00:00Z"
|
|
189
|
+
}
|
|
190
|
+
]
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
items: List[EntityListItem]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class EntityDetailResponse(BaseModel):
|
|
198
|
+
"""Response model for entity detail endpoint."""
|
|
199
|
+
model_config = ConfigDict(json_schema_extra={
|
|
200
|
+
"example": {
|
|
201
|
+
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
202
|
+
"canonical_name": "John",
|
|
203
|
+
"mention_count": 15,
|
|
204
|
+
"first_seen": "2024-01-15T10:30:00Z",
|
|
205
|
+
"last_seen": "2024-02-01T14:00:00Z",
|
|
206
|
+
"observations": [
|
|
207
|
+
{"text": "John works at Google", "mentioned_at": "2024-01-15T10:30:00Z"}
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
id: str
|
|
213
|
+
canonical_name: str
|
|
214
|
+
mention_count: int
|
|
215
|
+
first_seen: Optional[str] = None
|
|
216
|
+
last_seen: Optional[str] = None
|
|
217
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
218
|
+
observations: List[EntityObservationResponse]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class ChunkData(BaseModel):
|
|
222
|
+
"""Chunk data for a single chunk."""
|
|
223
|
+
id: str
|
|
224
|
+
text: str
|
|
225
|
+
chunk_index: int
|
|
226
|
+
truncated: bool = Field(default=False, description="Whether the chunk text was truncated due to token limits")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class RecallResponse(BaseModel):
|
|
230
|
+
"""Response model for recall endpoints."""
|
|
231
|
+
model_config = ConfigDict(json_schema_extra={
|
|
232
|
+
"example": {
|
|
233
|
+
"results": [
|
|
234
|
+
{
|
|
235
|
+
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
236
|
+
"text": "Alice works at Google on the AI team",
|
|
237
|
+
"type": "world",
|
|
238
|
+
"entities": ["Alice", "Google"],
|
|
239
|
+
"context": "work info",
|
|
240
|
+
"occurred_start": "2024-01-15T10:30:00Z",
|
|
241
|
+
"occurred_end": "2024-01-15T10:30:00Z",
|
|
242
|
+
"chunk_id": "456e7890-e12b-34d5-a678-901234567890"
|
|
243
|
+
}
|
|
244
|
+
],
|
|
245
|
+
"trace": {
|
|
246
|
+
"query": "What did Alice say about machine learning?",
|
|
247
|
+
"num_results": 1,
|
|
248
|
+
"time_seconds": 0.123
|
|
249
|
+
},
|
|
250
|
+
"entities": {
|
|
251
|
+
"Alice": {
|
|
252
|
+
"entity_id": "123e4567-e89b-12d3-a456-426614174001",
|
|
253
|
+
"canonical_name": "Alice",
|
|
254
|
+
"observations": [
|
|
255
|
+
{"text": "Alice works at Google on the AI team", "mentioned_at": "2024-01-15T10:30:00Z"}
|
|
256
|
+
]
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
"chunks": {
|
|
260
|
+
"456e7890-e12b-34d5-a678-901234567890": {
|
|
261
|
+
"id": "456e7890-e12b-34d5-a678-901234567890",
|
|
262
|
+
"text": "Alice works at Google on the AI team. She's been there for 3 years...",
|
|
263
|
+
"chunk_index": 0
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
results: List[RecallResult]
|
|
270
|
+
trace: Optional[Dict[str, Any]] = None
|
|
271
|
+
entities: Optional[Dict[str, EntityStateResponse]] = Field(default=None, description="Entity states for entities mentioned in results")
|
|
272
|
+
chunks: Optional[Dict[str, ChunkData]] = Field(default=None, description="Chunks for facts, keyed by chunk_id")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class MemoryItem(BaseModel):
|
|
276
|
+
"""Single memory item for retain."""
|
|
277
|
+
model_config = ConfigDict(json_schema_extra={
|
|
278
|
+
"example": {
|
|
279
|
+
"content": "Alice mentioned she's working on a new ML model",
|
|
280
|
+
"timestamp": "2024-01-15T10:30:00Z",
|
|
281
|
+
"context": "team meeting",
|
|
282
|
+
"metadata": {"source": "slack", "channel": "engineering"},
|
|
283
|
+
"document_id": "meeting_notes_2024_01_15"
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
content: str
|
|
288
|
+
timestamp: Optional[datetime] = None
|
|
289
|
+
context: Optional[str] = None
|
|
290
|
+
metadata: Optional[Dict[str, str]] = None
|
|
291
|
+
document_id: Optional[str] = Field(
|
|
292
|
+
default=None,
|
|
293
|
+
description="Optional document ID for this memory item."
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class RetainRequest(BaseModel):
|
|
298
|
+
"""Request model for retain endpoint."""
|
|
299
|
+
model_config = ConfigDict(json_schema_extra={
|
|
300
|
+
"example": {
|
|
301
|
+
"items": [
|
|
302
|
+
{
|
|
303
|
+
"content": "Alice works at Google",
|
|
304
|
+
"context": "work",
|
|
305
|
+
"document_id": "conversation_123"
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
"content": "Bob went hiking yesterday",
|
|
309
|
+
"timestamp": "2024-01-15T10:00:00Z",
|
|
310
|
+
"document_id": "conversation_123"
|
|
311
|
+
}
|
|
312
|
+
],
|
|
313
|
+
"async": False
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
items: List[MemoryItem]
|
|
318
|
+
async_: bool = Field(
|
|
319
|
+
default=False,
|
|
320
|
+
alias="async",
|
|
321
|
+
description="If true, process asynchronously in background. If false, wait for completion (default: false)"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class RetainResponse(BaseModel):
|
|
326
|
+
"""Response model for retain endpoint."""
|
|
327
|
+
model_config = ConfigDict(
|
|
328
|
+
populate_by_name=True,
|
|
329
|
+
json_schema_extra={
|
|
330
|
+
"example": {
|
|
331
|
+
"success": True,
|
|
332
|
+
"bank_id": "user123",
|
|
333
|
+
"items_count": 2,
|
|
334
|
+
"async": False
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
success: bool
|
|
340
|
+
bank_id: str
|
|
341
|
+
items_count: int
|
|
342
|
+
async_: bool = Field(alias="async", serialization_alias="async", description="Whether the operation was processed asynchronously")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class FactsIncludeOptions(BaseModel):
|
|
346
|
+
"""Options for including facts (based_on) in reflect results."""
|
|
347
|
+
pass # No additional options needed, just enable/disable
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class ReflectIncludeOptions(BaseModel):
|
|
351
|
+
"""Options for including additional data in reflect results."""
|
|
352
|
+
facts: Optional[FactsIncludeOptions] = Field(
|
|
353
|
+
default=None,
|
|
354
|
+
description="Include facts that the answer is based on. Set to {} to enable, null to disable (default: disabled)."
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class ReflectRequest(BaseModel):
|
|
359
|
+
"""Request model for reflect endpoint."""
|
|
360
|
+
model_config = ConfigDict(json_schema_extra={
|
|
361
|
+
"example": {
|
|
362
|
+
"query": "What do you think about artificial intelligence?",
|
|
363
|
+
"budget": "low",
|
|
364
|
+
"context": "This is for a research paper on AI ethics",
|
|
365
|
+
"filters": [{"key": "source", "value": "slack", "match_unset": True}],
|
|
366
|
+
"include": {
|
|
367
|
+
"facts": {}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
query: str
|
|
373
|
+
budget: Budget = Budget.LOW
|
|
374
|
+
context: Optional[str] = None
|
|
375
|
+
filters: Optional[List[MetadataFilter]] = Field(default=None, description="Filter by metadata. Multiple filters are ANDed together.")
|
|
376
|
+
include: ReflectIncludeOptions = Field(default_factory=ReflectIncludeOptions, description="Options for including additional data (disabled by default)")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class OpinionItem(BaseModel):
|
|
380
|
+
"""Model for an opinion with confidence score."""
|
|
381
|
+
text: str
|
|
382
|
+
confidence: float
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class ReflectFact(BaseModel):
|
|
386
|
+
"""A fact used in think response."""
|
|
387
|
+
model_config = ConfigDict(json_schema_extra={
|
|
388
|
+
"example": {
|
|
389
|
+
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
390
|
+
"text": "AI is used in healthcare",
|
|
391
|
+
"type": "world",
|
|
392
|
+
"context": "healthcare discussion",
|
|
393
|
+
"occurred_start": "2024-01-15T10:30:00Z",
|
|
394
|
+
"occurred_end": "2024-01-15T10:30:00Z"
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
id: Optional[str] = None
|
|
399
|
+
text: str
|
|
400
|
+
type: Optional[str] = None # fact type: world, agent, opinion
|
|
401
|
+
context: Optional[str] = None
|
|
402
|
+
occurred_start: Optional[str] = None
|
|
403
|
+
occurred_end: Optional[str] = None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class ReflectResponse(BaseModel):
|
|
407
|
+
"""Response model for think endpoint."""
|
|
408
|
+
model_config = ConfigDict(json_schema_extra={
|
|
409
|
+
"example": {
|
|
410
|
+
"text": "Based on my understanding, AI is a transformative technology...",
|
|
411
|
+
"based_on": [
|
|
412
|
+
{
|
|
413
|
+
"id": "123",
|
|
414
|
+
"text": "AI is used in healthcare",
|
|
415
|
+
"type": "world"
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
"id": "456",
|
|
419
|
+
"text": "I discussed AI applications last week",
|
|
420
|
+
"type": "bank"
|
|
421
|
+
}
|
|
422
|
+
]
|
|
423
|
+
}
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
text: str
|
|
427
|
+
based_on: List[ReflectFact] = [] # Facts used to generate the response
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class BanksResponse(BaseModel):
|
|
431
|
+
"""Response model for banks list endpoint."""
|
|
432
|
+
model_config = ConfigDict(json_schema_extra={
|
|
433
|
+
"example": {
|
|
434
|
+
"banks": ["user123", "bank_alice", "bank_bob"]
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
banks: List[str]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class PersonalityTraits(BaseModel):
|
|
442
|
+
"""Personality traits based on Big Five model."""
|
|
443
|
+
model_config = ConfigDict(json_schema_extra={
|
|
444
|
+
"example": {
|
|
445
|
+
"openness": 0.8,
|
|
446
|
+
"conscientiousness": 0.6,
|
|
447
|
+
"extraversion": 0.5,
|
|
448
|
+
"agreeableness": 0.7,
|
|
449
|
+
"neuroticism": 0.3,
|
|
450
|
+
"bias_strength": 0.7
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
openness: float = Field(ge=0.0, le=1.0, description="Openness to experience (0-1)")
|
|
455
|
+
conscientiousness: float = Field(ge=0.0, le=1.0, description="Conscientiousness (0-1)")
|
|
456
|
+
extraversion: float = Field(ge=0.0, le=1.0, description="Extraversion (0-1)")
|
|
457
|
+
agreeableness: float = Field(ge=0.0, le=1.0, description="Agreeableness (0-1)")
|
|
458
|
+
neuroticism: float = Field(ge=0.0, le=1.0, description="Neuroticism (0-1)")
|
|
459
|
+
bias_strength: float = Field(ge=0.0, le=1.0, description="How strongly personality influences opinions (0-1)")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class BankProfileResponse(BaseModel):
|
|
463
|
+
"""Response model for bank profile."""
|
|
464
|
+
model_config = ConfigDict(json_schema_extra={
|
|
465
|
+
"example": {
|
|
466
|
+
"bank_id": "user123",
|
|
467
|
+
"name": "Alice",
|
|
468
|
+
"personality": {
|
|
469
|
+
"openness": 0.8,
|
|
470
|
+
"conscientiousness": 0.6,
|
|
471
|
+
"extraversion": 0.5,
|
|
472
|
+
"agreeableness": 0.7,
|
|
473
|
+
"neuroticism": 0.3,
|
|
474
|
+
"bias_strength": 0.7
|
|
475
|
+
},
|
|
476
|
+
"background": "I am a software engineer with 10 years of experience in startups"
|
|
477
|
+
}
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
bank_id: str
|
|
481
|
+
name: str
|
|
482
|
+
personality: PersonalityTraits
|
|
483
|
+
background: str
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class UpdatePersonalityRequest(BaseModel):
|
|
487
|
+
"""Request model for updating personality traits."""
|
|
488
|
+
personality: PersonalityTraits
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class AddBackgroundRequest(BaseModel):
|
|
492
|
+
"""Request model for adding/merging background information."""
|
|
493
|
+
model_config = ConfigDict(json_schema_extra={
|
|
494
|
+
"example": {
|
|
495
|
+
"content": "I was born in Texas",
|
|
496
|
+
"update_personality": True
|
|
497
|
+
}
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
content: str = Field(description="New background information to add or merge")
|
|
501
|
+
update_personality: bool = Field(
|
|
502
|
+
default=True,
|
|
503
|
+
description="If true, infer Big Five personality traits from the merged background (default: true)"
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
class BackgroundResponse(BaseModel):
|
|
508
|
+
"""Response model for background update."""
|
|
509
|
+
model_config = ConfigDict(json_schema_extra={
|
|
510
|
+
"example": {
|
|
511
|
+
"background": "I was born in Texas. I am a software engineer with 10 years of experience.",
|
|
512
|
+
"personality": {
|
|
513
|
+
"openness": 0.7,
|
|
514
|
+
"conscientiousness": 0.6,
|
|
515
|
+
"extraversion": 0.5,
|
|
516
|
+
"agreeableness": 0.8,
|
|
517
|
+
"neuroticism": 0.4,
|
|
518
|
+
"bias_strength": 0.6
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
background: str
|
|
524
|
+
personality: Optional[PersonalityTraits] = None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
class BankListItem(BaseModel):
|
|
528
|
+
"""Bank list item with profile summary."""
|
|
529
|
+
bank_id: str
|
|
530
|
+
name: str
|
|
531
|
+
personality: PersonalityTraits
|
|
532
|
+
background: str
|
|
533
|
+
created_at: Optional[str] = None
|
|
534
|
+
updated_at: Optional[str] = None
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class BankListResponse(BaseModel):
|
|
538
|
+
"""Response model for listing all banks."""
|
|
539
|
+
model_config = ConfigDict(json_schema_extra={
|
|
540
|
+
"example": {
|
|
541
|
+
"banks": [
|
|
542
|
+
{
|
|
543
|
+
"bank_id": "user123",
|
|
544
|
+
"name": "Alice",
|
|
545
|
+
"personality": {
|
|
546
|
+
"openness": 0.5,
|
|
547
|
+
"conscientiousness": 0.5,
|
|
548
|
+
"extraversion": 0.5,
|
|
549
|
+
"agreeableness": 0.5,
|
|
550
|
+
"neuroticism": 0.5,
|
|
551
|
+
"bias_strength": 0.5
|
|
552
|
+
},
|
|
553
|
+
"background": "I am a software engineer",
|
|
554
|
+
"created_at": "2024-01-15T10:30:00Z",
|
|
555
|
+
"updated_at": "2024-01-16T14:20:00Z"
|
|
556
|
+
}
|
|
557
|
+
]
|
|
558
|
+
}
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
banks: List[BankListItem]
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
class CreateBankRequest(BaseModel):
|
|
565
|
+
"""Request model for creating/updating a bank."""
|
|
566
|
+
model_config = ConfigDict(json_schema_extra={
|
|
567
|
+
"example": {
|
|
568
|
+
"name": "Alice",
|
|
569
|
+
"personality": {
|
|
570
|
+
"openness": 0.8,
|
|
571
|
+
"conscientiousness": 0.6,
|
|
572
|
+
"extraversion": 0.5,
|
|
573
|
+
"agreeableness": 0.7,
|
|
574
|
+
"neuroticism": 0.3,
|
|
575
|
+
"bias_strength": 0.7
|
|
576
|
+
},
|
|
577
|
+
"background": "I am a creative software engineer with 10 years of experience"
|
|
578
|
+
}
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
name: Optional[str] = None
|
|
582
|
+
personality: Optional[PersonalityTraits] = None
|
|
583
|
+
background: Optional[str] = None
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
class GraphDataResponse(BaseModel):
|
|
587
|
+
"""Response model for graph data endpoint."""
|
|
588
|
+
model_config = ConfigDict(json_schema_extra={
|
|
589
|
+
"example": {
|
|
590
|
+
"nodes": [
|
|
591
|
+
{"id": "1", "label": "Alice works at Google", "type": "world"},
|
|
592
|
+
{"id": "2", "label": "Bob went hiking", "type": "world"}
|
|
593
|
+
],
|
|
594
|
+
"edges": [
|
|
595
|
+
{"from": "1", "to": "2", "type": "semantic", "weight": 0.8}
|
|
596
|
+
],
|
|
597
|
+
"table_rows": [
|
|
598
|
+
{"id": "abc12345...", "text": "Alice works at Google", "context": "Work info", "date": "2024-01-15 10:30", "entities": "Alice (PERSON), Google (ORGANIZATION)"}
|
|
599
|
+
],
|
|
600
|
+
"total_units": 2
|
|
601
|
+
}
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
nodes: List[Dict[str, Any]]
|
|
605
|
+
edges: List[Dict[str, Any]]
|
|
606
|
+
table_rows: List[Dict[str, Any]]
|
|
607
|
+
total_units: int
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
class ListMemoryUnitsResponse(BaseModel):
|
|
611
|
+
"""Response model for list memory units endpoint."""
|
|
612
|
+
model_config = ConfigDict(json_schema_extra={
|
|
613
|
+
"example": {
|
|
614
|
+
"items": [
|
|
615
|
+
{
|
|
616
|
+
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
617
|
+
"text": "Alice works at Google on the AI team",
|
|
618
|
+
"context": "Work conversation",
|
|
619
|
+
"date": "2024-01-15T10:30:00Z",
|
|
620
|
+
"type": "world",
|
|
621
|
+
"entities": "Alice (PERSON), Google (ORGANIZATION)"
|
|
622
|
+
}
|
|
623
|
+
],
|
|
624
|
+
"total": 150,
|
|
625
|
+
"limit": 100,
|
|
626
|
+
"offset": 0
|
|
627
|
+
}
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
items: List[Dict[str, Any]]
|
|
631
|
+
total: int
|
|
632
|
+
limit: int
|
|
633
|
+
offset: int
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class ListDocumentsResponse(BaseModel):
|
|
637
|
+
"""Response model for list documents endpoint."""
|
|
638
|
+
model_config = ConfigDict(json_schema_extra={
|
|
639
|
+
"example": {
|
|
640
|
+
"items": [
|
|
641
|
+
{
|
|
642
|
+
"id": "session_1",
|
|
643
|
+
"bank_id": "user123",
|
|
644
|
+
"content_hash": "abc123",
|
|
645
|
+
"created_at": "2024-01-15T10:30:00Z",
|
|
646
|
+
"updated_at": "2024-01-15T10:30:00Z",
|
|
647
|
+
"text_length": 5420,
|
|
648
|
+
"memory_unit_count": 15
|
|
649
|
+
}
|
|
650
|
+
],
|
|
651
|
+
"total": 50,
|
|
652
|
+
"limit": 100,
|
|
653
|
+
"offset": 0
|
|
654
|
+
}
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
items: List[Dict[str, Any]]
|
|
658
|
+
total: int
|
|
659
|
+
limit: int
|
|
660
|
+
offset: int
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
class DocumentResponse(BaseModel):
|
|
664
|
+
"""Response model for get document endpoint."""
|
|
665
|
+
model_config = ConfigDict(json_schema_extra={
|
|
666
|
+
"example": {
|
|
667
|
+
"id": "session_1",
|
|
668
|
+
"bank_id": "user123",
|
|
669
|
+
"original_text": "Full document text here...",
|
|
670
|
+
"content_hash": "abc123",
|
|
671
|
+
"created_at": "2024-01-15T10:30:00Z",
|
|
672
|
+
"updated_at": "2024-01-15T10:30:00Z",
|
|
673
|
+
"memory_unit_count": 15
|
|
674
|
+
}
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
id: str
|
|
678
|
+
bank_id: str
|
|
679
|
+
original_text: str
|
|
680
|
+
content_hash: Optional[str]
|
|
681
|
+
created_at: str
|
|
682
|
+
updated_at: str
|
|
683
|
+
memory_unit_count: int
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
class ChunkResponse(BaseModel):
|
|
687
|
+
"""Response model for get chunk endpoint."""
|
|
688
|
+
model_config = ConfigDict(json_schema_extra={
|
|
689
|
+
"example": {
|
|
690
|
+
"chunk_id": "user123_session_1_0",
|
|
691
|
+
"document_id": "session_1",
|
|
692
|
+
"bank_id": "user123",
|
|
693
|
+
"chunk_index": 0,
|
|
694
|
+
"chunk_text": "This is the first chunk of the document...",
|
|
695
|
+
"created_at": "2024-01-15T10:30:00Z"
|
|
696
|
+
}
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
chunk_id: str
|
|
700
|
+
document_id: str
|
|
701
|
+
bank_id: str
|
|
702
|
+
chunk_index: int
|
|
703
|
+
chunk_text: str
|
|
704
|
+
created_at: str
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
class DeleteResponse(BaseModel):
|
|
708
|
+
"""Response model for delete operations."""
|
|
709
|
+
model_config = ConfigDict(json_schema_extra={
|
|
710
|
+
"example": {
|
|
711
|
+
"success": True
|
|
712
|
+
}
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
success: bool
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def create_app(memory: MemoryEngine, run_migrations: bool = True, initialize_memory: bool = True) -> FastAPI:
|
|
719
|
+
"""
|
|
720
|
+
Create and configure the FastAPI application.
|
|
721
|
+
|
|
722
|
+
Args:
|
|
723
|
+
memory: MemoryEngine instance (already initialized with required parameters)
|
|
724
|
+
run_migrations: Whether to run database migrations on startup (default: True)
|
|
725
|
+
initialize_memory: Whether to initialize memory system on startup (default: True)
|
|
726
|
+
|
|
727
|
+
Returns:
|
|
728
|
+
Configured FastAPI application
|
|
729
|
+
|
|
730
|
+
Note:
|
|
731
|
+
When mounting this app as a sub-application, the lifespan events may not fire.
|
|
732
|
+
In that case, you should call memory.initialize() manually before starting the server
|
|
733
|
+
and memory.close() when shutting down.
|
|
734
|
+
"""
|
|
735
|
+
@asynccontextmanager
|
|
736
|
+
async def lifespan(app: FastAPI):
|
|
737
|
+
"""
|
|
738
|
+
Lifespan context manager for startup and shutdown events.
|
|
739
|
+
Note: This only fires when running the app standalone, not when mounted.
|
|
740
|
+
"""
|
|
741
|
+
# Initialize OpenTelemetry metrics
|
|
742
|
+
try:
|
|
743
|
+
prometheus_reader = initialize_metrics(
|
|
744
|
+
service_name="hindsight-api",
|
|
745
|
+
service_version="1.0.0"
|
|
746
|
+
)
|
|
747
|
+
create_metrics_collector()
|
|
748
|
+
app.state.prometheus_reader = prometheus_reader
|
|
749
|
+
logging.info("Metrics initialized - available at /metrics endpoint")
|
|
750
|
+
except Exception as e:
|
|
751
|
+
logging.warning(f"Failed to initialize metrics: {e}. Metrics will be disabled (using no-op collector).")
|
|
752
|
+
app.state.prometheus_reader = None
|
|
753
|
+
# Metrics collector is already initialized as no-op by default
|
|
754
|
+
|
|
755
|
+
# Startup: Initialize database and memory system
|
|
756
|
+
if initialize_memory:
|
|
757
|
+
await memory.initialize()
|
|
758
|
+
logging.info("Memory system initialized")
|
|
759
|
+
|
|
760
|
+
if run_migrations:
|
|
761
|
+
from hindsight_api.migrations import run_migrations as do_migrations
|
|
762
|
+
do_migrations(memory.db_url)
|
|
763
|
+
logging.info("Database migrations applied")
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
yield
|
|
768
|
+
|
|
769
|
+
# Shutdown: Cleanup memory system
|
|
770
|
+
await memory.close()
|
|
771
|
+
logging.info("Memory system closed")
|
|
772
|
+
|
|
773
|
+
app = FastAPI(
|
|
774
|
+
title="Hindsight HTTP API",
|
|
775
|
+
version="1.0.0",
|
|
776
|
+
description="HTTP API for Hindsight",
|
|
777
|
+
contact={
|
|
778
|
+
"name": "Memory System",
|
|
779
|
+
},
|
|
780
|
+
license_info={
|
|
781
|
+
"name": "Apache 2.0",
|
|
782
|
+
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
|
783
|
+
},
|
|
784
|
+
lifespan=lifespan
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
# IMPORTANT: Set memory on app.state immediately, don't wait for lifespan
|
|
788
|
+
# This is required for mounted sub-applications where lifespan may not fire
|
|
789
|
+
app.state.memory = memory
|
|
790
|
+
|
|
791
|
+
# Register all routes
|
|
792
|
+
_register_routes(app)
|
|
793
|
+
|
|
794
|
+
return app
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _register_routes(app: FastAPI):
|
|
798
|
+
"""Register all API routes on the given app instance."""
|
|
799
|
+
|
|
800
|
+
@app.get(
|
|
801
|
+
"/health",
|
|
802
|
+
summary="Health check endpoint",
|
|
803
|
+
description="Checks the health of the API and database connection",
|
|
804
|
+
tags=["Monitoring"]
|
|
805
|
+
)
|
|
806
|
+
async def health_endpoint():
|
|
807
|
+
"""
|
|
808
|
+
Health check endpoint that verifies database connectivity.
|
|
809
|
+
|
|
810
|
+
Returns 200 if healthy, 503 if unhealthy.
|
|
811
|
+
"""
|
|
812
|
+
from fastapi.responses import JSONResponse
|
|
813
|
+
|
|
814
|
+
health = await app.state.memory.health_check()
|
|
815
|
+
status_code = 200 if health.get("status") == "healthy" else 503
|
|
816
|
+
return JSONResponse(content=health, status_code=status_code)
|
|
817
|
+
|
|
818
|
+
@app.get(
|
|
819
|
+
"/metrics",
|
|
820
|
+
summary="Prometheus metrics endpoint",
|
|
821
|
+
description="Exports metrics in Prometheus format for scraping",
|
|
822
|
+
tags=["Monitoring"]
|
|
823
|
+
)
|
|
824
|
+
async def metrics_endpoint():
|
|
825
|
+
"""Return Prometheus metrics."""
|
|
826
|
+
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
|
|
827
|
+
from fastapi.responses import Response
|
|
828
|
+
|
|
829
|
+
metrics_data = generate_latest()
|
|
830
|
+
return Response(content=metrics_data, media_type=CONTENT_TYPE_LATEST)
|
|
831
|
+
|
|
832
|
+
@app.get(
|
|
833
|
+
"/v1/default/banks/{bank_id}/graph",
|
|
834
|
+
response_model=GraphDataResponse,
|
|
835
|
+
summary="Get memory graph data",
|
|
836
|
+
description="Retrieve graph data for visualization, optionally filtered by type (world/agent/opinion). Limited to 1000 most recent items.",
|
|
837
|
+
operation_id="get_graph"
|
|
838
|
+
)
|
|
839
|
+
async def api_graph(bank_id: str,
|
|
840
|
+
type: Optional[str] = None
|
|
841
|
+
):
|
|
842
|
+
"""Get graph data from database, filtered by bank_id and optionally by type."""
|
|
843
|
+
try:
|
|
844
|
+
data = await app.state.memory.get_graph_data(bank_id, type)
|
|
845
|
+
return data
|
|
846
|
+
except Exception as e:
|
|
847
|
+
import traceback
|
|
848
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
849
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/graph: {error_detail}")
|
|
850
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
@app.get(
|
|
854
|
+
"/v1/default/banks/{bank_id}/memories/list",
|
|
855
|
+
response_model=ListMemoryUnitsResponse,
|
|
856
|
+
summary="List memory units",
|
|
857
|
+
description="List memory units with pagination and optional full-text search. Supports filtering by type. Results are sorted by most recent first (mentioned_at DESC, then created_at DESC).",
|
|
858
|
+
operation_id="list_memories"
|
|
859
|
+
)
|
|
860
|
+
async def api_list(bank_id: str,
|
|
861
|
+
type: Optional[str] = None,
|
|
862
|
+
q: Optional[str] = None,
|
|
863
|
+
limit: int = 100,
|
|
864
|
+
offset: int = 0
|
|
865
|
+
):
|
|
866
|
+
"""
|
|
867
|
+
List memory units for table view with optional full-text search.
|
|
868
|
+
|
|
869
|
+
Results are ordered by most recent first, using mentioned_at timestamp
|
|
870
|
+
(when the memory was mentioned/learned), falling back to created_at.
|
|
871
|
+
|
|
872
|
+
Args:
|
|
873
|
+
bank_id: Memory Bank ID (from path)
|
|
874
|
+
type: Filter by fact type (world, agent, opinion)
|
|
875
|
+
q: Search query for full-text search (searches text and context)
|
|
876
|
+
limit: Maximum number of results (default: 100)
|
|
877
|
+
offset: Offset for pagination (default: 0)
|
|
878
|
+
"""
|
|
879
|
+
try:
|
|
880
|
+
data = await app.state.memory.list_memory_units(
|
|
881
|
+
bank_id=bank_id,
|
|
882
|
+
fact_type=type,
|
|
883
|
+
search_query=q,
|
|
884
|
+
limit=limit,
|
|
885
|
+
offset=offset
|
|
886
|
+
)
|
|
887
|
+
return data
|
|
888
|
+
except Exception as e:
|
|
889
|
+
import traceback
|
|
890
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
891
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/memories/list: {error_detail}")
|
|
892
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
@app.post(
|
|
896
|
+
"/v1/default/banks/{bank_id}/memories/recall",
|
|
897
|
+
response_model=RecallResponse,
|
|
898
|
+
summary="Recall memory",
|
|
899
|
+
description="""
|
|
900
|
+
Recall memory using semantic similarity and spreading activation.
|
|
901
|
+
|
|
902
|
+
The type parameter is optional and must be one of:
|
|
903
|
+
- 'world': General knowledge about people, places, events, and things that happen
|
|
904
|
+
- 'bank': Memories about what the AI agent did, actions taken, and tasks performed
|
|
905
|
+
- 'opinion': The bank's formed beliefs, perspectives, and viewpoints
|
|
906
|
+
|
|
907
|
+
Set include_entities=true to get entity observations alongside recall results.
|
|
908
|
+
""",
|
|
909
|
+
operation_id="recall_memories"
|
|
910
|
+
)
|
|
911
|
+
async def api_recall(bank_id: str, request: RecallRequest):
|
|
912
|
+
"""Run a recall and return results with trace."""
|
|
913
|
+
metrics = get_metrics_collector()
|
|
914
|
+
|
|
915
|
+
try:
|
|
916
|
+
# Validate types
|
|
917
|
+
valid_fact_types = ["world", "bank", "opinion"]
|
|
918
|
+
|
|
919
|
+
# Default to world, agent, opinion if not specified (exclude observation by default)
|
|
920
|
+
fact_types = request.types if request.types else ["world", "bank", "opinion"]
|
|
921
|
+
for ft in fact_types:
|
|
922
|
+
if ft not in valid_fact_types:
|
|
923
|
+
raise HTTPException(
|
|
924
|
+
status_code=400,
|
|
925
|
+
detail=f"Invalid type '{ft}'. Must be one of: {', '.join(valid_fact_types)}"
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
# Parse query_timestamp if provided
|
|
929
|
+
question_date = None
|
|
930
|
+
if request.query_timestamp:
|
|
931
|
+
try:
|
|
932
|
+
question_date = datetime.fromisoformat(request.query_timestamp.replace('Z', '+00:00'))
|
|
933
|
+
except ValueError as e:
|
|
934
|
+
raise HTTPException(
|
|
935
|
+
status_code=400,
|
|
936
|
+
detail=f"Invalid query_timestamp format. Expected ISO format (e.g., '2023-05-30T23:40:00'): {str(e)}"
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
# Determine entity inclusion settings
|
|
940
|
+
include_entities = request.include.entities is not None
|
|
941
|
+
max_entity_tokens = request.include.entities.max_tokens if include_entities else 500
|
|
942
|
+
|
|
943
|
+
# Determine chunk inclusion settings
|
|
944
|
+
include_chunks = request.include.chunks is not None
|
|
945
|
+
max_chunk_tokens = request.include.chunks.max_tokens if include_chunks else 8192
|
|
946
|
+
|
|
947
|
+
# Run recall with tracing (record metrics)
|
|
948
|
+
with metrics.record_operation("recall", bank_id=bank_id, budget=request.budget.value, max_tokens=request.max_tokens):
|
|
949
|
+
core_result = await app.state.memory.recall_async(
|
|
950
|
+
bank_id=bank_id,
|
|
951
|
+
query=request.query,
|
|
952
|
+
budget=request.budget,
|
|
953
|
+
max_tokens=request.max_tokens,
|
|
954
|
+
enable_trace=request.trace,
|
|
955
|
+
fact_type=fact_types,
|
|
956
|
+
question_date=question_date,
|
|
957
|
+
include_entities=include_entities,
|
|
958
|
+
max_entity_tokens=max_entity_tokens,
|
|
959
|
+
include_chunks=include_chunks,
|
|
960
|
+
max_chunk_tokens=max_chunk_tokens
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
# Convert core MemoryFact objects to API RecallResult objects (excluding internal metrics)
|
|
964
|
+
recall_results = [
|
|
965
|
+
RecallResult(
|
|
966
|
+
id=fact.id,
|
|
967
|
+
text=fact.text,
|
|
968
|
+
type=fact.fact_type,
|
|
969
|
+
entities=fact.entities,
|
|
970
|
+
context=fact.context,
|
|
971
|
+
occurred_start=fact.occurred_start,
|
|
972
|
+
occurred_end=fact.occurred_end,
|
|
973
|
+
mentioned_at=fact.mentioned_at,
|
|
974
|
+
document_id=fact.document_id,
|
|
975
|
+
chunk_id=fact.chunk_id
|
|
976
|
+
)
|
|
977
|
+
for fact in core_result.results
|
|
978
|
+
]
|
|
979
|
+
|
|
980
|
+
# Convert chunks from engine to HTTP API format
|
|
981
|
+
chunks_response = None
|
|
982
|
+
if core_result.chunks:
|
|
983
|
+
chunks_response = {}
|
|
984
|
+
for chunk_id, chunk_info in core_result.chunks.items():
|
|
985
|
+
chunks_response[chunk_id] = ChunkData(
|
|
986
|
+
id=chunk_id,
|
|
987
|
+
text=chunk_info.chunk_text,
|
|
988
|
+
chunk_index=chunk_info.chunk_index,
|
|
989
|
+
truncated=chunk_info.truncated
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
# Convert core EntityState objects to API EntityStateResponse objects
|
|
993
|
+
entities_response = None
|
|
994
|
+
if core_result.entities:
|
|
995
|
+
entities_response = {}
|
|
996
|
+
for name, state in core_result.entities.items():
|
|
997
|
+
entities_response[name] = EntityStateResponse(
|
|
998
|
+
entity_id=state.entity_id,
|
|
999
|
+
canonical_name=state.canonical_name,
|
|
1000
|
+
observations=[
|
|
1001
|
+
EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at)
|
|
1002
|
+
for obs in state.observations
|
|
1003
|
+
]
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
return RecallResponse(
|
|
1007
|
+
results=recall_results,
|
|
1008
|
+
trace=core_result.trace,
|
|
1009
|
+
entities=entities_response,
|
|
1010
|
+
chunks=chunks_response
|
|
1011
|
+
)
|
|
1012
|
+
except HTTPException:
|
|
1013
|
+
raise
|
|
1014
|
+
except Exception as e:
|
|
1015
|
+
import traceback
|
|
1016
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1017
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/memories/recall: {error_detail}")
|
|
1018
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
@app.post(
|
|
1022
|
+
"/v1/default/banks/{bank_id}/reflect",
|
|
1023
|
+
response_model=ReflectResponse,
|
|
1024
|
+
summary="Reflect and generate answer",
|
|
1025
|
+
description="""
|
|
1026
|
+
Reflect and formulate an answer using bank identity, world facts, and opinions.
|
|
1027
|
+
|
|
1028
|
+
This endpoint:
|
|
1029
|
+
1. Retrieves agent facts (bank's identity)
|
|
1030
|
+
2. Retrieves world facts relevant to the query
|
|
1031
|
+
3. Retrieves existing opinions (bank's perspectives)
|
|
1032
|
+
4. Uses LLM to formulate a contextual answer
|
|
1033
|
+
5. Extracts and stores any new opinions formed
|
|
1034
|
+
6. Returns plain text answer, the facts used, and new opinions
|
|
1035
|
+
""",
|
|
1036
|
+
operation_id="reflect"
|
|
1037
|
+
)
|
|
1038
|
+
async def api_reflect(bank_id: str, request: ReflectRequest):
|
|
1039
|
+
metrics = get_metrics_collector()
|
|
1040
|
+
|
|
1041
|
+
try:
|
|
1042
|
+
# Use the memory system's reflect_async method (record metrics)
|
|
1043
|
+
with metrics.record_operation("reflect", bank_id=bank_id, budget=request.budget.value):
|
|
1044
|
+
core_result = await app.state.memory.reflect_async(
|
|
1045
|
+
bank_id=bank_id,
|
|
1046
|
+
query=request.query,
|
|
1047
|
+
budget=request.budget,
|
|
1048
|
+
context=request.context
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
# Convert core MemoryFact objects to API ReflectFact objects if facts are requested
|
|
1052
|
+
based_on_facts = []
|
|
1053
|
+
if request.include.facts is not None:
|
|
1054
|
+
for fact_type, facts in core_result.based_on.items():
|
|
1055
|
+
for fact in facts:
|
|
1056
|
+
based_on_facts.append(ReflectFact(
|
|
1057
|
+
id=fact.id,
|
|
1058
|
+
text=fact.text,
|
|
1059
|
+
type=fact.fact_type,
|
|
1060
|
+
context=fact.context,
|
|
1061
|
+
occurred_start=fact.occurred_start,
|
|
1062
|
+
occurred_end=fact.occurred_end
|
|
1063
|
+
))
|
|
1064
|
+
|
|
1065
|
+
return ReflectResponse(
|
|
1066
|
+
text=core_result.text,
|
|
1067
|
+
based_on=based_on_facts,
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
except Exception as e:
|
|
1071
|
+
import traceback
|
|
1072
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1073
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/reflect: {error_detail}")
|
|
1074
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
@app.get(
|
|
1078
|
+
"/v1/default/banks",
|
|
1079
|
+
response_model=BankListResponse,
|
|
1080
|
+
summary="List all memory banks",
|
|
1081
|
+
description="Get a list of all agents with their profiles",
|
|
1082
|
+
operation_id="list_banks"
|
|
1083
|
+
)
|
|
1084
|
+
async def api_list_banks():
|
|
1085
|
+
"""Get list of all banks with their profiles."""
|
|
1086
|
+
try:
|
|
1087
|
+
banks = await app.state.memory.list_banks()
|
|
1088
|
+
return BankListResponse(banks=banks)
|
|
1089
|
+
except Exception as e:
|
|
1090
|
+
import traceback
|
|
1091
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1092
|
+
logger.error(f"Error in /v1/default/banks: {error_detail}")
|
|
1093
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1094
|
+
|
|
1095
|
+
@app.get(
|
|
1096
|
+
"/v1/default/banks/{bank_id}/stats",
|
|
1097
|
+
summary="Get statistics for memory bank",
|
|
1098
|
+
description="Get statistics about nodes and links for a specific agent",
|
|
1099
|
+
operation_id="get_agent_stats"
|
|
1100
|
+
)
|
|
1101
|
+
async def api_stats(bank_id: str):
|
|
1102
|
+
"""Get statistics about memory nodes and links for a memory bank."""
|
|
1103
|
+
try:
|
|
1104
|
+
pool = await app.state.memory._get_pool()
|
|
1105
|
+
async with acquire_with_retry(pool) as conn:
|
|
1106
|
+
# Get node counts by fact_type
|
|
1107
|
+
node_stats = await conn.fetch(
|
|
1108
|
+
"""
|
|
1109
|
+
SELECT fact_type, COUNT(*) as count
|
|
1110
|
+
FROM memory_units
|
|
1111
|
+
WHERE bank_id = $1
|
|
1112
|
+
GROUP BY fact_type
|
|
1113
|
+
""",
|
|
1114
|
+
bank_id
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
# Get link counts by link_type
|
|
1118
|
+
link_stats = await conn.fetch(
|
|
1119
|
+
"""
|
|
1120
|
+
SELECT ml.link_type, COUNT(*) as count
|
|
1121
|
+
FROM memory_links ml
|
|
1122
|
+
JOIN memory_units mu ON ml.from_unit_id = mu.id
|
|
1123
|
+
WHERE mu.bank_id = $1
|
|
1124
|
+
GROUP BY ml.link_type
|
|
1125
|
+
""",
|
|
1126
|
+
bank_id
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
# Get link counts by fact_type (from nodes)
|
|
1130
|
+
link_fact_type_stats = await conn.fetch(
|
|
1131
|
+
"""
|
|
1132
|
+
SELECT mu.fact_type, COUNT(*) as count
|
|
1133
|
+
FROM memory_links ml
|
|
1134
|
+
JOIN memory_units mu ON ml.from_unit_id = mu.id
|
|
1135
|
+
WHERE mu.bank_id = $1
|
|
1136
|
+
GROUP BY mu.fact_type
|
|
1137
|
+
""",
|
|
1138
|
+
bank_id
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
# Get link counts by fact_type AND link_type
|
|
1142
|
+
link_breakdown_stats = await conn.fetch(
|
|
1143
|
+
"""
|
|
1144
|
+
SELECT mu.fact_type, ml.link_type, COUNT(*) as count
|
|
1145
|
+
FROM memory_links ml
|
|
1146
|
+
JOIN memory_units mu ON ml.from_unit_id = mu.id
|
|
1147
|
+
WHERE mu.bank_id = $1
|
|
1148
|
+
GROUP BY mu.fact_type, ml.link_type
|
|
1149
|
+
""",
|
|
1150
|
+
bank_id
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
# Get pending and failed operations counts
|
|
1154
|
+
ops_stats = await conn.fetch(
|
|
1155
|
+
"""
|
|
1156
|
+
SELECT status, COUNT(*) as count
|
|
1157
|
+
FROM async_operations
|
|
1158
|
+
WHERE bank_id = $1
|
|
1159
|
+
GROUP BY status
|
|
1160
|
+
""",
|
|
1161
|
+
bank_id
|
|
1162
|
+
)
|
|
1163
|
+
ops_by_status = {row['status']: row['count'] for row in ops_stats}
|
|
1164
|
+
pending_operations = ops_by_status.get('pending', 0)
|
|
1165
|
+
failed_operations = ops_by_status.get('failed', 0)
|
|
1166
|
+
|
|
1167
|
+
# Get document count
|
|
1168
|
+
doc_count_result = await conn.fetchrow(
|
|
1169
|
+
"""
|
|
1170
|
+
SELECT COUNT(*) as count
|
|
1171
|
+
FROM documents
|
|
1172
|
+
WHERE bank_id = $1
|
|
1173
|
+
""",
|
|
1174
|
+
bank_id
|
|
1175
|
+
)
|
|
1176
|
+
total_documents = doc_count_result['count'] if doc_count_result else 0
|
|
1177
|
+
|
|
1178
|
+
# Format results
|
|
1179
|
+
nodes_by_type = {row['fact_type']: row['count'] for row in node_stats}
|
|
1180
|
+
links_by_type = {row['link_type']: row['count'] for row in link_stats}
|
|
1181
|
+
links_by_fact_type = {row['fact_type']: row['count'] for row in link_fact_type_stats}
|
|
1182
|
+
|
|
1183
|
+
# Build detailed breakdown: {fact_type: {link_type: count}}
|
|
1184
|
+
links_breakdown = {}
|
|
1185
|
+
for row in link_breakdown_stats:
|
|
1186
|
+
fact_type = row['fact_type']
|
|
1187
|
+
link_type = row['link_type']
|
|
1188
|
+
count = row['count']
|
|
1189
|
+
if fact_type not in links_breakdown:
|
|
1190
|
+
links_breakdown[fact_type] = {}
|
|
1191
|
+
links_breakdown[fact_type][link_type] = count
|
|
1192
|
+
|
|
1193
|
+
total_nodes = sum(nodes_by_type.values())
|
|
1194
|
+
total_links = sum(links_by_type.values())
|
|
1195
|
+
|
|
1196
|
+
return {
|
|
1197
|
+
"bank_id": bank_id,
|
|
1198
|
+
"total_nodes": total_nodes,
|
|
1199
|
+
"total_links": total_links,
|
|
1200
|
+
"total_documents": total_documents,
|
|
1201
|
+
"nodes_by_fact_type": nodes_by_type,
|
|
1202
|
+
"links_by_link_type": links_by_type,
|
|
1203
|
+
"links_by_fact_type": links_by_fact_type,
|
|
1204
|
+
"links_breakdown": links_breakdown,
|
|
1205
|
+
"pending_operations": pending_operations,
|
|
1206
|
+
"failed_operations": failed_operations
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
except Exception as e:
|
|
1210
|
+
import traceback
|
|
1211
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1212
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/stats: {error_detail}")
|
|
1213
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1214
|
+
|
|
1215
|
+
@app.get(
|
|
1216
|
+
"/v1/default/banks/{bank_id}/entities",
|
|
1217
|
+
response_model=EntityListResponse,
|
|
1218
|
+
summary="List entities",
|
|
1219
|
+
description="List all entities (people, organizations, etc.) known by the bank, ordered by mention count.",
|
|
1220
|
+
operation_id="list_entities"
|
|
1221
|
+
)
|
|
1222
|
+
async def api_list_entities(bank_id: str,
|
|
1223
|
+
limit: int = Query(default=100, description="Maximum number of entities to return")
|
|
1224
|
+
):
|
|
1225
|
+
"""List entities for a memory bank."""
|
|
1226
|
+
try:
|
|
1227
|
+
entities = await app.state.memory.list_entities(bank_id, limit=limit)
|
|
1228
|
+
return EntityListResponse(
|
|
1229
|
+
items=[EntityListItem(**e) for e in entities]
|
|
1230
|
+
)
|
|
1231
|
+
except Exception as e:
|
|
1232
|
+
import traceback
|
|
1233
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1234
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/entities: {error_detail}")
|
|
1235
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1236
|
+
|
|
1237
|
+
@app.get(
|
|
1238
|
+
"/v1/default/banks/{bank_id}/entities/{entity_id}",
|
|
1239
|
+
response_model=EntityDetailResponse,
|
|
1240
|
+
summary="Get entity details",
|
|
1241
|
+
description="Get detailed information about an entity including observations (mental model).",
|
|
1242
|
+
operation_id="get_entity"
|
|
1243
|
+
)
|
|
1244
|
+
async def api_get_entity(bank_id: str, entity_id: str):
|
|
1245
|
+
"""Get entity details with observations."""
|
|
1246
|
+
try:
|
|
1247
|
+
# First get the entity metadata
|
|
1248
|
+
pool = await app.state.memory._get_pool()
|
|
1249
|
+
async with acquire_with_retry(pool) as conn:
|
|
1250
|
+
entity_row = await conn.fetchrow(
|
|
1251
|
+
"""
|
|
1252
|
+
SELECT id, canonical_name, mention_count, first_seen, last_seen, metadata
|
|
1253
|
+
FROM entities
|
|
1254
|
+
WHERE bank_id = $1 AND id = $2
|
|
1255
|
+
""",
|
|
1256
|
+
bank_id, uuid.UUID(entity_id)
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
if not entity_row:
|
|
1260
|
+
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
|
1261
|
+
|
|
1262
|
+
# Get observations for the entity
|
|
1263
|
+
observations = await app.state.memory.get_entity_observations(
|
|
1264
|
+
bank_id, entity_id, limit=20
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
return EntityDetailResponse(
|
|
1268
|
+
id=str(entity_row['id']),
|
|
1269
|
+
canonical_name=entity_row['canonical_name'],
|
|
1270
|
+
mention_count=entity_row['mention_count'],
|
|
1271
|
+
first_seen=entity_row['first_seen'].isoformat() if entity_row['first_seen'] else None,
|
|
1272
|
+
last_seen=entity_row['last_seen'].isoformat() if entity_row['last_seen'] else None,
|
|
1273
|
+
metadata=_parse_metadata(entity_row['metadata']),
|
|
1274
|
+
observations=[
|
|
1275
|
+
EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at)
|
|
1276
|
+
for obs in observations
|
|
1277
|
+
]
|
|
1278
|
+
)
|
|
1279
|
+
except HTTPException:
|
|
1280
|
+
raise
|
|
1281
|
+
except Exception as e:
|
|
1282
|
+
import traceback
|
|
1283
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1284
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/entities/{entity_id}: {error_detail}")
|
|
1285
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1286
|
+
|
|
1287
|
+
@app.post(
|
|
1288
|
+
"/v1/default/banks/{bank_id}/entities/{entity_id}/regenerate",
|
|
1289
|
+
response_model=EntityDetailResponse,
|
|
1290
|
+
summary="Regenerate entity observations",
|
|
1291
|
+
description="Regenerate observations for an entity based on all facts mentioning it.",
|
|
1292
|
+
operation_id="regenerate_entity_observations"
|
|
1293
|
+
)
|
|
1294
|
+
async def api_regenerate_entity_observations(bank_id: str, entity_id: str):
|
|
1295
|
+
"""Regenerate observations for an entity."""
|
|
1296
|
+
try:
|
|
1297
|
+
# First get the entity metadata
|
|
1298
|
+
pool = await app.state.memory._get_pool()
|
|
1299
|
+
async with acquire_with_retry(pool) as conn:
|
|
1300
|
+
entity_row = await conn.fetchrow(
|
|
1301
|
+
"""
|
|
1302
|
+
SELECT id, canonical_name, mention_count, first_seen, last_seen, metadata
|
|
1303
|
+
FROM entities
|
|
1304
|
+
WHERE bank_id = $1 AND id = $2
|
|
1305
|
+
""",
|
|
1306
|
+
bank_id, uuid.UUID(entity_id)
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
if not entity_row:
|
|
1310
|
+
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
|
1311
|
+
|
|
1312
|
+
# Regenerate observations
|
|
1313
|
+
await app.state.memory.regenerate_entity_observations(
|
|
1314
|
+
bank_id=bank_id,
|
|
1315
|
+
entity_id=entity_id,
|
|
1316
|
+
entity_name=entity_row['canonical_name']
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
# Get updated observations
|
|
1320
|
+
observations = await app.state.memory.get_entity_observations(
|
|
1321
|
+
bank_id, entity_id, limit=20
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
return EntityDetailResponse(
|
|
1325
|
+
id=str(entity_row['id']),
|
|
1326
|
+
canonical_name=entity_row['canonical_name'],
|
|
1327
|
+
mention_count=entity_row['mention_count'],
|
|
1328
|
+
first_seen=entity_row['first_seen'].isoformat() if entity_row['first_seen'] else None,
|
|
1329
|
+
last_seen=entity_row['last_seen'].isoformat() if entity_row['last_seen'] else None,
|
|
1330
|
+
metadata=_parse_metadata(entity_row['metadata']),
|
|
1331
|
+
observations=[
|
|
1332
|
+
EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at)
|
|
1333
|
+
for obs in observations
|
|
1334
|
+
]
|
|
1335
|
+
)
|
|
1336
|
+
except HTTPException:
|
|
1337
|
+
raise
|
|
1338
|
+
except Exception as e:
|
|
1339
|
+
import traceback
|
|
1340
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1341
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/entities/{entity_id}/regenerate: {error_detail}")
|
|
1342
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1343
|
+
|
|
1344
|
+
@app.get(
|
|
1345
|
+
"/v1/default/banks/{bank_id}/documents",
|
|
1346
|
+
response_model=ListDocumentsResponse,
|
|
1347
|
+
summary="List documents",
|
|
1348
|
+
description="List documents with pagination and optional search. Documents are the source content from which memory units are extracted.",
|
|
1349
|
+
operation_id="list_documents"
|
|
1350
|
+
)
|
|
1351
|
+
async def api_list_documents(bank_id: str,
|
|
1352
|
+
q: Optional[str] = None,
|
|
1353
|
+
limit: int = 100,
|
|
1354
|
+
offset: int = 0
|
|
1355
|
+
):
|
|
1356
|
+
"""
|
|
1357
|
+
List documents for a memory bank with optional search.
|
|
1358
|
+
|
|
1359
|
+
Args:
|
|
1360
|
+
bank_id: Memory Bank ID (from path)
|
|
1361
|
+
q: Search query (searches document ID and metadata)
|
|
1362
|
+
limit: Maximum number of results (default: 100)
|
|
1363
|
+
offset: Offset for pagination (default: 0)
|
|
1364
|
+
"""
|
|
1365
|
+
try:
|
|
1366
|
+
data = await app.state.memory.list_documents(
|
|
1367
|
+
bank_id=bank_id,
|
|
1368
|
+
search_query=q,
|
|
1369
|
+
limit=limit,
|
|
1370
|
+
offset=offset
|
|
1371
|
+
)
|
|
1372
|
+
return data
|
|
1373
|
+
except Exception as e:
|
|
1374
|
+
import traceback
|
|
1375
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1376
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/documents: {error_detail}")
|
|
1377
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
@app.get(
|
|
1381
|
+
"/v1/default/banks/{bank_id}/documents/{document_id}",
|
|
1382
|
+
response_model=DocumentResponse,
|
|
1383
|
+
summary="Get document details",
|
|
1384
|
+
description="Get a specific document including its original text",
|
|
1385
|
+
operation_id="get_document"
|
|
1386
|
+
)
|
|
1387
|
+
async def api_get_document(bank_id: str,
|
|
1388
|
+
document_id: str
|
|
1389
|
+
):
|
|
1390
|
+
"""
|
|
1391
|
+
Get a specific document with its original text.
|
|
1392
|
+
|
|
1393
|
+
Args:
|
|
1394
|
+
bank_id: Memory Bank ID (from path)
|
|
1395
|
+
document_id: Document ID (from path)
|
|
1396
|
+
"""
|
|
1397
|
+
try:
|
|
1398
|
+
document = await app.state.memory.get_document(document_id, bank_id)
|
|
1399
|
+
if not document:
|
|
1400
|
+
raise HTTPException(status_code=404, detail="Document not found")
|
|
1401
|
+
return document
|
|
1402
|
+
except HTTPException:
|
|
1403
|
+
raise
|
|
1404
|
+
except Exception as e:
|
|
1405
|
+
import traceback
|
|
1406
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1407
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/documents/{document_id}: {error_detail}")
|
|
1408
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
@app.get(
|
|
1412
|
+
"/v1/default/chunks/{chunk_id}",
|
|
1413
|
+
response_model=ChunkResponse,
|
|
1414
|
+
summary="Get chunk details",
|
|
1415
|
+
description="Get a specific chunk by its ID",
|
|
1416
|
+
operation_id="get_chunk"
|
|
1417
|
+
)
|
|
1418
|
+
async def api_get_chunk(chunk_id: str):
|
|
1419
|
+
"""
|
|
1420
|
+
Get a specific chunk with its text.
|
|
1421
|
+
|
|
1422
|
+
Args:
|
|
1423
|
+
chunk_id: Chunk ID (from path, format: bank_id_document_id_chunk_index)
|
|
1424
|
+
"""
|
|
1425
|
+
try:
|
|
1426
|
+
chunk = await app.state.memory.get_chunk(chunk_id)
|
|
1427
|
+
if not chunk:
|
|
1428
|
+
raise HTTPException(status_code=404, detail="Chunk not found")
|
|
1429
|
+
return chunk
|
|
1430
|
+
except HTTPException:
|
|
1431
|
+
raise
|
|
1432
|
+
except Exception as e:
|
|
1433
|
+
import traceback
|
|
1434
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1435
|
+
logger.error(f"Error in /v1/default/chunks/{chunk_id}: {error_detail}")
|
|
1436
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
@app.delete(
|
|
1440
|
+
"/v1/default/banks/{bank_id}/documents/{document_id}",
|
|
1441
|
+
summary="Delete a document",
|
|
1442
|
+
description="""
|
|
1443
|
+
Delete a document and all its associated memory units and links.
|
|
1444
|
+
|
|
1445
|
+
This will cascade delete:
|
|
1446
|
+
- The document itself
|
|
1447
|
+
- All memory units extracted from this document
|
|
1448
|
+
- All links (temporal, semantic, entity) associated with those memory units
|
|
1449
|
+
|
|
1450
|
+
This operation cannot be undone.
|
|
1451
|
+
""",
|
|
1452
|
+
operation_id="delete_document"
|
|
1453
|
+
)
|
|
1454
|
+
async def api_delete_document(bank_id: str,
|
|
1455
|
+
document_id: str
|
|
1456
|
+
):
|
|
1457
|
+
"""
|
|
1458
|
+
Delete a document and all its associated memory units and links.
|
|
1459
|
+
|
|
1460
|
+
Args:
|
|
1461
|
+
bank_id: Memory Bank ID (from path)
|
|
1462
|
+
document_id: Document ID to delete (from path)
|
|
1463
|
+
"""
|
|
1464
|
+
try:
|
|
1465
|
+
result = await app.state.memory.delete_document(document_id, bank_id)
|
|
1466
|
+
|
|
1467
|
+
if result["document_deleted"] == 0:
|
|
1468
|
+
raise HTTPException(status_code=404, detail="Document not found")
|
|
1469
|
+
|
|
1470
|
+
return {
|
|
1471
|
+
"success": True,
|
|
1472
|
+
"message": f"Document '{document_id}' and {result['memory_units_deleted']} associated memory units deleted successfully",
|
|
1473
|
+
"document_id": document_id,
|
|
1474
|
+
"memory_units_deleted": result["memory_units_deleted"]
|
|
1475
|
+
}
|
|
1476
|
+
except HTTPException:
|
|
1477
|
+
raise
|
|
1478
|
+
except Exception as e:
|
|
1479
|
+
import traceback
|
|
1480
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1481
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/documents/{document_id}: {error_detail}")
|
|
1482
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
@app.get(
|
|
1486
|
+
"/v1/default/banks/{bank_id}/operations",
|
|
1487
|
+
summary="List async operations",
|
|
1488
|
+
description="Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations",
|
|
1489
|
+
operation_id="list_operations"
|
|
1490
|
+
)
|
|
1491
|
+
async def api_list_operations(bank_id: str):
|
|
1492
|
+
"""List all async operations (pending and failed) for a memory bank."""
|
|
1493
|
+
try:
|
|
1494
|
+
pool = await app.state.memory._get_pool()
|
|
1495
|
+
async with acquire_with_retry(pool) as conn:
|
|
1496
|
+
operations = await conn.fetch(
|
|
1497
|
+
"""
|
|
1498
|
+
SELECT operation_id, bank_id, operation_type, created_at, status, error_message, result_metadata
|
|
1499
|
+
FROM async_operations
|
|
1500
|
+
WHERE bank_id = $1
|
|
1501
|
+
ORDER BY created_at DESC
|
|
1502
|
+
""",
|
|
1503
|
+
bank_id
|
|
1504
|
+
)
|
|
1505
|
+
|
|
1506
|
+
return {
|
|
1507
|
+
"bank_id": bank_id,
|
|
1508
|
+
"operations": [
|
|
1509
|
+
{
|
|
1510
|
+
"id": str(row['operation_id']),
|
|
1511
|
+
"task_type": row['operation_type'],
|
|
1512
|
+
"items_count": row['result_metadata'].get('items_count', 0) if row['result_metadata'] else 0,
|
|
1513
|
+
"document_id": row['result_metadata'].get('document_id') if row['result_metadata'] else None,
|
|
1514
|
+
"created_at": row['created_at'].isoformat(),
|
|
1515
|
+
"status": row['status'],
|
|
1516
|
+
"error_message": row['error_message']
|
|
1517
|
+
}
|
|
1518
|
+
for row in operations
|
|
1519
|
+
]
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
except Exception as e:
|
|
1523
|
+
import traceback
|
|
1524
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1525
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/operations: {error_detail}")
|
|
1526
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
@app.delete(
|
|
1530
|
+
"/v1/default/banks/{bank_id}/operations/{operation_id}",
|
|
1531
|
+
summary="Cancel a pending async operation",
|
|
1532
|
+
description="Cancel a pending async operation by removing it from the queue",
|
|
1533
|
+
operation_id="cancel_operation"
|
|
1534
|
+
)
|
|
1535
|
+
async def api_cancel_operation(bank_id: str, operation_id: str):
|
|
1536
|
+
"""Cancel a pending async operation."""
|
|
1537
|
+
try:
|
|
1538
|
+
# Validate UUID format
|
|
1539
|
+
try:
|
|
1540
|
+
op_uuid = uuid.UUID(operation_id)
|
|
1541
|
+
except ValueError:
|
|
1542
|
+
raise HTTPException(status_code=400, detail=f"Invalid operation_id format: {operation_id}")
|
|
1543
|
+
|
|
1544
|
+
pool = await app.state.memory._get_pool()
|
|
1545
|
+
async with acquire_with_retry(pool) as conn:
|
|
1546
|
+
# Check if operation exists and belongs to this memory bank
|
|
1547
|
+
result = await conn.fetchrow(
|
|
1548
|
+
"SELECT bank_id FROM async_operations WHERE id = $1 AND bank_id = $2",
|
|
1549
|
+
op_uuid,
|
|
1550
|
+
bank_id
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1553
|
+
if not result:
|
|
1554
|
+
raise HTTPException(status_code=404, detail=f"Operation {operation_id} not found for memory bank {bank_id}")
|
|
1555
|
+
|
|
1556
|
+
# Delete the operation
|
|
1557
|
+
await conn.execute(
|
|
1558
|
+
"DELETE FROM async_operations WHERE id = $1",
|
|
1559
|
+
op_uuid
|
|
1560
|
+
)
|
|
1561
|
+
|
|
1562
|
+
return {
|
|
1563
|
+
"success": True,
|
|
1564
|
+
"message": f"Operation {operation_id} cancelled",
|
|
1565
|
+
"operation_id": operation_id,
|
|
1566
|
+
"bank_id": bank_id
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
except HTTPException:
|
|
1570
|
+
raise
|
|
1571
|
+
except Exception as e:
|
|
1572
|
+
import traceback
|
|
1573
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1574
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/operations/{operation_id}: {error_detail}")
|
|
1575
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
@app.get(
|
|
1579
|
+
"/v1/default/banks/{bank_id}/profile",
|
|
1580
|
+
response_model=BankProfileResponse,
|
|
1581
|
+
summary="Get memory bank profile",
|
|
1582
|
+
description="Get personality traits and background for a memory bank. Auto-creates agent with defaults if not exists.",
|
|
1583
|
+
operation_id="get_bank_profile"
|
|
1584
|
+
)
|
|
1585
|
+
async def api_get_bank_profile(bank_id: str):
|
|
1586
|
+
"""Get memory bank profile (personality + background)."""
|
|
1587
|
+
try:
|
|
1588
|
+
profile = await app.state.memory.get_bank_profile(bank_id)
|
|
1589
|
+
# Convert PersonalityTraits object to dict for Pydantic
|
|
1590
|
+
personality_dict = profile["personality"].model_dump() if hasattr(profile["personality"], 'model_dump') else dict(profile["personality"])
|
|
1591
|
+
return BankProfileResponse(
|
|
1592
|
+
bank_id=bank_id,
|
|
1593
|
+
name=profile["name"],
|
|
1594
|
+
personality=PersonalityTraits(**personality_dict),
|
|
1595
|
+
background=profile["background"]
|
|
1596
|
+
)
|
|
1597
|
+
except Exception as e:
|
|
1598
|
+
import traceback
|
|
1599
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1600
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/profile: {error_detail}")
|
|
1601
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
@app.put(
|
|
1605
|
+
"/v1/default/banks/{bank_id}/profile",
|
|
1606
|
+
response_model=BankProfileResponse,
|
|
1607
|
+
summary="Update memory bank personality",
|
|
1608
|
+
description="Update bank's Big Five personality traits and bias strength",
|
|
1609
|
+
operation_id="update_bank_personality"
|
|
1610
|
+
)
|
|
1611
|
+
async def api_update_bank_personality(bank_id: str,
|
|
1612
|
+
request: UpdatePersonalityRequest
|
|
1613
|
+
):
|
|
1614
|
+
"""Update bank personality traits."""
|
|
1615
|
+
try:
|
|
1616
|
+
# Update personality
|
|
1617
|
+
await app.state.memory.update_bank_personality(
|
|
1618
|
+
bank_id,
|
|
1619
|
+
request.personality.model_dump()
|
|
1620
|
+
)
|
|
1621
|
+
|
|
1622
|
+
# Get updated profile
|
|
1623
|
+
profile = await app.state.memory.get_bank_profile(bank_id)
|
|
1624
|
+
personality_dict = profile["personality"].model_dump() if hasattr(profile["personality"], 'model_dump') else dict(profile["personality"])
|
|
1625
|
+
return BankProfileResponse(
|
|
1626
|
+
bank_id=bank_id,
|
|
1627
|
+
name=profile["name"],
|
|
1628
|
+
personality=PersonalityTraits(**personality_dict),
|
|
1629
|
+
background=profile["background"]
|
|
1630
|
+
)
|
|
1631
|
+
except Exception as e:
|
|
1632
|
+
import traceback
|
|
1633
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1634
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/profile: {error_detail}")
|
|
1635
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
@app.post(
|
|
1639
|
+
"/v1/default/banks/{bank_id}/background",
|
|
1640
|
+
response_model=BackgroundResponse,
|
|
1641
|
+
summary="Add/merge memory bank background",
|
|
1642
|
+
description="Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers personality traits.",
|
|
1643
|
+
operation_id="add_bank_background"
|
|
1644
|
+
)
|
|
1645
|
+
async def api_add_bank_background(bank_id: str,
|
|
1646
|
+
request: AddBackgroundRequest
|
|
1647
|
+
):
|
|
1648
|
+
"""Add or merge bank background information. Optionally infer personality traits."""
|
|
1649
|
+
try:
|
|
1650
|
+
result = await app.state.memory.merge_bank_background(
|
|
1651
|
+
bank_id,
|
|
1652
|
+
request.content,
|
|
1653
|
+
update_personality=request.update_personality
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1656
|
+
response = BackgroundResponse(background=result["background"])
|
|
1657
|
+
if "personality" in result:
|
|
1658
|
+
response.personality = PersonalityTraits(**result["personality"])
|
|
1659
|
+
|
|
1660
|
+
return response
|
|
1661
|
+
except Exception as e:
|
|
1662
|
+
import traceback
|
|
1663
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1664
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/background: {error_detail}")
|
|
1665
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
@app.put(
|
|
1669
|
+
"/v1/default/banks/{bank_id}",
|
|
1670
|
+
response_model=BankProfileResponse,
|
|
1671
|
+
summary="Create or update memory bank",
|
|
1672
|
+
description="Create a new agent or update existing agent with personality and background. Auto-fills missing fields with defaults.",
|
|
1673
|
+
operation_id="create_or_update_bank"
|
|
1674
|
+
)
|
|
1675
|
+
async def api_create_or_update_bank(bank_id: str,
|
|
1676
|
+
request: CreateBankRequest
|
|
1677
|
+
):
|
|
1678
|
+
"""Create or update an agent with personality and background."""
|
|
1679
|
+
try:
|
|
1680
|
+
# Get existing profile or create with defaults
|
|
1681
|
+
profile = await app.state.memory.get_bank_profile(bank_id)
|
|
1682
|
+
|
|
1683
|
+
# Update name if provided
|
|
1684
|
+
if request.name is not None:
|
|
1685
|
+
pool = await app.state.memory._get_pool()
|
|
1686
|
+
async with acquire_with_retry(pool) as conn:
|
|
1687
|
+
await conn.execute(
|
|
1688
|
+
"""
|
|
1689
|
+
UPDATE banks
|
|
1690
|
+
SET name = $2,
|
|
1691
|
+
updated_at = NOW()
|
|
1692
|
+
WHERE bank_id = $1
|
|
1693
|
+
""",
|
|
1694
|
+
bank_id,
|
|
1695
|
+
request.name
|
|
1696
|
+
)
|
|
1697
|
+
profile["name"] = request.name
|
|
1698
|
+
|
|
1699
|
+
# Update personality if provided
|
|
1700
|
+
if request.personality is not None:
|
|
1701
|
+
await app.state.memory.update_bank_personality(
|
|
1702
|
+
bank_id,
|
|
1703
|
+
request.personality.model_dump()
|
|
1704
|
+
)
|
|
1705
|
+
profile["personality"] = request.personality.model_dump()
|
|
1706
|
+
|
|
1707
|
+
# Update background if provided (replace, not merge)
|
|
1708
|
+
if request.background is not None:
|
|
1709
|
+
pool = await app.state.memory._get_pool()
|
|
1710
|
+
async with acquire_with_retry(pool) as conn:
|
|
1711
|
+
await conn.execute(
|
|
1712
|
+
"""
|
|
1713
|
+
UPDATE banks
|
|
1714
|
+
SET background = $2,
|
|
1715
|
+
updated_at = NOW()
|
|
1716
|
+
WHERE bank_id = $1
|
|
1717
|
+
""",
|
|
1718
|
+
bank_id,
|
|
1719
|
+
request.background
|
|
1720
|
+
)
|
|
1721
|
+
profile["background"] = request.background
|
|
1722
|
+
|
|
1723
|
+
# Get final profile
|
|
1724
|
+
final_profile = await app.state.memory.get_bank_profile(bank_id)
|
|
1725
|
+
personality_dict = final_profile["personality"].model_dump() if hasattr(final_profile["personality"], 'model_dump') else dict(final_profile["personality"])
|
|
1726
|
+
return BankProfileResponse(
|
|
1727
|
+
bank_id=bank_id,
|
|
1728
|
+
name=final_profile["name"],
|
|
1729
|
+
personality=PersonalityTraits(**personality_dict),
|
|
1730
|
+
background=final_profile["background"]
|
|
1731
|
+
)
|
|
1732
|
+
except Exception as e:
|
|
1733
|
+
import traceback
|
|
1734
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1735
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}: {error_detail}")
|
|
1736
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
@app.post(
|
|
1740
|
+
"/v1/default/banks/{bank_id}/memories",
|
|
1741
|
+
response_model=RetainResponse,
|
|
1742
|
+
summary="Retain memories",
|
|
1743
|
+
description="""
|
|
1744
|
+
Retain memory items with automatic fact extraction.
|
|
1745
|
+
|
|
1746
|
+
This is the main endpoint for storing memories. It supports both synchronous and asynchronous processing
|
|
1747
|
+
via the async parameter.
|
|
1748
|
+
|
|
1749
|
+
Features:
|
|
1750
|
+
- Efficient batch processing
|
|
1751
|
+
- Automatic fact extraction from natural language
|
|
1752
|
+
- Entity recognition and linking
|
|
1753
|
+
- Document tracking with automatic upsert (when document_id is provided on items)
|
|
1754
|
+
- Temporal and semantic linking
|
|
1755
|
+
- Optional asynchronous processing
|
|
1756
|
+
|
|
1757
|
+
The system automatically:
|
|
1758
|
+
1. Extracts semantic facts from the content
|
|
1759
|
+
2. Generates embeddings
|
|
1760
|
+
3. Deduplicates similar facts
|
|
1761
|
+
4. Creates temporal, semantic, and entity links
|
|
1762
|
+
5. Tracks document metadata
|
|
1763
|
+
|
|
1764
|
+
When async=true:
|
|
1765
|
+
- Returns immediately after queuing the task
|
|
1766
|
+
- Processing happens in the background
|
|
1767
|
+
- Use the operations endpoint to monitor progress
|
|
1768
|
+
|
|
1769
|
+
When async=false (default):
|
|
1770
|
+
- Waits for processing to complete
|
|
1771
|
+
- Returns after all memories are stored
|
|
1772
|
+
|
|
1773
|
+
Note: If a memory item has a document_id that already exists, the old document and its memory units will be deleted before creating new ones (upsert behavior). Items with the same document_id are grouped together for efficient processing.
|
|
1774
|
+
""",
|
|
1775
|
+
operation_id="retain_memories"
|
|
1776
|
+
)
|
|
1777
|
+
async def api_retain(bank_id: str, request: RetainRequest):
|
|
1778
|
+
"""Retain memories with optional async processing."""
|
|
1779
|
+
metrics = get_metrics_collector()
|
|
1780
|
+
|
|
1781
|
+
try:
|
|
1782
|
+
# Prepare contents for processing
|
|
1783
|
+
contents = []
|
|
1784
|
+
for item in request.items:
|
|
1785
|
+
content_dict = {"content": item.content}
|
|
1786
|
+
if item.timestamp:
|
|
1787
|
+
content_dict["event_date"] = item.timestamp
|
|
1788
|
+
if item.context:
|
|
1789
|
+
content_dict["context"] = item.context
|
|
1790
|
+
if item.metadata:
|
|
1791
|
+
content_dict["metadata"] = item.metadata
|
|
1792
|
+
if item.document_id:
|
|
1793
|
+
content_dict["document_id"] = item.document_id
|
|
1794
|
+
contents.append(content_dict)
|
|
1795
|
+
|
|
1796
|
+
if request.async_:
|
|
1797
|
+
# Async processing: queue task and return immediately
|
|
1798
|
+
operation_id = uuid.uuid4()
|
|
1799
|
+
|
|
1800
|
+
# Insert operation record into database
|
|
1801
|
+
pool = await app.state.memory._get_pool()
|
|
1802
|
+
async with acquire_with_retry(pool) as conn:
|
|
1803
|
+
await conn.execute(
|
|
1804
|
+
"""
|
|
1805
|
+
INSERT INTO async_operations (id, bank_id, task_type, items_count)
|
|
1806
|
+
VALUES ($1, $2, $3, $4)
|
|
1807
|
+
""",
|
|
1808
|
+
operation_id,
|
|
1809
|
+
bank_id,
|
|
1810
|
+
'retain',
|
|
1811
|
+
len(contents)
|
|
1812
|
+
)
|
|
1813
|
+
|
|
1814
|
+
# Submit task to background queue
|
|
1815
|
+
await app.state.memory._task_backend.submit_task({
|
|
1816
|
+
'type': 'batch_put',
|
|
1817
|
+
'operation_id': str(operation_id),
|
|
1818
|
+
'bank_id': bank_id,
|
|
1819
|
+
'contents': contents
|
|
1820
|
+
})
|
|
1821
|
+
|
|
1822
|
+
logging.info(f"Retain task queued for bank_id={bank_id}, {len(contents)} items, operation_id={operation_id}")
|
|
1823
|
+
|
|
1824
|
+
return RetainResponse(
|
|
1825
|
+
success=True,
|
|
1826
|
+
bank_id=bank_id,
|
|
1827
|
+
items_count=len(contents),
|
|
1828
|
+
async_=True
|
|
1829
|
+
)
|
|
1830
|
+
else:
|
|
1831
|
+
# Synchronous processing: wait for completion (record metrics)
|
|
1832
|
+
with metrics.record_operation("retain", bank_id=bank_id):
|
|
1833
|
+
result = await app.state.memory.retain_batch_async(
|
|
1834
|
+
bank_id=bank_id,
|
|
1835
|
+
contents=contents
|
|
1836
|
+
)
|
|
1837
|
+
|
|
1838
|
+
return RetainResponse(
|
|
1839
|
+
success=True,
|
|
1840
|
+
bank_id=bank_id,
|
|
1841
|
+
items_count=len(contents),
|
|
1842
|
+
async_=False
|
|
1843
|
+
)
|
|
1844
|
+
except Exception as e:
|
|
1845
|
+
import traceback
|
|
1846
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1847
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/memories (retain): {error_detail}")
|
|
1848
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1849
|
+
|
|
1850
|
+
|
|
1851
|
+
@app.delete(
|
|
1852
|
+
"/v1/default/banks/{bank_id}/memories",
|
|
1853
|
+
response_model=DeleteResponse,
|
|
1854
|
+
summary="Clear memory bank memories",
|
|
1855
|
+
description="Delete memory units for a memory bank. Optionally filter by type (world, agent, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (personality and background) will be preserved.",
|
|
1856
|
+
operation_id="clear_bank_memories"
|
|
1857
|
+
)
|
|
1858
|
+
async def api_clear_bank_memories(bank_id: str,
|
|
1859
|
+
type: Optional[str] = Query(None, description="Optional fact type filter (world, agent, opinion)")
|
|
1860
|
+
):
|
|
1861
|
+
"""Clear memories for a memory bank, optionally filtered by type."""
|
|
1862
|
+
try:
|
|
1863
|
+
await app.state.memory.delete_bank(bank_id, fact_type=type)
|
|
1864
|
+
|
|
1865
|
+
return DeleteResponse(
|
|
1866
|
+
success=True
|
|
1867
|
+
)
|
|
1868
|
+
except Exception as e:
|
|
1869
|
+
import traceback
|
|
1870
|
+
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
1871
|
+
logger.error(f"Error in /v1/default/banks/{bank_id}/memories: {error_detail}")
|
|
1872
|
+
raise HTTPException(status_code=500, detail=str(e))
|