Backend efficiency & consistency pass

- Rewrite get_all_tags() with SQL unnest instead of loading all notes
- Consolidate convert_note_to_task/convert_task_to_note to single-session ops
- Add search_notes_for_context() with single OR-keyword query for build_context()
- Drop selectinload from list_conversations(), use correlated subquery for message_count
- Add set_settings_batch() for single-transaction multi-setting upserts
- Extract get_installed_models() shared helper into services/llm.py
- Delete services/tasks.py pass-through wrapper; routes/tasks.py imports from services.notes
- Add B-tree indexes on notes.title and conversations.updated_at (migration 0007)
- Add logging to services/notes.py, services/chat.py, services/settings.py
- Safe Conversation.to_dict() when messages relationship is not loaded

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 08:27:11 -05:00
parent fb18d2c41d
commit db01106714
12 changed files with 225 additions and 174 deletions
+55 -17
View File
@@ -1,3 +1,4 @@
import logging
from datetime import date, datetime, timezone
from sqlalchemy import func, or_, select, text
@@ -5,6 +6,8 @@ from sqlalchemy import func, or_, select, text
from fabledassistant.models import async_session
from fabledassistant.models.note import Note, TaskPriority, TaskStatus
logger = logging.getLogger(__name__)
async def create_note(
title: str = "",
@@ -148,34 +151,69 @@ async def delete_note(note_id: int) -> bool:
async def get_all_tags(q: str | None = None) -> list[str]:
async with async_session() as session:
all_tags: set[str] = set()
result = await session.execute(select(Note))
for obj in result.scalars():
if obj.tags:
all_tags.update(obj.tags)
result = await session.execute(
text("SELECT DISTINCT unnest(tags) AS tag FROM notes WHERE tags != '{}'")
)
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()}
all_tags = [t for t in all_tags if q_lower in t.lower()]
return sorted(all_tags)
async def convert_note_to_task(note_id: int) -> Note:
note = await get_note(note_id)
if note is None:
raise ValueError("Note not found")
updated = await update_note(note_id, status=TaskStatus.todo.value, priority=TaskPriority.none.value)
return updated
async with async_session() as session:
note = await session.get(Note, note_id)
if note is None:
logger.warning("convert_note_to_task: note %d not found", note_id)
raise ValueError("Note not found")
note.status = TaskStatus.todo.value
note.priority = TaskPriority.none.value
note.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(note)
logger.info("Converted note %d to task", note_id)
return note
async def convert_task_to_note(note_id: int) -> Note:
note = await get_note(note_id)
if note is None:
raise ValueError("Note not found")
updated = await update_note(note_id, status=None, priority=None, due_date=None)
return updated
async with async_session() as session:
note = await session.get(Note, note_id)
if note is None:
logger.warning("convert_task_to_note: note %d not found", note_id)
raise ValueError("Note not found")
note.status = None
note.priority = None
note.due_date = None
note.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(note)
logger.info("Converted task %d to note", note_id)
return note
async def search_notes_for_context(
keywords: list[str],
exclude_ids: set[int] | None = None,
limit: int = 3,
) -> list[Note]:
"""Search notes by keywords with OR logic. Optimized for context building — no count query."""
async with async_session() as session:
keyword_filters = []
for kw in keywords:
escaped = kw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped}%"
keyword_filters.append(or_(Note.title.ilike(pattern), Note.body.ilike(pattern)))
query = select(Note).where(or_(*keyword_filters))
if exclude_ids:
query = query.where(Note.id.notin_(exclude_ids))
query = query.order_by(Note.updated_at.desc()).limit(limit)
result = await session.execute(query)
return list(result.scalars().all())
async def get_backlinks(note_id: int) -> list[dict]: