diff --git a/docs/superpowers/plans/2026-04-08-knowledge-task-consolidation.md b/docs/superpowers/plans/2026-04-08-knowledge-task-consolidation.md new file mode 100644 index 0000000..1ee0d6c --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-knowledge-task-consolidation.md @@ -0,0 +1,746 @@ +# Knowledge View Task Consolidation — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Consolidate tasks into the Knowledge view as a fifth card type, deprecate `/notes` and `/tasks` list routes, and simplify navigation down to a single Knowledge hub. + +**Architecture:** The backend knowledge service (`services/knowledge.py`) stops excluding tasks from queries and adds `type=task` filtering via the `is_task` property (`Note.status IS NOT NULL`). The knowledge route validation gains `"task"` as a valid type. The frontend KnowledgeView gains task card rendering with status/priority/due-date badges. Router redirects replace the deleted list views. + +**Tech Stack:** Python/Quart backend (SQLAlchemy), Vue 3 + TypeScript frontend, Pinia stores, Vue Router. + +--- + +## File Map + +| Action | Path | +|--------|------| +| Modify | `src/fabledassistant/services/knowledge.py` | +| Modify | `src/fabledassistant/routes/knowledge.py` | +| Modify | `frontend/src/views/KnowledgeView.vue` | +| Modify | `frontend/src/router/index.ts` | +| Modify | `frontend/src/components/AppHeader.vue` | +| Modify | `frontend/src/App.vue` | +| Delete | `frontend/src/views/NotesListView.vue` | +| Delete | `frontend/src/views/TasksListView.vue` | + +--- + +### Task 1: Backend — Include tasks in knowledge queries + +**Files:** +- Modify: `src/fabledassistant/services/knowledge.py` +- Modify: `src/fabledassistant/routes/knowledge.py` + +**Context:** The knowledge service currently excludes tasks by filtering `Note.status.is_(None)`. Every query function (`query_knowledge`, `query_knowledge_ids`, `_semantic_knowledge_search`, `get_knowledge_tags`, `get_knowledge_counts`) has this exclusion. Adding task support means: (1) removing the task exclusion from the "all types" queries, (2) adding `type=task` as a filter option that maps to `Note.status.isnot(None)`, (3) enriching `_note_to_item` with task-specific fields, (4) updating counts to include tasks. + +- [ ] **Step 1: Add `"task"` to `_VALID_TYPES` in the route file** + +In `src/fabledassistant/routes/knowledge.py`, change: + +```python +_VALID_TYPES = {"note", "person", "place", "list"} +``` + +to: + +```python +_VALID_TYPES = {"note", "person", "place", "list", "task"} +``` + +- [ ] **Step 2: Update `_note_to_item` to include task fields** + +In `src/fabledassistant/services/knowledge.py`, the `_note_to_item` function builds the item dict. After the existing `elif note.entity_type == "list":` block (which ends around line 48), add a task branch. Find: + +```python + elif note.entity_type == "list": + # Parse markdown task list syntax into structured items + body = note.body or "" + list_items = [] + for line in body.split("\n"): + stripped = line.strip() + if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "): + checked_item = not stripped.startswith("- [ ] ") + list_items.append({"text": stripped[6:], "checked": checked_item}) + item["list_items"] = list_items + item["item_count"] = len(list_items) + item["checked_count"] = sum(1 for i in list_items if i["checked"]) + item["body"] = body + return item +``` + +Replace with: + +```python + elif note.entity_type == "list": + # Parse markdown task list syntax into structured items + body = note.body or "" + list_items = [] + for line in body.split("\n"): + stripped = line.strip() + if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "): + checked_item = not stripped.startswith("- [ ] ") + list_items.append({"text": stripped[6:], "checked": checked_item}) + item["list_items"] = list_items + item["item_count"] = len(list_items) + item["checked_count"] = sum(1 for i in list_items if i["checked"]) + item["body"] = body + + # Task fields — included for all items but only meaningful when is_task + if note.is_task: + item["note_type"] = "task" + item["status"] = note.status + item["priority"] = note.priority + item["due_date"] = note.due_date.isoformat() if note.due_date else None + + return item +``` + +This overrides `note_type` to `"task"` for task items (since `entity_type` returns the `note_type` column which is `"note"` for tasks) and adds status/priority/due_date fields. + +- [ ] **Step 3: Update `query_knowledge` to include tasks** + +In the `query_knowledge` function, the "all types" filter currently excludes tasks. Change the base query and the `else` branch. + +Find: + +```python + base = ( + select(Note) + .where(Note.user_id == user_id) + .where(Note.status.is_(None)) # exclude tasks + ) + + if note_type: + base = base.where(Note.note_type == note_type) + else: + # Exclude tasks — already done above; also exclude any legacy nulls + base = base.where(Note.note_type.in_(["note", "person", "place", "list"])) +``` + +Replace with: + +```python + base = select(Note).where(Note.user_id == user_id) + + if note_type == "task": + base = base.where(Note.status.isnot(None)) + elif note_type: + base = base.where(Note.note_type == note_type).where(Note.status.is_(None)) + else: + # All types including tasks + pass +``` + +- [ ] **Step 4: Update `_semantic_knowledge_search` to include tasks** + +Find: + +```python + candidates = await semantic_search_notes( + user_id=user_id, + query=q, + limit=min(200, limit * 8), + threshold=0.3, + is_task=False, + ) +``` + +Replace with: + +```python + is_task_filter = True if note_type == "task" else (False if note_type else None) + candidates = await semantic_search_notes( + user_id=user_id, + query=q, + limit=min(200, limit * 8), + threshold=0.3, + is_task=is_task_filter, + ) +``` + +Also update the type matching in the filter loop — find: + +```python + for _score, note in candidates: + if note_type and note.entity_type != note_type: + continue +``` + +Replace with: + +```python + for _score, note in candidates: + if note_type == "task" and not note.is_task: + continue + elif note_type and note_type != "task" and note.entity_type != note_type: + continue +``` + +- [ ] **Step 5: Update `query_knowledge_ids` to include tasks** + +Find: + +```python + base = ( + select(Note.id) + .where(Note.user_id == user_id) + .where(Note.status.is_(None)) + ) + if note_type: + base = base.where(Note.note_type == note_type) + else: + base = base.where(Note.note_type.in_(["note", "person", "place", "list"])) +``` + +Replace with: + +```python + base = select(Note.id).where(Note.user_id == user_id) + + if note_type == "task": + base = base.where(Note.status.isnot(None)) + elif note_type: + base = base.where(Note.note_type == note_type).where(Note.status.is_(None)) + else: + pass +``` + +- [ ] **Step 6: Update `get_knowledge_tags` to include task tags** + +Find: + +```python + base = ( + select(func.unnest(Note.tags).label("tag")) + .where(Note.user_id == user_id) + .where(Note.status.is_(None)) + ) + if note_type: + base = base.where(Note.note_type == note_type) + else: + base = base.where(Note.note_type.in_(["note", "person", "place", "list"])) +``` + +Replace with: + +```python + base = ( + select(func.unnest(Note.tags).label("tag")) + .where(Note.user_id == user_id) + ) + if note_type == "task": + base = base.where(Note.status.isnot(None)) + elif note_type: + base = base.where(Note.note_type == note_type).where(Note.status.is_(None)) + else: + pass +``` + +- [ ] **Step 7: Update `get_knowledge_counts` to include tasks** + +Find: + +```python + async with async_session() as session: + stmt = ( + select(Note.note_type, func.count(Note.id)) + .where(Note.user_id == user_id) + .where(Note.status.is_(None)) + .where(Note.note_type.in_(["note", "person", "place", "list"])) + .group_by(Note.note_type) + ) + if tags: + for tag in tags: + stmt = stmt.where(Note.tags.contains([tag])) + rows = list((await session.execute(stmt)).all()) + counts = {row[0]: row[1] for row in rows} + # Ensure all types present even if zero + for t in ("note", "person", "place", "list"): + counts.setdefault(t, 0) + counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list")) + return counts +``` + +Replace with: + +```python + async with async_session() as session: + # Count non-task types + stmt = ( + select(Note.note_type, func.count(Note.id)) + .where(Note.user_id == user_id) + .where(Note.status.is_(None)) + .where(Note.note_type.in_(["note", "person", "place", "list"])) + .group_by(Note.note_type) + ) + if tags: + for tag in tags: + stmt = stmt.where(Note.tags.contains([tag])) + rows = list((await session.execute(stmt)).all()) + counts = {row[0]: row[1] for row in rows} + + # Count tasks separately (is_task = status IS NOT NULL) + task_stmt = ( + select(func.count(Note.id)) + .where(Note.user_id == user_id) + .where(Note.status.isnot(None)) + ) + if tags: + for tag in tags: + task_stmt = task_stmt.where(Note.tags.contains([tag])) + task_count: int = (await session.execute(task_stmt)).scalar_one() + counts["task"] = task_count + + for t in ("note", "person", "place", "list", "task"): + counts.setdefault(t, 0) + counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task")) + return counts +``` + +- [ ] **Step 8: Verify backend changes** + +```bash +cd /path/to/fabledassistant +make typecheck +make test +``` + +Expected: no errors. + +- [ ] **Step 9: Commit** + +```bash +git add src/fabledassistant/services/knowledge.py src/fabledassistant/routes/knowledge.py +git commit -m "feat(knowledge): include tasks in knowledge queries and counts" +``` + +--- + +### Task 2: Frontend — Task card rendering in KnowledgeView + +**Files:** +- Modify: `frontend/src/views/KnowledgeView.vue` + +**Context:** `KnowledgeView.vue` has a `KnowledgeItem` interface and renders cards in a grid. Each card type has type-specific content (person shows relationship/email, list shows checkboxes, etc.). Task cards need status, priority, and due date display. The `activeType` ref controls filtering; it needs `"task"` as a valid value. The type filter sidebar needs a "Tasks" button. The new-note button interaction changes from split-button to toggle. + +- [ ] **Step 1: Add `"task"` to the KnowledgeItem interface and filter type** + +In the ` - - - - diff --git a/frontend/src/views/TasksListView.vue b/frontend/src/views/TasksListView.vue deleted file mode 100644 index 2b8c572..0000000 --- a/frontend/src/views/TasksListView.vue +++ /dev/null @@ -1,678 +0,0 @@ - - - - - diff --git a/src/fabledassistant/routes/knowledge.py b/src/fabledassistant/routes/knowledge.py index 5cc694b..9dfef6b 100644 --- a/src/fabledassistant/routes/knowledge.py +++ b/src/fabledassistant/routes/knowledge.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge") -_VALID_TYPES = {"note", "person", "place", "list"} +_VALID_TYPES = {"note", "person", "place", "list", "task"} _VALID_SORTS = {"modified", "created", "alpha", "type"} diff --git a/src/fabledassistant/services/knowledge.py b/src/fabledassistant/services/knowledge.py index 5c586b9..800f722 100644 --- a/src/fabledassistant/services/knowledge.py +++ b/src/fabledassistant/services/knowledge.py @@ -46,6 +46,14 @@ def _note_to_item(note: Note) -> dict: item["item_count"] = len(list_items) item["checked_count"] = sum(1 for i in list_items if i["checked"]) item["body"] = body + + # Task fields — override note_type and add status/priority/due_date + if note.is_task: + item["note_type"] = "task" + item["status"] = note.status + item["priority"] = note.priority + item["due_date"] = note.due_date.isoformat() if note.due_date else None + return item @@ -69,17 +77,15 @@ async def query_knowledge( ) async with async_session() as session: - base = ( - select(Note) - .where(Note.user_id == user_id) - .where(Note.status.is_(None)) # exclude tasks - ) + base = select(Note).where(Note.user_id == user_id) - if note_type: - base = base.where(Note.note_type == note_type) + if note_type == "task": + base = base.where(Note.status.isnot(None)) + elif note_type: + base = base.where(Note.note_type == note_type).where(Note.status.is_(None)) else: - # Exclude tasks — already done above; also exclude any legacy nulls - base = base.where(Note.note_type.in_(["note", "person", "place", "list"])) + # All types including tasks + pass for tag in tags: base = base.where(Note.tags.contains([tag])) @@ -115,12 +121,13 @@ async def _semantic_knowledge_search( try: from fabledassistant.services.embeddings import semantic_search_notes # Fetch a larger candidate set to allow for filtering + is_task_filter = True if note_type == "task" else (False if note_type else None) candidates = await semantic_search_notes( user_id=user_id, query=q, limit=min(200, limit * 8), threshold=0.3, - is_task=False, + is_task=is_task_filter, ) except Exception: logger.warning("Semantic search unavailable, falling back to SQL", exc_info=True) @@ -128,7 +135,9 @@ async def _semantic_knowledge_search( results = [] for _score, note in candidates: - if note_type and note.entity_type != note_type: + if note_type == "task" and not note.is_task: + continue + elif note_type and note_type != "task" and note.entity_type != note_type: continue if tags and not all(t in (note.tags or []) for t in tags): continue @@ -145,12 +154,13 @@ async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list base = ( select(func.unnest(Note.tags).label("tag")) .where(Note.user_id == user_id) - .where(Note.status.is_(None)) ) - if note_type: - base = base.where(Note.note_type == note_type) + if note_type == "task": + base = base.where(Note.status.isnot(None)) + elif note_type: + base = base.where(Note.note_type == note_type).where(Note.status.is_(None)) else: - base = base.where(Note.note_type.in_(["note", "person", "place", "list"])) + pass stmt = base.distinct().order_by("tag") rows = list((await session.execute(stmt)).scalars().all()) return [r for r in rows if r] @@ -159,6 +169,7 @@ async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> dict[str, int]: """Return per-type count of knowledge objects for the sidebar display.""" async with async_session() as session: + # Count non-task types stmt = ( select(Note.note_type, func.count(Note.id)) .where(Note.user_id == user_id) @@ -170,11 +181,23 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d for tag in tags: stmt = stmt.where(Note.tags.contains([tag])) rows = list((await session.execute(stmt)).all()) - counts = {row[0]: row[1] for row in rows} - # Ensure all types present even if zero - for t in ("note", "person", "place", "list"): + counts = {row[0]: row[1] for row in rows} + + # Count tasks separately (is_task = status IS NOT NULL) + task_stmt = ( + select(func.count(Note.id)) + .where(Note.user_id == user_id) + .where(Note.status.isnot(None)) + ) + if tags: + for tag in tags: + task_stmt = task_stmt.where(Note.tags.contains([tag])) + task_count: int = (await session.execute(task_stmt)).scalar_one() + counts["task"] = task_count + + for t in ("note", "person", "place", "list", "task"): counts.setdefault(t, 0) - counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list")) + counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task")) return counts @@ -197,15 +220,14 @@ async def query_knowledge_ids( return [item["id"] for item in items], total async with async_session() as session: - base = ( - select(Note.id) - .where(Note.user_id == user_id) - .where(Note.status.is_(None)) - ) - if note_type: - base = base.where(Note.note_type == note_type) + base = select(Note.id).where(Note.user_id == user_id) + + if note_type == "task": + base = base.where(Note.status.isnot(None)) + elif note_type: + base = base.where(Note.note_type == note_type).where(Note.status.is_(None)) else: - base = base.where(Note.note_type.in_(["note", "person", "place", "list"])) + pass for tag in tags: base = base.where(Note.tags.contains([tag]))