diff --git a/src/basic_memory/api/app.py b/src/basic_memory/api/app.py index 88317b03..99df9868 100644 --- a/src/basic_memory/api/app.py +++ b/src/basic_memory/api/app.py @@ -3,6 +3,7 @@ from fastapi import FastAPI from loguru import logger +from .routers import documents from .routers import knowledge # Initialize FastAPI app @@ -12,6 +13,7 @@ app = FastAPI( # Include routers app.include_router(knowledge.router) +app.include_router(documents.router) # Add startup event diff --git a/src/basic_memory/api/routers/documents.py b/src/basic_memory/api/routers/documents.py new file mode 100644 index 00000000..b0ec191c --- /dev/null +++ b/src/basic_memory/api/routers/documents.py @@ -0,0 +1,125 @@ +"""Router for document management endpoints.""" + +from typing import List + +from fastapi import APIRouter, HTTPException + +from basic_memory.deps import DocumentServiceDep +from basic_memory.schemas.request import DocumentCreate, DocumentUpdate, DocumentPatch +from basic_memory.schemas.response import DocumentResponse +from basic_memory.services.document_service import ( + DocumentNotFoundError, + DocumentWriteError, +) + +# Router +router = APIRouter(prefix="/documents", tags=["documents"]) + + +@router.post("/", response_model=DocumentResponse) +async def create_document( + doc: DocumentCreate, + service: DocumentServiceDep, +) -> DocumentResponse: + """Create a new document.""" + try: + document = await service.create_document( + path=doc.path, + content=doc.content, + metadata=doc.metadata, + ) + return document + except DocumentWriteError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/", response_model=List[DocumentResponse]) +async def list_documents( + service: DocumentServiceDep, +) -> List[DocumentResponse]: + """List all documents.""" + return await service.list_documents() + + +@router.get("/{path:path}", response_model=DocumentResponse) +async def get_document( + path: str, + service: DocumentServiceDep, +) -> DocumentResponse: + """Get a document by path.""" + try: + document, content = await service.read_document(path) + # Attach content to response + response = DocumentResponse.from_orm(document) + response.content = content # type: ignore + return response + except DocumentNotFoundError: + raise HTTPException(status_code=404, detail="Document not found") + except DocumentWriteError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("/{path:path}", response_model=DocumentResponse) +async def update_document( + path: str, + doc: DocumentUpdate, + service: DocumentServiceDep, +) -> DocumentResponse: + """Update a document's content and/or metadata.""" + try: + document = await service.update_document( + path=path, + content=doc.content, + metadata=doc.metadata, + ) + return document + except DocumentNotFoundError: + raise HTTPException(status_code=404, detail="Document not found") + except DocumentWriteError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.patch("/{path:path}", response_model=DocumentResponse) +async def patch_document( + path: str, + patch: DocumentPatch, + service: DocumentServiceDep, +) -> DocumentResponse: + """ + Partially update a document. + + TODO: Implement partial content updates to minimize data transfer. + For now, this is stubbed to require full content on update. + """ + # For now, require full content updates + if patch.content is None: + raise HTTPException( + status_code=400, + detail="Partial content updates not yet implemented. Please provide full content.", + ) + + try: + document = await service.update_document( + path=path, + content=patch.content, + metadata=patch.metadata, + ) + return document + except DocumentNotFoundError: + raise HTTPException(status_code=404, detail="Document not found") + except DocumentWriteError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/{path:path}", status_code=204) +async def delete_document( + path: str, + service: DocumentServiceDep, +) -> None: + """Delete a document.""" + try: + await service.delete_document(path) + except DocumentNotFoundError: + raise HTTPException(status_code=404, detail="Document not found") + except DocumentWriteError as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/src/basic_memory/deps.py b/src/basic_memory/deps.py index 2899e589..48b5cb40 100644 --- a/src/basic_memory/deps.py +++ b/src/basic_memory/deps.py @@ -13,10 +13,19 @@ from sqlalchemy.ext.asyncio import ( from basic_memory import db from basic_memory.config import ProjectConfig, config from basic_memory.db import DatabaseType +from basic_memory.repository.document_repository import DocumentRepository from basic_memory.repository.entity_repository import EntityRepository from basic_memory.repository.observation_repository import ObservationRepository from basic_memory.repository.relation_repository import RelationRepository -from basic_memory.services import EntityService, ObservationService, RelationService +from basic_memory.services import ( + EntityService, + ObservationService, + RelationService, + DocumentService, +) + + +## project def get_project_config() -> ProjectConfig: @@ -32,6 +41,8 @@ def get_project_path(project_config: ProjectConfigDep) -> Path: ProjectPathDep = Annotated[Path, Depends(get_project_path)] +## sqlalchemy + async def get_engine_factory( project_path: ProjectPathDep, db_type=DatabaseType.FILESYSTEM @@ -56,6 +67,8 @@ async def get_session_maker(engine_factory: EngineFactoryDep) -> async_sessionma SessionMakerDep = Annotated[async_sessionmaker, Depends(get_session_maker)] +## repositories + async def get_entity_repository( session_maker: SessionMakerDep, @@ -87,6 +100,18 @@ async def get_relation_repository( RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repository)] +async def get_document_repository( + session_maker: SessionMakerDep, +) -> DocumentRepository: + """Create a DocumentRepository instance.""" + return DocumentRepository(session_maker) + + +DocumentRepositoryDep = Annotated[DocumentRepository, Depends(get_document_repository)] + +## services + + async def get_entity_service(entity_repository: EntityRepositoryDep) -> EntityService: """Create EntityService with repository.""" return EntityService(entity_repository) @@ -111,3 +136,11 @@ async def get_relation_service(relation_repository: RelationRepositoryDep) -> Re RelationServiceDep = Annotated[RelationService, Depends(get_relation_service)] + + +async def get_document_service(document_repository: DocumentRepositoryDep) -> DocumentService: + """Create RelationService with repository.""" + return DocumentService(document_repository) + + +DocumentServiceDep = Annotated[DocumentService, Depends(get_relation_service)] diff --git a/src/basic_memory/schemas/request.py b/src/basic_memory/schemas/request.py index 3edcc8aa..a1898295 100644 --- a/src/basic_memory/schemas/request.py +++ b/src/basic_memory/schemas/request.py @@ -12,7 +12,7 @@ Request Types: 5. Node Retrieval - Load specific entities by ID """ -from typing import List, Optional, Annotated +from typing import List, Optional, Annotated, Dict, Any from annotated_types import MinLen, MaxLen from pydantic import BaseModel @@ -112,7 +112,7 @@ class SearchNodesRequest(BaseModel): The search looks across multiple fields: - Entity names - Entity types - - Descriptions + - Descriptions - Observations Features: @@ -159,7 +159,7 @@ class OpenNodesRequest(BaseModel): 2. Non-existent IDs are silently skipped 3. Returns complete entity objects 4. Relations are included in response - + Best Practice: Use this to explore the graph by following relations between entities that interest you. """ @@ -207,4 +207,23 @@ class CreateRelationsRequest(BaseModel): 6. Use relations to build a rich, navigable knowledge graph """ - relations: List[Relation] \ No newline at end of file + relations: List[Relation] + + +## document + + +class DocumentCreate(BaseModel): + path: str + content: str + metadata: Optional[Dict[str, Any]] = None + + +class DocumentUpdate(BaseModel): + content: str + metadata: Optional[Dict[str, Any]] = None + + +class DocumentPatch(BaseModel): + content: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None diff --git a/src/basic_memory/schemas/response.py b/src/basic_memory/schemas/response.py index 69ed5af6..2fc35c7e 100644 --- a/src/basic_memory/schemas/response.py +++ b/src/basic_memory/schemas/response.py @@ -11,7 +11,7 @@ Key Features: 4. Bulk operations return all affected items """ -from typing import List, Optional +from typing import List, Optional, Dict, Any from pydantic import BaseModel, ConfigDict @@ -324,3 +324,15 @@ class DeleteObservationsResponse(SQLAlchemyModel): """ deleted: bool + + +class DocumentResponse(BaseModel): + id: int + path: str + checksum: str + doc_metadata: Optional[Dict[str, Any]] = None + created_at: str + updated_at: str + + class Config: + from_attributes = True