Project-aware assist, link suggestions, project-scoped RAG, semantic search tool, SSE race fix

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 14:02:54 -05:00
parent 9036dfd931
commit 48f070f773
22 changed files with 767 additions and 113 deletions
+35 -5
View File
@@ -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)