feat: major platform expansion — Brain service, RSS reader, iOS app, AI assistants, Firefox extension
Brain Service: - Playwright stealth crawler replacing browserless (og:image, Readability, Reddit JSON API) - AI classification with tag definitions and folder assignment - YouTube video download via yt-dlp - Karakeep migration complete (96 items) - Taxonomy management (folders with icons/colors, tags) - Discovery shuffle, sort options, search (Meilisearch + pgvector) - Item tag/folder editing, card color accents RSS Reader Service: - Custom FastAPI reader replacing Miniflux - Feed management (add/delete/refresh), category support - Full article extraction via Readability - Background content fetching for new entries - Mark all read with confirmation - Infinite scroll, retention cleanup (30/60 day) - 17 feeds migrated from Miniflux iOS App (SwiftUI): - Native iOS 17+ app with @Observable architecture - Cookie-based auth, configurable gateway URL - Dashboard with custom background photo + frosted glass widgets - Full fitness module (today/templates/goals/food library) - AI assistant chat (fitness + brain, raw JSON state management) - 120fps ProMotion support AI Assistants (Gateway): - Unified dispatcher with fitness/brain domain detection - Fitness: natural language food logging, photo analysis, multi-item splitting - Brain: save/append/update/delete notes, search & answer, undo support - Madiha user gets fitness-only (brain disabled) Firefox Extension: - One-click save to Brain from any page - Login with platform credentials - Right-click context menu (save page/link/image) - Notes field for URL saves - Signed and published on AMO Other: - Reader bookmark button routes to Brain (was Karakeep) - Fitness food library with "Add" button + add-to-meal popup - Kindle send file size check (25MB SMTP2GO limit) - Atelier UI as default (useAtelierShell=true) - Mobile upload box in nav drawer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,10 +13,10 @@ 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.item import Item, ItemAsset, ItemAddition
|
||||
from app.models.schema import (
|
||||
ItemCreate, ItemUpdate, ItemOut, ItemList, SearchQuery, SemanticSearchQuery,
|
||||
HybridSearchQuery, SearchResult, ConfigOut,
|
||||
HybridSearchQuery, SearchResult, ConfigOut, ItemAdditionCreate, ItemAdditionOut,
|
||||
)
|
||||
from app.services.storage import storage
|
||||
from fastapi.responses import Response
|
||||
@@ -25,6 +25,46 @@ from app.worker.tasks import enqueue_process_item
|
||||
router = APIRouter(prefix="/api", tags=["brain"])
|
||||
|
||||
|
||||
async def refresh_item_search_state(db: AsyncSession, item: Item):
|
||||
"""Recompute embedding + Meilisearch doc after assistant additions change."""
|
||||
from app.search.engine import index_item
|
||||
from app.services.embed import generate_embedding
|
||||
|
||||
additions_result = await db.execute(
|
||||
select(ItemAddition)
|
||||
.where(ItemAddition.item_id == item.id, ItemAddition.user_id == item.user_id)
|
||||
.order_by(ItemAddition.created_at.asc())
|
||||
)
|
||||
additions = additions_result.scalars().all()
|
||||
additions_text = "\n\n".join(addition.content for addition in additions if addition.content.strip())
|
||||
|
||||
searchable_text_parts = [item.raw_content or "", item.extracted_text or "", additions_text]
|
||||
searchable_text = "\n\n".join(part.strip() for part in searchable_text_parts if part and part.strip())
|
||||
|
||||
embed_text = f"{item.title or ''}\n{item.summary or ''}\n{searchable_text}".strip()
|
||||
embedding = await generate_embedding(embed_text)
|
||||
if embedding:
|
||||
item.embedding = embedding
|
||||
|
||||
item.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
await index_item({
|
||||
"id": item.id,
|
||||
"user_id": item.user_id,
|
||||
"type": item.type,
|
||||
"title": item.title,
|
||||
"url": item.url,
|
||||
"folder": item.folder,
|
||||
"tags": item.tags or [],
|
||||
"summary": item.summary,
|
||||
"extracted_text": searchable_text[:10000],
|
||||
"processing_status": item.processing_status,
|
||||
"created_at": item.created_at.isoformat() if item.created_at else None,
|
||||
})
|
||||
|
||||
|
||||
# ── Health ──
|
||||
|
||||
@router.get("/health")
|
||||
@@ -201,14 +241,31 @@ async def update_item(
|
||||
item.title = body.title
|
||||
if body.folder is not None:
|
||||
item.folder = body.folder
|
||||
# Update folder_id FK
|
||||
from app.models.taxonomy import Folder as FolderModel
|
||||
folder_row = (await db.execute(
|
||||
select(FolderModel).where(FolderModel.user_id == user_id, FolderModel.name == body.folder)
|
||||
)).scalar_one_or_none()
|
||||
item.folder_id = folder_row.id if folder_row else None
|
||||
if body.tags is not None:
|
||||
item.tags = body.tags
|
||||
# Update item_tags relational entries
|
||||
from app.models.taxonomy import Tag as TagModel, ItemTag
|
||||
from sqlalchemy import delete as sa_delete
|
||||
await db.execute(sa_delete(ItemTag).where(ItemTag.item_id == item.id))
|
||||
for tag_name in body.tags:
|
||||
tag_row = (await db.execute(
|
||||
select(TagModel).where(TagModel.user_id == user_id, TagModel.name == tag_name)
|
||||
)).scalar_one_or_none()
|
||||
if tag_row:
|
||||
db.add(ItemTag(item_id=item.id, tag_id=tag_row.id))
|
||||
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)
|
||||
await refresh_item_search_state(db, item)
|
||||
return item
|
||||
|
||||
|
||||
@@ -238,6 +295,100 @@ async def delete_item(
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/items/{item_id}/additions", response_model=list[ItemAdditionOut])
|
||||
async def list_item_additions(
|
||||
item_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
item = (await db.execute(
|
||||
select(Item).where(Item.id == item_id, Item.user_id == user_id)
|
||||
)).scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
additions = (await db.execute(
|
||||
select(ItemAddition)
|
||||
.where(ItemAddition.item_id == item_id, ItemAddition.user_id == user_id)
|
||||
.order_by(ItemAddition.created_at.asc())
|
||||
)).scalars().all()
|
||||
return additions
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/additions", response_model=ItemAdditionOut, status_code=201)
|
||||
async def create_item_addition(
|
||||
item_id: str,
|
||||
body: ItemAdditionCreate,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
item = (await db.execute(
|
||||
select(Item).where(Item.id == item_id, Item.user_id == user_id)
|
||||
)).scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
content = body.content.strip()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="Addition content cannot be empty")
|
||||
|
||||
addition = ItemAddition(
|
||||
id=str(uuid.uuid4()),
|
||||
item_id=item.id,
|
||||
user_id=user_id,
|
||||
source=(body.source or "assistant").strip() or "assistant",
|
||||
kind=(body.kind or "append").strip() or "append",
|
||||
content=content,
|
||||
metadata_json=body.metadata_json or {},
|
||||
)
|
||||
db.add(addition)
|
||||
item.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(addition)
|
||||
|
||||
result = await db.execute(
|
||||
select(Item).where(Item.id == item.id, Item.user_id == user_id)
|
||||
)
|
||||
fresh_item = result.scalar_one()
|
||||
await refresh_item_search_state(db, fresh_item)
|
||||
return addition
|
||||
|
||||
|
||||
@router.delete("/items/{item_id}/additions/{addition_id}")
|
||||
async def delete_item_addition(
|
||||
item_id: str,
|
||||
addition_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
item = (await db.execute(
|
||||
select(Item).where(Item.id == item_id, Item.user_id == user_id)
|
||||
)).scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
addition = (await db.execute(
|
||||
select(ItemAddition).where(
|
||||
ItemAddition.id == addition_id,
|
||||
ItemAddition.item_id == item_id,
|
||||
ItemAddition.user_id == user_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not addition:
|
||||
raise HTTPException(status_code=404, detail="Addition not found")
|
||||
|
||||
await db.delete(addition)
|
||||
item.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(Item).where(Item.id == item.id, Item.user_id == user_id)
|
||||
)
|
||||
fresh_item = result.scalar_one()
|
||||
await refresh_item_search_state(db, fresh_item)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
# ── Reprocess item ──
|
||||
|
||||
@router.post("/items/{item_id}/reprocess", response_model=ItemOut)
|
||||
@@ -335,5 +486,7 @@ async def serve_asset(item_id: str, asset_type: str, filename: str):
|
||||
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"
|
||||
elif filename.endswith(".mp4"): ct = "video/mp4"
|
||||
elif filename.endswith(".webm"): ct = "video/webm"
|
||||
|
||||
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=3600"})
|
||||
|
||||
Reference in New Issue
Block a user