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:
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user