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:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("/<int:note_id>/draft", methods=["GET"])
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user