Files
personal-ai-assistant/backend/app/services/memory_service.py
dolgolyov.alexei 8b8fe916f0 Phase 4: Documents & Memory — upload, FTS, AI tools, context injection
Backend:
- Document + MemoryEntry models with Alembic migration (GIN FTS index)
- File upload endpoint with path traversal protection (sanitized filenames)
- Background document text extraction (PyMuPDF)
- Full-text search on extracted_text via PostgreSQL tsvector/tsquery
- Memory CRUD with enum-validated categories/importance, field allow-list
- AI tools: save_memory, search_documents, get_memory (Claude function calling)
- Tool execution loop in stream_ai_response (multi-turn tool use)
- Context assembly: injects critical memory + relevant doc excerpts
- File storage abstraction (local filesystem, S3-swappable)
- Secure file deletion (DB flush before disk delete)

Frontend:
- Document upload dialog (drag-and-drop + file picker)
- Document list with status badges, search, download (via authenticated blob)
- Document viewer with extracted text preview
- Memory list grouped by category with importance color coding
- Memory editor with category/importance dropdowns
- Documents + Memory pages with full CRUD
- Enabled sidebar navigation for both sections

Review fixes applied:
- Sanitized upload filenames (path traversal prevention)
- Download via axios blob (not bare <a href>, preserves auth)
- Route ordering: /search before /{id}/reindex
- Memory update allows is_active=False + field allow-list
- MemoryEditor form resets on mode switch
- Literal enum validation on category/importance schemas
- DB flush before file deletion for data integrity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:46:59 +03:00

72 lines
2.4 KiB
Python

import uuid
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.memory_entry import MemoryEntry
async def create_memory(db: AsyncSession, user_id: uuid.UUID, **kwargs) -> MemoryEntry:
entry = MemoryEntry(user_id=user_id, **kwargs)
db.add(entry)
await db.flush()
return entry
async def get_memory(db: AsyncSession, entry_id: uuid.UUID, user_id: uuid.UUID) -> MemoryEntry:
result = await db.execute(
select(MemoryEntry).where(MemoryEntry.id == entry_id, MemoryEntry.user_id == user_id)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Memory entry not found")
return entry
async def get_user_memories(
db: AsyncSession,
user_id: uuid.UUID,
category: str | None = None,
importance: str | None = None,
is_active: bool | None = None,
) -> list[MemoryEntry]:
stmt = select(MemoryEntry).where(MemoryEntry.user_id == user_id)
if category:
stmt = stmt.where(MemoryEntry.category == category)
if importance:
stmt = stmt.where(MemoryEntry.importance == importance)
if is_active is not None:
stmt = stmt.where(MemoryEntry.is_active == is_active)
stmt = stmt.order_by(MemoryEntry.created_at.desc())
result = await db.execute(stmt)
return list(result.scalars().all())
ALLOWED_UPDATE_FIELDS = {"category", "title", "content", "importance", "is_active"}
async def update_memory(db: AsyncSession, entry_id: uuid.UUID, user_id: uuid.UUID, **kwargs) -> MemoryEntry:
entry = await get_memory(db, entry_id, user_id)
for key, value in kwargs.items():
if key in ALLOWED_UPDATE_FIELDS:
setattr(entry, key, value)
await db.flush()
return entry
async def delete_memory(db: AsyncSession, entry_id: uuid.UUID, user_id: uuid.UUID) -> None:
entry = await get_memory(db, entry_id, user_id)
await db.delete(entry)
async def get_critical_memories(db: AsyncSession, user_id: uuid.UUID) -> list[MemoryEntry]:
result = await db.execute(
select(MemoryEntry).where(
MemoryEntry.user_id == user_id,
MemoryEntry.is_active == True, # noqa: E712
MemoryEntry.importance.in_(["critical", "high"]),
).order_by(MemoryEntry.importance, MemoryEntry.created_at.desc())
)
return list(result.scalars().all())