diff --git a/alembic/versions/0007_add_title_and_updated_at_indexes.py b/alembic/versions/0007_add_title_and_updated_at_indexes.py new file mode 100644 index 0000000..3a6ba65 --- /dev/null +++ b/alembic/versions/0007_add_title_and_updated_at_indexes.py @@ -0,0 +1,23 @@ +"""add title and updated_at indexes + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-02-11 00:00:00.000000 +""" + +from alembic import op + +revision = "0007" +down_revision = "0006" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("CREATE INDEX IF NOT EXISTS ix_notes_title ON notes (title)") + op.execute("CREATE INDEX IF NOT EXISTS ix_conversations_updated_at ON conversations (updated_at)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_notes_title") + op.execute("DROP INDEX IF EXISTS ix_conversations_updated_at") diff --git a/src/fabledassistant/models/conversation.py b/src/fabledassistant/models/conversation.py index 2b27300..c4015de 100644 --- a/src/fabledassistant/models/conversation.py +++ b/src/fabledassistant/models/conversation.py @@ -1,6 +1,7 @@ from datetime import datetime, timezone from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text +from sqlalchemy import inspect from sqlalchemy.orm import Mapped, mapped_column, relationship from fabledassistant.models import Base @@ -27,12 +28,21 @@ class Conversation(Base): order_by="Message.created_at", ) + __table_args__ = ( + Index("ix_conversations_updated_at", "updated_at"), + ) + def to_dict(self) -> dict: + # Use loaded messages if available, otherwise 0 + if "messages" in inspect(self).dict: + msg_count = len(self.messages) if self.messages else 0 + else: + msg_count = 0 return { "id": self.id, "title": self.title, "model": self.model, - "message_count": len(self.messages) if self.messages else 0, + "message_count": msg_count, "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), } diff --git a/src/fabledassistant/models/note.py b/src/fabledassistant/models/note.py index 3e870f4..137e6ed 100644 --- a/src/fabledassistant/models/note.py +++ b/src/fabledassistant/models/note.py @@ -46,6 +46,7 @@ class Note(Base): __table_args__ = ( Index("ix_notes_tags", "tags", postgresql_using="gin"), Index("ix_notes_status", "status"), + Index("ix_notes_title", "title"), ) @property diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py index 84c3d01..8f29b58 100644 --- a/src/fabledassistant/routes/chat.py +++ b/src/fabledassistant/routes/chat.py @@ -29,7 +29,7 @@ async def list_conversations_route(): offset = request.args.get("offset", 0, type=int) conversations, total = await list_conversations(limit=limit, offset=offset) return jsonify({ - "conversations": [c.to_dict() for c in conversations], + "conversations": conversations, "total": total, }) @@ -180,7 +180,7 @@ async def chat_status_route(): resp.raise_for_status() result["ollama"] = "available" data = resp.json() - model_names = [m["name"] for m in data.get("models", [])] + model_names = {m["name"] for m in data.get("models", [])} # Match with or without :latest tag if default_model in model_names or f"{default_model}:latest" in model_names: result["model"] = "ready" diff --git a/src/fabledassistant/routes/settings.py b/src/fabledassistant/routes/settings.py index bb30191..1bfb2b9 100644 --- a/src/fabledassistant/routes/settings.py +++ b/src/fabledassistant/routes/settings.py @@ -1,29 +1,11 @@ -import httpx from quart import Blueprint, jsonify, request -from fabledassistant.config import Config -from fabledassistant.services.settings import get_all_settings, set_setting +from fabledassistant.services.llm import get_installed_models +from fabledassistant.services.settings import get_all_settings, set_settings_batch settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings") -async def _get_installed_models() -> set[str]: - """Return set of installed Ollama model names, with and without :latest.""" - try: - async with httpx.AsyncClient(timeout=5.0) as client: - resp = await client.get(f"{Config.OLLAMA_URL}/api/tags") - resp.raise_for_status() - data = resp.json() - names: set[str] = set() - for m in data.get("models", []): - name = m["name"] - names.add(name) - names.add(name.replace(":latest", "")) - return names - except Exception: - return set() - - @settings_bp.route("", methods=["GET"]) async def get_settings_route(): settings = await get_all_settings() @@ -38,11 +20,10 @@ async def update_settings_route(): if "default_model" in data: model = str(data["default_model"]) - installed = await _get_installed_models() + installed = await get_installed_models() if installed and model not in installed: return jsonify({"error": f"Model '{model}' is not installed"}), 400 - for key, value in data.items(): - await set_setting(key, str(value)) + await set_settings_batch({k: str(v) for k, v in data.items()}) settings = await get_all_settings() return jsonify(settings) diff --git a/src/fabledassistant/routes/tasks.py b/src/fabledassistant/routes/tasks.py index ea87f73..f66fa03 100644 --- a/src/fabledassistant/routes/tasks.py +++ b/src/fabledassistant/routes/tasks.py @@ -3,12 +3,12 @@ from datetime import date from quart import Blueprint, jsonify, request from fabledassistant.models.note import TaskPriority, TaskStatus -from fabledassistant.services.tasks import ( - create_task, - delete_task, - get_task, - list_tasks, - update_task, +from fabledassistant.services.notes import ( + create_note, + delete_note, + get_note, + list_notes, + update_note, ) from fabledassistant.utils.tags import extract_tags @@ -26,9 +26,10 @@ async def list_tasks_route(): limit = request.args.get("limit", 50, type=int) offset = request.args.get("offset", 0, type=int) - tasks, total = await list_tasks( + tasks, total = await list_notes( q=q, tags=tag or None, + is_task=True, status=status, priority=priority, sort=sort, @@ -54,7 +55,7 @@ async def create_task_route(): TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value ) - task = await create_task( + task = await create_note( title=data.get("title", ""), body=body, status=status, @@ -67,7 +68,7 @@ async def create_task_route(): @tasks_bp.route("/", methods=["GET"]) async def get_task_route(task_id: int): - task = await get_task(task_id) + task = await get_note(task_id) if task is None: return jsonify({"error": "Task not found"}), 404 return jsonify(task.to_dict()) @@ -95,7 +96,7 @@ async def update_task_route(task_id: int): if "body" in fields: fields["tags"] = extract_tags(fields["body"]) - task = await update_task(task_id, **fields) + task = await update_note(task_id, **fields) if task is None: return jsonify({"error": "Task not found"}), 404 return jsonify(task.to_dict()) @@ -113,7 +114,7 @@ async def patch_task_status(task_id: int): except ValueError: return jsonify({"error": f"Invalid status: {status_val}"}), 400 - task = await update_task(task_id, status=status_val) + task = await update_note(task_id, status=status_val) if task is None: return jsonify({"error": "Task not found"}), 404 return jsonify(task.to_dict()) @@ -121,7 +122,7 @@ async def patch_task_status(task_id: int): @tasks_bp.route("/", methods=["DELETE"]) async def delete_task_route(task_id: int): - deleted = await delete_task(task_id) + deleted = await delete_note(task_id) if not deleted: return jsonify({"error": "Task not found"}), 404 return "", 204 diff --git a/src/fabledassistant/services/chat.py b/src/fabledassistant/services/chat.py index 48b929a..f2b4cc3 100644 --- a/src/fabledassistant/services/chat.py +++ b/src/fabledassistant/services/chat.py @@ -1,3 +1,4 @@ +import logging from datetime import datetime, timezone from sqlalchemy import func, select @@ -8,6 +9,8 @@ from fabledassistant.models.conversation import Conversation, Message from fabledassistant.services.llm import generate_completion from fabledassistant.services.notes import create_note +logger = logging.getLogger(__name__) + async def create_conversation(title: str = "", model: str = "") -> Conversation: async with async_session() as session: @@ -35,20 +38,40 @@ async def get_conversation(conversation_id: int) -> Conversation | None: async def list_conversations( limit: int = 50, offset: int = 0 -) -> tuple[list[Conversation], int]: +) -> tuple[list[dict], int]: async with async_session() as session: total = await session.scalar( select(func.count(Conversation.id)) ) or 0 + # Subquery for message count — avoids loading all messages + msg_count = ( + select(func.count(Message.id)) + .where(Message.conversation_id == Conversation.id) + .correlate(Conversation) + .scalar_subquery() + ) + result = await session.execute( - select(Conversation) - .options(selectinload(Conversation.messages)) + select(Conversation, msg_count.label("message_count")) .order_by(Conversation.updated_at.desc()) .limit(limit) .offset(offset) ) - conversations = list(result.scalars().all()) + + conversations = [] + for row in result.all(): + conv = row[0] + d = { + "id": conv.id, + "title": conv.title, + "model": conv.model, + "message_count": row[1], + "created_at": conv.created_at.isoformat(), + "updated_at": conv.updated_at.isoformat(), + } + conversations.append(d) + return conversations, total @@ -149,6 +172,7 @@ async def summarize_conversation_as_note( {"role": "user", "content": "\n\n".join(conv_text)}, ] + logger.info("Summarizing conversation %d with model %s", conversation_id, model) summary = await generate_completion(prompt_messages, model) title = conv.title or "Conversation Summary" diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index ba92289..65fe44f 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -6,7 +6,7 @@ from collections.abc import AsyncGenerator import httpx from fabledassistant.config import Config -from fabledassistant.services.notes import get_note, list_notes +from fabledassistant.services.notes import get_note, search_notes_for_context from fabledassistant.services.settings import get_setting logger = logging.getLogger(__name__) @@ -23,18 +23,32 @@ STOP_WORDS = frozenset({ }) -async def ensure_model(model: str) -> None: - """Check if model exists in Ollama, pull if missing.""" +async def get_installed_models() -> set[str]: + """Return set of installed Ollama model names (with and without :latest).""" try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(f"{Config.OLLAMA_URL}/api/tags") resp.raise_for_status() data = resp.json() - names = {m["name"] for m in data.get("models", [])} - # Check both with and without :latest tag - if model in names or f"{model}:latest" in names: - logger.info("Model '%s' already available", model) - return + names: set[str] = set() + for m in data.get("models", []): + name = m["name"] + names.add(name) + if name.endswith(":latest"): + names.add(name.removesuffix(":latest")) + return names + except Exception: + logger.warning("Failed to fetch installed Ollama models") + return set() + + +async def ensure_model(model: str) -> None: + """Check if model exists in Ollama, pull if missing.""" + try: + installed = await get_installed_models() + if model in installed or f"{model}:latest" in installed: + logger.info("Model '%s' already available", model) + return except Exception: logger.warning("Failed to check Ollama models, attempting pull anyway") @@ -172,39 +186,29 @@ async def build_context( f"--- End Note ---" ) - # Search notes by keywords from user message — search per keyword - # individually and deduplicate to get OR-style matching (any keyword hit - # is relevant context). Collect up to 3 unique notes. + # Search notes by keywords from user message — single OR query keywords = _extract_keywords(user_message) if keywords: - seen_ids: set[int] = set() - snippets: list[str] = [] - for kw in keywords[:5]: - if len(snippets) >= 3: - break - try: - notes, _ = await list_notes(q=kw, limit=3) - for n in notes: - if n.id in seen_ids: - continue - if current_note_id and n.id == current_note_id: - continue - if n.id in exclude_set: - continue - seen_ids.add(n.id) - body_preview = n.body[:2000] if n.body else "" - snippets.append(f"- {n.title}: {body_preview}") - context_meta["auto_notes"].append({"id": n.id, "title": n.title}) - if len(snippets) >= 3: - break - except Exception: - continue - if snippets: - system_parts.append( - "\n\n--- Related Notes ---\n" - + "\n".join(snippets) - + "\n--- End Related Notes ---" + search_exclude = set(exclude_set) + if current_note_id: + search_exclude.add(current_note_id) + try: + notes = await search_notes_for_context( + keywords, exclude_ids=search_exclude or None, limit=3 ) + snippets: list[str] = [] + for n in notes: + body_preview = n.body[:2000] if n.body else "" + snippets.append(f"- {n.title}: {body_preview}") + context_meta["auto_notes"].append({"id": n.id, "title": n.title}) + if snippets: + system_parts.append( + "\n\n--- Related Notes ---\n" + + "\n".join(snippets) + + "\n--- End Related Notes ---" + ) + except Exception: + logger.warning("Failed to search notes for context", exc_info=True) # Fetch URL content from user message urls = _find_urls(user_message) diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py index 65950ef..6c660b4 100644 --- a/src/fabledassistant/services/notes.py +++ b/src/fabledassistant/services/notes.py @@ -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]: diff --git a/src/fabledassistant/services/settings.py b/src/fabledassistant/services/settings.py index 1f426e8..1a02560 100644 --- a/src/fabledassistant/services/settings.py +++ b/src/fabledassistant/services/settings.py @@ -1,8 +1,12 @@ +import logging + from sqlalchemy import select from fabledassistant.models import async_session from fabledassistant.models.setting import Setting +logger = logging.getLogger(__name__) + async def get_setting(key: str, default: str = "") -> str: async with async_session() as session: @@ -22,6 +26,20 @@ async def set_setting(key: str, value: str) -> None: await session.commit() +async def set_settings_batch(settings: dict[str, str]) -> None: + """Update multiple settings in a single transaction.""" + async with async_session() as session: + for key, value in settings.items(): + result = await session.execute(select(Setting).where(Setting.key == key)) + setting = result.scalar_one_or_none() + if setting: + setting.value = value + else: + session.add(Setting(key=key, value=value)) + await session.commit() + logger.info("Batch-updated %d settings", len(settings)) + + async def get_all_settings() -> dict[str, str]: async with async_session() as session: result = await session.execute(select(Setting)) diff --git a/src/fabledassistant/services/tasks.py b/src/fabledassistant/services/tasks.py deleted file mode 100644 index 6e0df01..0000000 --- a/src/fabledassistant/services/tasks.py +++ /dev/null @@ -1,63 +0,0 @@ -from datetime import date - -from fabledassistant.models.note import Note, TaskPriority, TaskStatus -from fabledassistant.services.notes import ( - create_note, - delete_note, - get_note, - list_notes, - update_note, -) - - -async def create_task( - title: str = "", - body: str = "", - status: str = TaskStatus.todo.value, - priority: str = TaskPriority.none.value, - due_date: date | None = None, - tags: list[str] | None = None, -) -> Note: - return await create_note( - title=title, - body=body, - tags=tags, - status=status, - priority=priority, - due_date=due_date, - ) - - -async def get_task(task_id: int) -> Note | None: - return await get_note(task_id) - - -async def list_tasks( - q: str | None = None, - tags: list[str] | None = None, - status: str | None = None, - priority: str | None = None, - sort: str = "updated_at", - order: str = "desc", - limit: int = 50, - offset: int = 0, -) -> tuple[list[Note], int]: - return await list_notes( - q=q, - tags=tags, - is_task=True, - status=status, - priority=priority, - sort=sort, - order=order, - limit=limit, - offset=offset, - ) - - -async def update_task(task_id: int, **fields: object) -> Note | None: - return await update_note(task_id, **fields) - - -async def delete_task(task_id: int) -> bool: - return await delete_note(task_id) diff --git a/summary.md b/summary.md index 1b2565d..a1b5350 100644 --- a/summary.md +++ b/summary.md @@ -12,7 +12,7 @@ > Include file-level details in the commit body when the change is non-trivial. ## Last Updated -2026-02-10 — Phase 4.7: Styling consistency pass — design tokens, header alignment, standardized UI +2026-02-11 — Phase 4.8: Backend efficiency & consistency pass ## Project Overview Fabled Assistant is a self-hosted note-taking and task-tracking application with @@ -127,11 +127,11 @@ for AI-assisted features. - Auto-create via `get_or_create_note_by_title()` for wikilink clicks - `to_dict()` returns: `id`, `title`, `body`, `tags`, `parent_id`, `status`, `priority`, `due_date`, `is_task`, `created_at`, `updated_at` -- Indexes: GIN on tags, B-tree on status +- Indexes: GIN on tags, B-tree on status, B-tree on title ### Task ≡ Note with task attributes -- No separate tasks table. The `services/tasks.py` module is a thin wrapper - around `services/notes.py` that passes `is_task=True` for listing and +- No separate tasks table. `routes/tasks.py` imports directly from + `services/notes.py` with `is_task=True` for listing and defaults `status='todo'`, `priority='none'` for creation. - "Convert to task" = `update_note(id, status='todo', priority='none')` - "Convert to note" = `update_note(id, status=None, priority=None, due_date=None)` @@ -140,13 +140,15 @@ for AI-assisted features. ### Settings - `key` (text PK), `value` (text) - Key-value store for app-wide settings (assistant name, default model, etc.) -- CRUD via `services/settings.py`: `get_setting(key, default)`, `set_setting(key, value)`, `get_all_settings()` +- CRUD via `services/settings.py`: `get_setting(key, default)`, `set_setting(key, value)`, `set_settings_batch(dict)`, `get_all_settings()` ### Conversations - `id` (int PK), `title` (str), `model` (str), `created_at`, `updated_at` - Has many messages (cascade delete) - Title auto-generated from first user message if not set +- Index on `updated_at` for list ordering - `to_dict()` returns: `id`, `title`, `model`, `message_count`, `created_at`, `updated_at` +- `list_conversations()` uses a subquery for `message_count` instead of eager-loading all messages ### Messages - `id` (int PK), `conversation_id` (FK to conversations, CASCADE), `role` (str: system/user/assistant), @@ -171,7 +173,8 @@ fabledassistant/ │ ├── 0003_task_note_companion.py # Data migration: create companion notes for existing tasks │ ├── 0004_merge_tasks_into_notes.py # Add task columns to notes, migrate data, drop tasks table │ ├── 0005_add_chat_tables.py # Conversations + messages tables with FKs and indexes -│ └── 0006_add_settings_table.py # Settings key-value table +│ ├── 0006_add_settings_table.py # Settings key-value table +│ └── 0007_add_title_and_updated_at_indexes.py # B-tree indexes on notes.title and conversations.updated_at ├── src/ │ └── fabledassistant/ │ ├── __init__.py @@ -187,14 +190,13 @@ fabledassistant/ │ │ ├── api.py # /api blueprint with /health endpoint │ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming, save/summarize as note, models list/pull/delete, status check │ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /convert-to-note + /backlinks -│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (thin wrappers, accepts body not description) +│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (imports directly from services.notes, accepts body not description) │ │ └── settings.py # /api/settings GET/PUT — app-wide settings │ ├── services/ -│ │ ├── notes.py # CRUD, is_task filter, status/priority filters, convert_note_to_task, convert_task_to_note, get_backlinks -│ │ ├── tasks.py # Thin wrappers around notes.py (create_task, list_tasks with is_task=True, etc.) -│ │ ├── llm.py # Ollama interaction: ensure_model, stream_chat, generate_completion, fetch_url_content, build_context (keyword extraction, note search with 5 keywords + 2000-char previews, exclude_note_ids support, returns (messages, context_meta) tuple, URL fetching, configurable assistant name) -│ │ ├── chat.py # Conversation CRUD, add_message, save_response_as_note, summarize_conversation_as_note -│ │ └── settings.py # Settings CRUD: get_setting, set_setting, get_all_settings +│ │ ├── notes.py # CRUD, is_task filter, status/priority filters, convert_note_to_task, convert_task_to_note, get_backlinks, search_notes_for_context (OR-keyword search), get_all_tags (SQL unnest) +│ │ ├── llm.py # Ollama interaction: get_installed_models (shared helper), ensure_model, stream_chat, generate_completion, fetch_url_content, build_context (keyword extraction, single OR-query note search, exclude_note_ids support, returns (messages, context_meta) tuple, URL fetching, configurable assistant name) +│ │ ├── chat.py # Conversation CRUD (list uses subquery count), add_message, save_response_as_note, summarize_conversation_as_note +│ │ └── settings.py # Settings CRUD: get_setting, set_setting, set_settings_batch, get_all_settings │ ├── utils/ │ │ ├── __init__.py │ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences @@ -301,7 +303,7 @@ container startup. ### Migration Chain ``` -0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py → 0004_merge_tasks_into_notes.py → 0005_add_chat_tables.py → 0006_add_settings_table.py +0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py → 0004_merge_tasks_into_notes.py → 0005_add_chat_tables.py → 0006_add_settings_table.py → 0007_add_title_and_updated_at_indexes.py ``` ### How Migrations Run @@ -481,6 +483,18 @@ When adding a new migration, follow these conventions: - [x] **Focus-visible states:** Global `focus-visible` rule with `--focus-ring` shadow on inputs, textareas, selects, buttons - [x] **Modal overlays:** Replaced hardcoded `rgba(0,0,0,0.5)` with `var(--color-overlay)` across all components +### Phase 4.8 — Backend Efficiency & Consistency Pass ✓ +- [x] **`get_all_tags()` SQL rewrite:** Replaced O(n) Python-side tag extraction with single `SELECT DISTINCT unnest(tags)` query +- [x] **`list_conversations()` subquery count:** Replaced `selectinload(Conversation.messages)` with correlated subquery for `message_count`, avoiding loading all messages for list view +- [x] **Removed `services/tasks.py`:** Eliminated 64-line pass-through service; `routes/tasks.py` imports directly from `services.notes` +- [x] **Consolidated convert functions:** `convert_note_to_task()` and `convert_task_to_note()` now use single session (was 3 DB round trips) +- [x] **Batch settings updates:** New `set_settings_batch()` does all upserts in single transaction (was one transaction per setting) +- [x] **Shared `get_installed_models()`:** Extracted duplicate Ollama model-listing logic from 3 locations into `services/llm.py` +- [x] **`search_notes_for_context()`:** New function does single OR-keyword query (was up to 5 sequential `list_notes()` calls each with count query) +- [x] **Logging:** Added `logger = logging.getLogger(__name__)` to `services/notes.py`, `services/chat.py`, `services/settings.py` +- [x] **Database indexes:** Added B-tree indexes on `notes.title` (for `get_note_by_title` + backlinks) and `conversations.updated_at` (for list ordering) +- [x] **Safe `Conversation.to_dict()`:** Handles case where messages relationship is not loaded + ### Phase 5 — Polish & Production Hardening - [ ] Authentication (single-user or multi-user, TBD) - [ ] Docker Swarm production stack (secrets, volumes, networking) @@ -506,7 +520,7 @@ When adding a new migration, follow these conventions: - Authentication model: single-user (password-only) vs multi-user? ## Current Status -**Phase:** Phase 4.7 complete. Styling consistency pass with design tokens and standardized UI. +**Phase:** Phase 4.8 complete. Backend efficiency & consistency pass. - Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags - Unified note/task model (a task is a note with `status IS NOT NULL`) - LLM chat via Ollama with SSE streaming responses