feat: brain taxonomy — DB-backed folders/tags, sidebar, CRUD API

Backend:
- New Folder/Tag/ItemTag models with proper relational tables
- Taxonomy CRUD endpoints: list, create, rename, delete, merge tags
- Sidebar endpoint with folder/tag counts
- AI classification reads live folders/tags from DB, not hardcoded
- Default folders/tags seeded on first request per user
- folder_id FK on items for relational integrity

Frontend:
- Left sidebar with Folders/Tags tabs (like Karakeep)
- Click folder/tag to filter items
- "Manage" mode: add new folders/tags, delete existing
- Counts next to each folder/tag
- "All items" option to clear filter
- Replaces the old signal-strip cards

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-01 20:23:45 -05:00
parent 4805729f87
commit 68a8d4c228
7 changed files with 693 additions and 100 deletions

View File

@@ -159,18 +159,53 @@ async def _process_item(item_id: str):
if result.get("page_count"):
item.metadata_json["page_count"] = result["page_count"]
# ── Step 2: AI classification ──
log.info(f"Classifying item {item.id}")
# ── Step 2: Fetch live taxonomy from DB ──
from app.models.taxonomy import Folder as FolderModel, Tag as TagModel, ItemTag, ensure_user_taxonomy
await ensure_user_taxonomy(db, item.user_id)
active_folders = (await db.execute(
select(FolderModel).where(FolderModel.user_id == item.user_id, FolderModel.is_active == True)
.order_by(FolderModel.sort_order)
)).scalars().all()
active_tags = (await db.execute(
select(TagModel).where(TagModel.user_id == item.user_id, TagModel.is_active == True)
.order_by(TagModel.sort_order)
)).scalars().all()
folder_names = [f.name for f in active_folders]
tag_names = [t.name for t in active_tags]
folder_map = {f.name: f for f in active_folders}
tag_map = {t.name: t for t in active_tags}
# ── Step 3: AI classification ──
log.info(f"Classifying item {item.id} with {len(folder_names)} folders, {len(tag_names)} tags")
classification = await classify_item(
item_type=item.type,
url=item.url,
title=title,
text=extracted_text,
folders=folder_names,
tags=tag_names,
)
item.title = classification.get("title") or title or "Untitled"
item.folder = classification.get("folder", "Knowledge")
item.tags = classification.get("tags", ["reference", "read-later"])
# Set folder (relational + denormalized)
classified_folder = classification.get("folder", folder_names[0] if folder_names else "Knowledge")
item.folder = classified_folder
if classified_folder in folder_map:
item.folder_id = folder_map[classified_folder].id
# Set tags (relational + denormalized)
classified_tags = classification.get("tags", [])
item.tags = classified_tags
# Clear old item_tags and create new ones
from sqlalchemy import delete as sa_delete
await db.execute(sa_delete(ItemTag).where(ItemTag.item_id == item.id))
for tag_name in classified_tags:
if tag_name in tag_map:
db.add(ItemTag(item_id=item.id, tag_id=tag_map[tag_name].id))
# For notes: replace raw_content with spell-corrected version
corrected = classification.get("corrected_text", "")