context builder mostly working

This commit is contained in:
phernandez
2025-01-16 14:27:22 -06:00
parent e8abf42050
commit fe252a8578
14 changed files with 351 additions and 133 deletions
+1 -1
View File
@@ -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"]
+10 -4
View File
@@ -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(
+4 -1
View File
@@ -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)
+3 -2
View File
@@ -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(
+17 -2
View File
@@ -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."""
+56 -8
View File
@@ -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
+6 -6
View File
@@ -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,