From 48f070f7738deeefd6cacbbb61b49801d8d15ca2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 6 Mar 2026 14:02:54 -0500 Subject: [PATCH] Project-aware assist, link suggestions, project-scoped RAG, semantic search tool, SSE race fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Writing assistant: inject project notes as context (definition-tagged first), wikilink suggestions - Link suggestions: server-side endpoint finds unlinked term occurrences, NoteEditorView sidebar panel - Project-scoped RAG: ChatView ProjectSelector filters semantic+keyword search to selected project - Semantic search tool: LLM search_notes upgraded to hybrid semantic (0.40 threshold) + keyword merge - SSE race condition fix: drain remaining events after stream loop exits in chat.py and notes.py - RAG_AUTO_SNIPPET raised 800→4000; sidebar include uses full note body; MAX_BODY_CHARS 8000→24000 - Enter-to-submit on writing assistant instruction textareas (note and task editors) - DiffView: equal-line collapsing with 3-line context around changes Co-Authored-By: Claude Sonnet 4.6 --- .../0023_add_tags_to_note_versions.py | 16 ++ frontend/src/components/DiffView.vue | 74 +++++++- frontend/src/components/HistoryPanel.vue | 5 +- frontend/src/composables/useAssist.ts | 108 ++++++------ frontend/src/stores/chat.ts | 34 ++++ frontend/src/types/task.ts | 1 + frontend/src/views/ChatView.vue | 39 +++++ frontend/src/views/NoteEditorView.vue | 158 +++++++++++++++++- frontend/src/views/TaskEditorView.vue | 3 +- src/fabledassistant/app.py | 7 + src/fabledassistant/config.py | 17 ++ src/fabledassistant/models/note_version.py | 4 +- src/fabledassistant/routes/chat.py | 12 +- src/fabledassistant/routes/notes.py | 117 ++++++++++++- src/fabledassistant/services/assist.py | 48 +++++- src/fabledassistant/services/embeddings.py | 3 + .../services/generation_task.py | 17 +- src/fabledassistant/services/llm.py | 40 ++++- src/fabledassistant/services/note_versions.py | 6 +- src/fabledassistant/services/notes.py | 35 ++-- src/fabledassistant/services/tools.py | 88 ++++++++-- summary.md | 48 +++++- 22 files changed, 767 insertions(+), 113 deletions(-) create mode 100644 alembic/versions/0023_add_tags_to_note_versions.py diff --git a/alembic/versions/0023_add_tags_to_note_versions.py b/alembic/versions/0023_add_tags_to_note_versions.py new file mode 100644 index 0000000..e7ade1c --- /dev/null +++ b/alembic/versions/0023_add_tags_to_note_versions.py @@ -0,0 +1,16 @@ +"""Add tags column to note_versions for complete version snapshots.""" + +from alembic import op + +revision = "0023" +down_revision = "0022" + + +def upgrade() -> None: + op.execute( + "ALTER TABLE note_versions ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}'" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE note_versions DROP COLUMN IF EXISTS tags") diff --git a/frontend/src/components/DiffView.vue b/frontend/src/components/DiffView.vue index c6f5329..61f93e5 100644 --- a/frontend/src/components/DiffView.vue +++ b/frontend/src/components/DiffView.vue @@ -6,9 +6,52 @@ const props = defineProps<{ diff: DiffLine[]; }>(); +const CONTEXT_LINES = 3; +const MIN_COLLAPSE = 6; // Only collapse if there are more than this many consecutive equal lines + +type CollapseMarker = { type: 'collapse'; count: number }; +type DisplayItem = DiffLine | CollapseMarker; + const insertions = computed(() => props.diff.filter(l => l.type === 'insert').length); const deletions = computed(() => props.diff.filter(l => l.type === 'delete').length); +const displayItems = computed(() => { + const d = props.diff; + if (!d.length) return []; + + // Mark which indices are within CONTEXT_LINES of a change + const shown = new Set(); + for (let i = 0; i < d.length; i++) { + if (d[i].type !== 'equal') { + for (let c = Math.max(0, i - CONTEXT_LINES); c <= Math.min(d.length - 1, i + CONTEXT_LINES); c++) { + shown.add(c); + } + } + } + + // If nothing changed, show all + if (shown.size === 0) return d; + + const result: DisplayItem[] = []; + let i = 0; + while (i < d.length) { + if (shown.has(i)) { + result.push(d[i]); + i++; + } else { + let count = 0; + while (i < d.length && !shown.has(i)) { count++; i++; } + if (count >= MIN_COLLAPSE) { + result.push({ type: 'collapse', count }); + } else { + // Too short to collapse — show as-is + for (let k = i - count; k < i; k++) result.push(d[k]); + } + } + } + return result; +}); + function markerFor(type: DiffLine['type']): string { if (type === 'insert') return '+'; if (type === 'delete') return '−'; @@ -24,14 +67,19 @@ function markerFor(type: DiffLine['type']): string {
No changes.
-
- {{ markerFor(line.type) }} - {{ line.text }} -
+
@@ -102,6 +150,16 @@ function markerFor(type: DiffLine['type']): string { color: var(--color-text-muted); } +.diff-collapse { + color: var(--color-text-muted); + opacity: 0.6; + font-style: italic; + border-top: 1px dashed var(--color-border); + border-bottom: 1px dashed var(--color-border); + padding-top: 0.2rem; + padding-bottom: 0.2rem; +} + .diff-marker { flex-shrink: 0; width: 1rem; diff --git a/frontend/src/components/HistoryPanel.vue b/frontend/src/components/HistoryPanel.vue index 20df7bf..3ca002f 100644 --- a/frontend/src/components/HistoryPanel.vue +++ b/frontend/src/components/HistoryPanel.vue @@ -8,6 +8,7 @@ interface NoteVersion { id: number; note_id: number; title: string; + tags: string[]; body?: string; created_at: string; } @@ -18,7 +19,7 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (e: 'restore', body: string): void; + (e: 'restore', body: string, tags: string[]): void; (e: 'close'): void; }>(); @@ -101,7 +102,7 @@ async function selectVersionItem(v: NoteVersion) { function restore() { if (!selectedVersion.value?.body) return; - emit('restore', selectedVersion.value.body); + emit('restore', selectedVersion.value.body, selectedVersion.value.tags ?? []); } onMounted(loadVersions); diff --git a/frontend/src/composables/useAssist.ts b/frontend/src/composables/useAssist.ts index ded6b39..6ec14c7 100644 --- a/frontend/src/composables/useAssist.ts +++ b/frontend/src/composables/useAssist.ts @@ -53,7 +53,7 @@ function computeDiff(a: string, b: string): DiffLine[] { return result; } -export function useAssist(body: Ref, noteId?: Ref) { +export function useAssist(body: Ref, noteId?: Ref, projectId?: Ref) { const toast = useToastStore(); const state = ref("idle"); @@ -166,6 +166,17 @@ export function useAssist(body: Ref, noteId?: Ref) { } } + function _buildProposedFullBody(fullText: string, wholeDoc: boolean): string { + if (wholeDoc) return fullText; + const t = target.value; + if (t) { + let text = fullText; + if (!text.endsWith("\n")) text += "\n"; + return bodySnapshot.slice(0, t.startOffset) + text + bodySnapshot.slice(t.endOffset); + } + return fullText; + } + async function submit() { if (!canSubmit.value) return; @@ -185,71 +196,64 @@ export function useAssist(body: Ref, noteId?: Ref) { target_section: targetSection, instruction: instruction.value, whole_doc: wholeDoc, + ...(noteId?.value ? { note_id: noteId.value } : {}), + ...(projectId?.value ? { project_id: projectId.value } : {}), }); - streamHandle = apiSSEStream("/api/notes/assist/stream", async (evt) => { - if (evt.event === "chunk") { - streamingText.value += evt.data.chunk as string; - } - if (evt.event === "done") { - const fullText = (evt.data.full_text as string) || streamingText.value; - proposedText.value = fullText; + // Stream with automatic reconnection on dropped connections + const MAX_RETRIES = 3; + let lastEventId = -1; + let gotDone = false; - if (wholeDoc) { - proposedFullBody.value = fullText; - } else { - // Reconstruct full body with section replaced - const t = target.value; - if (t) { - let text = fullText; - if (!text.endsWith("\n")) text += "\n"; - proposedFullBody.value = - bodySnapshot.slice(0, t.startOffset) + - text + - bodySnapshot.slice(t.endOffset); - } else { - proposedFullBody.value = fullText; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (attempt > 0) { + if (state.value !== "streaming") break; + await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** (attempt - 1), 5000))); + if (state.value !== "streaming") break; + } + + gotDone = false; + streamHandle = apiSSEStream( + `/api/notes/assist/stream${lastEventId >= 0 ? `?last_event_id=${lastEventId}` : ""}`, + (evt) => { + lastEventId = evt.id; + if (evt.event === "chunk") { + streamingText.value += evt.data.chunk as string; } - } + if (evt.event === "done") { + gotDone = true; + const fullText = (evt.data.full_text as string) || streamingText.value; + proposedText.value = fullText; + proposedFullBody.value = _buildProposedFullBody(fullText, wholeDoc); + state.value = "review"; + streamHandle = null; + _saveDraft(); + } + if (evt.event === "error") { + gotDone = true; + error.value = evt.data.error as string; + toast.show("Assist failed: " + error.value, "error"); + state.value = "idle"; + streamHandle = null; + } + }, + ); - state.value = "review"; - streamHandle = null; - await _saveDraft(); - } - if (evt.event === "error") { - error.value = evt.data.error as string; - toast.show("Assist failed: " + error.value, "error"); - state.value = "idle"; - streamHandle = null; - } - }); - - await streamHandle.done; + await streamHandle.done; + streamHandle = null; + if (gotDone) break; + } + // If still streaming after all retries, treat accumulated text as result if (state.value === "streaming") { if (streamingText.value) { proposedText.value = streamingText.value; - if (wholeDoc) { - proposedFullBody.value = streamingText.value; - } else { - const t = target.value; - if (t) { - let text = streamingText.value; - if (!text.endsWith("\n")) text += "\n"; - proposedFullBody.value = - bodySnapshot.slice(0, t.startOffset) + - text + - bodySnapshot.slice(t.endOffset); - } else { - proposedFullBody.value = streamingText.value; - } - } + proposedFullBody.value = _buildProposedFullBody(streamingText.value, wholeDoc); state.value = "review"; await _saveDraft(); } else { state.value = "idle"; } - streamHandle = null; } } catch (e) { error.value = e instanceof Error ? e.message : "Request failed"; diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index b2aff0c..f9fb6b8 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -155,6 +155,7 @@ export const useChatStore = defineStore("chat", () => { think = false, contextNoteTitle?: string, excludeNoteIds?: number[], + ragProjectId?: number | null, ) { if (!currentConversation.value) return; @@ -204,6 +205,7 @@ export const useChatStore = defineStore("chat", () => { include_note_ids: includeNoteIds?.length ? includeNoteIds : undefined, excluded_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined, think, + rag_project_id: ragProjectId ?? undefined, }, ); assistantMessageId = resp.assistant_message_id; @@ -348,6 +350,37 @@ export const useChatStore = defineStore("chat", () => { } } + async function reconnectIfGenerating(convId: number): Promise { + // Skip if a stream is already active for this conversation + if (isStreamingConv(convId)) return; + + const conv = currentConversation.value; + if (!conv || conv.id !== convId) return; + + // Find the last assistant message still in generating state + const lastMsg = [...conv.messages].reverse().find( + (m) => m.role === "assistant" && m.status === "generating" + ); + if (!lastMsg) return; + + // Remove the partial message — the done event will re-add the final version + conv.messages = conv.messages.filter((m) => m.id !== lastMsg.id); + + const s = _getOrInitStream(convId); + s.streaming = true; + s.content = ""; + s.thinking = ""; + s.toolCalls = []; + s.status = "Reconnecting..."; + s.pendingTool = null; + s.contextMeta = null; + + // Reconnect from the beginning — the buffer replays all buffered events. + // If the buffer is gone (>60s stale), _streamGeneration will exhaust retries + // then fall back to fetchConversation to show the final saved content. + await _streamGeneration(convId, lastMsg.id); + } + async function confirmTool(confirmed: boolean) { const convId = currentConversation.value?.id; if (!convId) return; @@ -461,6 +494,7 @@ export const useChatStore = defineStore("chat", () => { deleteConversation, updateTitle, sendMessage, + reconnectIfGenerating, confirmTool, cancelGeneration, saveMessageAsNote, diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts index b147c41..3d838eb 100644 --- a/frontend/src/types/task.ts +++ b/frontend/src/types/task.ts @@ -29,6 +29,7 @@ export interface NoteVersion { id: number; note_id: number; title: string; + tags: string[]; body?: string; created_at: string; } diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index 020cadb..c9ff665 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -9,6 +9,7 @@ import ChatMessage from "@/components/ChatMessage.vue"; import ToolCallCard from "@/components/ToolCallCard.vue"; import ToolConfirmCard from "@/components/ToolConfirmCard.vue"; import type { Note } from "@/types/note"; +import ProjectSelector from "@/components/ProjectSelector.vue"; const route = useRoute(); const router = useRouter(); @@ -46,6 +47,9 @@ const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null // Note IDs excluded from auto-injection on next message const excludedNoteIds = ref([]); +// Project scope for RAG — when set, semantic & keyword search is restricted to this project +const ragProjectId = ref(null); + let prevConvId: number | null = null; const convId = computed(() => { @@ -89,6 +93,8 @@ onMounted(async () => { if (store.currentConversation?.id !== convId.value) { await store.fetchConversation(convId.value); } + // Reconnect if the last assistant message is still generating (e.g. after page refresh) + store.reconnectIfGenerating(convId.value); } else { await startNewConversation(); } @@ -116,6 +122,8 @@ watch(convId, async (newId) => { if (store.currentConversation?.id !== newId && !store.isStreamingConv(newId)) { await store.fetchConversation(newId); } + // Reconnect if a prior stream died while the backend was still generating + store.reconnectIfGenerating(newId); scrollToBottom(); } else { await startNewConversation(); @@ -220,6 +228,7 @@ async function sendMessage() { true, // enable thinking in the full chat view undefined, excludedNoteIds.value.length ? excludedNoteIds.value : undefined, + ragProjectId.value, ); sending.value = false; @@ -404,6 +413,14 @@ onUnmounted(() => { + + +
+ + +

RAG search restricted to this project

+
+
{ opacity: 0.9; } +.rag-scope-section { + padding: 0.5rem 0.75rem 0.25rem; + border-bottom: 1px solid var(--color-border); +} + +.rag-scope-label { + display: block; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + margin-bottom: 0.3rem; +} + +.rag-scope-hint { + margin: 0.3rem 0 0; + font-size: 0.7rem; + color: var(--color-primary); + font-style: italic; +} + .conv-list { flex: 1; overflow-y: auto; diff --git a/frontend/src/views/NoteEditorView.vue b/frontend/src/views/NoteEditorView.vue index 393fda6..3b7cd09 100644 --- a/frontend/src/views/NoteEditorView.vue +++ b/frontend/src/views/NoteEditorView.vue @@ -1,5 +1,5 @@
+ + +
@@ -451,6 +540,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload)); placeholder="What should I do?" class="assist-instruction" rows="3" + @keydown.enter.exact.prevent="assist.canSubmit.value && assist.submit()" >
diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 30ce131..26a71d1 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -33,6 +33,13 @@ def create_app() -> Quart: level=getattr(logging, Config.LOG_LEVEL.upper(), logging.INFO), ) + # Validate config early so misconfigurations surface immediately with clear messages + try: + Config.validate() + except ValueError as exc: + logging.getLogger(__name__).error("Invalid configuration:\n%s", exc) + raise + app = Quart(__name__, static_folder=None) app.secret_key = Config.SECRET_KEY app.config["SESSION_COOKIE_HTTPONLY"] = True diff --git a/src/fabledassistant/config.py b/src/fabledassistant/config.py index e7688d9..a898db7 100644 --- a/src/fabledassistant/config.py +++ b/src/fabledassistant/config.py @@ -73,3 +73,20 @@ class Config: @classmethod def searxng_enabled(cls) -> bool: return bool(cls.SEARXNG_URL) + + @classmethod + def validate(cls) -> None: + """Validate critical config values at startup. Raises ValueError on misconfiguration.""" + errors: list[str] = [] + if cls.OLLAMA_NUM_CTX < 512: + errors.append(f"OLLAMA_NUM_CTX={cls.OLLAMA_NUM_CTX} is too small (minimum 512)") + if cls.LOG_RETENTION_DAYS < 1: + errors.append(f"LOG_RETENTION_DAYS={cls.LOG_RETENTION_DAYS} must be >= 1") + if cls.IMAGE_MAX_BYTES < 1024: + errors.append(f"IMAGE_MAX_BYTES={cls.IMAGE_MAX_BYTES} must be >= 1024") + if not (1 <= cls.SMTP_PORT <= 65535): + errors.append(f"SMTP_PORT={cls.SMTP_PORT} must be between 1 and 65535") + if cls.oidc_enabled() and not cls.BASE_URL.startswith(("http://", "https://")): + errors.append(f"BASE_URL='{cls.BASE_URL}' must start with http:// or https:// when OIDC is enabled") + if errors: + raise ValueError("Configuration errors:\n" + "\n".join(f" - {e}" for e in errors)) diff --git a/src/fabledassistant/models/note_version.py b/src/fabledassistant/models/note_version.py index 0afc091..688f998 100644 --- a/src/fabledassistant/models/note_version.py +++ b/src/fabledassistant/models/note_version.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from sqlalchemy import DateTime, ForeignKey, Integer, Text +from sqlalchemy import ARRAY, DateTime, ForeignKey, Integer, Text from sqlalchemy.orm import Mapped, mapped_column from fabledassistant.models import Base @@ -14,6 +14,7 @@ class NoteVersion(Base): user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE")) body: Mapped[str] = mapped_column(Text) title: Mapped[str] = mapped_column(Text, default="") + tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) @@ -24,6 +25,7 @@ class NoteVersion(Base): "note_id": self.note_id, "user_id": self.user_id, "title": self.title, + "tags": self.tags or [], "created_at": self.created_at.isoformat(), } if include_body: diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py index acd6aa9..a664322 100644 --- a/src/fabledassistant/routes/chat.py +++ b/src/fabledassistant/routes/chat.py @@ -116,6 +116,7 @@ async def send_message_route(conv_id: int): include_note_ids = data.get("include_note_ids") or [] excluded_note_ids = data.get("excluded_note_ids") or [] think = bool(data.get("think", False)) + rag_project_id = data.get("rag_project_id") or None # Reject if generation already running for this conversation existing = get_buffer(conv_id) @@ -128,7 +129,10 @@ async def send_message_route(conv_id: int): # Create placeholder assistant message assistant_msg = await add_message(conv_id, "assistant", "", status="generating") - buf = create_buffer(conv_id, assistant_msg.id) + try: + buf = create_buffer(conv_id, assistant_msg.id) + except RuntimeError: + return jsonify({"error": "Generation already in progress"}), 409 # Build history from existing messages (excluding system and the placeholder) history = [] @@ -146,6 +150,7 @@ async def send_message_route(conv_id: int): include_note_ids=include_note_ids, excluded_note_ids=excluded_note_ids, think=think, + rag_project_id=rag_project_id, )) return jsonify({ @@ -192,6 +197,11 @@ async def generation_stream_route(conv_id: int): if not got_new: yield ": keepalive\n\n" + # Final drain: deliver any events appended between the last yield + # and the state check above (e.g. the 'done' event). + for event in buf.events_after(cursor): + yield buf.format_sse(event) + return Response( stream(), content_type="text/event-stream", diff --git a/src/fabledassistant/routes/notes.py b/src/fabledassistant/routes/notes.py index 4917409..053c783 100644 --- a/src/fabledassistant/routes/notes.py +++ b/src/fabledassistant/routes/notes.py @@ -1,5 +1,6 @@ import asyncio import logging +import re from datetime import date from fabledassistant.services.embeddings import upsert_note_embedding @@ -227,7 +228,10 @@ async def patch_note_route(note_id: int): if key in data: fields[key] = data[key] if "due_date" in data: - fields["due_date"] = date.fromisoformat(data["due_date"]) if data["due_date"] else None + try: + fields["due_date"] = date.fromisoformat(data["due_date"]) if data["due_date"] else None + except (ValueError, TypeError): + return jsonify({"error": "Invalid due_date format, expected YYYY-MM-DD"}), 400 if "tags" in data: fields["tags"] = data["tags"] note = await update_note(uid, note_id, **fields) @@ -286,16 +290,61 @@ async def assist_route(): body = data.get("body", "") target_section = data.get("target_section", "") instruction = data.get("instruction", "") - whole_doc = bool(data.get("whole_doc", False)) + project_id = data.get("project_id") + note_id = data.get("note_id") if not whole_doc and not target_section: return jsonify({"error": "target_section is required for section mode"}), 400 if not instruction: return jsonify({"error": "instruction is required"}), 400 + # Fetch related project notes for context (up to 5, excluding current note). + # Glossary-tagged notes are prioritised so the model knows canonical definitions. + context_notes: list[dict] = [] + if project_id: + try: + pid = int(project_id) + # 1) Glossary notes first (up to 3) + glossary_notes, _ = await list_notes( + uid, project_id=pid, tags=["definition"], sort="updated_at", order="desc", limit=3 + ) + seen_ids: set[int] = set() + for n in glossary_notes: + if n.id == note_id: + continue + seen_ids.add(n.id) + context_notes.append({ + "title": n.title or "Untitled", + "tags": n.tags or [], + "body": n.body or "", + }) + # 2) Fill remaining slots with recently-updated notes + if len(context_notes) < 5: + recent_notes, _ = await list_notes( + uid, project_id=pid, sort="updated_at", order="desc", + limit=5 - len(context_notes) + len(seen_ids) + 1, + ) + for n in recent_notes: + if n.id == note_id or n.id in seen_ids: + continue + if len(context_notes) >= 5: + break + seen_ids.add(n.id) + context_notes.append({ + "title": n.title or "Untitled", + "tags": n.tags or [], + "body": n.body or "", + }) + except Exception: + logger.warning("Failed to fetch project context notes for assist", exc_info=True) + model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL - messages = build_assist_messages(body, target_section, instruction, whole_doc=whole_doc) + messages = build_assist_messages( + body, target_section, instruction, + whole_doc=whole_doc, + context_notes=context_notes or None, + ) buf = create_assist_buffer(uid) asyncio.create_task(run_assist_generation(buf, messages, model)) @@ -334,6 +383,11 @@ async def assist_stream_route(): if not got_new: yield ": keepalive\n\n" + # Final drain: deliver any events appended between the last yield + # and the state check above (e.g. the 'done' event). + for event in buf.events_after(cursor): + yield buf.format_sse(event) + return Response( stream(), content_type="text/event-stream", @@ -344,6 +398,63 @@ async def assist_stream_route(): ) +# ── Link suggestions ───────────────────────────────────────────────────────── + +_WIKILINK_RE = re.compile(r'\[\[[^\]]+\]\]') + + +def _find_unlinked_terms(body: str, note_titles: list[tuple[int, str]]) -> list[dict]: + """Return project note titles that appear in body as plain text (not inside [[...]]).""" + linked_ranges = [(m.start(), m.end()) for m in _WIKILINK_RE.finditer(body)] + + def in_wikilink(start: int, end: int) -> bool: + return any(ls <= start and end <= le for ls, le in linked_ranges) + + suggestions = [] + for note_id, title in note_titles: + title = title.strip() + if len(title) < 3: + continue + pattern = re.compile(r'\b' + re.escape(title) + r'\b', re.IGNORECASE) + count = sum(1 for m in pattern.finditer(body) if not in_wikilink(m.start(), m.end())) + if count > 0: + suggestions.append({"note_id": note_id, "title": title, "count": count}) + + suggestions.sort(key=lambda x: x["count"], reverse=True) + return suggestions + + +@notes_bp.route("/link-suggestions", methods=["POST"]) +@login_required +async def link_suggestions_route(): + """Find project note titles that appear unlinked in the given body text.""" + uid = get_current_user_id() + data = await request.get_json() + + body_text = data.get("body", "") + project_id = data.get("project_id") + exclude_note_id = data.get("exclude_note_id") + + if not project_id or not body_text: + return jsonify({"suggestions": []}) + + try: + project_notes, _ = await list_notes( + uid, project_id=int(project_id), sort="title", order="asc", limit=200 + ) + titles = [ + (n.id, n.title) + for n in project_notes + if n.id != exclude_note_id and n.title + ] + suggestions = _find_unlinked_terms(body_text, titles) + except Exception: + logger.warning("Failed to compute link suggestions", exc_info=True) + suggestions = [] + + return jsonify({"suggestions": suggestions}) + + # ── Draft routes ───────────────────────────────────────────────────────────── @notes_bp.route("//draft", methods=["GET"]) diff --git a/src/fabledassistant/services/assist.py b/src/fabledassistant/services/assist.py index 91dcb58..c3aa2b0 100644 --- a/src/fabledassistant/services/assist.py +++ b/src/fabledassistant/services/assist.py @@ -1,18 +1,50 @@ -MAX_BODY_CHARS = 8000 +MAX_BODY_CHARS = 24000 +MAX_CONTEXT_NOTE_CHARS = 1500 + + +def _build_context_block(context_notes: list[dict]) -> str: + """Format project context notes into a prompt block.""" + lines = [ + "--- Project Context ---", + "The following notes are from the same project. Notes tagged 'definition' are " + "canonical term definitions — treat them as authoritative. Use them to maintain " + "consistency in terminology, tone, style, and [[wikilinks]] to other documents.", + "", + ] + for n in context_notes: + tags_str = f" [tags: {', '.join(n['tags'])}]" if n.get("tags") else "" + body_preview = (n["body"] or "")[:MAX_CONTEXT_NOTE_CHARS] + if len(n["body"] or "") > MAX_CONTEXT_NOTE_CHARS: + body_preview += "…" + lines.append(f"**{n['title']}**{tags_str}") + if body_preview: + lines.append(body_preview) + lines.append("") + lines.append("--- End Project Context ---") + return "\n".join(lines) def build_assist_messages( - body: str, target_section: str, instruction: str, whole_doc: bool = False + body: str, + target_section: str, + instruction: str, + whole_doc: bool = False, + context_notes: list[dict] | None = None, ) -> list[dict]: """Build Ollama messages for writing assist. When whole_doc=True, the model revises the entire document. When whole_doc=False (section mode), the full body is provided as read-only context and the model outputs only the replacement for the target section. + + context_notes: optional list of {title, tags, body} dicts from the same project, + injected so the model can maintain consistency with related documents. """ truncated_body = body[:MAX_BODY_CHARS] if len(body) > MAX_BODY_CHARS: - truncated_body += "\n... (truncated)" + truncated_body += "\n… (truncated)" + + context_block = (_build_context_block(context_notes) + "\n\n") if context_notes else "" if whole_doc: system_content = ( @@ -21,9 +53,12 @@ def build_assist_messages( "content not addressed by the instruction. " "Preserve all markdown formatting exactly — including bullet lists (- item), " "numbered lists (1. item), nested/indented sub-items ( - sub), bold (**text**), " - "italic (_text_), and code blocks. Never flatten nested lists into plain text." + "italic (_text_), and code blocks. Never flatten nested lists into plain text. " + "When referencing concepts defined in the Project Context, use [[wikilinks]] " + "to link to the relevant note by its exact title." ) user_content = ( + f"{context_block}" f"--- Document ---\n{truncated_body}\n--- End Document ---\n\n" f"Instruction: {instruction}" ) @@ -41,9 +76,12 @@ def build_assist_messages( "Match the document's existing tone and style. " "IMPORTANT: Preserve all markdown formatting exactly — including bullet lists (- item), " "numbered lists (1. item), nested/indented sub-items ( - sub), bold (**text**), " - "italic (_text_), and code blocks. Never flatten nested lists into plain text." + "italic (_text_), and code blocks. Never flatten nested lists into plain text. " + "When referencing concepts defined in the Project Context, use [[wikilinks]] " + "to link to the relevant note by its exact title." ) user_content = ( + f"{context_block}" f"--- Target Section ---\n{target_section}\n--- End Target Section ---\n\n" f"Instruction: {instruction}" ) diff --git a/src/fabledassistant/services/embeddings.py b/src/fabledassistant/services/embeddings.py index aa3b342..55947ee 100644 --- a/src/fabledassistant/services/embeddings.py +++ b/src/fabledassistant/services/embeddings.py @@ -79,6 +79,7 @@ async def semantic_search_notes( exclude_ids: set[int] | None = None, limit: int = 8, threshold: float = _SIMILARITY_THRESHOLD, + project_id: int | None = None, ) -> list[tuple[float, Note]]: """Return up to *limit* (score, note) pairs most relevant to *query*. @@ -99,6 +100,8 @@ async def semantic_search_notes( .join(Note, NoteEmbedding.note_id == Note.id) .where(NoteEmbedding.user_id == user_id) ) + if project_id is not None: + stmt = stmt.where(Note.project_id == project_id) if exclude_ids: stmt = stmt.where(NoteEmbedding.note_id.notin_(exclude_ids)) rows = list((await session.execute(stmt)).all()) diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py index 5d0f1d4..1ec227c 100644 --- a/src/fabledassistant/services/generation_task.py +++ b/src/fabledassistant/services/generation_task.py @@ -45,7 +45,7 @@ _TOOL_LABELS: dict[str, str] = { "get_note": "Reading note", "list_notes": "Listing notes", "list_tasks": "Searching tasks", - "search_notes": "Searching notes", + "search_notes": "Searching notes (semantic)", "create_event": "Creating calendar event", "list_events": "Searching calendar", "search_events": "Searching calendar", @@ -149,6 +149,7 @@ async def run_generation( include_note_ids: list[int] | None = None, excluded_note_ids: list[int] | None = None, think: bool = False, + rag_project_id: int | None = None, ) -> None: """Stream LLM response into buffer with periodic DB flushes.""" MAX_TOOL_ROUNDS = 5 @@ -172,13 +173,14 @@ async def run_generation( history_to_use, history_summary = await summarize_history_for_context(history, model) # Phase 3: Build context and wait for model in parallel. - model_load_task = asyncio.create_task(wait_for_model_loaded(model, timeout=90.0)) + model_load_task = asyncio.create_task(wait_for_model_loaded(model, timeout=180.0)) context_task = asyncio.create_task(build_context( user_id, history_to_use, context_note_id, user_content, history_summary=history_summary, include_note_ids=include_note_ids, excluded_note_ids=excluded_note_ids, + rag_project_id=rag_project_id, )) messages, context_meta = await context_task @@ -192,7 +194,7 @@ async def run_generation( buf.append_event("status", {"status": "Loading model..."}) loaded = await model_load_task if not loaded: - logger.warning("Model %s did not load within 90s — proceeding anyway", model) + logger.warning("Model %s did not load within 180s — proceeding anyway", model) t_start = time.monotonic() timing: dict = { @@ -415,6 +417,9 @@ async def run_assist_generation( On each retry the accumulated content is reset so the done event always reflects only the successful generation. """ + input_chars = sum(len(m.get("content", "")) for m in messages) + logger.info("Assist generation started: model=%s, input_chars=%d", model, input_chars) + last_exc: BaseException | None = None for attempt in range(3): if attempt > 0: @@ -429,9 +434,15 @@ async def run_assist_generation( buf.content_so_far += chunk buf.append_event("chunk", {"chunk": chunk}) + output_chars = len(buf.content_so_far) + logger.info( + "Assist generation complete: output_chars=%d, events=%d", + output_chars, len(buf.events), + ) buf.state = GenerationState.COMPLETED buf.finished_at = time.monotonic() buf.append_event("done", {"done": True, "full_text": buf.content_so_far}) + logger.info("Assist done event appended (event index %d)", len(buf.events) - 1) return except httpx.HTTPStatusError as exc: diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index ac217f3..6752c52 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -1,11 +1,14 @@ import asyncio +import ipaddress import json import logging import re +import socket import time from collections.abc import AsyncGenerator from dataclasses import dataclass, field from typing import Literal +from urllib.parse import urlparse import httpx @@ -29,7 +32,7 @@ STOP_WORDS = frozenset({ RAG_AUTO_THRESHOLD = 0.60 RAG_AUTO_LIMIT = 3 -RAG_AUTO_SNIPPET = 800 +RAG_AUTO_SNIPPET = 4000 async def get_installed_models() -> set[str]: @@ -268,11 +271,32 @@ async def generate_completion( raise last_exc +def _is_private_url(url: str) -> bool: + """Return True if the URL resolves to a private/loopback/link-local address (SSRF guard).""" + try: + host = urlparse(url).hostname + if not host: + return True + if host.lower() in ("localhost", "::1"): + return True + addr_info = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP) + for entry in addr_info: + ip = ipaddress.ip_address(entry[4][0]) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast: + return True + return False + except Exception: + return True # Block on resolution failure + + async def fetch_url_content(url: str) -> str: """Fetch a URL and return text content (HTML tags stripped).""" + if _is_private_url(url): + logger.warning("Blocked fetch of private/internal URL: %s", url) + return "[URL blocked: internal network access not permitted]" try: async with httpx.AsyncClient( - timeout=15.0, follow_redirects=True, headers={"User-Agent": "FabledAssistant/1.0"} + timeout=15.0, follow_redirects=False, headers={"User-Agent": "FabledAssistant/1.0"} ) as client: resp = await client.get(url) resp.raise_for_status() @@ -426,6 +450,7 @@ async def build_context( history_summary: str | None = None, include_note_ids: list[int] | None = None, excluded_note_ids: list[int] | None = None, + rag_project_id: int | None = None, ) -> tuple[list[dict], dict]: """Build messages array for Ollama with system prompt and context. @@ -465,6 +490,9 @@ async def build_context( "If a note was created earlier in the conversation and the user provides more content for it, use update_note. " "Use get_note to read the full content of a specific note. " "Use list_notes to browse notes by recency or tag. " + "Use search_notes for conceptual/semantic queries — e.g. 'what notes do I have about X' or " + "'find notes related to Y' — it uses semantic understanding to find thematically related content " + "even when exact words don't match. Pass project= to scope the search to a specific project. " "Use delete_note / delete_task only when explicitly asked to delete — these require confirmation." ) tool_guidance = "\n".join(tool_lines) @@ -511,7 +539,8 @@ async def build_context( try: from fabledassistant.services.embeddings import semantic_search_notes for score, note in await semantic_search_notes( - user_id, user_message, exclude_ids=search_exclude or None, limit=8 + user_id, user_message, exclude_ids=search_exclude or None, limit=8, + project_id=rag_project_id, ): found_scored.append((score, note)) except Exception: @@ -522,7 +551,8 @@ async def build_context( if keywords: try: for note in await search_notes_for_context( - user_id, keywords, exclude_ids=search_exclude or None, limit=8 + user_id, keywords, exclude_ids=search_exclude or None, limit=8, + project_id=rag_project_id, ): found_scored.append((None, note)) except Exception: @@ -587,7 +617,7 @@ async def build_context( try: n = await _get_note(user_id, nid) if n: - body_preview = n.body[:2000] if n.body else "" + body_preview = n.body or "" included_snippets.append(f"- {n.title}: {body_preview}") except Exception: logger.warning("Failed to load included note %d for context", nid, exc_info=True) diff --git a/src/fabledassistant/services/note_versions.py b/src/fabledassistant/services/note_versions.py index 77bf4a2..cbfa628 100644 --- a/src/fabledassistant/services/note_versions.py +++ b/src/fabledassistant/services/note_versions.py @@ -6,9 +6,11 @@ from fabledassistant.models.note_version import NoteVersion MAX_VERSIONS = 20 -async def create_version(user_id: int, note_id: int, body: str, title: str) -> NoteVersion: +async def create_version( + user_id: int, note_id: int, body: str, title: str, tags: list[str] | None = None +) -> NoteVersion: async with async_session() as session: - version = NoteVersion(note_id=note_id, user_id=user_id, body=body, title=title) + version = NoteVersion(note_id=note_id, user_id=user_id, body=body, title=title, tags=tags or []) session.add(version) await session.commit() await session.refresh(version) diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py index 3c719bd..3c91f4b 100644 --- a/src/fabledassistant/services/notes.py +++ b/src/fabledassistant/services/notes.py @@ -197,6 +197,7 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No # Snapshot before changes for version creation old_body = note.body old_title = note.title + old_tags = list(note.tags or []) for key, value in fields.items(): if not hasattr(note, key): continue @@ -214,7 +215,7 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No # Create a version snapshot when body actually changes if "body" in fields and fields["body"] != old_body: from fabledassistant.services.note_versions import create_version - await create_version(user_id, note_id, old_body, old_title) + await create_version(user_id, note_id, old_body, old_title, old_tags) return note @@ -234,19 +235,24 @@ async def delete_note(user_id: int, note_id: int) -> bool: async def get_all_tags(user_id: int, q: str | None = None) -> list[str]: async with async_session() as session: - result = await session.execute( - text( - "SELECT DISTINCT unnest(tags) AS tag FROM notes" - " WHERE tags != '{}' AND user_id = :user_id" - ).bindparams(user_id=user_id) - ) - all_tags = [row[0] for row in result.fetchall()] - if q: - q_lower = q.lower() - all_tags = [t for t in all_tags if q_lower in t.lower()] - - return sorted(all_tags) + result = await session.execute( + text( + "SELECT DISTINCT tag FROM" + " (SELECT unnest(tags) AS tag FROM notes" + " WHERE tags != '{}' AND user_id = :user_id) t" + " WHERE tag ILIKE :q_pattern ORDER BY tag LIMIT 100" + ).bindparams(user_id=user_id, q_pattern=f"%{q}%") + ) + else: + result = await session.execute( + text( + "SELECT DISTINCT unnest(tags) AS tag FROM notes" + " WHERE tags != '{}' AND user_id = :user_id" + " ORDER BY tag LIMIT 100" + ).bindparams(user_id=user_id) + ) + return [row[0] for row in result.fetchall()] async def convert_note_to_task(user_id: int, note_id: int) -> Note: @@ -291,6 +297,7 @@ async def search_notes_for_context( keywords: list[str], exclude_ids: set[int] | None = None, limit: int = 3, + project_id: int | None = None, ) -> list[Note]: """Search notes by keywords with OR logic. Optimized for context building — no count query.""" async with async_session() as session: @@ -301,6 +308,8 @@ async def search_notes_for_context( keyword_filters.append(or_(Note.title.ilike(pattern), Note.body.ilike(pattern))) query = select(Note).where(Note.user_id == user_id, or_(*keyword_filters)) + if project_id is not None: + query = query.where(Note.project_id == project_id) if exclude_ids: query = query.where(Note.id.notin_(exclude_ids)) query = query.order_by(Note.updated_at.desc()).limit(limit) diff --git a/src/fabledassistant/services/tools.py b/src/fabledassistant/services/tools.py index b6460e7..6b8378a 100644 --- a/src/fabledassistant/services/tools.py +++ b/src/fabledassistant/services/tools.py @@ -182,19 +182,29 @@ _CORE_TOOLS = [ "type": "function", "function": { "name": "search_notes", - "description": "Search the user's notes and tasks. Use this when the user asks about their existing notes, tasks, or wants to find something they've written.", + "description": ( + "Search notes and tasks using semantic understanding. Finds content related to a concept, " + "theme, or topic — even when exact keywords don't appear in the title or body. " + "Use this when the user asks what notes they have about a topic, wants to explore related " + "content, or is looking for something they've written. Falls back to keyword search " + "automatically if semantic search is unavailable." + ), "parameters": { "type": "object", "properties": { "query": { "type": "string", - "description": "Search query to find notes/tasks", + "description": "A natural-language description of what you're looking for — concepts, themes, topics, or keywords", }, "type": { "type": "string", "enum": ["note", "task"], "description": "Restrict results to only notes or only tasks. Omit to search both.", }, + "project": { + "type": "string", + "description": "Optional project name to restrict search to notes in that project", + }, }, "required": ["query"], }, @@ -1070,27 +1080,79 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict: elif tool_name == "search_notes": query = arguments.get("query", "") type_filter = arguments.get("type") + project_name = arguments.get("project") + is_task: bool | None = None if type_filter == "note": is_task = False elif type_filter == "task": is_task = True - notes, total = await list_notes(user_id=user_id, q=query, is_task=is_task, limit=5) - results = [] - for n in notes: - results.append({ - "id": n.id, - "title": n.title, - "type": "task" if n.status is not None else "note", - "status": n.status, - "preview": (n.body[:200] + "...") if n.body and len(n.body) > 200 else (n.body or ""), - }) + + # Resolve project name → id + search_project_id: int | None = None + if project_name: + from fabledassistant.services.projects import get_project_by_title as _gpbt2, list_projects as _lp2 + proj = await _gpbt2(user_id, project_name) + if proj is None: + all_p = await _lp2(user_id) + matches = [p for p in all_p if project_name.lower() in p.title.lower()] + proj = matches[0] if matches else None + if proj: + search_project_id = proj.id + + results_map: dict[int, dict] = {} + + # 1) Semantic search — broad threshold (0.40) since this is an intentional query + try: + from fabledassistant.services.embeddings import semantic_search_notes as _ssn + sem_results = await _ssn( + user_id, query, limit=8, threshold=0.40, + project_id=search_project_id, + ) + for score, n in sem_results: + n_is_task = n.status is not None + if is_task is True and not n_is_task: + continue + if is_task is False and n_is_task: + continue + results_map[n.id] = { + "id": n.id, + "title": n.title, + "type": "task" if n_is_task else "note", + "status": n.status, + "relevance": f"{round(score * 100)}%", + "preview": (n.body[:300] + "…") if n.body and len(n.body) > 300 else (n.body or ""), + } + logger.debug("search_notes semantic: %d results for %r", len(results_map), query) + except Exception: + logger.debug("Semantic search unavailable in search_notes, using keyword only", exc_info=True) + + # 2) Keyword search — merge in anything not already found + try: + kw_notes, _ = await list_notes( + user_id=user_id, q=query, is_task=is_task, + project_id=search_project_id, limit=8, + ) + for n in kw_notes: + if n.id not in results_map: + results_map[n.id] = { + "id": n.id, + "title": n.title, + "type": "task" if n.status is not None else "note", + "status": n.status, + "relevance": "keyword", + "preview": (n.body[:300] + "…") if n.body and len(n.body) > 300 else (n.body or ""), + } + except Exception: + logger.warning("Keyword fallback in search_notes failed", exc_info=True) + + results = list(results_map.values())[:8] return { "success": True, "type": "search", "data": { "query": query, - "total": total, + "count": len(results), "results": results, }, } diff --git a/summary.md b/summary.md index 43568e4..bd880bc 100644 --- a/summary.md +++ b/summary.md @@ -12,7 +12,53 @@ > Include file-level details in the commit body when the change is non-trivial. ## Last Updated -2026-03-05 — Note editor sidebar layout, full-document writing assist, persistent drafts, version history, context window bump. +2026-03-06 — Project-aware writing assist, link suggestions, project-scoped RAG, semantic note search tool, SSE done-event race fix, RAG snippet increase, sidebar include full body, Enter-to-submit assist. + +**SSE done-event race fix:** `routes/notes.py` and `routes/chat.py` — The SSE stream generator had a race condition where `yield buf.format_sse(event)` hands control back to the event loop, allowing the generation task to set `buf.state = COMPLETED` and append the `done` event between the last yield and the state check. The loop would then break without delivering the `done` event (containing `full_text`). Fixed by adding a final drain loop after the `while` exits in both stream generators. Added INFO-level logging to `run_assist_generation` showing `input_chars`, `output_chars`, event count, and done-event index. + +**RAG snippet + sidebar include:** `services/llm.py` — `RAG_AUTO_SNIPPET` raised from 800 to 4000 chars. Sidebar-included notes (`include_note_ids`) now deliver full body (`n.body or ""`) instead of the previous 2000-char truncation, matching the paperclip/context_note behaviour. + +**Assist `MAX_BODY_CHARS`:** `services/assist.py` — raised from 8000 to 24000 chars, safely within the 16384-token Ollama context (input + output headroom considered). + +**DiffView equal-line collapsing:** `DiffView.vue` — unchanged lines are now collapsed into `⋯ N unchanged lines` markers when a run of 6+ equal lines exists, showing only 3 lines of context around each change (git-style). Makes reviewing large-document diffs practical. + +**Enter-to-submit assist textarea:** Both `NoteEditorView.vue` and `TaskEditorView.vue` — writing assistant instruction textareas now submit on plain Enter (Shift+Enter inserts newline). Uses `@keydown.enter.exact.prevent` with `canSubmit` guard. + +**Project-aware writing assist:** `services/assist.py` — `build_assist_messages()` accepts `context_notes: list[dict] | None`; when provided, injects a `--- Project Context ---` block into the user message (before the document) listing related note titles, tags, and body previews. Instructs the model to use `[[wikilinks]]` for referenced concepts. `routes/notes.py` `assist_route` — accepts `project_id` and `note_id`; fetches up to 5 project notes as context: `definition`-tagged notes first (up to 3, always injected for canonical term definitions), then recently-updated notes fill remaining slots. `composables/useAssist.ts` — accepts `projectId?: Ref` third param; includes `note_id` and `project_id` in the POST body. Both `NoteEditorView.vue` and `TaskEditorView.vue` pass their project ref to `useAssist`. + +**Link suggestions:** `routes/notes.py` — new `POST /api/notes/link-suggestions` endpoint. Accepts `{body, project_id, exclude_note_id}`. Fetches all project note titles (up to 200), runs regex to find titles appearing as plain text outside `[[...]]` spans, returns `[{note_id, title, count}]` sorted by occurrence count. `NoteEditorView.vue` — new "Link Suggestions" sidebar section: fetches suggestions on mount and debounced 2s after each body edit (when project is set). Per-term "Link" button applies `[[Title]]` wrapping to all unlinked occurrences; "Link all" applies all at once. Text replacement splits on `[[...]]` spans to avoid double-linking. + +**Project-scoped RAG:** `services/embeddings.py` `semantic_search_notes()` — new `project_id` param filters the SQL join to `Note.project_id == project_id`. `services/notes.py` `search_notes_for_context()` — same `project_id` filter. `services/llm.py` `build_context()` — new `rag_project_id` param threaded to both search functions. `services/generation_task.py` `run_generation()` + `routes/chat.py` `send_message_route` — `rag_project_id` accepted from POST body and passed through. `stores/chat.ts` `sendMessage()` — new `ragProjectId` param included in POST. `ChatView.vue` — `ProjectSelector` added to chat sidebar under "Scope notes to project" label; when set, all RAG (semantic + keyword fallback) restricts to that project's notes. + +**Semantic note search tool:** `services/tools.py` `search_notes` — upgraded from pure keyword to hybrid semantic+keyword. Runs `semantic_search_notes()` first (threshold 0.40, broader than auto-inject 0.60 since this is an intentional query); merges keyword results for any not already found. Results include a `relevance` field (percentage for semantic, `"keyword"` for text matches). Added `project` parameter (resolves by name, same pattern as `list_notes`). Preview extended to 300 chars. Tool description updated to explain semantic capability. `services/llm.py` tool guidance updated to direct the model to use `search_notes` for conceptual/thematic queries. `generation_task.py` status label updated to "Searching notes (semantic)". + +**Previous session (2026-03-06):** Code review fixes: SSRF guard, config validation, race condition handling, tag autocomplete SQL, note versions now store tags, unmount cleanup, stale log string, PATCH due_date validation. + +**SSRF protection:** `services/llm.py` — new `_is_private_url(url)` helper uses `socket.getaddrinfo` + `ipaddress` to block requests to loopback, private, link-local, reserved, and multicast addresses before fetching. `follow_redirects` set to `False` (redirects to internal IPs would bypass the check). All web research and search_web tool calls go through `fetch_url_content`, so this covers the full pipeline. + +**Config validation:** `config.py` — new `Config.validate()` classmethod checks `OLLAMA_NUM_CTX`, `LOG_RETENTION_DAYS`, `IMAGE_MAX_BYTES`, `SMTP_PORT` bounds, and `BASE_URL` format when OIDC is enabled. `app.py` — `Config.validate()` called at the top of `create_app()` so misconfigurations surface immediately with clear messages instead of cryptic runtime errors. + +**create_buffer race:** `routes/chat.py` — wrapped `create_buffer(conv_id, assistant_msg.id)` in try/except RuntimeError → returns 409 instead of 500 if two concurrent requests race past the buffer-running check (which is possible because there are await points between the GET check and the create call). + +**Stale log message:** `services/generation_task.py` — "90s" → "180s" in the model-load timeout warning. + +**Tag autocomplete SQL:** `services/notes.py` — `get_all_tags()` now pushes the `q` filter into the SQL query (`ILIKE`) and adds `LIMIT 100` on both filtered and unfiltered paths, replacing the previous Python-side filter over a full table scan. + +**Note versions now store tags:** Migration `0023_add_tags_to_note_versions.py` adds `tags TEXT[] NOT NULL DEFAULT '{}'` to `note_versions`. `models/note_version.py` — added `tags` field; `to_dict()` includes it. `services/note_versions.py` — `create_version()` accepts optional `tags` param. `services/notes.py` — snapshots `old_tags` before update, passes them to `create_version`. Frontend: `NoteVersion` interface (in `HistoryPanel.vue` and `types/task.ts`) updated with `tags: string[]`. `HistoryPanel.vue` — `restore` emit updated to include tags as second argument. `NoteEditorView.vue` — restore handler now restores both body and tags. + +**useAssist unmount cleanup:** `NoteEditorView.vue` — added `onUnmounted(() => assist.clearSelection())` which closes any in-flight SSE stream handle, preventing orphaned server connections when navigating away mid-generate. + +**PATCH due_date validation:** `routes/notes.py` — `date.fromisoformat()` in the PATCH handler now wrapped in try/except `(ValueError, TypeError)`, returning 400 with a clear message instead of a 500. + +**Previous session (2026-03-06 earlier):** SSE reconnection for chat and writing assist. + +**SSE reconnection — chat:** `stores/chat.ts` — new `reconnectIfGenerating(convId)` function exported from the store. Checks `isStreamingConv` first (skips if stream already active). Finds the last assistant message with `status: 'generating'`, removes it from the messages array (the `done` event re-adds the final version), initialises stream state with `status: "Reconnecting..."`, then calls `_streamGeneration` which replays all buffered events from `last_event_id=-1`. If the buffer is >60s stale (already cleaned up), `_streamGeneration` exhausts its retries and falls back to `fetchConversation` to show the DB-saved final content. `ChatView.vue` — `reconnectIfGenerating` called (fire-and-forget, no await) in both `onMounted` and `watch(convId)` after the fetch step, covering both the page-refresh and navigate-away-and-back scenarios. + +**SSE reconnection — writing assistant:** `composables/useAssist.ts` — extracted `_buildProposedFullBody()` helper. `submit()` now wraps the SSE connection in a retry loop (up to 3 retries, exponential back-off 1 s / 2 s / 4 s, capped at 5 s). Tracks `lastEventId`; reconnects use `?last_event_id=N` so only new chunks are received (no duplication). Loop breaks early on `done`, `error`, or if state left `'streaming'` (user cancelled). Falls back to accumulated text as draft on exhausted retries. + +**Model load timeout:** `generation_task.py` — `wait_for_model_loaded` timeout raised from 90 s to 180 s. + +**Previous session (2026-03-05):** Note editor sidebar layout, full-document writing assist, persistent drafts, version history, context window bump. **Note editor sidebar layout:** `NoteEditorView.vue` fully refactored into two-column layout matching `TaskEditorView`. Right sidebar (280px) contains Project, Milestone, Tags + tag suggestions, and the Writing Assistant panel (always visible, no toggle). Mobile collapses to accordion. Removed `assistOpen` ref and `✨ Assist` toggle button. `InlineAssistPanel` removed from note editor.