- 3-column CSS masonry grid (2 on tablet, 1 on mobile) - Link cards show screenshot thumbnails - Note cards show content body inline - Tags as pills, folder/date in meta footer - Screenshot serving endpoint added to brain API - Auto-polling for pending items (3s interval) - Detail sheet shows raw_content for notes - Warm frosted glass card styling matching Atelier design Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
340 lines
9.6 KiB
Python
340 lines
9.6 KiB
Python
"""Brain API endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query
|
|
from sqlalchemy import select, func, desc
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.api.deps import get_user_id, get_db_session
|
|
from app.config import FOLDERS, TAGS
|
|
from app.models.item import Item, ItemAsset
|
|
from app.models.schema import (
|
|
ItemCreate, ItemUpdate, ItemOut, ItemList, SearchQuery, SemanticSearchQuery,
|
|
HybridSearchQuery, SearchResult, ConfigOut,
|
|
)
|
|
from app.services.storage import storage
|
|
from fastapi.responses import Response
|
|
from app.worker.tasks import enqueue_process_item
|
|
|
|
router = APIRouter(prefix="/api", tags=["brain"])
|
|
|
|
|
|
# ── Health ──
|
|
|
|
@router.get("/health")
|
|
async def health():
|
|
return {"status": "ok", "service": "brain"}
|
|
|
|
|
|
# ── Config ──
|
|
|
|
@router.get("/config", response_model=ConfigOut)
|
|
async def get_config():
|
|
return ConfigOut(folders=FOLDERS, tags=TAGS)
|
|
|
|
|
|
# ── Create item ──
|
|
|
|
@router.post("/items", response_model=ItemOut, status_code=201)
|
|
async def create_item(
|
|
body: ItemCreate,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
item = Item(
|
|
id=str(uuid.uuid4()),
|
|
user_id=user_id,
|
|
type=body.type,
|
|
url=body.url,
|
|
raw_content=body.raw_content,
|
|
title=body.title,
|
|
folder=body.folder,
|
|
tags=body.tags or [],
|
|
processing_status="pending",
|
|
)
|
|
db.add(item)
|
|
await db.commit()
|
|
await db.refresh(item, ["assets"])
|
|
|
|
# Enqueue background processing
|
|
enqueue_process_item(item.id)
|
|
|
|
return item
|
|
|
|
|
|
# ── Upload file ──
|
|
|
|
@router.post("/items/upload", response_model=ItemOut, status_code=201)
|
|
async def upload_file(
|
|
file: UploadFile = File(...),
|
|
title: Optional[str] = Form(None),
|
|
folder: Optional[str] = Form(None),
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
item_id = str(uuid.uuid4())
|
|
content_type = file.content_type or "application/octet-stream"
|
|
|
|
# Determine type from content_type
|
|
if content_type.startswith("image/"):
|
|
item_type = "image"
|
|
elif content_type == "application/pdf":
|
|
item_type = "pdf"
|
|
else:
|
|
item_type = "file"
|
|
|
|
# Store the uploaded file
|
|
data = await file.read()
|
|
path = storage.save(
|
|
item_id=item_id,
|
|
asset_type="original_upload",
|
|
filename=file.filename or "upload",
|
|
data=data,
|
|
)
|
|
|
|
item = Item(
|
|
id=item_id,
|
|
user_id=user_id,
|
|
type=item_type,
|
|
title=title or file.filename,
|
|
folder=folder,
|
|
processing_status="pending",
|
|
)
|
|
db.add(item)
|
|
|
|
asset = ItemAsset(
|
|
id=str(uuid.uuid4()),
|
|
item_id=item_id,
|
|
asset_type="original_upload",
|
|
filename=file.filename or "upload",
|
|
content_type=content_type,
|
|
size_bytes=len(data),
|
|
storage_path=path,
|
|
)
|
|
db.add(asset)
|
|
|
|
await db.commit()
|
|
await db.refresh(item, ["assets"])
|
|
|
|
enqueue_process_item(item.id)
|
|
return item
|
|
|
|
|
|
# ── Get item ──
|
|
|
|
@router.get("/items/{item_id}", response_model=ItemOut)
|
|
async def get_item(
|
|
item_id: str,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
result = await db.execute(
|
|
select(Item).options(selectinload(Item.assets))
|
|
.where(Item.id == item_id, Item.user_id == user_id)
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Item not found")
|
|
return item
|
|
|
|
|
|
# ── List items ──
|
|
|
|
@router.get("/items", response_model=ItemList)
|
|
async def list_items(
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
folder: Optional[str] = Query(None),
|
|
tag: Optional[str] = Query(None),
|
|
type: Optional[str] = Query(None),
|
|
status: Optional[str] = Query(None),
|
|
limit: int = Query(20, le=100),
|
|
offset: int = Query(0),
|
|
):
|
|
q = select(Item).options(selectinload(Item.assets)).where(Item.user_id == user_id)
|
|
|
|
if folder:
|
|
q = q.where(Item.folder == folder)
|
|
if tag:
|
|
q = q.where(Item.tags.contains([tag]))
|
|
if type:
|
|
q = q.where(Item.type == type)
|
|
if status:
|
|
q = q.where(Item.processing_status == status)
|
|
|
|
# Count
|
|
count_q = select(func.count()).select_from(q.subquery())
|
|
total = (await db.execute(count_q)).scalar() or 0
|
|
|
|
# Fetch
|
|
q = q.order_by(desc(Item.created_at)).offset(offset).limit(limit)
|
|
result = await db.execute(q)
|
|
items = result.scalars().all()
|
|
|
|
return ItemList(items=items, total=total)
|
|
|
|
|
|
# ── Update item ──
|
|
|
|
@router.patch("/items/{item_id}", response_model=ItemOut)
|
|
async def update_item(
|
|
item_id: str,
|
|
body: ItemUpdate,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
result = await db.execute(
|
|
select(Item).options(selectinload(Item.assets))
|
|
.where(Item.id == item_id, Item.user_id == user_id)
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Item not found")
|
|
|
|
if body.title is not None:
|
|
item.title = body.title
|
|
if body.folder is not None:
|
|
item.folder = body.folder
|
|
if body.tags is not None:
|
|
item.tags = body.tags
|
|
if body.raw_content is not None:
|
|
item.raw_content = body.raw_content
|
|
|
|
item.updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
await db.refresh(item)
|
|
return item
|
|
|
|
|
|
# ── Delete item ──
|
|
|
|
@router.delete("/items/{item_id}")
|
|
async def delete_item(
|
|
item_id: str,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
result = await db.execute(
|
|
select(Item).where(Item.id == item_id, Item.user_id == user_id)
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Item not found")
|
|
|
|
# Delete stored assets
|
|
for asset in (await db.execute(
|
|
select(ItemAsset).where(ItemAsset.item_id == item_id)
|
|
)).scalars().all():
|
|
storage.delete(asset.storage_path)
|
|
|
|
await db.delete(item)
|
|
await db.commit()
|
|
return {"status": "deleted"}
|
|
|
|
|
|
# ── Reprocess item ──
|
|
|
|
@router.post("/items/{item_id}/reprocess", response_model=ItemOut)
|
|
async def reprocess_item(
|
|
item_id: str,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
result = await db.execute(
|
|
select(Item).options(selectinload(Item.assets))
|
|
.where(Item.id == item_id, Item.user_id == user_id)
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Item not found")
|
|
|
|
item.processing_status = "pending"
|
|
item.processing_error = None
|
|
item.updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
|
|
enqueue_process_item(item.id)
|
|
return item
|
|
|
|
|
|
# ── Search (keyword via Meilisearch) ──
|
|
|
|
@router.post("/search", response_model=SearchResult)
|
|
async def search_items(
|
|
body: SearchQuery,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
from app.search.engine import keyword_search
|
|
item_ids, total = await keyword_search(
|
|
user_id=user_id, q=body.q, folder=body.folder, tags=body.tags,
|
|
item_type=body.type, limit=body.limit, offset=body.offset,
|
|
)
|
|
if not item_ids:
|
|
return SearchResult(items=[], total=0, query=body.q)
|
|
|
|
result = await db.execute(
|
|
select(Item).options(selectinload(Item.assets))
|
|
.where(Item.id.in_(item_ids))
|
|
)
|
|
items_map = {i.id: i for i in result.scalars().all()}
|
|
ordered = [items_map[id] for id in item_ids if id in items_map]
|
|
return SearchResult(items=ordered, total=total, query=body.q)
|
|
|
|
|
|
# ── Semantic search (pgvector) ──
|
|
|
|
@router.post("/search/semantic", response_model=SearchResult)
|
|
async def semantic_search(
|
|
body: SemanticSearchQuery,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
from app.search.engine import vector_search
|
|
items = await vector_search(
|
|
db=db, user_id=user_id, q=body.q,
|
|
folder=body.folder, item_type=body.type, limit=body.limit,
|
|
)
|
|
return SearchResult(items=items, total=len(items), query=body.q)
|
|
|
|
|
|
# ── Hybrid search ──
|
|
|
|
@router.post("/search/hybrid", response_model=SearchResult)
|
|
async def hybrid_search(
|
|
body: HybridSearchQuery,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
from app.search.engine import hybrid_search as do_hybrid
|
|
items = await do_hybrid(
|
|
db=db, user_id=user_id, q=body.q,
|
|
folder=body.folder, tags=body.tags, item_type=body.type, limit=body.limit,
|
|
)
|
|
return SearchResult(items=items, total=len(items), query=body.q)
|
|
|
|
|
|
# ── Serve stored files (screenshots, archived HTML) ──
|
|
|
|
@router.get("/storage/{item_id}/{asset_type}/{filename}")
|
|
async def serve_asset(item_id: str, asset_type: str, filename: str):
|
|
"""Serve a stored asset file."""
|
|
path = f"{item_id}/{asset_type}/{filename}"
|
|
if not storage.exists(path):
|
|
raise HTTPException(status_code=404, detail="Asset not found")
|
|
|
|
data = storage.read(path)
|
|
ct = "application/octet-stream"
|
|
if filename.endswith(".png"): ct = "image/png"
|
|
elif filename.endswith(".jpg") or filename.endswith(".jpeg"): ct = "image/jpeg"
|
|
elif filename.endswith(".html"): ct = "text/html"
|
|
elif filename.endswith(".pdf"): ct = "application/pdf"
|
|
|
|
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=3600"})
|