basic-memory 0.7.0__py3-none-any.whl → 0.9.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 +1 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +23 -1
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +106 -0
- basic_memory/api/app.py +9 -10
- basic_memory/api/routers/__init__.py +2 -1
- basic_memory/api/routers/knowledge_router.py +31 -5
- basic_memory/api/routers/memory_router.py +18 -17
- basic_memory/api/routers/project_info_router.py +275 -0
- basic_memory/api/routers/resource_router.py +105 -4
- basic_memory/api/routers/search_router.py +22 -4
- basic_memory/cli/app.py +54 -5
- basic_memory/cli/commands/__init__.py +15 -2
- basic_memory/cli/commands/db.py +9 -13
- basic_memory/cli/commands/import_chatgpt.py +26 -30
- basic_memory/cli/commands/import_claude_conversations.py +27 -29
- basic_memory/cli/commands/import_claude_projects.py +29 -31
- basic_memory/cli/commands/import_memory_json.py +26 -28
- basic_memory/cli/commands/mcp.py +7 -1
- basic_memory/cli/commands/project.py +119 -0
- basic_memory/cli/commands/project_info.py +167 -0
- basic_memory/cli/commands/status.py +14 -28
- basic_memory/cli/commands/sync.py +63 -22
- basic_memory/cli/commands/tool.py +253 -0
- basic_memory/cli/main.py +39 -1
- basic_memory/config.py +166 -4
- basic_memory/db.py +19 -4
- basic_memory/deps.py +10 -3
- basic_memory/file_utils.py +37 -19
- basic_memory/markdown/entity_parser.py +3 -3
- basic_memory/markdown/utils.py +5 -0
- basic_memory/mcp/async_client.py +1 -1
- basic_memory/mcp/main.py +24 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +26 -0
- basic_memory/mcp/prompts/continue_conversation.py +111 -0
- basic_memory/mcp/prompts/recent_activity.py +88 -0
- basic_memory/mcp/prompts/search.py +182 -0
- basic_memory/mcp/prompts/utils.py +155 -0
- basic_memory/mcp/server.py +2 -6
- basic_memory/mcp/tools/__init__.py +12 -21
- basic_memory/mcp/tools/build_context.py +85 -0
- basic_memory/mcp/tools/canvas.py +97 -0
- basic_memory/mcp/tools/delete_note.py +28 -0
- basic_memory/mcp/tools/project_info.py +51 -0
- basic_memory/mcp/tools/read_content.py +229 -0
- basic_memory/mcp/tools/read_note.py +190 -0
- basic_memory/mcp/tools/recent_activity.py +100 -0
- basic_memory/mcp/tools/search.py +56 -17
- basic_memory/mcp/tools/utils.py +245 -16
- basic_memory/mcp/tools/write_note.py +124 -0
- basic_memory/models/knowledge.py +27 -11
- basic_memory/models/search.py +2 -1
- basic_memory/repository/entity_repository.py +3 -2
- basic_memory/repository/project_info_repository.py +9 -0
- basic_memory/repository/repository.py +24 -7
- basic_memory/repository/search_repository.py +47 -14
- basic_memory/schemas/__init__.py +10 -9
- basic_memory/schemas/base.py +4 -1
- basic_memory/schemas/memory.py +14 -4
- basic_memory/schemas/project_info.py +96 -0
- basic_memory/schemas/search.py +29 -33
- basic_memory/services/context_service.py +3 -3
- basic_memory/services/entity_service.py +26 -13
- basic_memory/services/file_service.py +145 -26
- basic_memory/services/link_resolver.py +9 -46
- basic_memory/services/search_service.py +95 -22
- basic_memory/sync/__init__.py +3 -2
- basic_memory/sync/sync_service.py +523 -117
- basic_memory/sync/watch_service.py +258 -132
- basic_memory/utils.py +51 -36
- basic_memory-0.9.0.dist-info/METADATA +736 -0
- basic_memory-0.9.0.dist-info/RECORD +99 -0
- basic_memory/alembic/README +0 -1
- 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.9.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.9.0.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.9.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -21,31 +21,38 @@ class SearchIndexRow:
|
|
|
21
21
|
|
|
22
22
|
id: int
|
|
23
23
|
type: str
|
|
24
|
-
permalink: str
|
|
25
24
|
file_path: str
|
|
26
|
-
metadata: Optional[dict] = None
|
|
27
25
|
|
|
28
26
|
# date values
|
|
29
|
-
created_at:
|
|
30
|
-
updated_at:
|
|
27
|
+
created_at: datetime
|
|
28
|
+
updated_at: datetime
|
|
29
|
+
|
|
30
|
+
permalink: Optional[str] = None
|
|
31
|
+
metadata: Optional[dict] = None
|
|
31
32
|
|
|
32
33
|
# assigned in result
|
|
33
34
|
score: Optional[float] = None
|
|
34
35
|
|
|
35
36
|
# Type-specific fields
|
|
36
37
|
title: Optional[str] = None # entity
|
|
37
|
-
|
|
38
|
+
content_stems: Optional[str] = None # entity, observation
|
|
39
|
+
content_snippet: Optional[str] = None # entity, observation
|
|
38
40
|
entity_id: Optional[int] = None # observations
|
|
39
41
|
category: Optional[str] = None # observations
|
|
40
42
|
from_id: Optional[int] = None # relations
|
|
41
43
|
to_id: Optional[int] = None # relations
|
|
42
44
|
relation_type: Optional[str] = None # relations
|
|
43
45
|
|
|
46
|
+
@property
|
|
47
|
+
def content(self):
|
|
48
|
+
return self.content_snippet
|
|
49
|
+
|
|
44
50
|
def to_insert(self):
|
|
45
51
|
return {
|
|
46
52
|
"id": self.id,
|
|
47
53
|
"title": self.title,
|
|
48
|
-
"
|
|
54
|
+
"content_stems": self.content_stems,
|
|
55
|
+
"content_snippet": self.content_snippet,
|
|
49
56
|
"permalink": self.permalink,
|
|
50
57
|
"file_path": self.file_path,
|
|
51
58
|
"type": self.type,
|
|
@@ -87,10 +94,16 @@ class SearchRepository:
|
|
|
87
94
|
For FTS5:
|
|
88
95
|
- Special characters and phrases need to be quoted
|
|
89
96
|
- Terms with spaces or special chars need quotes
|
|
97
|
+
- Boolean operators (AND, OR, NOT) and parentheses are preserved
|
|
90
98
|
"""
|
|
91
99
|
if "*" in term:
|
|
92
100
|
return term
|
|
93
101
|
|
|
102
|
+
# Check for boolean operators - if present, return the term as is
|
|
103
|
+
boolean_operators = [" AND ", " OR ", " NOT ", "(", ")"]
|
|
104
|
+
if any(op in f" {term} " for op in boolean_operators):
|
|
105
|
+
return term
|
|
106
|
+
|
|
94
107
|
# List of special characters that need quoting (excluding *)
|
|
95
108
|
special_chars = ["/", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
|
|
96
109
|
|
|
@@ -123,9 +136,20 @@ class SearchRepository:
|
|
|
123
136
|
|
|
124
137
|
# Handle text search for title and content
|
|
125
138
|
if search_text:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
139
|
+
has_boolean = any(
|
|
140
|
+
op in f" {search_text} " for op in [" AND ", " OR ", " NOT ", "(", ")"]
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if has_boolean:
|
|
144
|
+
# If boolean operators are present, use the raw query
|
|
145
|
+
# No need to prepare it, FTS5 will understand the operators
|
|
146
|
+
params["text"] = search_text
|
|
147
|
+
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
|
148
|
+
else:
|
|
149
|
+
# Standard search with term preparation
|
|
150
|
+
processed_text = self._prepare_search_term(search_text.strip())
|
|
151
|
+
params["text"] = processed_text
|
|
152
|
+
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
|
129
153
|
|
|
130
154
|
# Handle title match search
|
|
131
155
|
if title:
|
|
@@ -187,7 +211,7 @@ class SearchRepository:
|
|
|
187
211
|
to_id,
|
|
188
212
|
relation_type,
|
|
189
213
|
entity_id,
|
|
190
|
-
|
|
214
|
+
content_snippet,
|
|
191
215
|
category,
|
|
192
216
|
created_at,
|
|
193
217
|
updated_at,
|
|
@@ -199,7 +223,7 @@ class SearchRepository:
|
|
|
199
223
|
OFFSET :offset
|
|
200
224
|
"""
|
|
201
225
|
|
|
202
|
-
logger.
|
|
226
|
+
logger.trace(f"Search {sql} params: {params}")
|
|
203
227
|
async with db.scoped_session(self.session_maker) as session:
|
|
204
228
|
result = await session.execute(text(sql), params)
|
|
205
229
|
rows = result.fetchall()
|
|
@@ -217,7 +241,7 @@ class SearchRepository:
|
|
|
217
241
|
to_id=row.to_id,
|
|
218
242
|
relation_type=row.relation_type,
|
|
219
243
|
entity_id=row.entity_id,
|
|
220
|
-
|
|
244
|
+
content_snippet=row.content_snippet,
|
|
221
245
|
category=row.category,
|
|
222
246
|
created_at=row.created_at,
|
|
223
247
|
updated_at=row.updated_at,
|
|
@@ -249,12 +273,12 @@ class SearchRepository:
|
|
|
249
273
|
await session.execute(
|
|
250
274
|
text("""
|
|
251
275
|
INSERT INTO search_index (
|
|
252
|
-
id, title,
|
|
276
|
+
id, title, content_stems, content_snippet, permalink, file_path, type, metadata,
|
|
253
277
|
from_id, to_id, relation_type,
|
|
254
278
|
entity_id, category,
|
|
255
279
|
created_at, updated_at
|
|
256
280
|
) VALUES (
|
|
257
|
-
:id, :title, :
|
|
281
|
+
:id, :title, :content_stems, :content_snippet, :permalink, :file_path, :type, :metadata,
|
|
258
282
|
:from_id, :to_id, :relation_type,
|
|
259
283
|
:entity_id, :category,
|
|
260
284
|
:created_at, :updated_at
|
|
@@ -265,6 +289,15 @@ class SearchRepository:
|
|
|
265
289
|
logger.debug(f"indexed row {search_index_row}")
|
|
266
290
|
await session.commit()
|
|
267
291
|
|
|
292
|
+
async def delete_by_entity_id(self, entity_id: int):
|
|
293
|
+
"""Delete an item from the search index by entity_id."""
|
|
294
|
+
async with db.scoped_session(self.session_maker) as session:
|
|
295
|
+
await session.execute(
|
|
296
|
+
text("DELETE FROM search_index WHERE entity_id = :entity_id"),
|
|
297
|
+
{"entity_id": entity_id},
|
|
298
|
+
)
|
|
299
|
+
await session.commit()
|
|
300
|
+
|
|
268
301
|
async def delete_by_permalink(self, permalink: str):
|
|
269
302
|
"""Delete an item from the search index."""
|
|
270
303
|
async with db.scoped_session(self.session_maker) as session:
|
basic_memory/schemas/__init__.py
CHANGED
|
@@ -37,11 +37,11 @@ from basic_memory.schemas.response import (
|
|
|
37
37
|
DeleteEntitiesResponse,
|
|
38
38
|
)
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
from basic_memory.schemas.project_info import (
|
|
41
|
+
ProjectStatistics,
|
|
42
|
+
ActivityMetrics,
|
|
43
|
+
SystemStatus,
|
|
44
|
+
ProjectInfoResponse,
|
|
45
45
|
)
|
|
46
46
|
|
|
47
47
|
# For convenient imports, export all models
|
|
@@ -66,8 +66,9 @@ __all__ = [
|
|
|
66
66
|
"DeleteEntitiesResponse",
|
|
67
67
|
# Delete Operations
|
|
68
68
|
"DeleteEntitiesRequest",
|
|
69
|
-
#
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
69
|
+
# Project Info
|
|
70
|
+
"ProjectStatistics",
|
|
71
|
+
"ActivityMetrics",
|
|
72
|
+
"SystemStatus",
|
|
73
|
+
"ProjectInfoResponse",
|
|
73
74
|
]
|
basic_memory/schemas/base.py
CHANGED
|
@@ -159,7 +159,10 @@ class Entity(BaseModel):
|
|
|
159
159
|
@property
|
|
160
160
|
def file_path(self):
|
|
161
161
|
"""Get the file path for this entity based on its permalink."""
|
|
162
|
-
|
|
162
|
+
if self.content_type == "text/markdown":
|
|
163
|
+
return f"{self.folder}/{self.title}.md" if self.folder else f"{self.title}.md"
|
|
164
|
+
else:
|
|
165
|
+
return f"{self.folder}/{self.title}" if self.folder else self.title
|
|
163
166
|
|
|
164
167
|
@property
|
|
165
168
|
def permalink(self) -> Permalink:
|
basic_memory/schemas/memory.py
CHANGED
|
@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field, BeforeValidator, TypeAdapter
|
|
|
9
9
|
from basic_memory.schemas.search import SearchItemType
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def normalize_memory_url(url: str) -> str:
|
|
12
|
+
def normalize_memory_url(url: str | None) -> str:
|
|
13
13
|
"""Normalize a MemoryUrl string.
|
|
14
14
|
|
|
15
15
|
Args:
|
|
@@ -24,6 +24,9 @@ def normalize_memory_url(url: str) -> str:
|
|
|
24
24
|
>>> normalize_memory_url("memory://specs/search")
|
|
25
25
|
'memory://specs/search'
|
|
26
26
|
"""
|
|
27
|
+
if not url:
|
|
28
|
+
return ""
|
|
29
|
+
|
|
27
30
|
clean_path = url.removeprefix("memory://")
|
|
28
31
|
return f"memory://{clean_path}"
|
|
29
32
|
|
|
@@ -59,8 +62,9 @@ class EntitySummary(BaseModel):
|
|
|
59
62
|
"""Simplified entity representation."""
|
|
60
63
|
|
|
61
64
|
type: str = "entity"
|
|
62
|
-
permalink: str
|
|
65
|
+
permalink: Optional[str]
|
|
63
66
|
title: str
|
|
67
|
+
content: Optional[str] = None
|
|
64
68
|
file_path: str
|
|
65
69
|
created_at: datetime
|
|
66
70
|
|
|
@@ -69,19 +73,25 @@ class RelationSummary(BaseModel):
|
|
|
69
73
|
"""Simplified relation representation."""
|
|
70
74
|
|
|
71
75
|
type: str = "relation"
|
|
76
|
+
title: str
|
|
77
|
+
file_path: str
|
|
72
78
|
permalink: str
|
|
73
79
|
relation_type: str
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
from_entity: str
|
|
81
|
+
to_entity: Optional[str] = None
|
|
82
|
+
created_at: datetime
|
|
76
83
|
|
|
77
84
|
|
|
78
85
|
class ObservationSummary(BaseModel):
|
|
79
86
|
"""Simplified observation representation."""
|
|
80
87
|
|
|
81
88
|
type: str = "observation"
|
|
89
|
+
title: str
|
|
90
|
+
file_path: str
|
|
82
91
|
permalink: str
|
|
83
92
|
category: str
|
|
84
93
|
content: str
|
|
94
|
+
created_at: datetime
|
|
85
95
|
|
|
86
96
|
|
|
87
97
|
class MemoryMetadata(BaseModel):
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Schema for project info response."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Dict, List, Optional, Any
|
|
5
|
+
|
|
6
|
+
from pydantic import Field, BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProjectStatistics(BaseModel):
|
|
10
|
+
"""Statistics about the current project."""
|
|
11
|
+
|
|
12
|
+
# Basic counts
|
|
13
|
+
total_entities: int = Field(description="Total number of entities in the knowledge base")
|
|
14
|
+
total_observations: int = Field(description="Total number of observations across all entities")
|
|
15
|
+
total_relations: int = Field(description="Total number of relations between entities")
|
|
16
|
+
total_unresolved_relations: int = Field(
|
|
17
|
+
description="Number of relations with unresolved targets"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Entity counts by type
|
|
21
|
+
entity_types: Dict[str, int] = Field(
|
|
22
|
+
description="Count of entities by type (e.g., note, conversation)"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Observation counts by category
|
|
26
|
+
observation_categories: Dict[str, int] = Field(
|
|
27
|
+
description="Count of observations by category (e.g., tech, decision)"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Relation counts by type
|
|
31
|
+
relation_types: Dict[str, int] = Field(
|
|
32
|
+
description="Count of relations by type (e.g., implements, relates_to)"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Graph metrics
|
|
36
|
+
most_connected_entities: List[Dict[str, Any]] = Field(
|
|
37
|
+
description="Entities with the most relations, including their titles and permalinks"
|
|
38
|
+
)
|
|
39
|
+
isolated_entities: int = Field(description="Number of entities with no relations")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ActivityMetrics(BaseModel):
|
|
43
|
+
"""Activity metrics for the current project."""
|
|
44
|
+
|
|
45
|
+
# Recent activity
|
|
46
|
+
recently_created: List[Dict[str, Any]] = Field(
|
|
47
|
+
description="Recently created entities with timestamps"
|
|
48
|
+
)
|
|
49
|
+
recently_updated: List[Dict[str, Any]] = Field(
|
|
50
|
+
description="Recently updated entities with timestamps"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Growth over time (last 6 months)
|
|
54
|
+
monthly_growth: Dict[str, Dict[str, int]] = Field(
|
|
55
|
+
description="Monthly growth statistics for entities, observations, and relations"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SystemStatus(BaseModel):
|
|
60
|
+
"""System status information."""
|
|
61
|
+
|
|
62
|
+
# Version information
|
|
63
|
+
version: str = Field(description="Basic Memory version")
|
|
64
|
+
|
|
65
|
+
# Database status
|
|
66
|
+
database_path: str = Field(description="Path to the SQLite database")
|
|
67
|
+
database_size: str = Field(description="Size of the database in human-readable format")
|
|
68
|
+
|
|
69
|
+
# Watch service status
|
|
70
|
+
watch_status: Optional[Dict[str, Any]] = Field(
|
|
71
|
+
default=None, description="Watch service status information (if running)"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# System information
|
|
75
|
+
timestamp: datetime = Field(description="Timestamp when the information was collected")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ProjectInfoResponse(BaseModel):
|
|
79
|
+
"""Response for the project_info tool."""
|
|
80
|
+
|
|
81
|
+
# Project configuration
|
|
82
|
+
project_name: str = Field(description="Name of the current project")
|
|
83
|
+
project_path: str = Field(description="Path to the current project files")
|
|
84
|
+
available_projects: Dict[str, str] = Field(
|
|
85
|
+
description="Map of configured project names to paths"
|
|
86
|
+
)
|
|
87
|
+
default_project: str = Field(description="Name of the default project")
|
|
88
|
+
|
|
89
|
+
# Statistics
|
|
90
|
+
statistics: ProjectStatistics = Field(description="Statistics about the knowledge base")
|
|
91
|
+
|
|
92
|
+
# Activity metrics
|
|
93
|
+
activity: ActivityMetrics = Field(description="Activity and growth metrics")
|
|
94
|
+
|
|
95
|
+
# System status
|
|
96
|
+
system: SystemStatus = Field(description="System and service status information")
|
basic_memory/schemas/search.py
CHANGED
|
@@ -11,6 +11,8 @@ from datetime import datetime
|
|
|
11
11
|
from enum import Enum
|
|
12
12
|
from pydantic import BaseModel, field_validator
|
|
13
13
|
|
|
14
|
+
from basic_memory.schemas.base import Permalink
|
|
15
|
+
|
|
14
16
|
|
|
15
17
|
class SearchItemType(str, Enum):
|
|
16
18
|
"""Types of searchable items."""
|
|
@@ -26,18 +28,24 @@ class SearchQuery(BaseModel):
|
|
|
26
28
|
Use ONE of these primary search modes:
|
|
27
29
|
- permalink: Exact permalink match
|
|
28
30
|
- permalink_match: Path pattern with *
|
|
29
|
-
- text: Full-text search of title/content
|
|
31
|
+
- text: Full-text search of title/content (supports boolean operators: AND, OR, NOT)
|
|
30
32
|
|
|
31
33
|
Optionally filter results by:
|
|
32
34
|
- types: Limit to specific item types
|
|
33
35
|
- entity_types: Limit to specific entity types
|
|
34
36
|
- after_date: Only items after date
|
|
37
|
+
|
|
38
|
+
Boolean search examples:
|
|
39
|
+
- "python AND flask" - Find items with both terms
|
|
40
|
+
- "python OR django" - Find items with either term
|
|
41
|
+
- "python NOT django" - Find items with python but not django
|
|
42
|
+
- "(python OR flask) AND web" - Use parentheses for grouping
|
|
35
43
|
"""
|
|
36
44
|
|
|
37
45
|
# Primary search modes (use ONE of these)
|
|
38
46
|
permalink: Optional[str] = None # Exact permalink match
|
|
39
|
-
permalink_match: Optional[str] = None #
|
|
40
|
-
text: Optional[str] = None # Full-text search
|
|
47
|
+
permalink_match: Optional[str] = None # Glob permalink match
|
|
48
|
+
text: Optional[str] = None # Full-text search (now supports boolean operators)
|
|
41
49
|
title: Optional[str] = None # title only search
|
|
42
50
|
|
|
43
51
|
# Optional filters
|
|
@@ -57,60 +65,48 @@ class SearchQuery(BaseModel):
|
|
|
57
65
|
return (
|
|
58
66
|
self.permalink is None
|
|
59
67
|
and self.permalink_match is None
|
|
68
|
+
and self.title is None
|
|
60
69
|
and self.text is None
|
|
61
70
|
and self.after_date is None
|
|
62
71
|
and self.types is None
|
|
63
72
|
and self.entity_types is None
|
|
64
73
|
)
|
|
65
74
|
|
|
75
|
+
def has_boolean_operators(self) -> bool:
|
|
76
|
+
"""Check if the text query contains boolean operators (AND, OR, NOT)."""
|
|
77
|
+
if not self.text: # pragma: no cover
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
# Check for common boolean operators with correct word boundaries
|
|
81
|
+
# to avoid matching substrings like "GRAND" containing "AND"
|
|
82
|
+
boolean_patterns = [" AND ", " OR ", " NOT ", "(", ")"]
|
|
83
|
+
text = f" {self.text} " # Add spaces to ensure we match word boundaries
|
|
84
|
+
return any(pattern in text for pattern in boolean_patterns)
|
|
85
|
+
|
|
66
86
|
|
|
67
87
|
class SearchResult(BaseModel):
|
|
68
88
|
"""Search result with score and metadata."""
|
|
69
89
|
|
|
70
|
-
|
|
90
|
+
title: str
|
|
71
91
|
type: SearchItemType
|
|
72
92
|
score: float
|
|
73
|
-
|
|
93
|
+
entity: Optional[Permalink]
|
|
94
|
+
permalink: Optional[str]
|
|
95
|
+
content: Optional[str] = None
|
|
74
96
|
file_path: str
|
|
75
97
|
|
|
76
98
|
metadata: Optional[dict] = None
|
|
77
99
|
|
|
78
100
|
# Type-specific fields
|
|
79
|
-
entity_id: Optional[int] = None # For observations
|
|
80
101
|
category: Optional[str] = None # For observations
|
|
81
|
-
|
|
82
|
-
|
|
102
|
+
from_entity: Optional[Permalink] = None # For relations
|
|
103
|
+
to_entity: Optional[Permalink] = None # For relations
|
|
83
104
|
relation_type: Optional[str] = None # For relations
|
|
84
105
|
|
|
85
106
|
|
|
86
|
-
class RelatedResult(BaseModel):
|
|
87
|
-
type: SearchItemType
|
|
88
|
-
id: int
|
|
89
|
-
title: str
|
|
90
|
-
permalink: str
|
|
91
|
-
depth: int
|
|
92
|
-
root_id: int
|
|
93
|
-
created_at: datetime
|
|
94
|
-
from_id: Optional[int] = None
|
|
95
|
-
to_id: Optional[int] = None
|
|
96
|
-
relation_type: Optional[str] = None
|
|
97
|
-
category: Optional[str] = None
|
|
98
|
-
entity_id: Optional[int] = None
|
|
99
|
-
|
|
100
|
-
|
|
101
107
|
class SearchResponse(BaseModel):
|
|
102
108
|
"""Wrapper for search results."""
|
|
103
109
|
|
|
104
110
|
results: List[SearchResult]
|
|
105
111
|
current_page: int
|
|
106
112
|
page_size: int
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
# Schema for future advanced search endpoint
|
|
110
|
-
class AdvancedSearchQuery(BaseModel):
|
|
111
|
-
"""Advanced full-text search with explicit FTS5 syntax."""
|
|
112
|
-
|
|
113
|
-
query: str # Raw FTS5 query (e.g., "foo AND bar")
|
|
114
|
-
types: Optional[List[SearchItemType]] = None
|
|
115
|
-
entity_types: Optional[List[str]] = None
|
|
116
|
-
after_date: Optional[Union[datetime, str]] = None
|
|
@@ -165,7 +165,7 @@ class ContextService:
|
|
|
165
165
|
from_id,
|
|
166
166
|
to_id,
|
|
167
167
|
relation_type,
|
|
168
|
-
content,
|
|
168
|
+
content_snippet as content,
|
|
169
169
|
category,
|
|
170
170
|
entity_id,
|
|
171
171
|
0 as depth,
|
|
@@ -189,7 +189,7 @@ class ContextService:
|
|
|
189
189
|
r.from_id,
|
|
190
190
|
r.to_id,
|
|
191
191
|
r.relation_type,
|
|
192
|
-
r.content,
|
|
192
|
+
r.content_snippet as content,
|
|
193
193
|
r.category,
|
|
194
194
|
r.entity_id,
|
|
195
195
|
cg.depth + 1,
|
|
@@ -218,7 +218,7 @@ class ContextService:
|
|
|
218
218
|
e.from_id,
|
|
219
219
|
e.to_id,
|
|
220
220
|
e.relation_type,
|
|
221
|
-
e.content,
|
|
221
|
+
e.content_snippet as content,
|
|
222
222
|
e.category,
|
|
223
223
|
e.entity_id,
|
|
224
224
|
cg.depth + 1, -- Increment depth for entities
|
|
@@ -124,17 +124,19 @@ class EntityService(BaseService[EntityModel]):
|
|
|
124
124
|
entity_markdown = await self.entity_parser.parse_file(file_path)
|
|
125
125
|
|
|
126
126
|
# create entity
|
|
127
|
-
await self.create_entity_from_markdown(file_path, entity_markdown)
|
|
127
|
+
created = await self.create_entity_from_markdown(file_path, entity_markdown)
|
|
128
128
|
|
|
129
129
|
# add relations
|
|
130
|
-
entity = await self.update_entity_relations(file_path, entity_markdown)
|
|
130
|
+
entity = await self.update_entity_relations(created.file_path, entity_markdown)
|
|
131
131
|
|
|
132
132
|
# Set final checksum to mark complete
|
|
133
133
|
return await self.repository.update(entity.id, {"checksum": checksum})
|
|
134
134
|
|
|
135
135
|
async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> EntityModel:
|
|
136
136
|
"""Update an entity's content and metadata."""
|
|
137
|
-
logger.debug(
|
|
137
|
+
logger.debug(
|
|
138
|
+
f"Updating entity with permalink: {entity.permalink} content-type: {schema.content_type}"
|
|
139
|
+
)
|
|
138
140
|
|
|
139
141
|
# Convert file path string to Path
|
|
140
142
|
file_path = Path(entity.file_path)
|
|
@@ -142,7 +144,7 @@ class EntityService(BaseService[EntityModel]):
|
|
|
142
144
|
post = await schema_to_markdown(schema)
|
|
143
145
|
|
|
144
146
|
# write file
|
|
145
|
-
final_content = frontmatter.dumps(post)
|
|
147
|
+
final_content = frontmatter.dumps(post, sort_keys=False)
|
|
146
148
|
checksum = await self.file_service.write_file(file_path, final_content)
|
|
147
149
|
|
|
148
150
|
# parse entity from file
|
|
@@ -152,20 +154,31 @@ class EntityService(BaseService[EntityModel]):
|
|
|
152
154
|
entity = await self.update_entity_and_observations(file_path, entity_markdown)
|
|
153
155
|
|
|
154
156
|
# add relations
|
|
155
|
-
await self.update_entity_relations(file_path, entity_markdown)
|
|
157
|
+
await self.update_entity_relations(str(file_path), entity_markdown)
|
|
156
158
|
|
|
157
159
|
# Set final checksum to match file
|
|
158
160
|
entity = await self.repository.update(entity.id, {"checksum": checksum})
|
|
159
161
|
|
|
160
162
|
return entity
|
|
161
163
|
|
|
162
|
-
async def delete_entity(self,
|
|
164
|
+
async def delete_entity(self, permalink_or_id: str | int) -> bool:
|
|
163
165
|
"""Delete entity and its file."""
|
|
164
|
-
logger.debug(f"Deleting entity: {
|
|
166
|
+
logger.debug(f"Deleting entity: {permalink_or_id}")
|
|
165
167
|
|
|
166
168
|
try:
|
|
167
169
|
# Get entity first for file deletion
|
|
168
|
-
|
|
170
|
+
if isinstance(permalink_or_id, str):
|
|
171
|
+
entity = await self.get_by_permalink(permalink_or_id)
|
|
172
|
+
else:
|
|
173
|
+
entities = await self.get_entities_by_id([permalink_or_id])
|
|
174
|
+
if len(entities) != 1: # pragma: no cover
|
|
175
|
+
logger.error(
|
|
176
|
+
"Entity lookup error", entity_id=permalink_or_id, found_count=len(entities)
|
|
177
|
+
)
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"Expected 1 entity with ID {permalink_or_id}, got {len(entities)}"
|
|
180
|
+
)
|
|
181
|
+
entity = entities[0]
|
|
169
182
|
|
|
170
183
|
# Delete file first
|
|
171
184
|
await self.file_service.delete_entity_file(entity)
|
|
@@ -174,7 +187,7 @@ class EntityService(BaseService[EntityModel]):
|
|
|
174
187
|
return await self.repository.delete(entity.id)
|
|
175
188
|
|
|
176
189
|
except EntityNotFoundError:
|
|
177
|
-
logger.info(f"Entity not found: {
|
|
190
|
+
logger.info(f"Entity not found: {permalink_or_id}")
|
|
178
191
|
return True # Already deleted
|
|
179
192
|
|
|
180
193
|
async def get_by_permalink(self, permalink: str) -> EntityModel:
|
|
@@ -256,13 +269,13 @@ class EntityService(BaseService[EntityModel]):
|
|
|
256
269
|
|
|
257
270
|
async def update_entity_relations(
|
|
258
271
|
self,
|
|
259
|
-
|
|
272
|
+
path: str,
|
|
260
273
|
markdown: EntityMarkdown,
|
|
261
274
|
) -> EntityModel:
|
|
262
275
|
"""Update relations for entity"""
|
|
263
|
-
logger.debug(f"Updating relations for entity: {
|
|
276
|
+
logger.debug(f"Updating relations for entity: {path}")
|
|
264
277
|
|
|
265
|
-
db_entity = await self.repository.get_by_file_path(
|
|
278
|
+
db_entity = await self.repository.get_by_file_path(path)
|
|
266
279
|
|
|
267
280
|
# Clear existing relations first
|
|
268
281
|
await self.relation_repository.delete_outgoing_relations_from_entity(db_entity.id)
|
|
@@ -296,4 +309,4 @@ class EntityService(BaseService[EntityModel]):
|
|
|
296
309
|
)
|
|
297
310
|
continue
|
|
298
311
|
|
|
299
|
-
return await self.repository.get_by_file_path(
|
|
312
|
+
return await self.repository.get_by_file_path(path)
|