basic-memory 0.17.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.
- basic_memory/__init__.py +7 -0
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +185 -0
- basic_memory/alembic/migrations.py +24 -0
- basic_memory/alembic/script.py.mako +26 -0
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
- basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/api/__init__.py +5 -0
- basic_memory/api/app.py +131 -0
- basic_memory/api/routers/__init__.py +11 -0
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +318 -0
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +90 -0
- basic_memory/api/routers/project_router.py +448 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +249 -0
- basic_memory/api/routers/search_router.py +36 -0
- basic_memory/api/routers/utils.py +169 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +182 -0
- basic_memory/api/v2/routers/knowledge_router.py +413 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +342 -0
- basic_memory/api/v2/routers/prompt_router.py +270 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/__init__.py +1 -0
- basic_memory/cli/app.py +84 -0
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/__init__.py +18 -0
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
- basic_memory/cli/commands/cloud/upload.py +233 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +77 -0
- basic_memory/cli/commands/db.py +44 -0
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +84 -0
- basic_memory/cli/commands/import_claude_conversations.py +87 -0
- basic_memory/cli/commands/import_claude_projects.py +86 -0
- basic_memory/cli/commands/import_memory_json.py +87 -0
- basic_memory/cli/commands/mcp.py +76 -0
- basic_memory/cli/commands/project.py +889 -0
- basic_memory/cli/commands/status.py +174 -0
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/main.py +28 -0
- basic_memory/config.py +616 -0
- basic_memory/db.py +394 -0
- basic_memory/deps.py +705 -0
- basic_memory/file_utils.py +478 -0
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +232 -0
- basic_memory/importers/claude_conversations_importer.py +180 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +108 -0
- basic_memory/importers/utils.py +61 -0
- basic_memory/markdown/__init__.py +21 -0
- basic_memory/markdown/entity_parser.py +279 -0
- basic_memory/markdown/markdown_processor.py +160 -0
- basic_memory/markdown/plugins.py +242 -0
- basic_memory/markdown/schemas.py +70 -0
- basic_memory/markdown/utils.py +117 -0
- basic_memory/mcp/__init__.py +1 -0
- basic_memory/mcp/async_client.py +139 -0
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +81 -0
- basic_memory/mcp/tools/__init__.py +48 -0
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +152 -0
- basic_memory/mcp/tools/chatgpt_tools.py +190 -0
- basic_memory/mcp/tools/delete_note.py +242 -0
- basic_memory/mcp/tools/edit_note.py +324 -0
- basic_memory/mcp/tools/list_directory.py +168 -0
- basic_memory/mcp/tools/move_note.py +551 -0
- basic_memory/mcp/tools/project_management.py +201 -0
- basic_memory/mcp/tools/read_content.py +281 -0
- basic_memory/mcp/tools/read_note.py +267 -0
- basic_memory/mcp/tools/recent_activity.py +534 -0
- basic_memory/mcp/tools/search.py +385 -0
- basic_memory/mcp/tools/utils.py +540 -0
- basic_memory/mcp/tools/view_note.py +78 -0
- basic_memory/mcp/tools/write_note.py +230 -0
- basic_memory/models/__init__.py +15 -0
- basic_memory/models/base.py +10 -0
- basic_memory/models/knowledge.py +226 -0
- basic_memory/models/project.py +87 -0
- basic_memory/models/search.py +85 -0
- basic_memory/repository/__init__.py +11 -0
- basic_memory/repository/entity_repository.py +503 -0
- basic_memory/repository/observation_repository.py +73 -0
- basic_memory/repository/postgres_search_repository.py +379 -0
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +128 -0
- basic_memory/repository/relation_repository.py +146 -0
- basic_memory/repository/repository.py +385 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +94 -0
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +439 -0
- basic_memory/schemas/__init__.py +86 -0
- basic_memory/schemas/base.py +297 -0
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/delete.py +37 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +285 -0
- basic_memory/schemas/project_info.py +212 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +112 -0
- basic_memory/schemas/response.py +229 -0
- basic_memory/schemas/search.py +117 -0
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +129 -0
- basic_memory/schemas/v2/resource.py +46 -0
- basic_memory/services/__init__.py +8 -0
- basic_memory/services/context_service.py +601 -0
- basic_memory/services/directory_service.py +308 -0
- basic_memory/services/entity_service.py +864 -0
- basic_memory/services/exceptions.py +37 -0
- basic_memory/services/file_service.py +541 -0
- basic_memory/services/initialization.py +216 -0
- basic_memory/services/link_resolver.py +121 -0
- basic_memory/services/project_service.py +880 -0
- basic_memory/services/search_service.py +404 -0
- basic_memory/services/service.py +15 -0
- basic_memory/sync/__init__.py +6 -0
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/sync_service.py +1259 -0
- basic_memory/sync/watch_service.py +510 -0
- basic_memory/telemetry.py +249 -0
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +468 -0
- basic_memory-0.17.1.dist-info/METADATA +617 -0
- basic_memory-0.17.1.dist-info/RECORD +171 -0
- basic_memory-0.17.1.dist-info/WHEEL +4 -0
- basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
- basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""V2 Prompt Router - ID-based prompt generation operations.
|
|
2
|
+
|
|
3
|
+
This router uses v2 dependencies for consistent project ID handling.
|
|
4
|
+
Prompt endpoints are action-based (not resource-based), so they don't
|
|
5
|
+
have entity IDs in URLs - they generate formatted prompts from queries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from fastapi import APIRouter, HTTPException, status
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from basic_memory.api.routers.utils import to_graph_context, to_search_results
|
|
13
|
+
from basic_memory.api.template_loader import template_loader
|
|
14
|
+
from basic_memory.schemas.base import parse_timeframe
|
|
15
|
+
from basic_memory.deps import (
|
|
16
|
+
ContextServiceV2Dep,
|
|
17
|
+
EntityRepositoryV2Dep,
|
|
18
|
+
SearchServiceV2Dep,
|
|
19
|
+
EntityServiceV2Dep,
|
|
20
|
+
ProjectIdPathDep,
|
|
21
|
+
)
|
|
22
|
+
from basic_memory.schemas.prompt import (
|
|
23
|
+
ContinueConversationRequest,
|
|
24
|
+
SearchPromptRequest,
|
|
25
|
+
PromptResponse,
|
|
26
|
+
PromptMetadata,
|
|
27
|
+
)
|
|
28
|
+
from basic_memory.schemas.search import SearchItemType, SearchQuery
|
|
29
|
+
|
|
30
|
+
router = APIRouter(prefix="/prompt", tags=["prompt-v2"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.post("/continue-conversation", response_model=PromptResponse)
|
|
34
|
+
async def continue_conversation(
|
|
35
|
+
project_id: ProjectIdPathDep,
|
|
36
|
+
search_service: SearchServiceV2Dep,
|
|
37
|
+
entity_service: EntityServiceV2Dep,
|
|
38
|
+
context_service: ContextServiceV2Dep,
|
|
39
|
+
entity_repository: EntityRepositoryV2Dep,
|
|
40
|
+
request: ContinueConversationRequest,
|
|
41
|
+
) -> PromptResponse:
|
|
42
|
+
"""Generate a prompt for continuing a conversation.
|
|
43
|
+
|
|
44
|
+
This endpoint takes a topic and/or timeframe and generates a prompt with
|
|
45
|
+
relevant context from the knowledge base.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
project_id: Validated numeric project ID from URL path
|
|
49
|
+
request: The request parameters
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Formatted continuation prompt with context
|
|
53
|
+
"""
|
|
54
|
+
logger.info(
|
|
55
|
+
f"V2 Generating continue conversation prompt for project {project_id}, "
|
|
56
|
+
f"topic: {request.topic}, timeframe: {request.timeframe}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
since = parse_timeframe(request.timeframe) if request.timeframe else None
|
|
60
|
+
|
|
61
|
+
# Initialize search results
|
|
62
|
+
search_results = []
|
|
63
|
+
|
|
64
|
+
# Get data needed for template
|
|
65
|
+
if request.topic:
|
|
66
|
+
query = SearchQuery(text=request.topic, after_date=request.timeframe)
|
|
67
|
+
results = await search_service.search(query, limit=request.search_items_limit)
|
|
68
|
+
search_results = await to_search_results(entity_service, results)
|
|
69
|
+
|
|
70
|
+
# Build context from results
|
|
71
|
+
all_hierarchical_results = []
|
|
72
|
+
for result in search_results:
|
|
73
|
+
if hasattr(result, "permalink") and result.permalink:
|
|
74
|
+
# Get hierarchical context using the new dataclass-based approach
|
|
75
|
+
context_result = await context_service.build_context(
|
|
76
|
+
result.permalink,
|
|
77
|
+
depth=request.depth,
|
|
78
|
+
since=since,
|
|
79
|
+
max_related=request.related_items_limit,
|
|
80
|
+
include_observations=True, # Include observations for entities
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Process results into the schema format
|
|
84
|
+
graph_context = await to_graph_context(
|
|
85
|
+
context_result, entity_repository=entity_repository
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Add results to our collection (limit to top results for each permalink)
|
|
89
|
+
if graph_context.results:
|
|
90
|
+
all_hierarchical_results.extend(graph_context.results[:3])
|
|
91
|
+
|
|
92
|
+
# Limit to a reasonable number of total results
|
|
93
|
+
all_hierarchical_results = all_hierarchical_results[:10]
|
|
94
|
+
|
|
95
|
+
template_context = {
|
|
96
|
+
"topic": request.topic,
|
|
97
|
+
"timeframe": request.timeframe,
|
|
98
|
+
"hierarchical_results": all_hierarchical_results,
|
|
99
|
+
"has_results": len(all_hierarchical_results) > 0,
|
|
100
|
+
}
|
|
101
|
+
else:
|
|
102
|
+
# If no topic, get recent activity
|
|
103
|
+
context_result = await context_service.build_context(
|
|
104
|
+
types=[SearchItemType.ENTITY],
|
|
105
|
+
depth=request.depth,
|
|
106
|
+
since=since,
|
|
107
|
+
max_related=request.related_items_limit,
|
|
108
|
+
include_observations=True,
|
|
109
|
+
)
|
|
110
|
+
recent_context = await to_graph_context(context_result, entity_repository=entity_repository)
|
|
111
|
+
|
|
112
|
+
hierarchical_results = recent_context.results[:5] # Limit to top 5 recent items
|
|
113
|
+
|
|
114
|
+
template_context = {
|
|
115
|
+
"topic": f"Recent Activity from ({request.timeframe})",
|
|
116
|
+
"timeframe": request.timeframe,
|
|
117
|
+
"hierarchical_results": hierarchical_results,
|
|
118
|
+
"has_results": len(hierarchical_results) > 0,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
# Render template
|
|
123
|
+
rendered_prompt = await template_loader.render(
|
|
124
|
+
"prompts/continue_conversation.hbs", template_context
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Calculate metadata
|
|
128
|
+
# Count items of different types
|
|
129
|
+
observation_count = 0
|
|
130
|
+
relation_count = 0
|
|
131
|
+
entity_count = 0
|
|
132
|
+
|
|
133
|
+
# Get the hierarchical results from the template context
|
|
134
|
+
hierarchical_results_for_count = template_context.get("hierarchical_results", [])
|
|
135
|
+
|
|
136
|
+
# For topic-based search
|
|
137
|
+
if request.topic:
|
|
138
|
+
for item in hierarchical_results_for_count:
|
|
139
|
+
if hasattr(item, "observations"):
|
|
140
|
+
observation_count += len(item.observations) if item.observations else 0
|
|
141
|
+
|
|
142
|
+
if hasattr(item, "related_results"):
|
|
143
|
+
for related in item.related_results or []:
|
|
144
|
+
if hasattr(related, "type"):
|
|
145
|
+
if related.type == "relation":
|
|
146
|
+
relation_count += 1
|
|
147
|
+
elif related.type == "entity": # pragma: no cover
|
|
148
|
+
entity_count += 1 # pragma: no cover
|
|
149
|
+
# For recent activity
|
|
150
|
+
else:
|
|
151
|
+
for item in hierarchical_results_for_count:
|
|
152
|
+
if hasattr(item, "observations"):
|
|
153
|
+
observation_count += len(item.observations) if item.observations else 0
|
|
154
|
+
|
|
155
|
+
if hasattr(item, "related_results"):
|
|
156
|
+
for related in item.related_results or []:
|
|
157
|
+
if hasattr(related, "type"):
|
|
158
|
+
if related.type == "relation":
|
|
159
|
+
relation_count += 1
|
|
160
|
+
elif related.type == "entity": # pragma: no cover
|
|
161
|
+
entity_count += 1 # pragma: no cover
|
|
162
|
+
|
|
163
|
+
# Build metadata
|
|
164
|
+
metadata = {
|
|
165
|
+
"query": request.topic,
|
|
166
|
+
"timeframe": request.timeframe,
|
|
167
|
+
"search_count": len(search_results)
|
|
168
|
+
if request.topic
|
|
169
|
+
else 0, # Original search results count
|
|
170
|
+
"context_count": len(hierarchical_results_for_count),
|
|
171
|
+
"observation_count": observation_count,
|
|
172
|
+
"relation_count": relation_count,
|
|
173
|
+
"total_items": (
|
|
174
|
+
len(hierarchical_results_for_count)
|
|
175
|
+
+ observation_count
|
|
176
|
+
+ relation_count
|
|
177
|
+
+ entity_count
|
|
178
|
+
),
|
|
179
|
+
"search_limit": request.search_items_limit,
|
|
180
|
+
"context_depth": request.depth,
|
|
181
|
+
"related_limit": request.related_items_limit,
|
|
182
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
prompt_metadata = PromptMetadata(**metadata)
|
|
186
|
+
|
|
187
|
+
return PromptResponse(
|
|
188
|
+
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
|
189
|
+
)
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.error(f"Error rendering continue conversation template: {e}")
|
|
192
|
+
raise HTTPException(
|
|
193
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
194
|
+
detail=f"Error rendering prompt template: {str(e)}",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@router.post("/search", response_model=PromptResponse)
|
|
199
|
+
async def search_prompt(
|
|
200
|
+
project_id: ProjectIdPathDep,
|
|
201
|
+
search_service: SearchServiceV2Dep,
|
|
202
|
+
entity_service: EntityServiceV2Dep,
|
|
203
|
+
request: SearchPromptRequest,
|
|
204
|
+
page: int = 1,
|
|
205
|
+
page_size: int = 10,
|
|
206
|
+
) -> PromptResponse:
|
|
207
|
+
"""Generate a prompt for search results.
|
|
208
|
+
|
|
209
|
+
This endpoint takes a search query and formats the results into a helpful
|
|
210
|
+
prompt with context and suggestions.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
project_id: Validated numeric project ID from URL path
|
|
214
|
+
request: The search parameters
|
|
215
|
+
page: The page number for pagination
|
|
216
|
+
page_size: The number of results per page, defaults to 10
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Formatted search results prompt with context
|
|
220
|
+
"""
|
|
221
|
+
logger.info(
|
|
222
|
+
f"V2 Generating search prompt for project {project_id}, "
|
|
223
|
+
f"query: {request.query}, timeframe: {request.timeframe}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
limit = page_size
|
|
227
|
+
offset = (page - 1) * page_size
|
|
228
|
+
|
|
229
|
+
query = SearchQuery(text=request.query, after_date=request.timeframe)
|
|
230
|
+
results = await search_service.search(query, limit=limit, offset=offset)
|
|
231
|
+
search_results = await to_search_results(entity_service, results)
|
|
232
|
+
|
|
233
|
+
template_context = {
|
|
234
|
+
"query": request.query,
|
|
235
|
+
"timeframe": request.timeframe,
|
|
236
|
+
"results": search_results,
|
|
237
|
+
"has_results": len(search_results) > 0,
|
|
238
|
+
"result_count": len(search_results),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
# Render template
|
|
243
|
+
rendered_prompt = await template_loader.render("prompts/search.hbs", template_context)
|
|
244
|
+
|
|
245
|
+
# Build metadata
|
|
246
|
+
metadata = {
|
|
247
|
+
"query": request.query,
|
|
248
|
+
"timeframe": request.timeframe,
|
|
249
|
+
"search_count": len(search_results),
|
|
250
|
+
"context_count": len(search_results),
|
|
251
|
+
"observation_count": 0, # Search results don't include observations
|
|
252
|
+
"relation_count": 0, # Search results don't include relations
|
|
253
|
+
"total_items": len(search_results),
|
|
254
|
+
"search_limit": limit,
|
|
255
|
+
"context_depth": 0, # No context depth for basic search
|
|
256
|
+
"related_limit": 0, # No related items for basic search
|
|
257
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
prompt_metadata = PromptMetadata(**metadata)
|
|
261
|
+
|
|
262
|
+
return PromptResponse(
|
|
263
|
+
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
|
264
|
+
)
|
|
265
|
+
except Exception as e:
|
|
266
|
+
logger.error(f"Error rendering search template: {e}")
|
|
267
|
+
raise HTTPException(
|
|
268
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
269
|
+
detail=f"Error rendering prompt template: {str(e)}",
|
|
270
|
+
)
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""V2 Resource Router - ID-based resource content operations.
|
|
2
|
+
|
|
3
|
+
This router uses entity IDs for all operations, with file paths in request bodies
|
|
4
|
+
when needed. This is consistent with v2's ID-first design.
|
|
5
|
+
|
|
6
|
+
Key differences from v1:
|
|
7
|
+
- Uses integer entity IDs in URL paths instead of file paths
|
|
8
|
+
- File paths are in request bodies for create/update operations
|
|
9
|
+
- More RESTful: POST for create, PUT for update, GET for read
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, HTTPException, Response
|
|
15
|
+
from loguru import logger
|
|
16
|
+
|
|
17
|
+
from basic_memory.deps import (
|
|
18
|
+
ProjectConfigV2Dep,
|
|
19
|
+
EntityServiceV2Dep,
|
|
20
|
+
FileServiceV2Dep,
|
|
21
|
+
EntityRepositoryV2Dep,
|
|
22
|
+
SearchServiceV2Dep,
|
|
23
|
+
ProjectIdPathDep,
|
|
24
|
+
)
|
|
25
|
+
from basic_memory.models.knowledge import Entity as EntityModel
|
|
26
|
+
from basic_memory.schemas.v2.resource import (
|
|
27
|
+
CreateResourceRequest,
|
|
28
|
+
UpdateResourceRequest,
|
|
29
|
+
ResourceResponse,
|
|
30
|
+
)
|
|
31
|
+
from basic_memory.utils import validate_project_path
|
|
32
|
+
|
|
33
|
+
router = APIRouter(prefix="/resource", tags=["resources-v2"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.get("/{entity_id}")
|
|
37
|
+
async def get_resource_content(
|
|
38
|
+
project_id: ProjectIdPathDep,
|
|
39
|
+
entity_id: int,
|
|
40
|
+
config: ProjectConfigV2Dep,
|
|
41
|
+
entity_service: EntityServiceV2Dep,
|
|
42
|
+
file_service: FileServiceV2Dep,
|
|
43
|
+
) -> Response:
|
|
44
|
+
"""Get raw resource content by entity ID.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
project_id: Validated numeric project ID from URL path
|
|
48
|
+
entity_id: Numeric entity ID
|
|
49
|
+
config: Project configuration
|
|
50
|
+
entity_service: Entity service for fetching entity data
|
|
51
|
+
file_service: File service for reading file content
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Response with entity content
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
HTTPException: 404 if entity or file not found
|
|
58
|
+
"""
|
|
59
|
+
logger.debug(f"V2 Getting content for project {project_id}, entity_id: {entity_id}")
|
|
60
|
+
|
|
61
|
+
# Get entity by ID
|
|
62
|
+
entities = await entity_service.get_entities_by_id([entity_id])
|
|
63
|
+
if not entities:
|
|
64
|
+
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
|
65
|
+
|
|
66
|
+
entity = entities[0]
|
|
67
|
+
|
|
68
|
+
# Validate entity file path to prevent path traversal
|
|
69
|
+
project_path = Path(config.home)
|
|
70
|
+
if not validate_project_path(entity.file_path, project_path):
|
|
71
|
+
logger.error(f"Invalid file path in entity {entity.id}: {entity.file_path}")
|
|
72
|
+
raise HTTPException(
|
|
73
|
+
status_code=500,
|
|
74
|
+
detail="Entity contains invalid file path",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Check file exists via file_service (for cloud compatibility)
|
|
78
|
+
if not await file_service.exists(entity.file_path):
|
|
79
|
+
raise HTTPException(
|
|
80
|
+
status_code=404,
|
|
81
|
+
detail=f"File not found: {entity.file_path}",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Read content via file_service as bytes (works with both local and S3)
|
|
85
|
+
content = await file_service.read_file_bytes(entity.file_path)
|
|
86
|
+
content_type = file_service.content_type(entity.file_path)
|
|
87
|
+
|
|
88
|
+
return Response(content=content, media_type=content_type)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@router.post("", response_model=ResourceResponse)
|
|
92
|
+
async def create_resource(
|
|
93
|
+
project_id: ProjectIdPathDep,
|
|
94
|
+
data: CreateResourceRequest,
|
|
95
|
+
config: ProjectConfigV2Dep,
|
|
96
|
+
file_service: FileServiceV2Dep,
|
|
97
|
+
entity_repository: EntityRepositoryV2Dep,
|
|
98
|
+
search_service: SearchServiceV2Dep,
|
|
99
|
+
) -> ResourceResponse:
|
|
100
|
+
"""Create a new resource file.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
project_id: Validated numeric project ID from URL path
|
|
104
|
+
data: Create resource request with file_path and content
|
|
105
|
+
config: Project configuration
|
|
106
|
+
file_service: File service for writing files
|
|
107
|
+
entity_repository: Entity repository for creating entities
|
|
108
|
+
search_service: Search service for indexing
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
ResourceResponse with file information including entity_id
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
HTTPException: 400 for invalid file paths, 409 if file already exists
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
# Validate path to prevent path traversal attacks
|
|
118
|
+
project_path = Path(config.home)
|
|
119
|
+
if not validate_project_path(data.file_path, project_path):
|
|
120
|
+
logger.warning(
|
|
121
|
+
f"Invalid file path attempted: {data.file_path} in project {config.name}"
|
|
122
|
+
)
|
|
123
|
+
raise HTTPException(
|
|
124
|
+
status_code=400,
|
|
125
|
+
detail=f"Invalid file path: {data.file_path}. "
|
|
126
|
+
"Path must be relative and stay within project boundaries.",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Check if entity already exists
|
|
130
|
+
existing_entity = await entity_repository.get_by_file_path(data.file_path)
|
|
131
|
+
if existing_entity:
|
|
132
|
+
raise HTTPException(
|
|
133
|
+
status_code=409,
|
|
134
|
+
detail=f"Resource already exists at {data.file_path} with entity_id {existing_entity.id}. "
|
|
135
|
+
f"Use PUT /resource/{existing_entity.id} to update it.",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Cloud compatibility: avoid assuming a local filesystem path.
|
|
139
|
+
# Delegate directory creation + writes to FileService (local or S3).
|
|
140
|
+
await file_service.ensure_directory(Path(data.file_path).parent)
|
|
141
|
+
checksum = await file_service.write_file(data.file_path, data.content)
|
|
142
|
+
|
|
143
|
+
# Get file info
|
|
144
|
+
file_metadata = await file_service.get_file_metadata(data.file_path)
|
|
145
|
+
|
|
146
|
+
# Determine file details
|
|
147
|
+
file_name = Path(data.file_path).name
|
|
148
|
+
content_type = file_service.content_type(data.file_path)
|
|
149
|
+
entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
|
|
150
|
+
|
|
151
|
+
# Create a new entity model
|
|
152
|
+
entity = EntityModel(
|
|
153
|
+
title=file_name,
|
|
154
|
+
entity_type=entity_type,
|
|
155
|
+
content_type=content_type,
|
|
156
|
+
file_path=data.file_path,
|
|
157
|
+
checksum=checksum,
|
|
158
|
+
created_at=file_metadata.created_at,
|
|
159
|
+
updated_at=file_metadata.modified_at,
|
|
160
|
+
)
|
|
161
|
+
entity = await entity_repository.add(entity)
|
|
162
|
+
|
|
163
|
+
# Index the file for search
|
|
164
|
+
await search_service.index_entity(entity) # pyright: ignore
|
|
165
|
+
|
|
166
|
+
# Return success response
|
|
167
|
+
return ResourceResponse(
|
|
168
|
+
entity_id=entity.id,
|
|
169
|
+
file_path=data.file_path,
|
|
170
|
+
checksum=checksum,
|
|
171
|
+
size=file_metadata.size,
|
|
172
|
+
created_at=file_metadata.created_at.timestamp(),
|
|
173
|
+
modified_at=file_metadata.modified_at.timestamp(),
|
|
174
|
+
)
|
|
175
|
+
except HTTPException:
|
|
176
|
+
# Re-raise HTTP exceptions without wrapping
|
|
177
|
+
raise
|
|
178
|
+
except Exception as e: # pragma: no cover
|
|
179
|
+
logger.error(f"Error creating resource {data.file_path}: {e}")
|
|
180
|
+
raise HTTPException(status_code=500, detail=f"Failed to create resource: {str(e)}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@router.put("/{entity_id}", response_model=ResourceResponse)
|
|
184
|
+
async def update_resource(
|
|
185
|
+
project_id: ProjectIdPathDep,
|
|
186
|
+
entity_id: int,
|
|
187
|
+
data: UpdateResourceRequest,
|
|
188
|
+
config: ProjectConfigV2Dep,
|
|
189
|
+
file_service: FileServiceV2Dep,
|
|
190
|
+
entity_repository: EntityRepositoryV2Dep,
|
|
191
|
+
search_service: SearchServiceV2Dep,
|
|
192
|
+
) -> ResourceResponse:
|
|
193
|
+
"""Update an existing resource by entity ID.
|
|
194
|
+
|
|
195
|
+
Can update content and optionally move the file to a new path.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
project_id: Validated numeric project ID from URL path
|
|
199
|
+
entity_id: Entity ID of the resource to update
|
|
200
|
+
data: Update resource request with content and optional new file_path
|
|
201
|
+
config: Project configuration
|
|
202
|
+
file_service: File service for writing files
|
|
203
|
+
entity_repository: Entity repository for updating entities
|
|
204
|
+
search_service: Search service for indexing
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
ResourceResponse with updated file information
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
HTTPException: 404 if entity not found, 400 for invalid paths
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
# Get existing entity
|
|
214
|
+
entity = await entity_repository.get_by_id(entity_id)
|
|
215
|
+
if not entity:
|
|
216
|
+
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
|
217
|
+
|
|
218
|
+
# Determine target file path
|
|
219
|
+
target_file_path = data.file_path if data.file_path else entity.file_path
|
|
220
|
+
|
|
221
|
+
# Validate path to prevent path traversal attacks
|
|
222
|
+
project_path = Path(config.home)
|
|
223
|
+
if not validate_project_path(target_file_path, project_path):
|
|
224
|
+
logger.warning(
|
|
225
|
+
f"Invalid file path attempted: {target_file_path} in project {config.name}"
|
|
226
|
+
)
|
|
227
|
+
raise HTTPException(
|
|
228
|
+
status_code=400,
|
|
229
|
+
detail=f"Invalid file path: {target_file_path}. "
|
|
230
|
+
"Path must be relative and stay within project boundaries.",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# If moving file, handle the move
|
|
234
|
+
if data.file_path and data.file_path != entity.file_path:
|
|
235
|
+
# Ensure new parent directory exists (no-op for S3)
|
|
236
|
+
await file_service.ensure_directory(Path(target_file_path).parent)
|
|
237
|
+
|
|
238
|
+
# If old file exists, remove it via file_service (for cloud compatibility)
|
|
239
|
+
if await file_service.exists(entity.file_path):
|
|
240
|
+
await file_service.delete_file(entity.file_path)
|
|
241
|
+
else:
|
|
242
|
+
# Ensure directory exists for in-place update
|
|
243
|
+
await file_service.ensure_directory(Path(target_file_path).parent)
|
|
244
|
+
|
|
245
|
+
# Write content to target file
|
|
246
|
+
checksum = await file_service.write_file(target_file_path, data.content)
|
|
247
|
+
|
|
248
|
+
# Get file info
|
|
249
|
+
file_metadata = await file_service.get_file_metadata(target_file_path)
|
|
250
|
+
|
|
251
|
+
# Determine file details
|
|
252
|
+
file_name = Path(target_file_path).name
|
|
253
|
+
content_type = file_service.content_type(target_file_path)
|
|
254
|
+
entity_type = "canvas" if target_file_path.endswith(".canvas") else "file"
|
|
255
|
+
|
|
256
|
+
# Update entity
|
|
257
|
+
updated_entity = await entity_repository.update(
|
|
258
|
+
entity_id,
|
|
259
|
+
{
|
|
260
|
+
"title": file_name,
|
|
261
|
+
"entity_type": entity_type,
|
|
262
|
+
"content_type": content_type,
|
|
263
|
+
"file_path": target_file_path,
|
|
264
|
+
"checksum": checksum,
|
|
265
|
+
"updated_at": file_metadata.modified_at,
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Index the updated file for search
|
|
270
|
+
await search_service.index_entity(updated_entity) # pyright: ignore
|
|
271
|
+
|
|
272
|
+
# Return success response
|
|
273
|
+
return ResourceResponse(
|
|
274
|
+
entity_id=entity_id,
|
|
275
|
+
file_path=target_file_path,
|
|
276
|
+
checksum=checksum,
|
|
277
|
+
size=file_metadata.size,
|
|
278
|
+
created_at=file_metadata.created_at.timestamp(),
|
|
279
|
+
modified_at=file_metadata.modified_at.timestamp(),
|
|
280
|
+
)
|
|
281
|
+
except HTTPException:
|
|
282
|
+
# Re-raise HTTP exceptions without wrapping
|
|
283
|
+
raise
|
|
284
|
+
except Exception as e: # pragma: no cover
|
|
285
|
+
logger.error(f"Error updating resource {entity_id}: {e}")
|
|
286
|
+
raise HTTPException(status_code=500, detail=f"Failed to update resource: {str(e)}")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""V2 router for search operations.
|
|
2
|
+
|
|
3
|
+
This router uses integer project IDs for stable, efficient routing.
|
|
4
|
+
V1 uses string-based project names which are less efficient and less stable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, BackgroundTasks
|
|
8
|
+
|
|
9
|
+
from basic_memory.api.routers.utils import to_search_results
|
|
10
|
+
from basic_memory.schemas.search import SearchQuery, SearchResponse
|
|
11
|
+
from basic_memory.deps import SearchServiceV2Dep, EntityServiceV2Dep, ProjectIdPathDep
|
|
12
|
+
|
|
13
|
+
# Note: No prefix here - it's added during registration as /v2/{project_id}/search
|
|
14
|
+
router = APIRouter(tags=["search"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.post("/search/", response_model=SearchResponse)
|
|
18
|
+
async def search(
|
|
19
|
+
project_id: ProjectIdPathDep,
|
|
20
|
+
query: SearchQuery,
|
|
21
|
+
search_service: SearchServiceV2Dep,
|
|
22
|
+
entity_service: EntityServiceV2Dep,
|
|
23
|
+
page: int = 1,
|
|
24
|
+
page_size: int = 10,
|
|
25
|
+
):
|
|
26
|
+
"""Search across all knowledge and documents in a project.
|
|
27
|
+
|
|
28
|
+
V2 uses integer project IDs for improved performance and stability.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
project_id: Validated numeric project ID from URL path
|
|
32
|
+
query: Search query parameters (text, filters, etc.)
|
|
33
|
+
search_service: Search service scoped to project
|
|
34
|
+
entity_service: Entity service scoped to project
|
|
35
|
+
page: Page number for pagination
|
|
36
|
+
page_size: Number of results per page
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
SearchResponse with paginated search results
|
|
40
|
+
"""
|
|
41
|
+
limit = page_size
|
|
42
|
+
offset = (page - 1) * page_size
|
|
43
|
+
results = await search_service.search(query, limit=limit, offset=offset)
|
|
44
|
+
search_results = await to_search_results(entity_service, results)
|
|
45
|
+
return SearchResponse(
|
|
46
|
+
results=search_results,
|
|
47
|
+
current_page=page,
|
|
48
|
+
page_size=page_size,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.post("/search/reindex")
|
|
53
|
+
async def reindex(
|
|
54
|
+
project_id: ProjectIdPathDep,
|
|
55
|
+
background_tasks: BackgroundTasks,
|
|
56
|
+
search_service: SearchServiceV2Dep,
|
|
57
|
+
):
|
|
58
|
+
"""Recreate and populate the search index for a project.
|
|
59
|
+
|
|
60
|
+
This is a background operation that rebuilds the search index
|
|
61
|
+
from scratch. Useful after bulk updates or if the index becomes
|
|
62
|
+
corrupted.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
project_id: Validated numeric project ID from URL path
|
|
66
|
+
background_tasks: FastAPI background tasks handler
|
|
67
|
+
search_service: Search service scoped to project
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Status message indicating reindex has been initiated
|
|
71
|
+
"""
|
|
72
|
+
await search_service.reindex_all(background_tasks=background_tasks)
|
|
73
|
+
return {"status": "ok", "message": "Reindex initiated"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI tools for basic-memory"""
|