feat(plan): KnowledgeView Plans facet + plan badge (knowledge endpoints + UI)

This commit is contained in:
2026-05-28 11:12:59 -04:00
parent 2f5ef9124a
commit dc93675470
3 changed files with 48 additions and 34 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
_VALID_TYPES = {"note", "person", "place", "list", "task"}
_VALID_TYPES = {"note", "person", "place", "list", "task", "plan"}
_VALID_SORTS = {"modified", "created", "alpha", "type"}
+38 -26
View File
@@ -55,6 +55,7 @@ def _note_to_item(note: Note) -> dict:
# Task fields — override note_type and add status/priority/due_date
if note.is_task:
item["note_type"] = "task"
item["task_kind"] = note.task_kind
item["status"] = note.status
item["priority"] = note.priority
item["due_date"] = note.due_date.isoformat() if note.due_date else None
@@ -62,6 +63,21 @@ def _note_to_item(note: Note) -> dict:
return item
def _apply_type_filter(stmt, note_type: str | None):
"""Apply the type facet to a Note select.
'task' = any task (status not null); 'plan' = a task with task_kind='plan';
any other non-empty type = a non-task note of that note_type; None = all.
"""
if note_type == "task":
return stmt.where(Note.status.isnot(None))
if note_type == "plan":
return stmt.where(Note.status.isnot(None)).where(Note.task_kind == "plan")
if note_type:
return stmt.where(Note.note_type == note_type).where(Note.status.is_(None))
return stmt
async def query_knowledge(
user_id: int,
note_type: str | None,
@@ -84,13 +100,7 @@ async def query_knowledge(
async with async_session() as session:
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
base = _apply_type_filter(base, note_type)
for tag in tags:
base = base.where(Note.tags.contains([tag]))
@@ -138,10 +148,7 @@ async def _semantic_knowledge_search(
.where(Note.user_id == user_id)
.where(Note.title.ilike(pattern) | Note.body.ilike(pattern))
)
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))
base = _apply_type_filter(base, note_type)
for tag in tags:
base = base.where(Note.tags.contains([tag]))
# Title matches first, then body-only matches, newest first within each
@@ -157,7 +164,7 @@ async def _semantic_knowledge_search(
semantic_notes: list[Note] = []
try:
from fabledassistant.services.embeddings import semantic_search_notes
is_task_filter = True if note_type == "task" else (False if note_type else None)
is_task_filter = True if note_type in ("task", "plan") else (False if note_type else None)
candidates = await semantic_search_notes(
user_id=user_id,
query=q,
@@ -168,7 +175,9 @@ async def _semantic_knowledge_search(
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:
elif note_type == "plan" and (not note.is_task or note.task_kind != "plan"):
continue
elif note_type and note_type not in ("task", "plan") and note.entity_type != note_type:
continue
if tags and not all(t in (note.tags or []) for t in tags):
continue
@@ -200,12 +209,7 @@ async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list
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
base = _apply_type_filter(base, note_type)
stmt = base.distinct().order_by("tag")
rows = list((await session.execute(stmt)).scalars().all())
return [r for r in rows if r]
@@ -240,7 +244,20 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d
task_count: int = (await session.execute(task_stmt)).scalar_one()
counts["task"] = task_count
for t in ("note", "person", "place", "list", "task"):
# Plans are a subset of tasks (task_kind='plan'); counted for the facet
# but NOT added to total to avoid double-counting against "task".
plan_stmt = (
select(func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.isnot(None))
.where(Note.task_kind == "plan")
)
if tags:
for tag in tags:
plan_stmt = plan_stmt.where(Note.tags.contains([tag]))
counts["plan"] = (await session.execute(plan_stmt)).scalar_one()
for t in ("note", "person", "place", "list", "task", "plan"):
counts.setdefault(t, 0)
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task"))
return counts
@@ -267,12 +284,7 @@ async def query_knowledge_ids(
async with async_session() as session:
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
base = _apply_type_filter(base, note_type)
for tag in tags:
base = base.where(Note.tags.contains([tag]))