basic-memory 0.7.0__py3-none-any.whl → 0.17.4__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.
Potentially problematic release.
This version of basic-memory might be problematic. Click here for more details.
- basic_memory/__init__.py +5 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +130 -20
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -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/6830751f5fb6_merge_multiple_heads.py +24 -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/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
- basic_memory/api/app.py +87 -20
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/__init__.py +4 -1
- 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 +180 -23
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +9 -64
- basic_memory/api/routers/project_router.py +460 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +136 -11
- basic_memory/api/routers/search_router.py +5 -5
- 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 +181 -0
- basic_memory/api/v2/routers/knowledge_router.py +427 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +359 -0
- basic_memory/api/v2/routers/prompt_router.py +269 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/app.py +80 -10
- basic_memory/cli/auth.py +300 -0
- basic_memory/cli/commands/__init__.py +15 -2
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +127 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +397 -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 +240 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +99 -0
- basic_memory/cli/commands/db.py +87 -12
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +47 -223
- basic_memory/cli/commands/import_claude_conversations.py +48 -171
- basic_memory/cli/commands/import_claude_projects.py +53 -160
- basic_memory/cli/commands/import_memory_json.py +55 -111
- basic_memory/cli/commands/mcp.py +67 -11
- basic_memory/cli/commands/project.py +889 -0
- basic_memory/cli/commands/status.py +52 -34
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +14 -6
- basic_memory/config.py +580 -26
- basic_memory/db.py +285 -28
- basic_memory/deps/__init__.py +293 -0
- basic_memory/deps/config.py +26 -0
- basic_memory/deps/db.py +56 -0
- basic_memory/deps/importers.py +200 -0
- basic_memory/deps/projects.py +238 -0
- basic_memory/deps/repositories.py +179 -0
- basic_memory/deps/services.py +480 -0
- basic_memory/deps.py +16 -185
- basic_memory/file_utils.py +318 -54
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +100 -0
- basic_memory/importers/chatgpt_importer.py +245 -0
- basic_memory/importers/claude_conversations_importer.py +192 -0
- basic_memory/importers/claude_projects_importer.py +184 -0
- basic_memory/importers/memory_json_importer.py +128 -0
- basic_memory/importers/utils.py +61 -0
- basic_memory/markdown/entity_parser.py +182 -23
- basic_memory/markdown/markdown_processor.py +70 -7
- basic_memory/markdown/plugins.py +43 -23
- basic_memory/markdown/schemas.py +1 -1
- basic_memory/markdown/utils.py +38 -14
- basic_memory/mcp/async_client.py +135 -4
- basic_memory/mcp/clients/__init__.py +28 -0
- basic_memory/mcp/clients/directory.py +70 -0
- basic_memory/mcp/clients/knowledge.py +176 -0
- basic_memory/mcp/clients/memory.py +120 -0
- basic_memory/mcp/clients/project.py +89 -0
- basic_memory/mcp/clients/resource.py +71 -0
- basic_memory/mcp/clients/search.py +65 -0
- basic_memory/mcp/container.py +110 -0
- basic_memory/mcp/project_context.py +155 -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 +61 -9
- basic_memory/mcp/tools/__init__.py +33 -21
- 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 +249 -0
- basic_memory/mcp/tools/edit_note.py +325 -0
- basic_memory/mcp/tools/list_directory.py +157 -0
- basic_memory/mcp/tools/move_note.py +549 -0
- basic_memory/mcp/tools/project_management.py +204 -0
- basic_memory/mcp/tools/read_content.py +281 -0
- basic_memory/mcp/tools/read_note.py +265 -0
- basic_memory/mcp/tools/recent_activity.py +528 -0
- basic_memory/mcp/tools/search.py +377 -24
- basic_memory/mcp/tools/utils.py +402 -16
- basic_memory/mcp/tools/view_note.py +78 -0
- basic_memory/mcp/tools/write_note.py +230 -0
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +82 -17
- basic_memory/models/project.py +93 -0
- basic_memory/models/search.py +68 -8
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +437 -8
- basic_memory/repository/observation_repository.py +36 -3
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +140 -0
- basic_memory/repository/relation_repository.py +79 -4
- basic_memory/repository/repository.py +148 -29
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +79 -268
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +437 -0
- basic_memory/runtime.py +61 -0
- basic_memory/schemas/__init__.py +22 -9
- basic_memory/schemas/base.py +131 -12
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/directory.py +31 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +194 -25
- basic_memory/schemas/project_info.py +213 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/response.py +85 -28
- basic_memory/schemas/search.py +36 -35
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +133 -0
- basic_memory/schemas/v2/resource.py +47 -0
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +451 -138
- basic_memory/services/directory_service.py +310 -0
- basic_memory/services/entity_service.py +636 -71
- basic_memory/services/exceptions.py +21 -0
- basic_memory/services/file_service.py +402 -33
- basic_memory/services/initialization.py +216 -0
- basic_memory/services/link_resolver.py +50 -56
- basic_memory/services/project_service.py +888 -0
- basic_memory/services/search_service.py +232 -37
- basic_memory/sync/__init__.py +4 -2
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +1200 -109
- basic_memory/sync/watch_service.py +432 -135
- 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 +407 -54
- basic_memory-0.17.4.dist-info/METADATA +617 -0
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
- basic_memory/alembic/README +0 -1
- basic_memory/cli/commands/sync.py +0 -206
- basic_memory/cli/commands/tools.py +0 -157
- basic_memory/mcp/tools/knowledge.py +0 -68
- basic_memory/mcp/tools/memory.py +0 -170
- basic_memory/mcp/tools/notes.py +0 -202
- basic_memory/schemas/discovery.py +0 -28
- basic_memory/sync/file_change_scanner.py +0 -158
- basic_memory/sync/utils.py +0 -31
- basic_memory-0.7.0.dist-info/METADATA +0 -378
- basic_memory-0.7.0.dist-info/RECORD +0 -82
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
"""Service for building rich context from the knowledge graph."""
|
|
2
2
|
|
|
3
|
-
from dataclasses import dataclass
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
4
|
from datetime import datetime, timezone
|
|
5
5
|
from typing import List, Optional, Tuple
|
|
6
6
|
|
|
7
|
+
|
|
7
8
|
from loguru import logger
|
|
8
9
|
from sqlalchemy import text
|
|
9
10
|
|
|
10
11
|
from basic_memory.repository.entity_repository import EntityRepository
|
|
11
|
-
from basic_memory.repository.
|
|
12
|
+
from basic_memory.repository.observation_repository import ObservationRepository
|
|
13
|
+
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
|
14
|
+
from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
|
|
12
15
|
from basic_memory.schemas.memory import MemoryUrl, memory_url_path
|
|
13
16
|
from basic_memory.schemas.search import SearchItemType
|
|
17
|
+
from basic_memory.utils import generate_permalink
|
|
14
18
|
|
|
15
19
|
|
|
16
20
|
@dataclass
|
|
@@ -31,6 +35,38 @@ class ContextResultRow:
|
|
|
31
35
|
entity_id: Optional[int] = None
|
|
32
36
|
|
|
33
37
|
|
|
38
|
+
@dataclass
|
|
39
|
+
class ContextResultItem:
|
|
40
|
+
"""A hierarchical result containing a primary item with its observations and related items."""
|
|
41
|
+
|
|
42
|
+
primary_result: ContextResultRow | SearchIndexRow
|
|
43
|
+
observations: List[ContextResultRow] = field(default_factory=list)
|
|
44
|
+
related_results: List[ContextResultRow] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ContextMetadata:
|
|
49
|
+
"""Metadata about a context result."""
|
|
50
|
+
|
|
51
|
+
uri: Optional[str] = None
|
|
52
|
+
types: Optional[List[SearchItemType]] = None
|
|
53
|
+
depth: int = 1
|
|
54
|
+
timeframe: Optional[str] = None
|
|
55
|
+
generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
56
|
+
primary_count: int = 0
|
|
57
|
+
related_count: int = 0
|
|
58
|
+
total_observations: int = 0
|
|
59
|
+
total_relations: int = 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ContextResult:
|
|
64
|
+
"""Complete context result with metadata."""
|
|
65
|
+
|
|
66
|
+
results: List[ContextResultItem] = field(default_factory=list)
|
|
67
|
+
metadata: ContextMetadata = field(default_factory=ContextMetadata)
|
|
68
|
+
|
|
69
|
+
|
|
34
70
|
class ContextService:
|
|
35
71
|
"""Service for building rich context from memory:// URIs.
|
|
36
72
|
|
|
@@ -44,9 +80,11 @@ class ContextService:
|
|
|
44
80
|
self,
|
|
45
81
|
search_repository: SearchRepository,
|
|
46
82
|
entity_repository: EntityRepository,
|
|
83
|
+
observation_repository: ObservationRepository,
|
|
47
84
|
):
|
|
48
85
|
self.search_repository = search_repository
|
|
49
86
|
self.entity_repository = entity_repository
|
|
87
|
+
self.observation_repository = observation_repository
|
|
50
88
|
|
|
51
89
|
async def build_context(
|
|
52
90
|
self,
|
|
@@ -57,31 +95,42 @@ class ContextService:
|
|
|
57
95
|
limit=10,
|
|
58
96
|
offset=0,
|
|
59
97
|
max_related: int = 10,
|
|
60
|
-
|
|
98
|
+
include_observations: bool = True,
|
|
99
|
+
) -> ContextResult:
|
|
61
100
|
"""Build rich context from a memory:// URI."""
|
|
62
101
|
logger.debug(
|
|
63
102
|
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' limit: '{limit}' offset: '{offset}' max_related: '{max_related}'"
|
|
64
103
|
)
|
|
65
104
|
|
|
105
|
+
normalized_path: Optional[str] = None
|
|
66
106
|
if memory_url:
|
|
67
107
|
path = memory_url_path(memory_url)
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
108
|
+
# Check for wildcards before normalization
|
|
109
|
+
has_wildcard = "*" in path
|
|
110
|
+
|
|
111
|
+
if has_wildcard:
|
|
112
|
+
# For wildcard patterns, normalize each segment separately to preserve the *
|
|
113
|
+
parts = path.split("*")
|
|
114
|
+
normalized_parts = [
|
|
115
|
+
generate_permalink(part, split_extension=False) if part else ""
|
|
116
|
+
for part in parts
|
|
117
|
+
]
|
|
118
|
+
normalized_path = "*".join(normalized_parts)
|
|
119
|
+
logger.debug(f"Pattern search for '{normalized_path}'")
|
|
71
120
|
primary = await self.search_repository.search(
|
|
72
|
-
permalink_match=
|
|
121
|
+
permalink_match=normalized_path, limit=limit, offset=offset
|
|
73
122
|
)
|
|
74
|
-
|
|
75
|
-
# Direct lookup for exact path
|
|
76
123
|
else:
|
|
77
|
-
|
|
124
|
+
# For exact paths, normalize the whole thing
|
|
125
|
+
normalized_path = generate_permalink(path, split_extension=False)
|
|
126
|
+
logger.debug(f"Direct lookup for '{normalized_path}'")
|
|
78
127
|
primary = await self.search_repository.search(
|
|
79
|
-
permalink=
|
|
128
|
+
permalink=normalized_path, limit=limit, offset=offset
|
|
80
129
|
)
|
|
81
130
|
else:
|
|
82
131
|
logger.debug(f"Build context for '{types}'")
|
|
83
132
|
primary = await self.search_repository.search(
|
|
84
|
-
|
|
133
|
+
search_item_types=types, after_date=since, limit=limit, offset=offset
|
|
85
134
|
)
|
|
86
135
|
|
|
87
136
|
# Get type_id pairs for traversal
|
|
@@ -94,24 +143,78 @@ class ContextService:
|
|
|
94
143
|
type_id_pairs, max_depth=depth, since=since, max_results=max_related
|
|
95
144
|
)
|
|
96
145
|
logger.debug(f"Found {len(related)} related results")
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
146
|
+
|
|
147
|
+
# Collect entity IDs from primary and related results
|
|
148
|
+
entity_ids = []
|
|
149
|
+
for result in primary:
|
|
150
|
+
if result.type == SearchItemType.ENTITY.value:
|
|
151
|
+
entity_ids.append(result.id)
|
|
152
|
+
|
|
153
|
+
for result in related:
|
|
154
|
+
if result.type == SearchItemType.ENTITY.value:
|
|
155
|
+
entity_ids.append(result.id)
|
|
156
|
+
|
|
157
|
+
# Fetch observations for all entities if requested
|
|
158
|
+
observations_by_entity = {}
|
|
159
|
+
if include_observations and entity_ids:
|
|
160
|
+
# Use our observation repository to get observations for all entities at once
|
|
161
|
+
observations_by_entity = await self.observation_repository.find_by_entities(entity_ids)
|
|
162
|
+
logger.debug(f"Found observations for {len(observations_by_entity)} entities")
|
|
163
|
+
|
|
164
|
+
# Create metadata dataclass
|
|
165
|
+
metadata = ContextMetadata(
|
|
166
|
+
uri=normalized_path if memory_url else None,
|
|
167
|
+
types=types,
|
|
168
|
+
depth=depth,
|
|
169
|
+
timeframe=since.isoformat() if since else None,
|
|
170
|
+
primary_count=len(primary),
|
|
171
|
+
related_count=len(related),
|
|
172
|
+
total_observations=sum(len(obs) for obs in observations_by_entity.values()),
|
|
173
|
+
total_relations=sum(1 for r in related if r.type == SearchItemType.RELATION),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Build context results list directly with ContextResultItem objects
|
|
177
|
+
context_results = []
|
|
178
|
+
|
|
179
|
+
# For each primary result
|
|
180
|
+
for primary_item in primary:
|
|
181
|
+
# Find all related items with this primary item as root
|
|
182
|
+
related_to_primary = [r for r in related if r.root_id == primary_item.id]
|
|
183
|
+
|
|
184
|
+
# Get observations for this item if it's an entity
|
|
185
|
+
item_observations = []
|
|
186
|
+
if primary_item.type == SearchItemType.ENTITY.value and include_observations:
|
|
187
|
+
# Convert Observation models to ContextResultRows
|
|
188
|
+
for obs in observations_by_entity.get(primary_item.id, []):
|
|
189
|
+
item_observations.append(
|
|
190
|
+
ContextResultRow(
|
|
191
|
+
type="observation",
|
|
192
|
+
id=obs.id,
|
|
193
|
+
title=f"{obs.category}: {obs.content[:50]}...",
|
|
194
|
+
permalink=generate_permalink(
|
|
195
|
+
f"{primary_item.permalink}/observations/{obs.category}/{obs.content}"
|
|
196
|
+
),
|
|
197
|
+
file_path=primary_item.file_path,
|
|
198
|
+
content=obs.content,
|
|
199
|
+
category=obs.category,
|
|
200
|
+
entity_id=primary_item.id,
|
|
201
|
+
depth=0,
|
|
202
|
+
root_id=primary_item.id,
|
|
203
|
+
created_at=primary_item.created_at, # created_at time from entity
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Create ContextResultItem directly
|
|
208
|
+
context_item = ContextResultItem(
|
|
209
|
+
primary_result=primary_item,
|
|
210
|
+
observations=item_observations,
|
|
211
|
+
related_results=related_to_primary,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
context_results.append(context_item)
|
|
215
|
+
|
|
216
|
+
# Return the structured ContextResult
|
|
217
|
+
return ContextResult(results=context_results, metadata=metadata)
|
|
115
218
|
|
|
116
219
|
async def find_related(
|
|
117
220
|
self,
|
|
@@ -124,7 +227,6 @@ class ContextService:
|
|
|
124
227
|
|
|
125
228
|
Uses recursive CTE to find:
|
|
126
229
|
- Connected entities
|
|
127
|
-
- Their observations
|
|
128
230
|
- Relations that connect them
|
|
129
231
|
|
|
130
232
|
Note on depth:
|
|
@@ -138,107 +240,344 @@ class ContextService:
|
|
|
138
240
|
if not type_id_pairs:
|
|
139
241
|
return []
|
|
140
242
|
|
|
141
|
-
|
|
243
|
+
# Extract entity IDs from type_id_pairs for the optimized query
|
|
244
|
+
entity_ids = [i for t, i in type_id_pairs if t == "entity"]
|
|
245
|
+
|
|
246
|
+
if not entity_ids:
|
|
247
|
+
logger.debug("No entity IDs found in type_id_pairs")
|
|
248
|
+
return []
|
|
249
|
+
|
|
250
|
+
logger.debug(
|
|
251
|
+
f"Finding connected items for {len(entity_ids)} entities with depth {max_depth}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Build the VALUES clause for entity IDs
|
|
255
|
+
entity_id_values = ", ".join([str(i) for i in entity_ids])
|
|
142
256
|
|
|
143
|
-
#
|
|
144
|
-
|
|
257
|
+
# Parameters for bindings - include project_id for security filtering
|
|
258
|
+
params = {
|
|
259
|
+
"max_depth": max_depth,
|
|
260
|
+
"max_results": max_results,
|
|
261
|
+
"project_id": self.search_repository.project_id,
|
|
262
|
+
}
|
|
145
263
|
|
|
146
|
-
#
|
|
147
|
-
params = {"max_depth": max_depth, "max_results": max_results}
|
|
264
|
+
# Build date and timeframe filters conditionally based on since parameter
|
|
148
265
|
if since:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
266
|
+
# SQLite accepts ISO strings, but Postgres/asyncpg requires datetime objects
|
|
267
|
+
if isinstance(self.search_repository, PostgresSearchRepository): # pragma: no cover
|
|
268
|
+
# asyncpg expects timezone-NAIVE datetime in UTC for DateTime(timezone=True) columns
|
|
269
|
+
# even though the column stores timezone-aware values
|
|
270
|
+
since_utc = since.astimezone(timezone.utc) if since.tzinfo else since # pragma: no cover
|
|
271
|
+
params["since_date"] = since_utc.replace(tzinfo=None) # pyright: ignore # pragma: no cover
|
|
272
|
+
else:
|
|
273
|
+
params["since_date"] = since.isoformat() # pyright: ignore
|
|
274
|
+
date_filter = "AND e.created_at >= :since_date"
|
|
275
|
+
relation_date_filter = "AND e_from.created_at >= :since_date"
|
|
276
|
+
timeframe_condition = "AND eg.relation_date >= :since_date"
|
|
277
|
+
else:
|
|
278
|
+
date_filter = ""
|
|
279
|
+
relation_date_filter = ""
|
|
280
|
+
timeframe_condition = ""
|
|
281
|
+
|
|
282
|
+
# Add project filtering for security - ensure all entities and relations belong to the same project
|
|
283
|
+
project_filter = "AND e.project_id = :project_id"
|
|
284
|
+
relation_project_filter = "AND e_from.project_id = :project_id"
|
|
285
|
+
|
|
286
|
+
# Use a CTE that operates directly on entity and relation tables
|
|
287
|
+
# This avoids the overhead of the search_index virtual table
|
|
288
|
+
# Note: Postgres and SQLite have different CTE limitations:
|
|
289
|
+
# - Postgres: doesn't allow multiple UNION ALL branches referencing the CTE
|
|
290
|
+
# - SQLite: doesn't support LATERAL joins
|
|
291
|
+
# So we need different queries for each database backend
|
|
292
|
+
|
|
293
|
+
# Detect database backend
|
|
294
|
+
is_postgres = isinstance(self.search_repository, PostgresSearchRepository)
|
|
295
|
+
|
|
296
|
+
if is_postgres: # pragma: no cover
|
|
297
|
+
query = self._build_postgres_query(
|
|
298
|
+
entity_id_values,
|
|
299
|
+
date_filter,
|
|
300
|
+
project_filter,
|
|
301
|
+
relation_date_filter,
|
|
302
|
+
relation_project_filter,
|
|
303
|
+
timeframe_condition,
|
|
304
|
+
)
|
|
305
|
+
else:
|
|
306
|
+
# SQLite needs VALUES clause for exclusion (not needed for Postgres)
|
|
307
|
+
values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
|
|
308
|
+
query = self._build_sqlite_query(
|
|
309
|
+
entity_id_values,
|
|
310
|
+
date_filter,
|
|
311
|
+
project_filter,
|
|
312
|
+
relation_date_filter,
|
|
313
|
+
relation_project_filter,
|
|
314
|
+
timeframe_condition,
|
|
315
|
+
values,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
result = await self.search_repository.execute_query(query, params=params)
|
|
319
|
+
rows = result.all()
|
|
320
|
+
|
|
321
|
+
context_rows = [
|
|
322
|
+
ContextResultRow(
|
|
323
|
+
type=row.type,
|
|
324
|
+
id=row.id,
|
|
325
|
+
title=row.title,
|
|
326
|
+
permalink=row.permalink,
|
|
327
|
+
file_path=row.file_path,
|
|
328
|
+
from_id=row.from_id,
|
|
329
|
+
to_id=row.to_id,
|
|
330
|
+
relation_type=row.relation_type,
|
|
331
|
+
content=row.content,
|
|
332
|
+
category=row.category,
|
|
333
|
+
entity_id=row.entity_id,
|
|
334
|
+
depth=row.depth,
|
|
335
|
+
root_id=row.root_id,
|
|
336
|
+
created_at=row.created_at,
|
|
337
|
+
)
|
|
338
|
+
for row in rows
|
|
339
|
+
]
|
|
340
|
+
return context_rows
|
|
341
|
+
|
|
342
|
+
def _build_postgres_query( # pragma: no cover
|
|
343
|
+
self,
|
|
344
|
+
entity_id_values: str,
|
|
345
|
+
date_filter: str,
|
|
346
|
+
project_filter: str,
|
|
347
|
+
relation_date_filter: str,
|
|
348
|
+
relation_project_filter: str,
|
|
349
|
+
timeframe_condition: str,
|
|
350
|
+
):
|
|
351
|
+
"""Build Postgres-specific CTE query using LATERAL joins."""
|
|
352
|
+
return text(f"""
|
|
353
|
+
WITH RECURSIVE entity_graph AS (
|
|
354
|
+
-- Base case: seed entities
|
|
355
|
+
SELECT
|
|
356
|
+
e.id,
|
|
357
|
+
'entity' as type,
|
|
358
|
+
e.title,
|
|
359
|
+
e.permalink,
|
|
360
|
+
e.file_path,
|
|
361
|
+
CAST(NULL AS INTEGER) as from_id,
|
|
362
|
+
CAST(NULL AS INTEGER) as to_id,
|
|
363
|
+
CAST(NULL AS TEXT) as relation_type,
|
|
364
|
+
CAST(NULL AS TEXT) as content,
|
|
365
|
+
CAST(NULL AS TEXT) as category,
|
|
366
|
+
CAST(NULL AS INTEGER) as entity_id,
|
|
171
367
|
0 as depth,
|
|
172
|
-
id as root_id,
|
|
173
|
-
created_at,
|
|
174
|
-
created_at as relation_date
|
|
368
|
+
e.id as root_id,
|
|
369
|
+
e.created_at,
|
|
370
|
+
e.created_at as relation_date
|
|
371
|
+
FROM entity e
|
|
372
|
+
WHERE e.id IN ({entity_id_values})
|
|
373
|
+
{date_filter}
|
|
374
|
+
{project_filter}
|
|
375
|
+
|
|
376
|
+
UNION ALL
|
|
377
|
+
|
|
378
|
+
-- Fetch BOTH relations AND connected entities in a single recursive step
|
|
379
|
+
-- Postgres only allows ONE reference to the recursive CTE in the recursive term
|
|
380
|
+
-- We use CROSS JOIN LATERAL to generate two rows (relation + entity) from each traversal
|
|
381
|
+
SELECT
|
|
382
|
+
CASE
|
|
383
|
+
WHEN step_type = 1 THEN r.id
|
|
384
|
+
ELSE e.id
|
|
385
|
+
END as id,
|
|
386
|
+
CASE
|
|
387
|
+
WHEN step_type = 1 THEN 'relation'
|
|
388
|
+
ELSE 'entity'
|
|
389
|
+
END as type,
|
|
390
|
+
CASE
|
|
391
|
+
WHEN step_type = 1 THEN r.relation_type || ': ' || r.to_name
|
|
392
|
+
ELSE e.title
|
|
393
|
+
END as title,
|
|
394
|
+
CASE
|
|
395
|
+
WHEN step_type = 1 THEN ''
|
|
396
|
+
ELSE COALESCE(e.permalink, '')
|
|
397
|
+
END as permalink,
|
|
398
|
+
CASE
|
|
399
|
+
WHEN step_type = 1 THEN e_from.file_path
|
|
400
|
+
ELSE e.file_path
|
|
401
|
+
END as file_path,
|
|
402
|
+
CASE
|
|
403
|
+
WHEN step_type = 1 THEN r.from_id
|
|
404
|
+
ELSE NULL
|
|
405
|
+
END as from_id,
|
|
406
|
+
CASE
|
|
407
|
+
WHEN step_type = 1 THEN r.to_id
|
|
408
|
+
ELSE NULL
|
|
409
|
+
END as to_id,
|
|
410
|
+
CASE
|
|
411
|
+
WHEN step_type = 1 THEN r.relation_type
|
|
412
|
+
ELSE NULL
|
|
413
|
+
END as relation_type,
|
|
414
|
+
CAST(NULL AS TEXT) as content,
|
|
415
|
+
CAST(NULL AS TEXT) as category,
|
|
416
|
+
CAST(NULL AS INTEGER) as entity_id,
|
|
417
|
+
eg.depth + step_type as depth,
|
|
418
|
+
eg.root_id,
|
|
419
|
+
CASE
|
|
420
|
+
WHEN step_type = 1 THEN e_from.created_at
|
|
421
|
+
ELSE e.created_at
|
|
422
|
+
END as created_at,
|
|
423
|
+
CASE
|
|
424
|
+
WHEN step_type = 1 THEN e_from.created_at
|
|
425
|
+
ELSE eg.relation_date
|
|
426
|
+
END as relation_date
|
|
427
|
+
FROM entity_graph eg
|
|
428
|
+
CROSS JOIN LATERAL (VALUES (1), (2)) AS steps(step_type)
|
|
429
|
+
JOIN relation r ON (
|
|
430
|
+
eg.type = 'entity' AND
|
|
431
|
+
(r.from_id = eg.id OR r.to_id = eg.id)
|
|
432
|
+
)
|
|
433
|
+
JOIN entity e_from ON (
|
|
434
|
+
r.from_id = e_from.id
|
|
435
|
+
{relation_project_filter}
|
|
436
|
+
)
|
|
437
|
+
LEFT JOIN entity e ON (
|
|
438
|
+
step_type = 2 AND
|
|
439
|
+
e.id = CASE
|
|
440
|
+
WHEN r.from_id = eg.id THEN r.to_id
|
|
441
|
+
ELSE r.from_id
|
|
442
|
+
END
|
|
443
|
+
{date_filter}
|
|
444
|
+
{project_filter}
|
|
445
|
+
)
|
|
446
|
+
WHERE eg.depth < :max_depth
|
|
447
|
+
AND (step_type = 1 OR (step_type = 2 AND e.id IS NOT NULL AND e.id != eg.id))
|
|
448
|
+
{timeframe_condition}
|
|
449
|
+
)
|
|
450
|
+
-- Materialize and filter
|
|
451
|
+
SELECT DISTINCT
|
|
452
|
+
type,
|
|
453
|
+
id,
|
|
454
|
+
title,
|
|
455
|
+
permalink,
|
|
456
|
+
file_path,
|
|
457
|
+
from_id,
|
|
458
|
+
to_id,
|
|
459
|
+
relation_type,
|
|
460
|
+
content,
|
|
461
|
+
category,
|
|
462
|
+
entity_id,
|
|
463
|
+
MIN(depth) as depth,
|
|
464
|
+
root_id,
|
|
465
|
+
created_at
|
|
466
|
+
FROM entity_graph
|
|
467
|
+
WHERE depth > 0
|
|
468
|
+
GROUP BY type, id, title, permalink, file_path, from_id, to_id,
|
|
469
|
+
relation_type, content, category, entity_id, root_id, created_at
|
|
470
|
+
ORDER BY depth, type, id
|
|
471
|
+
LIMIT :max_results
|
|
472
|
+
""")
|
|
473
|
+
|
|
474
|
+
def _build_sqlite_query(
|
|
475
|
+
self,
|
|
476
|
+
entity_id_values: str,
|
|
477
|
+
date_filter: str,
|
|
478
|
+
project_filter: str,
|
|
479
|
+
relation_date_filter: str,
|
|
480
|
+
relation_project_filter: str,
|
|
481
|
+
timeframe_condition: str,
|
|
482
|
+
values: str,
|
|
483
|
+
):
|
|
484
|
+
"""Build SQLite-specific CTE query using multiple UNION ALL branches."""
|
|
485
|
+
return text(f"""
|
|
486
|
+
WITH RECURSIVE entity_graph AS (
|
|
487
|
+
-- Base case: seed entities
|
|
488
|
+
SELECT
|
|
489
|
+
e.id,
|
|
490
|
+
'entity' as type,
|
|
491
|
+
e.title,
|
|
492
|
+
e.permalink,
|
|
493
|
+
e.file_path,
|
|
494
|
+
NULL as from_id,
|
|
495
|
+
NULL as to_id,
|
|
496
|
+
NULL as relation_type,
|
|
497
|
+
NULL as content,
|
|
498
|
+
NULL as category,
|
|
499
|
+
NULL as entity_id,
|
|
500
|
+
0 as depth,
|
|
501
|
+
e.id as root_id,
|
|
502
|
+
e.created_at,
|
|
503
|
+
e.created_at as relation_date,
|
|
175
504
|
0 as is_incoming
|
|
176
|
-
FROM
|
|
177
|
-
WHERE
|
|
505
|
+
FROM entity e
|
|
506
|
+
WHERE e.id IN ({entity_id_values})
|
|
178
507
|
{date_filter}
|
|
508
|
+
{project_filter}
|
|
179
509
|
|
|
180
|
-
UNION ALL
|
|
510
|
+
UNION ALL
|
|
181
511
|
|
|
182
|
-
-- Get relations from current entities
|
|
183
|
-
SELECT
|
|
512
|
+
-- Get relations from current entities
|
|
513
|
+
SELECT
|
|
184
514
|
r.id,
|
|
185
|
-
|
|
186
|
-
r.title,
|
|
187
|
-
|
|
188
|
-
|
|
515
|
+
'relation' as type,
|
|
516
|
+
r.relation_type || ': ' || r.to_name as title,
|
|
517
|
+
'' as permalink,
|
|
518
|
+
e_from.file_path,
|
|
189
519
|
r.from_id,
|
|
190
520
|
r.to_id,
|
|
191
521
|
r.relation_type,
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
CASE WHEN r.from_id =
|
|
200
|
-
FROM
|
|
201
|
-
JOIN
|
|
202
|
-
|
|
203
|
-
r.
|
|
204
|
-
|
|
205
|
-
|
|
522
|
+
NULL as content,
|
|
523
|
+
NULL as category,
|
|
524
|
+
NULL as entity_id,
|
|
525
|
+
eg.depth + 1,
|
|
526
|
+
eg.root_id,
|
|
527
|
+
e_from.created_at,
|
|
528
|
+
e_from.created_at as relation_date,
|
|
529
|
+
CASE WHEN r.from_id = eg.id THEN 0 ELSE 1 END as is_incoming
|
|
530
|
+
FROM entity_graph eg
|
|
531
|
+
JOIN relation r ON (
|
|
532
|
+
eg.type = 'entity' AND
|
|
533
|
+
(r.from_id = eg.id OR r.to_id = eg.id)
|
|
534
|
+
)
|
|
535
|
+
JOIN entity e_from ON (
|
|
536
|
+
r.from_id = e_from.id
|
|
537
|
+
{relation_date_filter}
|
|
538
|
+
{relation_project_filter}
|
|
206
539
|
)
|
|
207
|
-
|
|
540
|
+
LEFT JOIN entity e_to ON (r.to_id = e_to.id)
|
|
541
|
+
WHERE eg.depth < :max_depth
|
|
542
|
+
AND (r.to_id IS NULL OR e_to.project_id = :project_id)
|
|
208
543
|
|
|
209
544
|
UNION ALL
|
|
210
545
|
|
|
211
546
|
-- Get entities connected by relations
|
|
212
|
-
SELECT
|
|
547
|
+
SELECT
|
|
213
548
|
e.id,
|
|
214
|
-
|
|
549
|
+
'entity' as type,
|
|
215
550
|
e.title,
|
|
216
|
-
|
|
551
|
+
CASE
|
|
552
|
+
WHEN e.permalink IS NULL THEN ''
|
|
553
|
+
ELSE e.permalink
|
|
554
|
+
END as permalink,
|
|
217
555
|
e.file_path,
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
556
|
+
NULL as from_id,
|
|
557
|
+
NULL as to_id,
|
|
558
|
+
NULL as relation_type,
|
|
559
|
+
NULL as content,
|
|
560
|
+
NULL as category,
|
|
561
|
+
NULL as entity_id,
|
|
562
|
+
eg.depth + 1,
|
|
563
|
+
eg.root_id,
|
|
226
564
|
e.created_at,
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
FROM
|
|
230
|
-
JOIN
|
|
231
|
-
|
|
232
|
-
e.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
ELSE cg.from_id
|
|
565
|
+
eg.relation_date,
|
|
566
|
+
eg.is_incoming
|
|
567
|
+
FROM entity_graph eg
|
|
568
|
+
JOIN entity e ON (
|
|
569
|
+
eg.type = 'relation' AND
|
|
570
|
+
e.id = CASE
|
|
571
|
+
WHEN eg.is_incoming = 0 THEN eg.to_id
|
|
572
|
+
ELSE eg.from_id
|
|
236
573
|
END
|
|
237
|
-
{
|
|
574
|
+
{date_filter}
|
|
575
|
+
{project_filter}
|
|
238
576
|
)
|
|
239
|
-
WHERE
|
|
577
|
+
WHERE eg.depth < :max_depth
|
|
578
|
+
{timeframe_condition}
|
|
240
579
|
)
|
|
241
|
-
SELECT DISTINCT
|
|
580
|
+
SELECT DISTINCT
|
|
242
581
|
type,
|
|
243
582
|
id,
|
|
244
583
|
title,
|
|
@@ -253,36 +592,10 @@ class ContextService:
|
|
|
253
592
|
MIN(depth) as depth,
|
|
254
593
|
root_id,
|
|
255
594
|
created_at
|
|
256
|
-
FROM
|
|
257
|
-
WHERE
|
|
258
|
-
GROUP BY
|
|
259
|
-
|
|
260
|
-
relation_type, category, entity_id,
|
|
261
|
-
root_id, created_at
|
|
595
|
+
FROM entity_graph
|
|
596
|
+
WHERE depth > 0
|
|
597
|
+
GROUP BY type, id, title, permalink, file_path, from_id, to_id,
|
|
598
|
+
relation_type, content, category, entity_id, root_id, created_at
|
|
262
599
|
ORDER BY depth, type, id
|
|
263
600
|
LIMIT :max_results
|
|
264
601
|
""")
|
|
265
|
-
|
|
266
|
-
result = await self.search_repository.execute_query(query, params=params)
|
|
267
|
-
rows = result.all()
|
|
268
|
-
|
|
269
|
-
context_rows = [
|
|
270
|
-
ContextResultRow(
|
|
271
|
-
type=row.type,
|
|
272
|
-
id=row.id,
|
|
273
|
-
title=row.title,
|
|
274
|
-
permalink=row.permalink,
|
|
275
|
-
file_path=row.file_path,
|
|
276
|
-
from_id=row.from_id,
|
|
277
|
-
to_id=row.to_id,
|
|
278
|
-
relation_type=row.relation_type,
|
|
279
|
-
content=row.content,
|
|
280
|
-
category=row.category,
|
|
281
|
-
entity_id=row.entity_id,
|
|
282
|
-
depth=row.depth,
|
|
283
|
-
root_id=row.root_id,
|
|
284
|
-
created_at=row.created_at,
|
|
285
|
-
)
|
|
286
|
-
for row in rows
|
|
287
|
-
]
|
|
288
|
-
return context_rows
|