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,204 @@
|
|
|
1
|
+
"""Knowledge graph models."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import (
|
|
8
|
+
Integer,
|
|
9
|
+
String,
|
|
10
|
+
Text,
|
|
11
|
+
ForeignKey,
|
|
12
|
+
UniqueConstraint,
|
|
13
|
+
DateTime,
|
|
14
|
+
Index,
|
|
15
|
+
JSON,
|
|
16
|
+
)
|
|
17
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
|
18
|
+
|
|
19
|
+
from basic_memory.models.base import Base
|
|
20
|
+
from enum import Enum
|
|
21
|
+
|
|
22
|
+
from basic_memory.utils import generate_permalink
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Entity(Base):
|
|
26
|
+
"""
|
|
27
|
+
Core entity in the knowledge graph.
|
|
28
|
+
|
|
29
|
+
Entities represent semantic nodes maintained by the AI layer. Each entity:
|
|
30
|
+
- Has a unique numeric ID (database-generated)
|
|
31
|
+
- Maps to a file on disk
|
|
32
|
+
- Maintains a checksum for change detection
|
|
33
|
+
- Tracks both source file and semantic properties
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__tablename__ = "entity"
|
|
37
|
+
__table_args__ = (
|
|
38
|
+
UniqueConstraint("permalink", name="uix_entity_permalink"), # Make permalink unique
|
|
39
|
+
Index("ix_entity_type", "entity_type"),
|
|
40
|
+
Index("ix_entity_title", "title"),
|
|
41
|
+
Index("ix_entity_created_at", "created_at"), # For timeline queries
|
|
42
|
+
Index("ix_entity_updated_at", "updated_at"), # For timeline queries
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Core identity
|
|
46
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
47
|
+
title: Mapped[str] = mapped_column(String)
|
|
48
|
+
entity_type: Mapped[str] = mapped_column(String)
|
|
49
|
+
entity_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
|
50
|
+
content_type: Mapped[str] = mapped_column(String)
|
|
51
|
+
|
|
52
|
+
# Normalized path for URIs
|
|
53
|
+
permalink: Mapped[str] = mapped_column(String, unique=True, index=True)
|
|
54
|
+
# Actual filesystem relative path
|
|
55
|
+
file_path: Mapped[str] = mapped_column(String, unique=True, index=True)
|
|
56
|
+
# checksum of file
|
|
57
|
+
checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
58
|
+
|
|
59
|
+
# Metadata and tracking
|
|
60
|
+
created_at: Mapped[datetime] = mapped_column(DateTime)
|
|
61
|
+
updated_at: Mapped[datetime] = mapped_column(DateTime)
|
|
62
|
+
|
|
63
|
+
# Relationships
|
|
64
|
+
observations = relationship(
|
|
65
|
+
"Observation", back_populates="entity", cascade="all, delete-orphan"
|
|
66
|
+
)
|
|
67
|
+
outgoing_relations = relationship(
|
|
68
|
+
"Relation",
|
|
69
|
+
back_populates="from_entity",
|
|
70
|
+
foreign_keys="[Relation.from_id]",
|
|
71
|
+
cascade="all, delete-orphan",
|
|
72
|
+
)
|
|
73
|
+
incoming_relations = relationship(
|
|
74
|
+
"Relation",
|
|
75
|
+
back_populates="to_entity",
|
|
76
|
+
foreign_keys="[Relation.to_id]",
|
|
77
|
+
cascade="all, delete-orphan",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def relations(self):
|
|
82
|
+
return self.incoming_relations + self.outgoing_relations
|
|
83
|
+
|
|
84
|
+
@validates("permalink")
|
|
85
|
+
def validate_permalink(self, key, value):
|
|
86
|
+
"""Validate permalink format.
|
|
87
|
+
|
|
88
|
+
Requirements:
|
|
89
|
+
1. Must be valid URI path component
|
|
90
|
+
2. Only lowercase letters, numbers, and hyphens (no underscores)
|
|
91
|
+
3. Path segments separated by forward slashes
|
|
92
|
+
4. No leading/trailing hyphens in segments
|
|
93
|
+
"""
|
|
94
|
+
if not value:
|
|
95
|
+
raise ValueError("Permalink must not be None")
|
|
96
|
+
|
|
97
|
+
if not re.match(r"^[a-z0-9][a-z0-9\-/]*[a-z0-9]$", value):
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Invalid permalink format: {value}. "
|
|
100
|
+
"Use only lowercase letters, numbers, and hyphens."
|
|
101
|
+
)
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
def __repr__(self) -> str:
|
|
105
|
+
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ObservationCategory(str, Enum):
|
|
109
|
+
TECH = "tech"
|
|
110
|
+
DESIGN = "design"
|
|
111
|
+
FEATURE = "feature"
|
|
112
|
+
NOTE = "note"
|
|
113
|
+
ISSUE = "issue"
|
|
114
|
+
TODO = "todo"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Observation(Base):
|
|
118
|
+
"""
|
|
119
|
+
An observation about an entity.
|
|
120
|
+
|
|
121
|
+
Observations are atomic facts or notes about an entity.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
__tablename__ = "observation"
|
|
125
|
+
__table_args__ = (
|
|
126
|
+
Index("ix_observation_entity_id", "entity_id"), # Add FK index
|
|
127
|
+
Index("ix_observation_category", "category"), # Add category index
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
131
|
+
entity_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
|
|
132
|
+
content: Mapped[str] = mapped_column(Text)
|
|
133
|
+
category: Mapped[str] = mapped_column(
|
|
134
|
+
String,
|
|
135
|
+
nullable=False,
|
|
136
|
+
default=ObservationCategory.NOTE.value,
|
|
137
|
+
server_default=ObservationCategory.NOTE.value,
|
|
138
|
+
)
|
|
139
|
+
context: Mapped[str] = mapped_column(Text, nullable=True)
|
|
140
|
+
tags: Mapped[Optional[list[str]]] = mapped_column(
|
|
141
|
+
JSON, nullable=True, default=list, server_default="[]"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Relationships
|
|
145
|
+
entity = relationship("Entity", back_populates="observations")
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def permalink(self) -> str:
|
|
149
|
+
"""
|
|
150
|
+
Create synthetic permalink for the observation
|
|
151
|
+
We can construct these because observations are always
|
|
152
|
+
defined in and owned by a single entity
|
|
153
|
+
"""
|
|
154
|
+
return generate_permalink(
|
|
155
|
+
f"{self.entity.permalink}/observations/{self.category}/{self.content}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def __repr__(self) -> str:
|
|
159
|
+
return f"Observation(id={self.id}, entity_id={self.entity_id}, content='{self.content}')"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Relation(Base):
|
|
163
|
+
"""
|
|
164
|
+
A directed relation between two entities.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
__tablename__ = "relation"
|
|
168
|
+
__table_args__ = (
|
|
169
|
+
UniqueConstraint("from_id", "to_id", "relation_type", name="uix_relation"),
|
|
170
|
+
Index("ix_relation_type", "relation_type"),
|
|
171
|
+
Index("ix_relation_from_id", "from_id"), # Add FK indexes
|
|
172
|
+
Index("ix_relation_to_id", "to_id"),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
176
|
+
from_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
|
|
177
|
+
to_id: Mapped[int] = mapped_column(
|
|
178
|
+
Integer, ForeignKey("entity.id", ondelete="CASCADE"), nullable=True
|
|
179
|
+
)
|
|
180
|
+
to_name: Mapped[str] = mapped_column(String)
|
|
181
|
+
relation_type: Mapped[str] = mapped_column(String)
|
|
182
|
+
context: Mapped[str] = mapped_column(Text, nullable=True)
|
|
183
|
+
|
|
184
|
+
# Relationships
|
|
185
|
+
from_entity = relationship(
|
|
186
|
+
"Entity", foreign_keys=[from_id], back_populates="outgoing_relations"
|
|
187
|
+
)
|
|
188
|
+
to_entity = relationship("Entity", foreign_keys=[to_id], back_populates="incoming_relations")
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def permalink(self) -> str:
|
|
192
|
+
"""Create relation permalink showing the semantic connection:
|
|
193
|
+
source/relation_type/target
|
|
194
|
+
e.g., "specs/search/implements/features/search-ui"
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
return generate_permalink(
|
|
198
|
+
f"{self.from_entity.permalink}/{self.relation_type}/{self.to_entity.permalink}"
|
|
199
|
+
if self.to_entity
|
|
200
|
+
else f"{self.from_entity.permalink}/{self.relation_type}/{self.to_name}"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def __repr__(self) -> str:
|
|
204
|
+
return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, to_name={self.to_name}, type='{self.relation_type}')"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Search models and tables."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import DDL
|
|
4
|
+
|
|
5
|
+
# Define FTS5 virtual table creation
|
|
6
|
+
CREATE_SEARCH_INDEX = DDL("""
|
|
7
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
|
8
|
+
-- Core entity fields
|
|
9
|
+
id UNINDEXED, -- Row ID
|
|
10
|
+
title, -- Title for searching
|
|
11
|
+
content, -- Main searchable content
|
|
12
|
+
permalink, -- Stable identifier (now indexed for path search)
|
|
13
|
+
file_path UNINDEXED, -- Physical location
|
|
14
|
+
type UNINDEXED, -- entity/relation/observation
|
|
15
|
+
|
|
16
|
+
-- Relation fields
|
|
17
|
+
from_id UNINDEXED, -- Source entity
|
|
18
|
+
to_id UNINDEXED, -- Target entity
|
|
19
|
+
relation_type UNINDEXED, -- Type of relation
|
|
20
|
+
|
|
21
|
+
-- Observation fields
|
|
22
|
+
entity_id UNINDEXED, -- Parent entity
|
|
23
|
+
category UNINDEXED, -- Observation category
|
|
24
|
+
|
|
25
|
+
-- Common fields
|
|
26
|
+
metadata UNINDEXED, -- JSON metadata
|
|
27
|
+
created_at UNINDEXED, -- Creation timestamp
|
|
28
|
+
updated_at UNINDEXED, -- Last update
|
|
29
|
+
|
|
30
|
+
-- Configuration
|
|
31
|
+
tokenize='unicode61 tokenchars 0x2F', -- Hex code for /
|
|
32
|
+
prefix='1,2,3,4' -- Support longer prefixes for paths
|
|
33
|
+
);
|
|
34
|
+
""")
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Repository for managing entities in the knowledge graph."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Sequence
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import select, or_, asc
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
7
|
+
from sqlalchemy.orm import selectinload
|
|
8
|
+
from sqlalchemy.orm.interfaces import LoaderOption
|
|
9
|
+
|
|
10
|
+
from basic_memory.models.knowledge import Entity, Observation, Relation
|
|
11
|
+
from basic_memory.repository.repository import Repository
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EntityRepository(Repository[Entity]):
|
|
15
|
+
"""Repository for Entity model."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, session_maker: async_sessionmaker[AsyncSession]):
|
|
18
|
+
"""Initialize with session maker."""
|
|
19
|
+
super().__init__(session_maker, Entity)
|
|
20
|
+
|
|
21
|
+
async def get_by_permalink(self, permalink: str) -> Optional[Entity]:
|
|
22
|
+
"""Get entity by permalink."""
|
|
23
|
+
query = self.select().where(Entity.permalink == permalink).options(*self.get_load_options())
|
|
24
|
+
return await self.find_one(query)
|
|
25
|
+
|
|
26
|
+
async def get_by_title(self, title: str) -> Optional[Entity]:
|
|
27
|
+
"""Get entity by title."""
|
|
28
|
+
query = self.select().where(Entity.title == title).options(*self.get_load_options())
|
|
29
|
+
return await self.find_one(query)
|
|
30
|
+
|
|
31
|
+
async def get_by_file_path(self, file_path: str) -> Optional[Entity]:
|
|
32
|
+
"""Get entity by file_path."""
|
|
33
|
+
query = self.select().where(Entity.file_path == file_path).options(*self.get_load_options())
|
|
34
|
+
return await self.find_one(query)
|
|
35
|
+
|
|
36
|
+
async def list_entities(
|
|
37
|
+
self,
|
|
38
|
+
entity_type: Optional[str] = None,
|
|
39
|
+
sort_by: Optional[str] = "updated_at",
|
|
40
|
+
include_related: bool = False,
|
|
41
|
+
) -> Sequence[Entity]:
|
|
42
|
+
"""List all entities, optionally filtered by type and sorted."""
|
|
43
|
+
query = self.select()
|
|
44
|
+
|
|
45
|
+
# Always load base relations
|
|
46
|
+
query = query.options(*self.get_load_options())
|
|
47
|
+
|
|
48
|
+
# Apply filters
|
|
49
|
+
if entity_type:
|
|
50
|
+
# When include_related is True, get both:
|
|
51
|
+
# 1. Entities of the requested type
|
|
52
|
+
# 2. Entities that have relations with entities of the requested type
|
|
53
|
+
if include_related:
|
|
54
|
+
query = query.where(
|
|
55
|
+
or_(
|
|
56
|
+
Entity.entity_type == entity_type,
|
|
57
|
+
Entity.outgoing_relations.any(
|
|
58
|
+
Relation.to_entity.has(entity_type=entity_type)
|
|
59
|
+
),
|
|
60
|
+
Entity.incoming_relations.any(
|
|
61
|
+
Relation.from_entity.has(entity_type=entity_type)
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
query = query.where(Entity.entity_type == entity_type)
|
|
67
|
+
|
|
68
|
+
# Apply sorting
|
|
69
|
+
if sort_by:
|
|
70
|
+
sort_field = getattr(Entity, sort_by, Entity.updated_at)
|
|
71
|
+
query = query.order_by(asc(sort_field))
|
|
72
|
+
|
|
73
|
+
result = await self.execute_query(query)
|
|
74
|
+
return list(result.scalars().all())
|
|
75
|
+
|
|
76
|
+
async def get_entity_types(self) -> List[str]:
|
|
77
|
+
"""Get list of distinct entity types."""
|
|
78
|
+
query = select(Entity.entity_type).distinct()
|
|
79
|
+
|
|
80
|
+
result = await self.execute_query(query, use_query_options=False)
|
|
81
|
+
return list(result.scalars().all())
|
|
82
|
+
|
|
83
|
+
async def search(self, query_str: str) -> List[Entity]:
|
|
84
|
+
"""
|
|
85
|
+
Search for entities.
|
|
86
|
+
|
|
87
|
+
Searches across:
|
|
88
|
+
- Entity names
|
|
89
|
+
- Entity types
|
|
90
|
+
- Entity descriptions
|
|
91
|
+
- Associated Observations content
|
|
92
|
+
"""
|
|
93
|
+
search_term = f"%{query_str}%"
|
|
94
|
+
query = (
|
|
95
|
+
self.select()
|
|
96
|
+
.where(
|
|
97
|
+
or_(
|
|
98
|
+
Entity.title.ilike(search_term),
|
|
99
|
+
Entity.entity_type.ilike(search_term),
|
|
100
|
+
Entity.summary.ilike(search_term),
|
|
101
|
+
Entity.observations.any(Observation.content.ilike(search_term)),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
.options(*self.get_load_options())
|
|
105
|
+
)
|
|
106
|
+
result = await self.execute_query(query)
|
|
107
|
+
return list(result.scalars().all())
|
|
108
|
+
|
|
109
|
+
async def delete_entities_by_doc_id(self, doc_id: int) -> bool:
|
|
110
|
+
"""Delete all entities associated with a document."""
|
|
111
|
+
return await self.delete_by_fields(doc_id=doc_id)
|
|
112
|
+
|
|
113
|
+
async def delete_by_file_path(self, file_path: str) -> bool:
|
|
114
|
+
"""Delete entity with the provided file_path."""
|
|
115
|
+
return await self.delete_by_fields(file_path=file_path)
|
|
116
|
+
|
|
117
|
+
def get_load_options(self) -> List[LoaderOption]:
|
|
118
|
+
return [
|
|
119
|
+
selectinload(Entity.observations).selectinload(Observation.entity),
|
|
120
|
+
# Load from_relations and both entities for each relation
|
|
121
|
+
selectinload(Entity.outgoing_relations).selectinload(Relation.from_entity),
|
|
122
|
+
selectinload(Entity.outgoing_relations).selectinload(Relation.to_entity),
|
|
123
|
+
# Load to_relations and both entities for each relation
|
|
124
|
+
selectinload(Entity.incoming_relations).selectinload(Relation.from_entity),
|
|
125
|
+
selectinload(Entity.incoming_relations).selectinload(Relation.to_entity),
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
async def find_by_permalinks(self, permalinks: List[str]) -> Sequence[Entity]:
|
|
129
|
+
"""Find multiple entities by their permalink."""
|
|
130
|
+
|
|
131
|
+
# Handle empty input explicitly
|
|
132
|
+
if not permalinks:
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
# Use existing select pattern
|
|
136
|
+
query = (
|
|
137
|
+
self.select().options(*self.get_load_options()).where(Entity.permalink.in_(permalinks))
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
result = await self.execute_query(query)
|
|
141
|
+
return list(result.scalars().all())
|
|
142
|
+
|
|
143
|
+
async def delete_by_permalinks(self, permalinks: List[str]) -> int:
|
|
144
|
+
"""Delete multiple entities by permalink."""
|
|
145
|
+
|
|
146
|
+
# Handle empty input explicitly
|
|
147
|
+
if not permalinks:
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
# Find matching entities
|
|
151
|
+
entities = await self.find_by_permalinks(permalinks)
|
|
152
|
+
if not entities:
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
# Use existing delete_by_ids
|
|
156
|
+
return await self.delete_by_ids([entity.id for entity in entities])
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Repository for managing Observation objects."""
|
|
2
|
+
|
|
3
|
+
from typing import Sequence
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
7
|
+
|
|
8
|
+
from basic_memory.models import Observation
|
|
9
|
+
from basic_memory.repository.repository import Repository
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ObservationRepository(Repository[Observation]):
|
|
13
|
+
"""Repository for Observation model with memory-specific operations."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, session_maker: async_sessionmaker):
|
|
16
|
+
super().__init__(session_maker, Observation)
|
|
17
|
+
|
|
18
|
+
async def find_by_entity(self, entity_id: int) -> Sequence[Observation]:
|
|
19
|
+
"""Find all observations for a specific entity."""
|
|
20
|
+
query = select(Observation).filter(Observation.entity_id == entity_id)
|
|
21
|
+
result = await self.execute_query(query)
|
|
22
|
+
return result.scalars().all()
|
|
23
|
+
|
|
24
|
+
async def find_by_context(self, context: str) -> Sequence[Observation]:
|
|
25
|
+
"""Find observations with a specific context."""
|
|
26
|
+
query = select(Observation).filter(Observation.context == context)
|
|
27
|
+
result = await self.execute_query(query)
|
|
28
|
+
return result.scalars().all()
|
|
29
|
+
|
|
30
|
+
async def find_by_category(self, category: str) -> Sequence[Observation]:
|
|
31
|
+
"""Find observations with a specific context."""
|
|
32
|
+
query = select(Observation).filter(Observation.category == category)
|
|
33
|
+
result = await self.execute_query(query)
|
|
34
|
+
return result.scalars().all()
|
|
35
|
+
|
|
36
|
+
async def observation_categories(self) -> Sequence[str]:
|
|
37
|
+
"""Return a list of all observation categories."""
|
|
38
|
+
query = select(Observation.category).distinct()
|
|
39
|
+
result = await self.execute_query(query, use_query_options=False)
|
|
40
|
+
return result.scalars().all()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Repository for managing Relation objects."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import and_, delete
|
|
4
|
+
from typing import Sequence, List, Optional
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import select
|
|
7
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
8
|
+
from sqlalchemy.orm import selectinload, aliased
|
|
9
|
+
from sqlalchemy.orm.interfaces import LoaderOption
|
|
10
|
+
|
|
11
|
+
from basic_memory import db
|
|
12
|
+
from basic_memory.models import Relation, Entity
|
|
13
|
+
from basic_memory.repository.repository import Repository
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RelationRepository(Repository[Relation]):
|
|
17
|
+
"""Repository for Relation model with memory-specific operations."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, session_maker: async_sessionmaker):
|
|
20
|
+
super().__init__(session_maker, Relation)
|
|
21
|
+
|
|
22
|
+
async def find_relation(
|
|
23
|
+
self, from_permalink: str, to_permalink: str, relation_type: str
|
|
24
|
+
) -> Optional[Relation]:
|
|
25
|
+
"""Find a relation by its from and to path IDs."""
|
|
26
|
+
from_entity = aliased(Entity)
|
|
27
|
+
to_entity = aliased(Entity)
|
|
28
|
+
|
|
29
|
+
query = (
|
|
30
|
+
select(Relation)
|
|
31
|
+
.join(from_entity, Relation.from_id == from_entity.id)
|
|
32
|
+
.join(to_entity, Relation.to_id == to_entity.id)
|
|
33
|
+
.where(
|
|
34
|
+
and_(
|
|
35
|
+
from_entity.permalink == from_permalink,
|
|
36
|
+
to_entity.permalink == to_permalink,
|
|
37
|
+
Relation.relation_type == relation_type,
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
return await self.find_one(query)
|
|
42
|
+
|
|
43
|
+
async def find_by_entity(self, from_entity_id: int) -> Sequence[Relation]:
|
|
44
|
+
"""Find all relations from a specific entity."""
|
|
45
|
+
query = select(Relation).filter(Relation.from_id == from_entity_id)
|
|
46
|
+
result = await self.execute_query(query)
|
|
47
|
+
return result.scalars().all()
|
|
48
|
+
|
|
49
|
+
async def find_by_entities(self, from_id: int, to_id: int) -> Sequence[Relation]:
|
|
50
|
+
"""Find all relations between two entities."""
|
|
51
|
+
query = select(Relation).where((Relation.from_id == from_id) & (Relation.to_id == to_id))
|
|
52
|
+
result = await self.execute_query(query)
|
|
53
|
+
return result.scalars().all()
|
|
54
|
+
|
|
55
|
+
async def find_by_type(self, relation_type: str) -> Sequence[Relation]:
|
|
56
|
+
"""Find all relations of a specific type."""
|
|
57
|
+
query = select(Relation).filter(Relation.relation_type == relation_type)
|
|
58
|
+
result = await self.execute_query(query)
|
|
59
|
+
return result.scalars().all()
|
|
60
|
+
|
|
61
|
+
async def delete_outgoing_relations_from_entity(self, entity_id: int) -> None:
|
|
62
|
+
"""Delete outgoing relations for an entity.
|
|
63
|
+
|
|
64
|
+
Only deletes relations where this entity is the source (from_id),
|
|
65
|
+
as these are the ones owned by this entity's markdown file.
|
|
66
|
+
"""
|
|
67
|
+
async with db.scoped_session(self.session_maker) as session:
|
|
68
|
+
await session.execute(delete(Relation).where(Relation.from_id == entity_id))
|
|
69
|
+
|
|
70
|
+
async def find_unresolved_relations(self) -> Sequence[Relation]:
|
|
71
|
+
"""Find all unresolved relations, where to_id is null."""
|
|
72
|
+
query = select(Relation).filter(Relation.to_id.is_(None))
|
|
73
|
+
result = await self.execute_query(query)
|
|
74
|
+
return result.scalars().all()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_load_options(self) -> List[LoaderOption]:
|
|
78
|
+
return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)]
|