basic-memory 0.0.0__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 +3 -0
- basic_memory/api/__init__.py +4 -0
- basic_memory/api/app.py +42 -0
- basic_memory/api/routers/__init__.py +8 -0
- basic_memory/api/routers/knowledge_router.py +168 -0
- basic_memory/api/routers/memory_router.py +123 -0
- basic_memory/api/routers/resource_router.py +34 -0
- basic_memory/api/routers/search_router.py +34 -0
- basic_memory/cli/__init__.py +1 -0
- basic_memory/cli/app.py +4 -0
- basic_memory/cli/commands/__init__.py +9 -0
- basic_memory/cli/commands/init.py +38 -0
- basic_memory/cli/commands/status.py +152 -0
- basic_memory/cli/commands/sync.py +254 -0
- basic_memory/cli/main.py +48 -0
- basic_memory/config.py +53 -0
- basic_memory/db.py +135 -0
- basic_memory/deps.py +182 -0
- basic_memory/file_utils.py +248 -0
- basic_memory/markdown/__init__.py +19 -0
- basic_memory/markdown/entity_parser.py +137 -0
- basic_memory/markdown/markdown_processor.py +153 -0
- basic_memory/markdown/plugins.py +236 -0
- basic_memory/markdown/schemas.py +73 -0
- basic_memory/markdown/utils.py +144 -0
- basic_memory/mcp/__init__.py +1 -0
- basic_memory/mcp/async_client.py +10 -0
- basic_memory/mcp/main.py +21 -0
- basic_memory/mcp/server.py +39 -0
- basic_memory/mcp/tools/__init__.py +34 -0
- basic_memory/mcp/tools/ai_edit.py +84 -0
- basic_memory/mcp/tools/knowledge.py +56 -0
- basic_memory/mcp/tools/memory.py +142 -0
- basic_memory/mcp/tools/notes.py +122 -0
- basic_memory/mcp/tools/search.py +28 -0
- basic_memory/mcp/tools/utils.py +154 -0
- basic_memory/models/__init__.py +12 -0
- basic_memory/models/base.py +9 -0
- basic_memory/models/knowledge.py +204 -0
- basic_memory/models/search.py +34 -0
- basic_memory/repository/__init__.py +7 -0
- basic_memory/repository/entity_repository.py +156 -0
- basic_memory/repository/observation_repository.py +40 -0
- basic_memory/repository/relation_repository.py +78 -0
- basic_memory/repository/repository.py +303 -0
- basic_memory/repository/search_repository.py +259 -0
- basic_memory/schemas/__init__.py +73 -0
- basic_memory/schemas/base.py +216 -0
- basic_memory/schemas/delete.py +38 -0
- basic_memory/schemas/discovery.py +25 -0
- basic_memory/schemas/memory.py +111 -0
- basic_memory/schemas/request.py +77 -0
- basic_memory/schemas/response.py +220 -0
- basic_memory/schemas/search.py +117 -0
- basic_memory/services/__init__.py +11 -0
- basic_memory/services/context_service.py +274 -0
- basic_memory/services/entity_service.py +281 -0
- basic_memory/services/exceptions.py +15 -0
- basic_memory/services/file_service.py +213 -0
- basic_memory/services/link_resolver.py +126 -0
- basic_memory/services/search_service.py +218 -0
- basic_memory/services/service.py +36 -0
- basic_memory/sync/__init__.py +5 -0
- basic_memory/sync/file_change_scanner.py +162 -0
- basic_memory/sync/sync_service.py +140 -0
- basic_memory/sync/utils.py +66 -0
- basic_memory/sync/watch_service.py +197 -0
- basic_memory/utils.py +78 -0
- basic_memory-0.0.0.dist-info/METADATA +71 -0
- basic_memory-0.0.0.dist-info/RECORD +73 -0
- basic_memory-0.0.0.dist-info/WHEEL +4 -0
- basic_memory-0.0.0.dist-info/entry_points.txt +2 -0
- basic_memory-0.0.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Service for file operations with checksum tracking."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from basic_memory import file_utils
|
|
9
|
+
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
|
10
|
+
from basic_memory.markdown.utils import entity_model_to_markdown
|
|
11
|
+
from basic_memory.models import Entity as EntityModel
|
|
12
|
+
from basic_memory.services.exceptions import FileOperationError
|
|
13
|
+
from basic_memory.schemas import Entity as EntitySchema
|
|
14
|
+
|
|
15
|
+
class FileService:
|
|
16
|
+
"""
|
|
17
|
+
Service for handling file operations.
|
|
18
|
+
|
|
19
|
+
Features:
|
|
20
|
+
- Consistent file writing with checksums
|
|
21
|
+
- Frontmatter management
|
|
22
|
+
- Atomic operations
|
|
23
|
+
- Error handling
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
base_path: Path,
|
|
29
|
+
markdown_processor: MarkdownProcessor,
|
|
30
|
+
):
|
|
31
|
+
self.base_path = base_path
|
|
32
|
+
self.markdown_processor = markdown_processor
|
|
33
|
+
|
|
34
|
+
def get_entity_path(self, entity: EntityModel| EntitySchema) -> Path:
|
|
35
|
+
"""Generate absolute filesystem path for entity."""
|
|
36
|
+
return self.base_path / f"{entity.file_path}"
|
|
37
|
+
|
|
38
|
+
# TODO move to tests
|
|
39
|
+
async def write_entity_file(
|
|
40
|
+
self,
|
|
41
|
+
entity: EntityModel,
|
|
42
|
+
content: Optional[str] = None,
|
|
43
|
+
expected_checksum: Optional[str] = None,
|
|
44
|
+
) -> Tuple[Path, str]:
|
|
45
|
+
"""Write entity to filesystem and return path and checksum.
|
|
46
|
+
|
|
47
|
+
Uses read->modify->write pattern:
|
|
48
|
+
1. Read existing file if it exists
|
|
49
|
+
2. Update with new content if provided
|
|
50
|
+
3. Write back atomically
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
entity: Entity model to write
|
|
54
|
+
content: Optional new content (preserves existing if None)
|
|
55
|
+
expected_checksum: Optional checksum to verify file hasn't changed
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Tuple of (file path, new checksum)
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
FileOperationError: If write fails
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
path = self.get_entity_path(entity)
|
|
65
|
+
|
|
66
|
+
# Read current state if file exists
|
|
67
|
+
if path.exists():
|
|
68
|
+
# read the existing file
|
|
69
|
+
existing_markdown = await self.markdown_processor.read_file(path)
|
|
70
|
+
|
|
71
|
+
# if content is supplied use it or existing content
|
|
72
|
+
content=content or existing_markdown.content
|
|
73
|
+
|
|
74
|
+
# Create new file structure with provided content
|
|
75
|
+
markdown = entity_model_to_markdown(entity, content=content)
|
|
76
|
+
|
|
77
|
+
# Write back atomically
|
|
78
|
+
checksum = await self.markdown_processor.write_file(
|
|
79
|
+
path=path, markdown=markdown, expected_checksum=expected_checksum
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return path, checksum
|
|
83
|
+
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.exception(f"Failed to write entity file: {e}")
|
|
86
|
+
raise FileOperationError(f"Failed to write entity file: {e}")
|
|
87
|
+
|
|
88
|
+
async def read_entity_content(self, entity: EntityModel) -> str:
|
|
89
|
+
"""Get entity's content without frontmatter or structured sections (used to index for search)
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
entity: Entity to read content for
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Raw content without frontmatter, observations, or relations
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
FileOperationError: If entity file doesn't exist
|
|
99
|
+
"""
|
|
100
|
+
logger.debug(f"Reading entity with permalink: {entity.permalink}")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
file_path = self.get_entity_path(entity)
|
|
104
|
+
markdown = await self.markdown_processor.read_file(file_path)
|
|
105
|
+
return markdown.content or ""
|
|
106
|
+
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error(f"Failed to read entity content: {e}")
|
|
109
|
+
raise FileOperationError(f"Failed to read entity content: {e}")
|
|
110
|
+
|
|
111
|
+
async def delete_entity_file(self, entity: EntityModel) -> None:
|
|
112
|
+
"""Delete entity file from filesystem."""
|
|
113
|
+
try:
|
|
114
|
+
path = self.get_entity_path(entity)
|
|
115
|
+
await self.delete_file(path)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logger.error(f"Failed to delete entity file: {e}")
|
|
118
|
+
raise FileOperationError(f"Failed to delete entity file: {e}")
|
|
119
|
+
|
|
120
|
+
async def exists(self, path: Path) -> bool:
|
|
121
|
+
"""
|
|
122
|
+
Check if file exists at the provided path. If path is relative, it is assumed to be relative to base_path.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
path: Path to check
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
True if file exists, False otherwise
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
if path.is_absolute():
|
|
132
|
+
return path.exists()
|
|
133
|
+
else:
|
|
134
|
+
return (self.base_path / path).exists()
|
|
135
|
+
except Exception as e:
|
|
136
|
+
logger.error(f"Failed to check file existence {path}: {e}")
|
|
137
|
+
raise FileOperationError(f"Failed to check file existence: {e}")
|
|
138
|
+
|
|
139
|
+
async def write_file(self, path: Path, content: str) -> str:
|
|
140
|
+
"""
|
|
141
|
+
Write content to file and return checksum.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
path: Path where to write
|
|
145
|
+
content: Content to write
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Checksum of written content
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
FileOperationError: If write fails
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
path = path if path.is_absolute() else self.base_path / path
|
|
155
|
+
try:
|
|
156
|
+
# Ensure parent directory exists
|
|
157
|
+
await file_utils.ensure_directory(path.parent)
|
|
158
|
+
|
|
159
|
+
# Write content atomically
|
|
160
|
+
await file_utils.write_file_atomic(path, content)
|
|
161
|
+
|
|
162
|
+
# Compute and return checksum
|
|
163
|
+
checksum = await file_utils.compute_checksum(content)
|
|
164
|
+
logger.debug(f"wrote file: {path}, checksum: {checksum} content: \n{content}")
|
|
165
|
+
return checksum
|
|
166
|
+
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.error(f"Failed to write file {path}: {e}")
|
|
169
|
+
raise FileOperationError(f"Failed to write file: {e}")
|
|
170
|
+
|
|
171
|
+
async def read_file(self, path: Path) -> Tuple[str, str]:
|
|
172
|
+
"""
|
|
173
|
+
Read file and compute checksum.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
path: Path to read
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Tuple of (content, checksum)
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
FileOperationError: If read fails
|
|
183
|
+
"""
|
|
184
|
+
path = path if path.is_absolute() else self.base_path / path
|
|
185
|
+
try:
|
|
186
|
+
content = path.read_text()
|
|
187
|
+
checksum = await file_utils.compute_checksum(content)
|
|
188
|
+
logger.debug(f"read file: {path}, checksum: {checksum}")
|
|
189
|
+
return content, checksum
|
|
190
|
+
|
|
191
|
+
except Exception as e:
|
|
192
|
+
logger.error(f"Failed to read file {path}: {e}")
|
|
193
|
+
raise FileOperationError(f"Failed to read file: {e}")
|
|
194
|
+
|
|
195
|
+
async def delete_file(self, path: Path) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Delete file if it exists.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
path: Path to delete
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
FileOperationError: If deletion fails
|
|
204
|
+
"""
|
|
205
|
+
path = path if path.is_absolute() else self.base_path / path
|
|
206
|
+
try:
|
|
207
|
+
path.unlink(missing_ok=True)
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.error(f"Failed to delete file {path}: {e}")
|
|
210
|
+
raise FileOperationError(f"Failed to delete file: {e}")
|
|
211
|
+
|
|
212
|
+
def path(self, path_string: str, absolute: bool = False):
|
|
213
|
+
return Path( self.base_path / path_string ) if absolute else Path(path_string).relative_to(self.base_path)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Service for resolving markdown links to permalinks."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Tuple, List
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
from basic_memory.repository.entity_repository import EntityRepository
|
|
8
|
+
from basic_memory.services.search_service import SearchService
|
|
9
|
+
from basic_memory.models import Entity
|
|
10
|
+
from basic_memory.schemas.search import SearchQuery, SearchResult, SearchItemType
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LinkResolver:
|
|
14
|
+
"""Service for resolving markdown links to permalinks.
|
|
15
|
+
|
|
16
|
+
Uses a combination of exact matching and search-based resolution:
|
|
17
|
+
1. Try exact permalink match (fastest)
|
|
18
|
+
2. Try exact title match
|
|
19
|
+
3. Fall back to search for fuzzy matching
|
|
20
|
+
4. Generate new permalink if no match found
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, entity_repository: EntityRepository, search_service: SearchService):
|
|
24
|
+
"""Initialize with repositories."""
|
|
25
|
+
self.entity_repository = entity_repository
|
|
26
|
+
self.search_service = search_service
|
|
27
|
+
|
|
28
|
+
async def resolve_link(self, link_text: str, use_search: bool = True) -> Optional[Entity]:
|
|
29
|
+
"""Resolve a markdown link to a permalink."""
|
|
30
|
+
logger.debug(f"Resolving link: {link_text}")
|
|
31
|
+
|
|
32
|
+
# Clean link text and extract any alias
|
|
33
|
+
clean_text, alias = self._normalize_link_text(link_text)
|
|
34
|
+
|
|
35
|
+
# 1. Try exact permalink match first (most efficient)
|
|
36
|
+
entity = await self.entity_repository.get_by_permalink(clean_text)
|
|
37
|
+
if entity:
|
|
38
|
+
logger.debug(f"Found exact permalink match: {entity.permalink}")
|
|
39
|
+
return entity
|
|
40
|
+
|
|
41
|
+
# 2. Try exact title match
|
|
42
|
+
entity = await self.entity_repository.get_by_title(clean_text)
|
|
43
|
+
if entity:
|
|
44
|
+
logger.debug(f"Found title match: {entity.title}")
|
|
45
|
+
return entity
|
|
46
|
+
|
|
47
|
+
if use_search:
|
|
48
|
+
|
|
49
|
+
# 3. Fall back to search for fuzzy matching on title if specified
|
|
50
|
+
results = await self.search_service.search(
|
|
51
|
+
query=SearchQuery(title=clean_text, types=[SearchItemType.ENTITY]),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if results:
|
|
55
|
+
# Look for best match
|
|
56
|
+
best_match = self._select_best_match(clean_text, results)
|
|
57
|
+
logger.debug(f"Selected best match from {len(results)} results: {best_match.permalink}")
|
|
58
|
+
return await self.entity_repository.get_by_permalink(best_match.permalink)
|
|
59
|
+
|
|
60
|
+
# if we couldn't find anything then return None
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
|
|
64
|
+
"""Normalize link text and extract alias if present.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
link_text: Raw link text from markdown
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Tuple of (normalized_text, alias or None)
|
|
71
|
+
"""
|
|
72
|
+
# Strip whitespace
|
|
73
|
+
text = link_text.strip()
|
|
74
|
+
|
|
75
|
+
# Remove enclosing brackets if present
|
|
76
|
+
if text.startswith("[[") and text.endswith("]]"):
|
|
77
|
+
text = text[2:-2]
|
|
78
|
+
|
|
79
|
+
# Handle Obsidian-style aliases (format: [[actual|alias]])
|
|
80
|
+
alias = None
|
|
81
|
+
if "|" in text:
|
|
82
|
+
text, alias = text.split("|", 1)
|
|
83
|
+
text = text.strip()
|
|
84
|
+
alias = alias.strip()
|
|
85
|
+
|
|
86
|
+
return text, alias
|
|
87
|
+
|
|
88
|
+
def _select_best_match(self, search_text: str, results: List[SearchResult]) -> Entity:
|
|
89
|
+
"""Select best match from search results.
|
|
90
|
+
|
|
91
|
+
Uses multiple criteria:
|
|
92
|
+
1. Word matches in title field
|
|
93
|
+
2. Word matches in path
|
|
94
|
+
3. Overall search score
|
|
95
|
+
"""
|
|
96
|
+
if not results:
|
|
97
|
+
raise ValueError("Cannot select from empty results")
|
|
98
|
+
|
|
99
|
+
# Get search terms for matching
|
|
100
|
+
terms = search_text.lower().split()
|
|
101
|
+
|
|
102
|
+
# Score each result
|
|
103
|
+
scored_results = []
|
|
104
|
+
for result in results:
|
|
105
|
+
# Start with base score (lower is better)
|
|
106
|
+
score = result.score
|
|
107
|
+
|
|
108
|
+
# Parse path components
|
|
109
|
+
path_parts = result.permalink.lower().split("/")
|
|
110
|
+
last_part = path_parts[-1] if path_parts else ""
|
|
111
|
+
|
|
112
|
+
# Title word match boosts
|
|
113
|
+
term_matches = [term for term in terms if term in last_part]
|
|
114
|
+
if term_matches:
|
|
115
|
+
score *= 0.5 # Boost for each matching term
|
|
116
|
+
|
|
117
|
+
# Exact title match is best
|
|
118
|
+
if last_part == search_text.lower():
|
|
119
|
+
score *= 0.2
|
|
120
|
+
|
|
121
|
+
scored_results.append((score, result))
|
|
122
|
+
|
|
123
|
+
# Sort by score (lowest first) and return best
|
|
124
|
+
scored_results.sort(key=lambda x: x[0], reverse=True)
|
|
125
|
+
|
|
126
|
+
return scored_results[0][1]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Service for search operations."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Set
|
|
4
|
+
|
|
5
|
+
from fastapi import BackgroundTasks
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from basic_memory.models import Entity
|
|
9
|
+
from basic_memory.repository import EntityRepository
|
|
10
|
+
from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
|
|
11
|
+
from basic_memory.schemas.search import SearchQuery, SearchResult, SearchItemType
|
|
12
|
+
from basic_memory.services import FileService
|
|
13
|
+
from basic_memory.services.exceptions import FileOperationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SearchService:
|
|
17
|
+
"""Service for search operations.
|
|
18
|
+
|
|
19
|
+
Supports three primary search modes:
|
|
20
|
+
1. Exact permalink lookup
|
|
21
|
+
2. Pattern matching with * (e.g., 'specs/*')
|
|
22
|
+
3. Full-text search across title/content
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
search_repository: SearchRepository,
|
|
28
|
+
entity_repository: EntityRepository,
|
|
29
|
+
file_service: FileService,
|
|
30
|
+
):
|
|
31
|
+
self.repository = search_repository
|
|
32
|
+
self.entity_repository = entity_repository
|
|
33
|
+
self.file_service = file_service
|
|
34
|
+
|
|
35
|
+
async def init_search_index(self):
|
|
36
|
+
"""Create FTS5 virtual table if it doesn't exist."""
|
|
37
|
+
await self.repository.init_search_index()
|
|
38
|
+
|
|
39
|
+
async def reindex_all(self, background_tasks: Optional[BackgroundTasks] = None) -> None:
|
|
40
|
+
"""Reindex all content from database."""
|
|
41
|
+
logger.info("Starting full reindex")
|
|
42
|
+
|
|
43
|
+
# Clear and recreate search index
|
|
44
|
+
await self.init_search_index()
|
|
45
|
+
|
|
46
|
+
# Reindex all entities
|
|
47
|
+
logger.debug("Indexing entities")
|
|
48
|
+
entities = await self.entity_repository.find_all()
|
|
49
|
+
for entity in entities:
|
|
50
|
+
await self.index_entity(entity, background_tasks)
|
|
51
|
+
|
|
52
|
+
logger.info("Reindex complete")
|
|
53
|
+
|
|
54
|
+
async def search(
|
|
55
|
+
self, query: SearchQuery, context: Optional[List[str]] = None
|
|
56
|
+
) -> List[SearchResult]:
|
|
57
|
+
"""Search across all indexed content.
|
|
58
|
+
|
|
59
|
+
Supports three modes:
|
|
60
|
+
1. Exact permalink: finds direct matches for a specific path
|
|
61
|
+
2. Pattern match: handles * wildcards in paths
|
|
62
|
+
3. Text search: full-text search across title/content
|
|
63
|
+
"""
|
|
64
|
+
if query.no_criteria():
|
|
65
|
+
logger.debug("no criteria passed to query")
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
logger.debug(f"Searching with query: {query}")
|
|
69
|
+
|
|
70
|
+
# permalink search
|
|
71
|
+
results = await self.repository.search(
|
|
72
|
+
search_text=query.text,
|
|
73
|
+
permalink=query.permalink,
|
|
74
|
+
permalink_match=query.permalink_match,
|
|
75
|
+
title=query.title,
|
|
76
|
+
types=query.types,
|
|
77
|
+
entity_types=query.entity_types,
|
|
78
|
+
after_date=query.after_date,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return results
|
|
82
|
+
|
|
83
|
+
def _generate_variants(self, text: str) -> Set[str]:
|
|
84
|
+
"""Generate text variants for better fuzzy matching.
|
|
85
|
+
|
|
86
|
+
Creates variations of the text to improve match chances:
|
|
87
|
+
- Original form
|
|
88
|
+
- Lowercase form
|
|
89
|
+
- Path segments (for permalinks)
|
|
90
|
+
- Common word boundaries
|
|
91
|
+
"""
|
|
92
|
+
variants = {text, text.lower()}
|
|
93
|
+
|
|
94
|
+
# Add path segments
|
|
95
|
+
if "/" in text:
|
|
96
|
+
variants.update(p.strip() for p in text.split("/") if p.strip())
|
|
97
|
+
|
|
98
|
+
# Add word boundaries
|
|
99
|
+
variants.update(w.strip() for w in text.lower().split() if w.strip())
|
|
100
|
+
|
|
101
|
+
# Add trigrams for fuzzy matching
|
|
102
|
+
variants.update(text[i : i + 3].lower() for i in range(len(text) - 2))
|
|
103
|
+
|
|
104
|
+
return variants
|
|
105
|
+
|
|
106
|
+
async def index_entity(
|
|
107
|
+
self,
|
|
108
|
+
entity: Entity,
|
|
109
|
+
background_tasks: Optional[BackgroundTasks] = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Index an entity and all its observations and relations.
|
|
112
|
+
|
|
113
|
+
Indexing structure:
|
|
114
|
+
1. Entities
|
|
115
|
+
- permalink: direct from entity (e.g., "specs/search")
|
|
116
|
+
- file_path: physical file location
|
|
117
|
+
|
|
118
|
+
2. Observations
|
|
119
|
+
- permalink: entity permalink + /observations/id (e.g., "specs/search/observations/123")
|
|
120
|
+
- file_path: parent entity's file (where observation is defined)
|
|
121
|
+
|
|
122
|
+
3. Relations (only index outgoing relations defined in this file)
|
|
123
|
+
- permalink: from_entity/relation_type/to_entity (e.g., "specs/search/implements/features/search-ui")
|
|
124
|
+
- file_path: source entity's file (where relation is defined)
|
|
125
|
+
|
|
126
|
+
Each type gets its own row in the search index with appropriate metadata.
|
|
127
|
+
"""
|
|
128
|
+
if background_tasks:
|
|
129
|
+
background_tasks.add_task(self.index_entity_data, entity)
|
|
130
|
+
else:
|
|
131
|
+
await self.index_entity_data(entity)
|
|
132
|
+
|
|
133
|
+
async def index_entity_data(
|
|
134
|
+
self,
|
|
135
|
+
entity: Entity,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Actually perform the indexing."""
|
|
138
|
+
|
|
139
|
+
content_parts = []
|
|
140
|
+
title_variants = self._generate_variants(entity.title)
|
|
141
|
+
content_parts.extend(title_variants)
|
|
142
|
+
|
|
143
|
+
# TODO should we do something to content on indexing?
|
|
144
|
+
content = await self.file_service.read_entity_content(entity)
|
|
145
|
+
if content:
|
|
146
|
+
content_parts.append(content)
|
|
147
|
+
|
|
148
|
+
content_parts.extend(self._generate_variants(entity.permalink))
|
|
149
|
+
content_parts.extend(self._generate_variants(entity.file_path))
|
|
150
|
+
|
|
151
|
+
entity_content = "\n".join(p for p in content_parts if p and p.strip())
|
|
152
|
+
|
|
153
|
+
# Index entity
|
|
154
|
+
await self.repository.index_item(
|
|
155
|
+
SearchIndexRow(
|
|
156
|
+
id=entity.id,
|
|
157
|
+
type=SearchItemType.ENTITY.value,
|
|
158
|
+
title=entity.title,
|
|
159
|
+
content=entity_content,
|
|
160
|
+
permalink=entity.permalink,
|
|
161
|
+
file_path=entity.file_path,
|
|
162
|
+
metadata={
|
|
163
|
+
"entity_type": entity.entity_type,
|
|
164
|
+
},
|
|
165
|
+
created_at=entity.created_at.isoformat(),
|
|
166
|
+
updated_at=entity.updated_at.isoformat(),
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Index each observation with permalink
|
|
171
|
+
for obs in entity.observations:
|
|
172
|
+
# Index with parent entity's file path since that's where it's defined
|
|
173
|
+
await self.repository.index_item(
|
|
174
|
+
SearchIndexRow(
|
|
175
|
+
id=obs.id,
|
|
176
|
+
type=SearchItemType.OBSERVATION.value,
|
|
177
|
+
title=f"{obs.category}: {obs.content[:50]}...",
|
|
178
|
+
content=obs.content,
|
|
179
|
+
permalink=obs.permalink,
|
|
180
|
+
file_path=entity.file_path,
|
|
181
|
+
category=obs.category,
|
|
182
|
+
entity_id=entity.id,
|
|
183
|
+
metadata={
|
|
184
|
+
"tags": obs.tags,
|
|
185
|
+
},
|
|
186
|
+
created_at=entity.created_at.isoformat(),
|
|
187
|
+
updated_at=entity.updated_at.isoformat(),
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Only index outgoing relations (ones defined in this file)
|
|
192
|
+
for rel in entity.outgoing_relations:
|
|
193
|
+
# Create descriptive title showing the relationship
|
|
194
|
+
relation_title = (
|
|
195
|
+
f"{rel.from_entity.title} → {rel.to_entity.title}"
|
|
196
|
+
if rel.to_entity
|
|
197
|
+
else f"{rel.from_entity.title}"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
await self.repository.index_item(
|
|
201
|
+
SearchIndexRow(
|
|
202
|
+
id=rel.id,
|
|
203
|
+
title=relation_title,
|
|
204
|
+
content=rel.context or "",
|
|
205
|
+
permalink=rel.permalink,
|
|
206
|
+
file_path=entity.file_path,
|
|
207
|
+
type=SearchItemType.RELATION.value,
|
|
208
|
+
from_id=rel.from_id,
|
|
209
|
+
to_id=rel.to_id,
|
|
210
|
+
relation_type=rel.relation_type,
|
|
211
|
+
created_at=entity.created_at.isoformat(),
|
|
212
|
+
updated_at=entity.updated_at.isoformat(),
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def delete_by_permalink(self, path_id: str):
|
|
217
|
+
"""Delete an item from the search index."""
|
|
218
|
+
await self.repository.delete_by_permalink(path_id)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Base service class."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import TypeVar, Generic, List, Sequence
|
|
5
|
+
|
|
6
|
+
from basic_memory.models import Base
|
|
7
|
+
from basic_memory.repository.repository import Repository
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T", bound=Base)
|
|
10
|
+
R = TypeVar("R", bound=Repository)
|
|
11
|
+
|
|
12
|
+
class BaseService(Generic[T]):
|
|
13
|
+
"""Base service that takes a repository."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, repository: R):
|
|
16
|
+
"""Initialize service with repository."""
|
|
17
|
+
self.repository = repository
|
|
18
|
+
|
|
19
|
+
async def add(self, model: T) -> T:
|
|
20
|
+
"""Add model to repository."""
|
|
21
|
+
return await self.repository.add(model)
|
|
22
|
+
|
|
23
|
+
async def add_all(self, models: List[T]) -> Sequence[T]:
|
|
24
|
+
"""Add a List of models to repository."""
|
|
25
|
+
return await self.repository.add_all(models)
|
|
26
|
+
|
|
27
|
+
async def get_modified_since(self, since: datetime) -> Sequence[T]:
|
|
28
|
+
"""Get all items modified since the given timestamp.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
since: Datetime to search from
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Sequence of items modified since the timestamp
|
|
35
|
+
"""
|
|
36
|
+
return await self.repository.find_modified_since(since)
|