feat(plan): KnowledgeView Plans facet + plan badge (knowledge endpoints + UI)
This commit is contained in:
@@ -49,6 +49,7 @@ interface KnowledgeItem {
|
|||||||
status?: string;
|
status?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
due_date?: string;
|
due_date?: string;
|
||||||
|
task_kind?: "work" | "plan";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpcomingEvent {
|
interface UpcomingEvent {
|
||||||
@@ -60,7 +61,7 @@ interface UpcomingEvent {
|
|||||||
|
|
||||||
// ─── Filter state ─────────────────────────────────────────────────────────────
|
// ─── Filter state ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task">("");
|
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task" | "plan">("");
|
||||||
const activeTag = ref("");
|
const activeTag = ref("");
|
||||||
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
@@ -68,8 +69,8 @@ let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
|||||||
|
|
||||||
// ─── Type counts ──────────────────────────────────────────────────────────────
|
// ─── Type counts ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface KnowledgeCounts { note: number; person: number; place: number; list: number; total: number }
|
interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; total: number }
|
||||||
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, total: 0 });
|
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, task: 0, plan: 0, total: 0 });
|
||||||
|
|
||||||
async function fetchCounts() {
|
async function fetchCounts() {
|
||||||
try {
|
try {
|
||||||
@@ -416,11 +417,11 @@ onUnmounted(() => {
|
|||||||
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
|
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
|
||||||
:key="val"
|
:key="val"
|
||||||
class="filter-btn"
|
class="filter-btn"
|
||||||
:class="{ active: activeType === val }"
|
:class="{ active: activeType === val }"
|
||||||
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task')"
|
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task' | 'plan')"
|
||||||
>
|
>
|
||||||
<span class="filter-btn-label">{{ label }}</span>
|
<span class="filter-btn-label">{{ label }}</span>
|
||||||
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
||||||
@@ -488,11 +489,11 @@ onUnmounted(() => {
|
|||||||
@click="openItem(item)"
|
@click="openItem(item)"
|
||||||
>
|
>
|
||||||
<!-- Type badge -->
|
<!-- Type badge -->
|
||||||
<span class="type-badge" :class="`badge--${item.note_type}`">
|
<span class="type-badge" :class="item.task_kind === 'plan' ? 'badge--plan' : `badge--${item.note_type}`">
|
||||||
<span v-if="item.note_type === 'note'">Note</span>
|
<span v-if="item.note_type === 'note'">Note</span>
|
||||||
<span v-else-if="item.note_type === 'person'">Person</span>
|
<span v-else-if="item.note_type === 'person'">Person</span>
|
||||||
<span v-else-if="item.note_type === 'place'">Place</span>
|
<span v-else-if="item.note_type === 'place'">Place</span>
|
||||||
<span v-else-if="item.note_type === 'task'">Task</span>
|
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
||||||
<span v-else>List</span>
|
<span v-else>List</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -982,6 +983,7 @@ onUnmounted(() => {
|
|||||||
.badge--place { background: rgba(245,158,11,0.15); color: #fbbf24; }
|
.badge--place { background: rgba(245,158,11,0.15); color: #fbbf24; }
|
||||||
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
|
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
|
||||||
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
||||||
|
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
|
||||||
|
|
||||||
.k-card-body { flex: 1; padding-right: 40px; }
|
.k-card-body { flex: 1; padding-right: 40px; }
|
||||||
.k-card-title {
|
.k-card-title {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
|
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"}
|
_VALID_SORTS = {"modified", "created", "alpha", "type"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ def _note_to_item(note: Note) -> dict:
|
|||||||
# Task fields — override note_type and add status/priority/due_date
|
# Task fields — override note_type and add status/priority/due_date
|
||||||
if note.is_task:
|
if note.is_task:
|
||||||
item["note_type"] = "task"
|
item["note_type"] = "task"
|
||||||
|
item["task_kind"] = note.task_kind
|
||||||
item["status"] = note.status
|
item["status"] = note.status
|
||||||
item["priority"] = note.priority
|
item["priority"] = note.priority
|
||||||
item["due_date"] = note.due_date.isoformat() if note.due_date else None
|
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
|
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(
|
async def query_knowledge(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
note_type: str | None,
|
note_type: str | None,
|
||||||
@@ -84,13 +100,7 @@ async def query_knowledge(
|
|||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
base = select(Note).where(Note.user_id == user_id)
|
base = select(Note).where(Note.user_id == user_id)
|
||||||
|
|
||||||
if note_type == "task":
|
base = _apply_type_filter(base, note_type)
|
||||||
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
|
|
||||||
|
|
||||||
for tag in tags:
|
for tag in tags:
|
||||||
base = base.where(Note.tags.contains([tag]))
|
base = base.where(Note.tags.contains([tag]))
|
||||||
@@ -138,10 +148,7 @@ async def _semantic_knowledge_search(
|
|||||||
.where(Note.user_id == user_id)
|
.where(Note.user_id == user_id)
|
||||||
.where(Note.title.ilike(pattern) | Note.body.ilike(pattern))
|
.where(Note.title.ilike(pattern) | Note.body.ilike(pattern))
|
||||||
)
|
)
|
||||||
if note_type == "task":
|
base = _apply_type_filter(base, note_type)
|
||||||
base = base.where(Note.status.isnot(None))
|
|
||||||
elif note_type:
|
|
||||||
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
|
|
||||||
for tag in tags:
|
for tag in tags:
|
||||||
base = base.where(Note.tags.contains([tag]))
|
base = base.where(Note.tags.contains([tag]))
|
||||||
# Title matches first, then body-only matches, newest first within each
|
# Title matches first, then body-only matches, newest first within each
|
||||||
@@ -157,7 +164,7 @@ async def _semantic_knowledge_search(
|
|||||||
semantic_notes: list[Note] = []
|
semantic_notes: list[Note] = []
|
||||||
try:
|
try:
|
||||||
from fabledassistant.services.embeddings import semantic_search_notes
|
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(
|
candidates = await semantic_search_notes(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
query=q,
|
query=q,
|
||||||
@@ -168,7 +175,9 @@ async def _semantic_knowledge_search(
|
|||||||
for _score, note in candidates:
|
for _score, note in candidates:
|
||||||
if note_type == "task" and not note.is_task:
|
if note_type == "task" and not note.is_task:
|
||||||
continue
|
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
|
continue
|
||||||
if tags and not all(t in (note.tags or []) for t in tags):
|
if tags and not all(t in (note.tags or []) for t in tags):
|
||||||
continue
|
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"))
|
select(func.unnest(Note.tags).label("tag"))
|
||||||
.where(Note.user_id == user_id)
|
.where(Note.user_id == user_id)
|
||||||
)
|
)
|
||||||
if note_type == "task":
|
base = _apply_type_filter(base, note_type)
|
||||||
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
|
|
||||||
stmt = base.distinct().order_by("tag")
|
stmt = base.distinct().order_by("tag")
|
||||||
rows = list((await session.execute(stmt)).scalars().all())
|
rows = list((await session.execute(stmt)).scalars().all())
|
||||||
return [r for r in rows if r]
|
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()
|
task_count: int = (await session.execute(task_stmt)).scalar_one()
|
||||||
counts["task"] = task_count
|
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.setdefault(t, 0)
|
||||||
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task"))
|
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task"))
|
||||||
return counts
|
return counts
|
||||||
@@ -267,12 +284,7 @@ async def query_knowledge_ids(
|
|||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
base = select(Note.id).where(Note.user_id == user_id)
|
base = select(Note.id).where(Note.user_id == user_id)
|
||||||
|
|
||||||
if note_type == "task":
|
base = _apply_type_filter(base, note_type)
|
||||||
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
|
|
||||||
for tag in tags:
|
for tag in tags:
|
||||||
base = base.where(Note.tags.contains([tag]))
|
base = base.where(Note.tags.contains([tag]))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user