basic-memory 0.1.1__py3-none-any.whl → 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of basic-memory might be problematic. Click here for more details.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/README +1 -0
- basic_memory/alembic/env.py +75 -0
- basic_memory/alembic/migrations.py +29 -0
- basic_memory/alembic/script.py.mako +26 -0
- basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
- basic_memory/api/__init__.py +2 -1
- basic_memory/api/app.py +26 -24
- basic_memory/api/routers/knowledge_router.py +28 -26
- basic_memory/api/routers/memory_router.py +17 -11
- basic_memory/api/routers/search_router.py +6 -12
- basic_memory/cli/__init__.py +1 -1
- basic_memory/cli/app.py +0 -1
- basic_memory/cli/commands/__init__.py +3 -3
- basic_memory/cli/commands/db.py +25 -0
- basic_memory/cli/commands/import_memory_json.py +35 -31
- basic_memory/cli/commands/mcp.py +20 -0
- basic_memory/cli/commands/status.py +10 -6
- basic_memory/cli/commands/sync.py +5 -56
- basic_memory/cli/main.py +5 -38
- basic_memory/config.py +3 -3
- basic_memory/db.py +15 -22
- basic_memory/deps.py +3 -4
- basic_memory/file_utils.py +36 -35
- basic_memory/markdown/entity_parser.py +13 -30
- basic_memory/markdown/markdown_processor.py +7 -7
- basic_memory/markdown/plugins.py +109 -123
- basic_memory/markdown/schemas.py +7 -8
- basic_memory/markdown/utils.py +70 -121
- basic_memory/mcp/__init__.py +1 -1
- basic_memory/mcp/async_client.py +0 -2
- basic_memory/mcp/server.py +3 -27
- basic_memory/mcp/tools/__init__.py +5 -3
- basic_memory/mcp/tools/knowledge.py +2 -2
- basic_memory/mcp/tools/memory.py +8 -4
- basic_memory/mcp/tools/search.py +2 -1
- basic_memory/mcp/tools/utils.py +1 -1
- basic_memory/models/__init__.py +1 -2
- basic_memory/models/base.py +3 -3
- basic_memory/models/knowledge.py +23 -60
- basic_memory/models/search.py +1 -1
- basic_memory/repository/__init__.py +5 -3
- basic_memory/repository/entity_repository.py +34 -98
- basic_memory/repository/relation_repository.py +0 -7
- basic_memory/repository/repository.py +2 -39
- basic_memory/repository/search_repository.py +20 -25
- basic_memory/schemas/__init__.py +4 -4
- basic_memory/schemas/base.py +21 -62
- basic_memory/schemas/delete.py +2 -3
- basic_memory/schemas/discovery.py +4 -1
- basic_memory/schemas/memory.py +12 -13
- basic_memory/schemas/request.py +4 -23
- basic_memory/schemas/response.py +10 -9
- basic_memory/schemas/search.py +4 -7
- basic_memory/services/__init__.py +2 -7
- basic_memory/services/context_service.py +116 -110
- basic_memory/services/entity_service.py +25 -62
- basic_memory/services/exceptions.py +1 -0
- basic_memory/services/file_service.py +73 -109
- basic_memory/services/link_resolver.py +9 -9
- basic_memory/services/search_service.py +22 -15
- basic_memory/services/service.py +3 -24
- basic_memory/sync/__init__.py +2 -2
- basic_memory/sync/file_change_scanner.py +3 -7
- basic_memory/sync/sync_service.py +35 -40
- basic_memory/sync/utils.py +6 -38
- basic_memory/sync/watch_service.py +26 -5
- basic_memory/utils.py +42 -33
- {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/METADATA +2 -7
- basic_memory-0.1.2.dist-info/RECORD +78 -0
- basic_memory/mcp/main.py +0 -21
- basic_memory/mcp/tools/ai_edit.py +0 -84
- basic_memory/services/database_service.py +0 -159
- basic_memory-0.1.1.dist-info/RECORD +0 -74
- {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/WHEEL +0 -0
- {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
"""Service for managing entities in the database."""
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import Sequence, List, Optional
|
|
4
|
+
from typing import Sequence, List, Optional, Tuple, Union
|
|
5
5
|
|
|
6
6
|
import frontmatter
|
|
7
|
-
from frontmatter import Post
|
|
8
7
|
from loguru import logger
|
|
9
8
|
from sqlalchemy.exc import IntegrityError
|
|
10
9
|
|
|
@@ -14,6 +13,7 @@ from basic_memory.models import Entity as EntityModel, Observation, Relation
|
|
|
14
13
|
from basic_memory.repository import ObservationRepository, RelationRepository
|
|
15
14
|
from basic_memory.repository.entity_repository import EntityRepository
|
|
16
15
|
from basic_memory.schemas import Entity as EntitySchema
|
|
16
|
+
from basic_memory.schemas.base import Permalink
|
|
17
17
|
from basic_memory.services.exceptions import EntityNotFoundError, EntityCreationError
|
|
18
18
|
from basic_memory.services import FileService
|
|
19
19
|
from basic_memory.services import BaseService
|
|
@@ -42,9 +42,7 @@ class EntityService(BaseService[EntityModel]):
|
|
|
42
42
|
self.link_resolver = link_resolver
|
|
43
43
|
|
|
44
44
|
async def resolve_permalink(
|
|
45
|
-
|
|
46
|
-
file_path: Path,
|
|
47
|
-
markdown: Optional[EntityMarkdown] = None
|
|
45
|
+
self, file_path: Permalink | Path, markdown: Optional[EntityMarkdown] = None
|
|
48
46
|
) -> str:
|
|
49
47
|
"""Get or generate unique permalink for an entity.
|
|
50
48
|
|
|
@@ -54,19 +52,17 @@ class EntityService(BaseService[EntityModel]):
|
|
|
54
52
|
3. For existing files, keep current permalink from db
|
|
55
53
|
4. Generate new unique permalink from file path
|
|
56
54
|
"""
|
|
57
|
-
file_path = str(file_path)
|
|
58
|
-
|
|
59
55
|
# If markdown has explicit permalink, try to validate it
|
|
60
56
|
if markdown and markdown.frontmatter.permalink:
|
|
61
57
|
desired_permalink = markdown.frontmatter.permalink
|
|
62
58
|
existing = await self.repository.get_by_permalink(desired_permalink)
|
|
63
59
|
|
|
64
60
|
# If no conflict or it's our own file, use as is
|
|
65
|
-
if not existing or existing.file_path == file_path:
|
|
61
|
+
if not existing or existing.file_path == str(file_path):
|
|
66
62
|
return desired_permalink
|
|
67
63
|
|
|
68
64
|
# For existing files, try to find current permalink
|
|
69
|
-
existing = await self.repository.get_by_file_path(file_path)
|
|
65
|
+
existing = await self.repository.get_by_file_path(str(file_path))
|
|
70
66
|
if existing:
|
|
71
67
|
return existing.permalink
|
|
72
68
|
|
|
@@ -85,12 +81,11 @@ class EntityService(BaseService[EntityModel]):
|
|
|
85
81
|
logger.debug(f"creating unique permalink: {permalink}")
|
|
86
82
|
|
|
87
83
|
return permalink
|
|
88
|
-
|
|
89
|
-
async def create_or_update_entity(self, schema: EntitySchema) ->
|
|
84
|
+
|
|
85
|
+
async def create_or_update_entity(self, schema: EntitySchema) -> Tuple[EntityModel, bool]:
|
|
90
86
|
"""Create new entity or update existing one.
|
|
91
|
-
|
|
87
|
+
Returns: (entity, is_new) where is_new is True if a new entity was created
|
|
92
88
|
"""
|
|
93
|
-
|
|
94
89
|
logger.debug(f"Creating or updating entity: {schema}")
|
|
95
90
|
|
|
96
91
|
# Try to find existing entity using smart resolution
|
|
@@ -107,7 +102,7 @@ class EntityService(BaseService[EntityModel]):
|
|
|
107
102
|
"""Create a new entity and write to filesystem."""
|
|
108
103
|
logger.debug(f"Creating entity: {schema.permalink}")
|
|
109
104
|
|
|
110
|
-
#
|
|
105
|
+
# Get file path and ensure it's a Path object
|
|
111
106
|
file_path = Path(schema.file_path)
|
|
112
107
|
|
|
113
108
|
if await self.file_service.exists(file_path):
|
|
@@ -127,11 +122,9 @@ class EntityService(BaseService[EntityModel]):
|
|
|
127
122
|
|
|
128
123
|
# parse entity from file
|
|
129
124
|
entity_markdown = await self.entity_parser.parse_file(file_path)
|
|
130
|
-
|
|
125
|
+
|
|
131
126
|
# create entity
|
|
132
|
-
|
|
133
|
-
file_path, entity_markdown
|
|
134
|
-
)
|
|
127
|
+
await self.create_entity_from_markdown(file_path, entity_markdown)
|
|
135
128
|
|
|
136
129
|
# add relations
|
|
137
130
|
entity = await self.update_entity_relations(file_path, entity_markdown)
|
|
@@ -139,12 +132,11 @@ class EntityService(BaseService[EntityModel]):
|
|
|
139
132
|
# Set final checksum to mark complete
|
|
140
133
|
return await self.repository.update(entity.id, {"checksum": checksum})
|
|
141
134
|
|
|
142
|
-
|
|
143
135
|
async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> EntityModel:
|
|
144
136
|
"""Update an entity's content and metadata."""
|
|
145
137
|
logger.debug(f"Updating entity with permalink: {entity.permalink}")
|
|
146
138
|
|
|
147
|
-
#
|
|
139
|
+
# Convert file path string to Path
|
|
148
140
|
file_path = Path(entity.file_path)
|
|
149
141
|
|
|
150
142
|
post = await schema_to_markdown(schema)
|
|
@@ -157,9 +149,7 @@ class EntityService(BaseService[EntityModel]):
|
|
|
157
149
|
entity_markdown = await self.entity_parser.parse_file(file_path)
|
|
158
150
|
|
|
159
151
|
# update entity in db
|
|
160
|
-
entity = await self.update_entity_and_observations(
|
|
161
|
-
file_path, entity_markdown
|
|
162
|
-
)
|
|
152
|
+
entity = await self.update_entity_and_observations(file_path, entity_markdown)
|
|
163
153
|
|
|
164
154
|
# add relations
|
|
165
155
|
await self.update_entity_relations(file_path, entity_markdown)
|
|
@@ -187,10 +177,6 @@ class EntityService(BaseService[EntityModel]):
|
|
|
187
177
|
logger.info(f"Entity not found: {permalink}")
|
|
188
178
|
return True # Already deleted
|
|
189
179
|
|
|
190
|
-
except Exception as e:
|
|
191
|
-
logger.error(f"Failed to delete entity: {e}")
|
|
192
|
-
raise
|
|
193
|
-
|
|
194
180
|
async def get_by_permalink(self, permalink: str) -> EntityModel:
|
|
195
181
|
"""Get entity by type and name combination."""
|
|
196
182
|
logger.debug(f"Getting entity by permalink: {permalink}")
|
|
@@ -199,32 +185,14 @@ class EntityService(BaseService[EntityModel]):
|
|
|
199
185
|
raise EntityNotFoundError(f"Entity not found: {permalink}")
|
|
200
186
|
return db_entity
|
|
201
187
|
|
|
202
|
-
async def get_all(self) -> Sequence[EntityModel]:
|
|
203
|
-
"""Get all entities."""
|
|
204
|
-
return await self.repository.find_all()
|
|
205
|
-
|
|
206
|
-
async def get_entity_types(self) -> List[str]:
|
|
207
|
-
"""Get list of all distinct entity types in the system."""
|
|
208
|
-
logger.debug("Getting all distinct entity types")
|
|
209
|
-
return await self.repository.get_entity_types()
|
|
210
|
-
|
|
211
|
-
async def list_entities(
|
|
212
|
-
self,
|
|
213
|
-
entity_type: Optional[str] = None,
|
|
214
|
-
sort_by: Optional[str] = "updated_at",
|
|
215
|
-
include_related: bool = False,
|
|
216
|
-
) -> Sequence[EntityModel]:
|
|
217
|
-
"""List entities with optional filtering and sorting."""
|
|
218
|
-
logger.debug(f"Listing entities: type={entity_type} sort={sort_by}")
|
|
219
|
-
return await self.repository.list_entities(entity_type=entity_type, sort_by=sort_by)
|
|
220
|
-
|
|
221
188
|
async def get_entities_by_permalinks(self, permalinks: List[str]) -> Sequence[EntityModel]:
|
|
222
189
|
"""Get specific nodes and their relationships."""
|
|
223
190
|
logger.debug(f"Getting entities permalinks: {permalinks}")
|
|
224
191
|
return await self.repository.find_by_permalinks(permalinks)
|
|
225
192
|
|
|
226
|
-
async def delete_entity_by_file_path(self, file_path):
|
|
227
|
-
|
|
193
|
+
async def delete_entity_by_file_path(self, file_path: Union[str, Path]) -> None:
|
|
194
|
+
"""Delete entity by file path."""
|
|
195
|
+
await self.repository.delete_by_file_path(str(file_path))
|
|
228
196
|
|
|
229
197
|
async def create_entity_from_markdown(
|
|
230
198
|
self, file_path: Path, markdown: EntityMarkdown
|
|
@@ -234,15 +202,15 @@ class EntityService(BaseService[EntityModel]):
|
|
|
234
202
|
Creates the entity with null checksum to indicate sync not complete.
|
|
235
203
|
Relations will be added in second pass.
|
|
236
204
|
"""
|
|
237
|
-
logger.debug(f"Creating entity: {markdown.frontmatter.title}")
|
|
205
|
+
logger.debug(f"Creating entity: {markdown.frontmatter.title}")
|
|
238
206
|
model = entity_model_from_markdown(file_path, markdown)
|
|
239
207
|
|
|
240
|
-
# Mark as incomplete
|
|
208
|
+
# Mark as incomplete because we still need to add relations
|
|
241
209
|
model.checksum = None
|
|
242
|
-
return await self.add(model)
|
|
210
|
+
return await self.repository.add(model)
|
|
243
211
|
|
|
244
212
|
async def update_entity_and_observations(
|
|
245
|
-
self, file_path: Path
|
|
213
|
+
self, file_path: Path, markdown: EntityMarkdown
|
|
246
214
|
) -> EntityModel:
|
|
247
215
|
"""Update entity fields and observations.
|
|
248
216
|
|
|
@@ -250,11 +218,8 @@ class EntityService(BaseService[EntityModel]):
|
|
|
250
218
|
to indicate sync not complete.
|
|
251
219
|
"""
|
|
252
220
|
logger.debug(f"Updating entity and observations: {file_path}")
|
|
253
|
-
file_path = str(file_path)
|
|
254
221
|
|
|
255
|
-
db_entity = await self.repository.get_by_file_path(file_path)
|
|
256
|
-
if not db_entity:
|
|
257
|
-
raise EntityNotFoundError(f"Entity not found: {file_path}")
|
|
222
|
+
db_entity = await self.repository.get_by_file_path(str(file_path))
|
|
258
223
|
|
|
259
224
|
# Clear observations for entity
|
|
260
225
|
await self.observation_repository.delete_by_fields(entity_id=db_entity.id)
|
|
@@ -277,9 +242,8 @@ class EntityService(BaseService[EntityModel]):
|
|
|
277
242
|
|
|
278
243
|
# checksum value is None == not finished with sync
|
|
279
244
|
db_entity.checksum = None
|
|
280
|
-
|
|
245
|
+
|
|
281
246
|
# update entity
|
|
282
|
-
# checksum value is None == not finished with sync
|
|
283
247
|
return await self.repository.update(
|
|
284
248
|
db_entity.id,
|
|
285
249
|
db_entity,
|
|
@@ -287,14 +251,13 @@ class EntityService(BaseService[EntityModel]):
|
|
|
287
251
|
|
|
288
252
|
async def update_entity_relations(
|
|
289
253
|
self,
|
|
290
|
-
file_path: Path
|
|
254
|
+
file_path: Path,
|
|
291
255
|
markdown: EntityMarkdown,
|
|
292
256
|
) -> EntityModel:
|
|
293
257
|
"""Update relations for entity"""
|
|
294
258
|
logger.debug(f"Updating relations for entity: {file_path}")
|
|
295
259
|
|
|
296
|
-
|
|
297
|
-
db_entity = await self.repository.get_by_file_path(file_path)
|
|
260
|
+
db_entity = await self.repository.get_by_file_path(str(file_path))
|
|
298
261
|
|
|
299
262
|
# Clear existing relations first
|
|
300
263
|
await self.relation_repository.delete_outgoing_relations_from_entity(db_entity.id)
|
|
@@ -328,4 +291,4 @@ class EntityService(BaseService[EntityModel]):
|
|
|
328
291
|
)
|
|
329
292
|
continue
|
|
330
293
|
|
|
331
|
-
return await self.repository.get_by_file_path(file_path)
|
|
294
|
+
return await self.repository.get_by_file_path(str(file_path))
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
"""Service for file operations with checksum tracking."""
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import
|
|
4
|
+
from typing import Tuple, Union
|
|
5
5
|
|
|
6
6
|
from loguru import logger
|
|
7
7
|
|
|
8
8
|
from basic_memory import file_utils
|
|
9
9
|
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
|
10
|
-
from basic_memory.markdown.utils import entity_model_to_markdown
|
|
11
10
|
from basic_memory.models import Entity as EntityModel
|
|
12
|
-
from basic_memory.services.exceptions import FileOperationError
|
|
13
11
|
from basic_memory.schemas import Entity as EntitySchema
|
|
12
|
+
from basic_memory.services.exceptions import FileOperationError
|
|
13
|
+
|
|
14
14
|
|
|
15
15
|
class FileService:
|
|
16
|
-
"""
|
|
17
|
-
|
|
16
|
+
"""Service for handling file operations.
|
|
17
|
+
|
|
18
|
+
All paths are handled as Path objects internally. Strings are converted to
|
|
19
|
+
Path objects when passed in. Relative paths are assumed to be relative to
|
|
20
|
+
base_path.
|
|
18
21
|
|
|
19
22
|
Features:
|
|
20
23
|
- Consistent file writing with checksums
|
|
@@ -28,105 +31,66 @@ class FileService:
|
|
|
28
31
|
base_path: Path,
|
|
29
32
|
markdown_processor: MarkdownProcessor,
|
|
30
33
|
):
|
|
31
|
-
self.base_path = base_path
|
|
34
|
+
self.base_path = base_path.resolve() # Get absolute path
|
|
32
35
|
self.markdown_processor = markdown_processor
|
|
33
36
|
|
|
34
|
-
def get_entity_path(self, entity: EntityModel
|
|
35
|
-
"""Generate absolute filesystem path for entity.
|
|
36
|
-
return self.base_path / f"{entity.file_path}"
|
|
37
|
-
|
|
38
|
-
async def write_entity_file(
|
|
39
|
-
self,
|
|
40
|
-
entity: EntityModel,
|
|
41
|
-
content: Optional[str] = None,
|
|
42
|
-
expected_checksum: Optional[str] = None,
|
|
43
|
-
) -> Tuple[Path, str]:
|
|
44
|
-
"""Write entity to filesystem and return path and checksum.
|
|
45
|
-
|
|
46
|
-
Uses read->modify->write pattern:
|
|
47
|
-
1. Read existing file if it exists
|
|
48
|
-
2. Update with new content if provided
|
|
49
|
-
3. Write back atomically
|
|
37
|
+
def get_entity_path(self, entity: Union[EntityModel, EntitySchema]) -> Path:
|
|
38
|
+
"""Generate absolute filesystem path for entity.
|
|
50
39
|
|
|
51
40
|
Args:
|
|
52
|
-
entity: Entity model
|
|
53
|
-
content: Optional new content (preserves existing if None)
|
|
54
|
-
expected_checksum: Optional checksum to verify file hasn't changed
|
|
41
|
+
entity: Entity model or schema with file_path attribute
|
|
55
42
|
|
|
56
43
|
Returns:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
Raises:
|
|
60
|
-
FileOperationError: If write fails
|
|
44
|
+
Absolute Path to the entity file
|
|
61
45
|
"""
|
|
62
|
-
|
|
63
|
-
path = self.get_entity_path(entity)
|
|
64
|
-
|
|
65
|
-
# Read current state if file exists
|
|
66
|
-
if path.exists():
|
|
67
|
-
# read the existing file
|
|
68
|
-
existing_markdown = await self.markdown_processor.read_file(path)
|
|
69
|
-
|
|
70
|
-
# if content is supplied use it or existing content
|
|
71
|
-
content=content or existing_markdown.content
|
|
72
|
-
|
|
73
|
-
# Create new file structure with provided content
|
|
74
|
-
markdown = entity_model_to_markdown(entity, content=content)
|
|
75
|
-
|
|
76
|
-
# Write back atomically
|
|
77
|
-
checksum = await self.markdown_processor.write_file(
|
|
78
|
-
path=path, markdown=markdown, expected_checksum=expected_checksum
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
return path, checksum
|
|
82
|
-
|
|
83
|
-
except Exception as e:
|
|
84
|
-
logger.exception(f"Failed to write entity file: {e}")
|
|
85
|
-
raise FileOperationError(f"Failed to write entity file: {e}")
|
|
46
|
+
return self.base_path / entity.file_path
|
|
86
47
|
|
|
87
48
|
async def read_entity_content(self, entity: EntityModel) -> str:
|
|
88
|
-
"""Get entity's content without frontmatter or structured sections
|
|
49
|
+
"""Get entity's content without frontmatter or structured sections.
|
|
50
|
+
|
|
51
|
+
Used to index for search. Returns raw content without frontmatter,
|
|
52
|
+
observations, or relations.
|
|
89
53
|
|
|
90
54
|
Args:
|
|
91
55
|
entity: Entity to read content for
|
|
92
56
|
|
|
93
57
|
Returns:
|
|
94
|
-
Raw content without
|
|
95
|
-
|
|
96
|
-
Raises:
|
|
97
|
-
FileOperationError: If entity file doesn't exist
|
|
58
|
+
Raw content string without metadata sections
|
|
98
59
|
"""
|
|
99
60
|
logger.debug(f"Reading entity with permalink: {entity.permalink}")
|
|
100
61
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return markdown.content or ""
|
|
105
|
-
|
|
106
|
-
except Exception as e:
|
|
107
|
-
logger.error(f"Failed to read entity content: {e}")
|
|
108
|
-
raise FileOperationError(f"Failed to read entity content: {e}")
|
|
62
|
+
file_path = self.get_entity_path(entity)
|
|
63
|
+
markdown = await self.markdown_processor.read_file(file_path)
|
|
64
|
+
return markdown.content or ""
|
|
109
65
|
|
|
110
66
|
async def delete_entity_file(self, entity: EntityModel) -> None:
|
|
111
|
-
"""Delete entity file from filesystem.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
except Exception as e:
|
|
116
|
-
logger.error(f"Failed to delete entity file: {e}")
|
|
117
|
-
raise FileOperationError(f"Failed to delete entity file: {e}")
|
|
67
|
+
"""Delete entity file from filesystem.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
entity: Entity model whose file should be deleted
|
|
118
71
|
|
|
119
|
-
|
|
72
|
+
Raises:
|
|
73
|
+
FileOperationError: If deletion fails
|
|
120
74
|
"""
|
|
121
|
-
|
|
75
|
+
path = self.get_entity_path(entity)
|
|
76
|
+
await self.delete_file(path)
|
|
77
|
+
|
|
78
|
+
async def exists(self, path: Union[Path, str]) -> bool:
|
|
79
|
+
"""Check if file exists at the provided path.
|
|
80
|
+
|
|
81
|
+
If path is relative, it is assumed to be relative to base_path.
|
|
122
82
|
|
|
123
83
|
Args:
|
|
124
|
-
path: Path to check
|
|
84
|
+
path: Path to check (Path object or string)
|
|
125
85
|
|
|
126
86
|
Returns:
|
|
127
87
|
True if file exists, False otherwise
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
FileOperationError: If check fails
|
|
128
91
|
"""
|
|
129
92
|
try:
|
|
93
|
+
path = Path(path)
|
|
130
94
|
if path.is_absolute():
|
|
131
95
|
return path.exists()
|
|
132
96
|
else:
|
|
@@ -135,12 +99,14 @@ class FileService:
|
|
|
135
99
|
logger.error(f"Failed to check file existence {path}: {e}")
|
|
136
100
|
raise FileOperationError(f"Failed to check file existence: {e}")
|
|
137
101
|
|
|
138
|
-
async def write_file(self, path: Path, content: str) -> str:
|
|
139
|
-
"""
|
|
140
|
-
|
|
102
|
+
async def write_file(self, path: Union[Path, str], content: str) -> str:
|
|
103
|
+
"""Write content to file and return checksum.
|
|
104
|
+
|
|
105
|
+
Handles both absolute and relative paths. Relative paths are resolved
|
|
106
|
+
against base_path.
|
|
141
107
|
|
|
142
108
|
Args:
|
|
143
|
-
path: Path
|
|
109
|
+
path: Where to write (Path object or string)
|
|
144
110
|
content: Content to write
|
|
145
111
|
|
|
146
112
|
Returns:
|
|
@@ -149,30 +115,33 @@ class FileService:
|
|
|
149
115
|
Raises:
|
|
150
116
|
FileOperationError: If write fails
|
|
151
117
|
"""
|
|
152
|
-
|
|
153
|
-
|
|
118
|
+
path = Path(path)
|
|
119
|
+
full_path = path if path.is_absolute() else self.base_path / path
|
|
120
|
+
|
|
154
121
|
try:
|
|
155
122
|
# Ensure parent directory exists
|
|
156
|
-
await file_utils.ensure_directory(
|
|
123
|
+
await file_utils.ensure_directory(full_path.parent)
|
|
157
124
|
|
|
158
125
|
# Write content atomically
|
|
159
|
-
await file_utils.write_file_atomic(
|
|
126
|
+
await file_utils.write_file_atomic(full_path, content)
|
|
160
127
|
|
|
161
128
|
# Compute and return checksum
|
|
162
129
|
checksum = await file_utils.compute_checksum(content)
|
|
163
|
-
logger.debug(f"wrote file: {
|
|
130
|
+
logger.debug(f"wrote file: {full_path}, checksum: {checksum}")
|
|
164
131
|
return checksum
|
|
165
132
|
|
|
166
133
|
except Exception as e:
|
|
167
|
-
logger.error(f"Failed to write file {
|
|
134
|
+
logger.error(f"Failed to write file {full_path}: {e}")
|
|
168
135
|
raise FileOperationError(f"Failed to write file: {e}")
|
|
169
136
|
|
|
170
|
-
async def read_file(self, path: Path) -> Tuple[str, str]:
|
|
171
|
-
"""
|
|
172
|
-
|
|
137
|
+
async def read_file(self, path: Union[Path, str]) -> Tuple[str, str]:
|
|
138
|
+
"""Read file and compute checksum.
|
|
139
|
+
|
|
140
|
+
Handles both absolute and relative paths. Relative paths are resolved
|
|
141
|
+
against base_path.
|
|
173
142
|
|
|
174
143
|
Args:
|
|
175
|
-
path: Path to read
|
|
144
|
+
path: Path to read (Path object or string)
|
|
176
145
|
|
|
177
146
|
Returns:
|
|
178
147
|
Tuple of (content, checksum)
|
|
@@ -180,33 +149,28 @@ class FileService:
|
|
|
180
149
|
Raises:
|
|
181
150
|
FileOperationError: If read fails
|
|
182
151
|
"""
|
|
183
|
-
path = path
|
|
152
|
+
path = Path(path)
|
|
153
|
+
full_path = path if path.is_absolute() else self.base_path / path
|
|
154
|
+
|
|
184
155
|
try:
|
|
185
156
|
content = path.read_text()
|
|
186
157
|
checksum = await file_utils.compute_checksum(content)
|
|
187
|
-
logger.debug(f"read file: {
|
|
158
|
+
logger.debug(f"read file: {full_path}, checksum: {checksum}")
|
|
188
159
|
return content, checksum
|
|
189
160
|
|
|
190
161
|
except Exception as e:
|
|
191
|
-
logger.error(f"Failed to read file {
|
|
162
|
+
logger.error(f"Failed to read file {full_path}: {e}")
|
|
192
163
|
raise FileOperationError(f"Failed to read file: {e}")
|
|
193
164
|
|
|
194
|
-
async def delete_file(self, path: Path) -> None:
|
|
195
|
-
"""
|
|
196
|
-
Delete file if it exists.
|
|
165
|
+
async def delete_file(self, path: Union[Path, str]) -> None:
|
|
166
|
+
"""Delete file if it exists.
|
|
197
167
|
|
|
198
|
-
|
|
199
|
-
|
|
168
|
+
Handles both absolute and relative paths. Relative paths are resolved
|
|
169
|
+
against base_path.
|
|
200
170
|
|
|
201
|
-
|
|
202
|
-
|
|
171
|
+
Args:
|
|
172
|
+
path: Path to delete (Path object or string)
|
|
203
173
|
"""
|
|
204
|
-
path = path
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
except Exception as e:
|
|
208
|
-
logger.error(f"Failed to delete file {path}: {e}")
|
|
209
|
-
raise FileOperationError(f"Failed to delete file: {e}")
|
|
210
|
-
|
|
211
|
-
def path(self, path_string: str, absolute: bool = False):
|
|
212
|
-
return Path( self.base_path / path_string ) if absolute else Path(path_string).relative_to(self.base_path)
|
|
174
|
+
path = Path(path)
|
|
175
|
+
full_path = path if path.is_absolute() else self.base_path / path
|
|
176
|
+
full_path.unlink(missing_ok=True)
|
|
@@ -5,9 +5,10 @@ from typing import Optional, Tuple, List
|
|
|
5
5
|
from loguru import logger
|
|
6
6
|
|
|
7
7
|
from basic_memory.repository.entity_repository import EntityRepository
|
|
8
|
+
from basic_memory.repository.search_repository import SearchIndexRow
|
|
8
9
|
from basic_memory.services.search_service import SearchService
|
|
9
10
|
from basic_memory.models import Entity
|
|
10
|
-
from basic_memory.schemas.search import SearchQuery,
|
|
11
|
+
from basic_memory.schemas.search import SearchQuery, SearchItemType
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class LinkResolver:
|
|
@@ -45,18 +46,19 @@ class LinkResolver:
|
|
|
45
46
|
return entity
|
|
46
47
|
|
|
47
48
|
if use_search:
|
|
48
|
-
|
|
49
49
|
# 3. Fall back to search for fuzzy matching on title if specified
|
|
50
50
|
results = await self.search_service.search(
|
|
51
51
|
query=SearchQuery(title=clean_text, types=[SearchItemType.ENTITY]),
|
|
52
52
|
)
|
|
53
|
-
|
|
53
|
+
|
|
54
54
|
if results:
|
|
55
55
|
# Look for best match
|
|
56
56
|
best_match = self._select_best_match(clean_text, results)
|
|
57
|
-
logger.debug(
|
|
57
|
+
logger.debug(
|
|
58
|
+
f"Selected best match from {len(results)} results: {best_match.permalink}"
|
|
59
|
+
)
|
|
58
60
|
return await self.entity_repository.get_by_permalink(best_match.permalink)
|
|
59
|
-
|
|
61
|
+
|
|
60
62
|
# if we couldn't find anything then return None
|
|
61
63
|
return None
|
|
62
64
|
|
|
@@ -85,7 +87,7 @@ class LinkResolver:
|
|
|
85
87
|
|
|
86
88
|
return text, alias
|
|
87
89
|
|
|
88
|
-
def _select_best_match(self, search_text: str, results: List[
|
|
90
|
+
def _select_best_match(self, search_text: str, results: List[SearchIndexRow]) -> Entity:
|
|
89
91
|
"""Select best match from search results.
|
|
90
92
|
|
|
91
93
|
Uses multiple criteria:
|
|
@@ -93,9 +95,6 @@ class LinkResolver:
|
|
|
93
95
|
2. Word matches in path
|
|
94
96
|
3. Overall search score
|
|
95
97
|
"""
|
|
96
|
-
if not results:
|
|
97
|
-
raise ValueError("Cannot select from empty results")
|
|
98
|
-
|
|
99
98
|
# Get search terms for matching
|
|
100
99
|
terms = search_text.lower().split()
|
|
101
100
|
|
|
@@ -104,6 +103,7 @@ class LinkResolver:
|
|
|
104
103
|
for result in results:
|
|
105
104
|
# Start with base score (lower is better)
|
|
106
105
|
score = result.score
|
|
106
|
+
assert score is not None
|
|
107
107
|
|
|
108
108
|
# Parse path components
|
|
109
109
|
path_parts = result.permalink.lower().split("/")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Service for search operations."""
|
|
2
2
|
|
|
3
|
+
from datetime import datetime
|
|
3
4
|
from typing import List, Optional, Set
|
|
4
5
|
|
|
5
6
|
from fastapi import BackgroundTasks
|
|
@@ -8,9 +9,8 @@ from loguru import logger
|
|
|
8
9
|
from basic_memory.models import Entity
|
|
9
10
|
from basic_memory.repository import EntityRepository
|
|
10
11
|
from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
|
|
11
|
-
from basic_memory.schemas.search import SearchQuery,
|
|
12
|
+
from basic_memory.schemas.search import SearchQuery, SearchItemType
|
|
12
13
|
from basic_memory.services import FileService
|
|
13
|
-
from basic_memory.services.exceptions import FileOperationError
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class SearchService:
|
|
@@ -51,9 +51,7 @@ class SearchService:
|
|
|
51
51
|
|
|
52
52
|
logger.info("Reindex complete")
|
|
53
53
|
|
|
54
|
-
async def search(
|
|
55
|
-
self, query: SearchQuery, context: Optional[List[str]] = None
|
|
56
|
-
) -> List[SearchResult]:
|
|
54
|
+
async def search(self, query: SearchQuery) -> List[SearchIndexRow]:
|
|
57
55
|
"""Search across all indexed content.
|
|
58
56
|
|
|
59
57
|
Supports three modes:
|
|
@@ -67,6 +65,16 @@ class SearchService:
|
|
|
67
65
|
|
|
68
66
|
logger.debug(f"Searching with query: {query}")
|
|
69
67
|
|
|
68
|
+
after_date = (
|
|
69
|
+
(
|
|
70
|
+
query.after_date
|
|
71
|
+
if isinstance(query.after_date, datetime)
|
|
72
|
+
else datetime.fromisoformat(query.after_date)
|
|
73
|
+
)
|
|
74
|
+
if query.after_date
|
|
75
|
+
else None
|
|
76
|
+
)
|
|
77
|
+
|
|
70
78
|
# permalink search
|
|
71
79
|
results = await self.repository.search(
|
|
72
80
|
search_text=query.text,
|
|
@@ -75,12 +83,13 @@ class SearchService:
|
|
|
75
83
|
title=query.title,
|
|
76
84
|
types=query.types,
|
|
77
85
|
entity_types=query.entity_types,
|
|
78
|
-
after_date=
|
|
86
|
+
after_date=after_date,
|
|
79
87
|
)
|
|
80
88
|
|
|
81
89
|
return results
|
|
82
90
|
|
|
83
|
-
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _generate_variants(text: str) -> Set[str]:
|
|
84
93
|
"""Generate text variants for better fuzzy matching.
|
|
85
94
|
|
|
86
95
|
Creates variations of the text to improve match chances:
|
|
@@ -140,7 +149,6 @@ class SearchService:
|
|
|
140
149
|
title_variants = self._generate_variants(entity.title)
|
|
141
150
|
content_parts.extend(title_variants)
|
|
142
151
|
|
|
143
|
-
# TODO should we do something to content on indexing?
|
|
144
152
|
content = await self.file_service.read_entity_content(entity)
|
|
145
153
|
if content:
|
|
146
154
|
content_parts.append(content)
|
|
@@ -162,8 +170,8 @@ class SearchService:
|
|
|
162
170
|
metadata={
|
|
163
171
|
"entity_type": entity.entity_type,
|
|
164
172
|
},
|
|
165
|
-
created_at=entity.created_at
|
|
166
|
-
updated_at=entity.updated_at
|
|
173
|
+
created_at=entity.created_at,
|
|
174
|
+
updated_at=entity.updated_at,
|
|
167
175
|
)
|
|
168
176
|
)
|
|
169
177
|
|
|
@@ -183,8 +191,8 @@ class SearchService:
|
|
|
183
191
|
metadata={
|
|
184
192
|
"tags": obs.tags,
|
|
185
193
|
},
|
|
186
|
-
created_at=entity.created_at
|
|
187
|
-
updated_at=entity.updated_at
|
|
194
|
+
created_at=entity.created_at,
|
|
195
|
+
updated_at=entity.updated_at,
|
|
188
196
|
)
|
|
189
197
|
)
|
|
190
198
|
|
|
@@ -201,15 +209,14 @@ class SearchService:
|
|
|
201
209
|
SearchIndexRow(
|
|
202
210
|
id=rel.id,
|
|
203
211
|
title=relation_title,
|
|
204
|
-
content=rel.context or "",
|
|
205
212
|
permalink=rel.permalink,
|
|
206
213
|
file_path=entity.file_path,
|
|
207
214
|
type=SearchItemType.RELATION.value,
|
|
208
215
|
from_id=rel.from_id,
|
|
209
216
|
to_id=rel.to_id,
|
|
210
217
|
relation_type=rel.relation_type,
|
|
211
|
-
created_at=entity.created_at
|
|
212
|
-
updated_at=entity.updated_at
|
|
218
|
+
created_at=entity.created_at,
|
|
219
|
+
updated_at=entity.updated_at,
|
|
213
220
|
)
|
|
214
221
|
)
|
|
215
222
|
|