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
+22 -13
View File
@@ -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)