mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
context builder mostly working
This commit is contained in:
@@ -2,6 +2,6 @@
|
||||
|
||||
from . import knowledge_router as knowledge
|
||||
from . import discovery_router as discovery
|
||||
from . import discovery_router as memory
|
||||
from . import memory_router as memory
|
||||
|
||||
__all__ = ["knowledge", "discovery", "memory"]
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""Routes for memory:// URI operations."""
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter
|
||||
|
||||
from basic_memory.config import config
|
||||
from basic_memory.schemas.memory import MemoryUrl, GraphContext
|
||||
from basic_memory.deps import ContextServiceDep
|
||||
from basic_memory.schemas.search import SearchResult, RelatedResult
|
||||
|
||||
router = APIRouter(prefix="/memory")
|
||||
router = APIRouter(prefix="/memory", tags=["memory"])
|
||||
|
||||
|
||||
def parse_timeframe(timeframe: str) -> Optional[datetime]:
|
||||
@@ -36,8 +38,9 @@ async def get_memory_context(
|
||||
timeframe: str = "7d",
|
||||
) -> GraphContext:
|
||||
"""Get rich context from memory:// URI."""
|
||||
# add the project name from the config to the url as the "host
|
||||
# Parse URI
|
||||
memory_url = MemoryUrl.parse(f"memory://{uri}")
|
||||
memory_url = MemoryUrl.parse(f"memory://{config.project}/{uri}")
|
||||
|
||||
# Parse timeframe
|
||||
since = parse_timeframe(timeframe)
|
||||
@@ -45,8 +48,11 @@ async def get_memory_context(
|
||||
# Build context
|
||||
context = await context_service.build_context(str(memory_url), depth=depth, since=since)
|
||||
|
||||
primary_entities = [SearchResult(**asdict(r)) for r in context["primary_entities"]]
|
||||
related_entities = [RelatedResult(**asdict(r)) for r in context["related_entities"]]
|
||||
metadata = context["metadata"]
|
||||
# Transform to GraphContext
|
||||
return GraphContext.model_validate(context)
|
||||
return GraphContext(primary_entities=primary_entities, related_entities=related_entities, metadata=metadata)
|
||||
|
||||
|
||||
@router.get("/related/{permalink}", response_model=GraphContext)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Router for search operations."""
|
||||
from dataclasses import asdict
|
||||
|
||||
from fastapi import APIRouter, Depends, BackgroundTasks
|
||||
from typing import List
|
||||
@@ -17,7 +18,8 @@ async def search(
|
||||
):
|
||||
"""Search across all knowledge and documents."""
|
||||
results = await search_service.search(query)
|
||||
return SearchResponse(results=results)
|
||||
search_results = [SearchResult.model_validate(asdict(r)) for r in results]
|
||||
return SearchResponse(results=search_results)
|
||||
|
||||
@router.post("/reindex")
|
||||
async def reindex(
|
||||
|
||||
@@ -18,6 +18,10 @@ class ProjectConfig(BaseSettings):
|
||||
description="Base path for basic-memory files",
|
||||
)
|
||||
|
||||
# Name of the project
|
||||
project: str = Field(default="default", description="Project name")
|
||||
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="BASIC_MEMORY_",
|
||||
extra="ignore",
|
||||
@@ -25,7 +29,6 @@ class ProjectConfig(BaseSettings):
|
||||
env_file_encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@property
|
||||
def database_path(self) -> Path:
|
||||
"""Get SQLite database path."""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Repository for search operations."""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Any, Dict
|
||||
|
||||
from loguru import logger
|
||||
@@ -10,7 +11,26 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from basic_memory import db
|
||||
from basic_memory.models.search import CREATE_SEARCH_INDEX
|
||||
from basic_memory.repository.repository import Repository
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResult, SearchItemType
|
||||
from basic_memory.schemas.search import SearchQuery, SearchItemType
|
||||
|
||||
@dataclass
|
||||
class SearchResultRow():
|
||||
"""Search result with score and metadata."""
|
||||
id: int
|
||||
type: str
|
||||
score: float
|
||||
metadata: dict
|
||||
|
||||
# Common fields
|
||||
permalink: Optional[str] = None
|
||||
file_path: Optional[str] = None
|
||||
|
||||
# Type-specific fields
|
||||
entity_id: Optional[int] = None # For observations
|
||||
category: Optional[str] = None # For observations
|
||||
from_id: Optional[int] = None # For relations
|
||||
to_id: Optional[int] = None # For relations
|
||||
relation_type: Optional[str] = None # For relations
|
||||
|
||||
|
||||
class SearchRepository:
|
||||
@@ -31,7 +51,7 @@ class SearchRepository:
|
||||
return f'"{term}"'
|
||||
return term
|
||||
|
||||
async def search(self, query: SearchQuery) -> List[SearchResult]:
|
||||
async def search(self, query: SearchQuery) -> List[SearchResultRow]:
|
||||
"""Search across all indexed content with fuzzy matching."""
|
||||
conditions = []
|
||||
params = {}
|
||||
@@ -88,20 +108,18 @@ class SearchRepository:
|
||||
WHERE {where_clause}
|
||||
ORDER BY score ASC
|
||||
"""
|
||||
|
||||
logger.debug(f"Search query: {sql}")
|
||||
|
||||
logger.debug(f"Search params: {params}")
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
result = await session.execute(text(sql), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
results = [
|
||||
SearchResultRow(
|
||||
id=row.id,
|
||||
permalink=row.permalink,
|
||||
file_path=row.file_path,
|
||||
type=SearchItemType(row.type),
|
||||
type=row.type,
|
||||
score=row.score,
|
||||
metadata=json.loads(row.metadata),
|
||||
from_id=row.from_id,
|
||||
@@ -112,6 +130,10 @@ class SearchRepository:
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
logger.debug(f"Search results: {results}")
|
||||
return results
|
||||
|
||||
|
||||
async def index_item(
|
||||
self,
|
||||
@@ -157,7 +179,7 @@ class SearchRepository:
|
||||
"content": content,
|
||||
"permalink": permalink,
|
||||
"file_path": file_path,
|
||||
"type": type.value,
|
||||
"type": type,
|
||||
"metadata": json.dumps(metadata),
|
||||
"from_id": from_id,
|
||||
"to_id": to_id,
|
||||
@@ -168,7 +190,7 @@ class SearchRepository:
|
||||
"updated_at": metadata.get("updated_at")
|
||||
},
|
||||
)
|
||||
logger.debug(f"indexed {permalink}")
|
||||
logger.debug(f"indexed permalink {permalink}")
|
||||
await session.commit()
|
||||
|
||||
async def delete_by_permalink(self, permalink: str):
|
||||
@@ -187,7 +209,7 @@ class SearchRepository:
|
||||
use_query_options:bool = True
|
||||
) -> Result[Any]:
|
||||
"""Execute a query asynchronously."""
|
||||
logger.debug(f"Executing query: {query}")
|
||||
#logger.debug(f"Executing query: {query}")
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
if params:
|
||||
result = await session.execute(query, params)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Schemas for memory context."""
|
||||
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import field_validator
|
||||
|
||||
from basic_memory.schemas.search import SearchResult
|
||||
from basic_memory.schemas.search import SearchResult, RelatedResult
|
||||
|
||||
"""Memory URL schema for knowledge addressing.
|
||||
|
||||
@@ -101,7 +102,7 @@ class GraphContext(BaseModel):
|
||||
primary_entities: List[SearchResult] = Field(description="Entities directly matching URI")
|
||||
|
||||
# Related entities
|
||||
related_entities: List[SearchResult] = Field(description="Entities found via relations")
|
||||
related_entities: List[RelatedResult] = Field(description="Entities found via relations")
|
||||
|
||||
# Context metadata
|
||||
metadata: Dict[str, Any] = Field(
|
||||
|
||||
@@ -57,8 +57,8 @@ class SearchResult(BaseModel):
|
||||
"""Search result with score and metadata."""
|
||||
id: int
|
||||
type: SearchItemType
|
||||
score: float
|
||||
metadata: dict
|
||||
score: Optional[float] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
# Common fields
|
||||
permalink: Optional[str] = None
|
||||
@@ -71,6 +71,21 @@ class SearchResult(BaseModel):
|
||||
to_id: Optional[int] = None # For relations
|
||||
relation_type: Optional[str] = None # For relations
|
||||
|
||||
class RelatedResult(BaseModel):
|
||||
type: SearchItemType
|
||||
id: int
|
||||
title: str
|
||||
permalink: str
|
||||
depth:int
|
||||
root_id: int
|
||||
created_at: datetime
|
||||
from_id: Optional[int] = None
|
||||
to_id: Optional[int] = None
|
||||
relation_type: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
entity_id: Optional[int] = None
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Wrapper for search results."""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Service for building rich context from the knowledge graph."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, UTC, timezone
|
||||
from typing import List, Optional, Tuple
|
||||
from loguru import logger
|
||||
@@ -10,6 +10,22 @@ from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.schemas.memory import MemoryUrl
|
||||
from basic_memory.schemas.search import SearchQuery, SearchItemType
|
||||
|
||||
@dataclass
|
||||
class ContextResultRow:
|
||||
type: str
|
||||
id: int
|
||||
title: str
|
||||
permalink: str
|
||||
depth:int
|
||||
root_id: int
|
||||
created_at: datetime
|
||||
from_id: Optional[int] = None
|
||||
to_id: Optional[int] = None
|
||||
relation_type: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
entity_id: Optional[int] = None
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
class ContextService:
|
||||
"""Service for building rich context from memory:// URIs.
|
||||
@@ -44,16 +60,24 @@ class ContextService:
|
||||
if memory_url.params.get("type") == "related":
|
||||
# Special mode for finding related content
|
||||
target = memory_url.params["target"]
|
||||
primary = await self.find_related(target)
|
||||
logger.debug(f"Finding related content for '{target}'")
|
||||
primary = await self.find_related_1(target)
|
||||
elif memory_url.pattern:
|
||||
# Pattern matching with *
|
||||
logger.debug(f"Pattern matching for '{memory_url.pattern}'")
|
||||
primary = await self.find_by_pattern(memory_url.pattern)
|
||||
else:
|
||||
# Direct permalink lookup
|
||||
logger.debug(f"Direct permalink lookup for '{memory_url.relative_path()}'")
|
||||
primary = await self.find_by_permalink(memory_url.relative_path())
|
||||
|
||||
logger.debug(f"Found {len(primary)} primary entities")
|
||||
for p in primary:
|
||||
logger.debug(f"Found primary entity: {p}")
|
||||
|
||||
# Get type_id pairs for traversal
|
||||
type_id_pairs = [(r.type, r.id) for r in primary] if primary else []
|
||||
logger.debug(f"type_id_pairs: {type_id_pairs}")
|
||||
|
||||
# Find connected content
|
||||
related = await self.find_connected(
|
||||
@@ -61,6 +85,10 @@ class ContextService:
|
||||
max_depth=depth,
|
||||
since=since
|
||||
)
|
||||
logger.debug(f"Found {len(related)} related entities")
|
||||
for r in related:
|
||||
logger.debug(f"Found related entity: {r}")
|
||||
|
||||
|
||||
# Build response
|
||||
return {
|
||||
@@ -87,15 +115,15 @@ class ContextService:
|
||||
query = SearchQuery(permalink=permalink)
|
||||
return await self.search_repository.search(query)
|
||||
|
||||
async def find_related(self, permalink: str):
|
||||
async def find_related_1(self, permalink: str):
|
||||
"""Find entities related to a given permalink."""
|
||||
# First find the target entity
|
||||
target = await self.find_by_permalink(permalink)
|
||||
if not target:
|
||||
return []
|
||||
|
||||
# Use find_connected to get related items
|
||||
type_id_pairs = [(r.type.value, r.id) for r in target]
|
||||
# Use find_connected to get related items at depth=1
|
||||
type_id_pairs = [(r.type, r.id) for r in target]
|
||||
return await self.find_connected(
|
||||
type_id_pairs,
|
||||
max_depth=1 # Only immediate relations
|
||||
@@ -177,7 +205,7 @@ class ContextService:
|
||||
r1.type = 'relation' AND
|
||||
(r1.from_id = cg.id OR r1.to_id = cg.id)
|
||||
{r1_date_filter}
|
||||
)
|
||||
)
|
||||
-- Then join to ALL related items at the same depth
|
||||
JOIN search_index related ON (
|
||||
-- The found relation
|
||||
@@ -217,5 +245,25 @@ class ContextService:
|
||||
ORDER BY depth, type, id
|
||||
""")
|
||||
|
||||
results = await self.search_repository.execute_query(query, params=params)
|
||||
return results.all()
|
||||
result = await self.search_repository.execute_query(query, params=params)
|
||||
rows = result.all()
|
||||
|
||||
context_rows = [
|
||||
ContextResultRow(
|
||||
type=row.type,
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
permalink=row.permalink,
|
||||
from_id=row.from_id,
|
||||
to_id=row.to_id,
|
||||
relation_type=row.relation_type,
|
||||
category=row.category,
|
||||
entity_id=row.entity_id,
|
||||
content=row.content,
|
||||
depth=row.depth,
|
||||
root_id=row.root_id,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return context_rows
|
||||
@@ -155,7 +155,7 @@ class SearchService:
|
||||
content=entity_content,
|
||||
permalink=entity.permalink,
|
||||
file_path=entity.file_path,
|
||||
type=SearchItemType.ENTITY,
|
||||
type=SearchItemType.ENTITY.value,
|
||||
metadata={
|
||||
"entity_type": entity.entity_type,
|
||||
"created_at": entity.created_at.isoformat(),
|
||||
@@ -169,7 +169,7 @@ class SearchService:
|
||||
content=entity_content,
|
||||
permalink=entity.permalink,
|
||||
file_path=entity.file_path,
|
||||
type=SearchItemType.ENTITY,
|
||||
type=SearchItemType.ENTITY.value,
|
||||
metadata={
|
||||
"entity_type": entity.entity_type,
|
||||
"created_at": entity.created_at.isoformat(),
|
||||
@@ -193,7 +193,7 @@ class SearchService:
|
||||
content=obs.content,
|
||||
permalink=observation_permalink,
|
||||
file_path=entity.file_path,
|
||||
type=SearchItemType.OBSERVATION,
|
||||
type=SearchItemType.OBSERVATION.value,
|
||||
category=obs.category,
|
||||
entity_id=entity.id,
|
||||
metadata={
|
||||
@@ -209,7 +209,7 @@ class SearchService:
|
||||
content=obs.content,
|
||||
permalink=observation_permalink,
|
||||
file_path=entity.file_path,
|
||||
type=SearchItemType.OBSERVATION,
|
||||
type=SearchItemType.OBSERVATION.value,
|
||||
category=obs.category,
|
||||
entity_id=entity.id,
|
||||
metadata={
|
||||
@@ -237,7 +237,7 @@ class SearchService:
|
||||
content=rel.context or "",
|
||||
permalink=relation_permalink,
|
||||
file_path=entity.file_path,
|
||||
type=SearchItemType.RELATION,
|
||||
type=SearchItemType.RELATION.value,
|
||||
from_id=rel.from_id,
|
||||
to_id=rel.to_id,
|
||||
relation_type=rel.relation_type,
|
||||
@@ -253,7 +253,7 @@ class SearchService:
|
||||
content=rel.context or "",
|
||||
permalink=relation_permalink,
|
||||
file_path=entity.file_path,
|
||||
type=SearchItemType.RELATION,
|
||||
type=SearchItemType.RELATION.value,
|
||||
from_id=rel.from_id,
|
||||
to_id=rel.to_id,
|
||||
relation_type=rel.relation_type,
|
||||
|
||||
Reference in New Issue
Block a user