From 90afd3f1311ce6547f81e0fee0a74abc1683655d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 1 Mar 2026 12:10:39 -0500 Subject: [PATCH] Improve suggested notes: limit 8, threshold 0.45, show relevance scores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Raise similarity threshold 0.30 → 0.45: only genuinely relevant notes shown; loosely-related notes no longer pad the sidebar - Increase max suggested notes 3 → 8 (zero added compute — threshold is the real gate; the embedding call is fixed regardless of limit) - semantic_search_notes now returns list[tuple[float, Note]] instead of list[Note] so scores propagate through context_meta to the frontend - Keyword fallback notes carry score=null (no cosine similarity available) - ChatView sidebar shows % badge on each suggested note: green ≥75%, amber 60–74%, muted <60% Hovering reveals the raw score in a tooltip Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/types/chat.ts | 2 +- frontend/src/views/ChatView.vue | 24 +++++++++++++++- src/fabledassistant/services/embeddings.py | 14 ++++++---- src/fabledassistant/services/llm.py | 32 ++++++++++++++-------- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts index 6667022..51c32df 100644 --- a/frontend/src/types/chat.ts +++ b/frontend/src/types/chat.ts @@ -54,7 +54,7 @@ export interface ConversationDetail extends Conversation { export interface ContextMeta { context_note_id: number | null; context_note_title: string | null; - auto_notes: { id: number; title: string }[]; + auto_notes: { id: number; title: string; score?: number | null }[]; } export interface OllamaStatus { diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index fc3b656..9919f88 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -38,7 +38,7 @@ const includedNoteIds = ref>(new Set()); const includedNotes = ref<{ id: number; title: string }[]>([]); // Suggested notes — auto-found by search, not yet included -const suggestedNotes = ref<{ id: number; title: string }[]>([]); +const suggestedNotes = ref<{ id: number; title: string; score?: number | null }[]>([]); let prevConvId: number | null = null; @@ -482,6 +482,16 @@ onUnmounted(() => { {{ note.title }} + {{ Math.round(note.score * 100) }}% @@ -807,6 +817,18 @@ onUnmounted(() => { .context-note-suggested { opacity: 0.85; } +.context-note-score { + font-size: 0.67rem; + font-weight: 600; + padding: 0.05rem 0.22rem; + border-radius: 3px; + flex-shrink: 0; + font-variant-numeric: tabular-nums; + letter-spacing: 0; +} +.score-high { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 12%, transparent); } +.score-medium { color: #c98a00; background: rgba(201, 138, 0, 0.12); } +.score-low { color: var(--color-text-muted); background: var(--color-bg-secondary); } .context-note-remove { background: none; border: none; diff --git a/src/fabledassistant/services/embeddings.py b/src/fabledassistant/services/embeddings.py index bd7a6a2..8ce0fed 100644 --- a/src/fabledassistant/services/embeddings.py +++ b/src/fabledassistant/services/embeddings.py @@ -21,7 +21,9 @@ logger = logging.getLogger(__name__) # Minimum cosine similarity to include a note in context results. # nomic-embed-text produces unit-normalized vectors, so range is [-1, 1]. -_SIMILARITY_THRESHOLD = 0.30 +# 0.45 keeps only genuinely relevant notes; lower values like 0.30 let in +# loosely-related results that pad the sidebar without adding real value. +_SIMILARITY_THRESHOLD = 0.45 async def get_embedding(text: str, model: str | None = None) -> list[float]: @@ -75,10 +77,12 @@ async def semantic_search_notes( user_id: int, query: str, exclude_ids: set[int] | None = None, - limit: int = 3, -) -> list[Note]: - """Return up to *limit* notes most relevant to *query* using cosine similarity. + limit: int = 8, +) -> list[tuple[float, Note]]: + """Return up to *limit* (score, note) pairs most relevant to *query*. + Scores are cosine similarities in [-1, 1]; only notes at or above + _SIMILARITY_THRESHOLD are returned, sorted highest-first. Returns an empty list if the embedding model is unavailable or on any error. """ try: @@ -114,7 +118,7 @@ async def semantic_search_notes( scored.append((sim, note)) scored.sort(key=lambda x: x[0], reverse=True) - return [note for _, note in scored[:limit]] + return scored[:limit] async def backfill_note_embeddings() -> None: diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index 8a04e79..8d00305 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -444,30 +444,38 @@ async def build_context( if current_note_id: search_exclude.add(current_note_id) - found_notes = [] + # (score, note) pairs — score is float for semantic results, None for keyword fallback. + found_scored: list[tuple[float | None, object]] = [] + # Try semantic search first; fall back to keyword search on failure / no results. try: from fabledassistant.services.embeddings import semantic_search_notes - found_notes = await semantic_search_notes( - user_id, user_message, exclude_ids=search_exclude or None, limit=3 - ) + for score, note in await semantic_search_notes( + user_id, user_message, exclude_ids=search_exclude or None, limit=8 + ): + found_scored.append((score, note)) except Exception: logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True) - if not found_notes: + if not found_scored: keywords = _extract_keywords(user_message) if keywords: try: - found_notes = await search_notes_for_context( - user_id, keywords, exclude_ids=search_exclude or None, limit=3 - ) + for note in await search_notes_for_context( + user_id, keywords, exclude_ids=search_exclude or None, limit=8 + ): + found_scored.append((None, note)) except Exception: logger.warning("Failed to search notes for context", exc_info=True) - # Populate sidebar candidates (never auto-injected). - for n in found_notes: - context_meta["auto_notes"].append({"id": n.id, "title": n.title}) - context_meta["auto_note_ids"] = [n.id for n in found_notes] + # Populate sidebar candidates (never auto-injected into system prompt). + for score, n in found_scored: + context_meta["auto_notes"].append({ + "id": n.id, + "title": n.title, + "score": round(score, 2) if score is not None else None, + }) + context_meta["auto_note_ids"] = [n.id for _, n in found_scored] # Inject explicitly included notes (user opted in via sidebar click). if include_note_ids: