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
@@ -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")
+11 -1
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy import inspect
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from fabledassistant.models import Base from fabledassistant.models import Base
@@ -27,12 +28,21 @@ class Conversation(Base):
order_by="Message.created_at", order_by="Message.created_at",
) )
__table_args__ = (
Index("ix_conversations_updated_at", "updated_at"),
)
def to_dict(self) -> dict: 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 { return {
"id": self.id, "id": self.id,
"title": self.title, "title": self.title,
"model": self.model, "model": self.model,
"message_count": len(self.messages) if self.messages else 0, "message_count": msg_count,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }
+1
View File
@@ -46,6 +46,7 @@ class Note(Base):
__table_args__ = ( __table_args__ = (
Index("ix_notes_tags", "tags", postgresql_using="gin"), Index("ix_notes_tags", "tags", postgresql_using="gin"),
Index("ix_notes_status", "status"), Index("ix_notes_status", "status"),
Index("ix_notes_title", "title"),
) )
@property @property
+2 -2
View File
@@ -29,7 +29,7 @@ async def list_conversations_route():
offset = request.args.get("offset", 0, type=int) offset = request.args.get("offset", 0, type=int)
conversations, total = await list_conversations(limit=limit, offset=offset) conversations, total = await list_conversations(limit=limit, offset=offset)
return jsonify({ return jsonify({
"conversations": [c.to_dict() for c in conversations], "conversations": conversations,
"total": total, "total": total,
}) })
@@ -180,7 +180,7 @@ async def chat_status_route():
resp.raise_for_status() resp.raise_for_status()
result["ollama"] = "available" result["ollama"] = "available"
data = resp.json() 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 # Match with or without :latest tag
if default_model in model_names or f"{default_model}:latest" in model_names: if default_model in model_names or f"{default_model}:latest" in model_names:
result["model"] = "ready" result["model"] = "ready"
+4 -23
View File
@@ -1,29 +1,11 @@
import httpx
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.config import Config from fabledassistant.services.llm import get_installed_models
from fabledassistant.services.settings import get_all_settings, set_setting from fabledassistant.services.settings import get_all_settings, set_settings_batch
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings") 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"]) @settings_bp.route("", methods=["GET"])
async def get_settings_route(): async def get_settings_route():
settings = await get_all_settings() settings = await get_all_settings()
@@ -38,11 +20,10 @@ async def update_settings_route():
if "default_model" in data: if "default_model" in data:
model = str(data["default_model"]) model = str(data["default_model"])
installed = await _get_installed_models() installed = await get_installed_models()
if installed and model not in installed: if installed and model not in installed:
return jsonify({"error": f"Model '{model}' is not installed"}), 400 return jsonify({"error": f"Model '{model}' is not installed"}), 400
for key, value in data.items(): await set_settings_batch({k: str(v) for k, v in data.items()})
await set_setting(key, str(value))
settings = await get_all_settings() settings = await get_all_settings()
return jsonify(settings) return jsonify(settings)
+13 -12
View File
@@ -3,12 +3,12 @@ from datetime import date
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.models.note import TaskPriority, TaskStatus from fabledassistant.models.note import TaskPriority, TaskStatus
from fabledassistant.services.tasks import ( from fabledassistant.services.notes import (
create_task, create_note,
delete_task, delete_note,
get_task, get_note,
list_tasks, list_notes,
update_task, update_note,
) )
from fabledassistant.utils.tags import extract_tags from fabledassistant.utils.tags import extract_tags
@@ -26,9 +26,10 @@ async def list_tasks_route():
limit = request.args.get("limit", 50, type=int) limit = request.args.get("limit", 50, type=int)
offset = request.args.get("offset", 0, type=int) offset = request.args.get("offset", 0, type=int)
tasks, total = await list_tasks( tasks, total = await list_notes(
q=q, q=q,
tags=tag or None, tags=tag or None,
is_task=True,
status=status, status=status,
priority=priority, priority=priority,
sort=sort, sort=sort,
@@ -54,7 +55,7 @@ async def create_task_route():
TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
) )
task = await create_task( task = await create_note(
title=data.get("title", ""), title=data.get("title", ""),
body=body, body=body,
status=status, status=status,
@@ -67,7 +68,7 @@ async def create_task_route():
@tasks_bp.route("/<int:task_id>", methods=["GET"]) @tasks_bp.route("/<int:task_id>", methods=["GET"])
async def get_task_route(task_id: int): async def get_task_route(task_id: int):
task = await get_task(task_id) task = await get_note(task_id)
if task is None: if task is None:
return jsonify({"error": "Task not found"}), 404 return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict()) return jsonify(task.to_dict())
@@ -95,7 +96,7 @@ async def update_task_route(task_id: int):
if "body" in fields: if "body" in fields:
fields["tags"] = extract_tags(fields["body"]) fields["tags"] = extract_tags(fields["body"])
task = await update_task(task_id, **fields) task = await update_note(task_id, **fields)
if task is None: if task is None:
return jsonify({"error": "Task not found"}), 404 return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict()) return jsonify(task.to_dict())
@@ -113,7 +114,7 @@ async def patch_task_status(task_id: int):
except ValueError: except ValueError:
return jsonify({"error": f"Invalid status: {status_val}"}), 400 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: if task is None:
return jsonify({"error": "Task not found"}), 404 return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict()) return jsonify(task.to_dict())
@@ -121,7 +122,7 @@ async def patch_task_status(task_id: int):
@tasks_bp.route("/<int:task_id>", methods=["DELETE"]) @tasks_bp.route("/<int:task_id>", methods=["DELETE"])
async def delete_task_route(task_id: int): async def delete_task_route(task_id: int):
deleted = await delete_task(task_id) deleted = await delete_note(task_id)
if not deleted: if not deleted:
return jsonify({"error": "Task not found"}), 404 return jsonify({"error": "Task not found"}), 404
return "", 204 return "", 204
+28 -4
View File
@@ -1,3 +1,4 @@
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import func, select 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.llm import generate_completion
from fabledassistant.services.notes import create_note from fabledassistant.services.notes import create_note
logger = logging.getLogger(__name__)
async def create_conversation(title: str = "", model: str = "") -> Conversation: async def create_conversation(title: str = "", model: str = "") -> Conversation:
async with async_session() as session: async with async_session() as session:
@@ -35,20 +38,40 @@ async def get_conversation(conversation_id: int) -> Conversation | None:
async def list_conversations( async def list_conversations(
limit: int = 50, offset: int = 0 limit: int = 50, offset: int = 0
) -> tuple[list[Conversation], int]: ) -> tuple[list[dict], int]:
async with async_session() as session: async with async_session() as session:
total = await session.scalar( total = await session.scalar(
select(func.count(Conversation.id)) select(func.count(Conversation.id))
) or 0 ) 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( result = await session.execute(
select(Conversation) select(Conversation, msg_count.label("message_count"))
.options(selectinload(Conversation.messages))
.order_by(Conversation.updated_at.desc()) .order_by(Conversation.updated_at.desc())
.limit(limit) .limit(limit)
.offset(offset) .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 return conversations, total
@@ -149,6 +172,7 @@ async def summarize_conversation_as_note(
{"role": "user", "content": "\n\n".join(conv_text)}, {"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) summary = await generate_completion(prompt_messages, model)
title = conv.title or "Conversation Summary" title = conv.title or "Conversation Summary"
+42 -38
View File
@@ -6,7 +6,7 @@ from collections.abc import AsyncGenerator
import httpx import httpx
from fabledassistant.config import Config 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 from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,18 +23,32 @@ STOP_WORDS = frozenset({
}) })
async def ensure_model(model: str) -> None: async def get_installed_models() -> set[str]:
"""Check if model exists in Ollama, pull if missing.""" """Return set of installed Ollama model names (with and without :latest)."""
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags") resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
names = {m["name"] for m in data.get("models", [])} names: set[str] = set()
# Check both with and without :latest tag for m in data.get("models", []):
if model in names or f"{model}:latest" in names: name = m["name"]
logger.info("Model '%s' already available", model) names.add(name)
return 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: except Exception:
logger.warning("Failed to check Ollama models, attempting pull anyway") logger.warning("Failed to check Ollama models, attempting pull anyway")
@@ -172,39 +186,29 @@ async def build_context(
f"--- End Note ---" f"--- End Note ---"
) )
# Search notes by keywords from user message — search per keyword # Search notes by keywords from user message — single OR query
# individually and deduplicate to get OR-style matching (any keyword hit
# is relevant context). Collect up to 3 unique notes.
keywords = _extract_keywords(user_message) keywords = _extract_keywords(user_message)
if keywords: if keywords:
seen_ids: set[int] = set() search_exclude = set(exclude_set)
snippets: list[str] = [] if current_note_id:
for kw in keywords[:5]: search_exclude.add(current_note_id)
if len(snippets) >= 3: try:
break notes = await search_notes_for_context(
try: keywords, exclude_ids=search_exclude or None, limit=3
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 ---"
) )
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 # Fetch URL content from user message
urls = _find_urls(user_message) urls = _find_urls(user_message)
+55 -17
View File
@@ -1,3 +1,4 @@
import logging
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from sqlalchemy import func, or_, select, text 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 import async_session
from fabledassistant.models.note import Note, TaskPriority, TaskStatus from fabledassistant.models.note import Note, TaskPriority, TaskStatus
logger = logging.getLogger(__name__)
async def create_note( async def create_note(
title: str = "", 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 def get_all_tags(q: str | None = None) -> list[str]:
async with async_session() as session: async with async_session() as session:
all_tags: set[str] = set() result = await session.execute(
text("SELECT DISTINCT unnest(tags) AS tag FROM notes WHERE tags != '{}'")
result = await session.execute(select(Note)) )
for obj in result.scalars(): all_tags = [row[0] for row in result.fetchall()]
if obj.tags:
all_tags.update(obj.tags)
if q: if q:
q_lower = q.lower() 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) return sorted(all_tags)
async def convert_note_to_task(note_id: int) -> Note: async def convert_note_to_task(note_id: int) -> Note:
note = await get_note(note_id) async with async_session() as session:
if note is None: note = await session.get(Note, note_id)
raise ValueError("Note not found") if note is None:
updated = await update_note(note_id, status=TaskStatus.todo.value, priority=TaskPriority.none.value) logger.warning("convert_note_to_task: note %d not found", note_id)
return updated 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: async def convert_task_to_note(note_id: int) -> Note:
note = await get_note(note_id) async with async_session() as session:
if note is None: note = await session.get(Note, note_id)
raise ValueError("Note not found") if note is None:
updated = await update_note(note_id, status=None, priority=None, due_date=None) logger.warning("convert_task_to_note: note %d not found", note_id)
return updated 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]: async def get_backlinks(note_id: int) -> list[dict]:
+18
View File
@@ -1,8 +1,12 @@
import logging
from sqlalchemy import select from sqlalchemy import select
from fabledassistant.models import async_session from fabledassistant.models import async_session
from fabledassistant.models.setting import Setting from fabledassistant.models.setting import Setting
logger = logging.getLogger(__name__)
async def get_setting(key: str, default: str = "") -> str: async def get_setting(key: str, default: str = "") -> str:
async with async_session() as session: async with async_session() as session:
@@ -22,6 +26,20 @@ async def set_setting(key: str, value: str) -> None:
await session.commit() 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 def get_all_settings() -> dict[str, str]:
async with async_session() as session: async with async_session() as session:
result = await session.execute(select(Setting)) result = await session.execute(select(Setting))
-63
View File
@@ -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)
+28 -14
View File
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial. > Include file-level details in the commit body when the change is non-trivial.
## Last Updated ## 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 ## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with 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 - Auto-create via `get_or_create_note_by_title()` for wikilink clicks
- `to_dict()` returns: `id`, `title`, `body`, `tags`, `parent_id`, `status`, - `to_dict()` returns: `id`, `title`, `body`, `tags`, `parent_id`, `status`,
`priority`, `due_date`, `is_task`, `created_at`, `updated_at` `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 ### Task ≡ Note with task attributes
- No separate tasks table. The `services/tasks.py` module is a thin wrapper - No separate tasks table. `routes/tasks.py` imports directly from
around `services/notes.py` that passes `is_task=True` for listing and `services/notes.py` with `is_task=True` for listing and
defaults `status='todo'`, `priority='none'` for creation. defaults `status='todo'`, `priority='none'` for creation.
- "Convert to task" = `update_note(id, status='todo', priority='none')` - "Convert to task" = `update_note(id, status='todo', priority='none')`
- "Convert to note" = `update_note(id, status=None, priority=None, due_date=None)` - "Convert to note" = `update_note(id, status=None, priority=None, due_date=None)`
@@ -140,13 +140,15 @@ for AI-assisted features.
### Settings ### Settings
- `key` (text PK), `value` (text) - `key` (text PK), `value` (text)
- Key-value store for app-wide settings (assistant name, default model, etc.) - 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 ### Conversations
- `id` (int PK), `title` (str), `model` (str), `created_at`, `updated_at` - `id` (int PK), `title` (str), `model` (str), `created_at`, `updated_at`
- Has many messages (cascade delete) - Has many messages (cascade delete)
- Title auto-generated from first user message if not set - 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` - `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 ### Messages
- `id` (int PK), `conversation_id` (FK to conversations, CASCADE), `role` (str: system/user/assistant), - `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 │ ├── 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 │ ├── 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 │ ├── 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/ ├── src/
│ └── fabledassistant/ │ └── fabledassistant/
│ ├── __init__.py │ ├── __init__.py
@@ -187,14 +190,13 @@ fabledassistant/
│ │ ├── api.py # /api blueprint with /health endpoint │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 │ │ └── settings.py # /api/settings GET/PUT — app-wide settings
│ ├── services/ │ ├── services/
│ │ ├── notes.py # CRUD, is_task filter, status/priority filters, convert_note_to_task, convert_task_to_note, get_backlinks │ │ ├── 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)
│ │ ├── tasks.py # Thin wrappers around notes.py (create_task, list_tasks with is_task=True, etc.) │ │ ├── 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)
│ │ ├── 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 (list uses subquery count), add_message, save_response_as_note, summarize_conversation_as_note
│ │ ── chat.py # Conversation CRUD, add_message, save_response_as_note, summarize_conversation_as_note │ │ ── settings.py # Settings CRUD: get_setting, set_setting, set_settings_batch, get_all_settings
│ │ └── settings.py # Settings CRUD: get_setting, set_setting, get_all_settings
│ ├── utils/ │ ├── utils/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences │ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences
@@ -301,7 +303,7 @@ container startup.
### Migration Chain ### 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 ### 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] **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 - [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 ### Phase 5 — Polish & Production Hardening
- [ ] Authentication (single-user or multi-user, TBD) - [ ] Authentication (single-user or multi-user, TBD)
- [ ] Docker Swarm production stack (secrets, volumes, networking) - [ ] 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? - Authentication model: single-user (password-only) vs multi-user?
## Current Status ## 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 - 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`) - Unified note/task model (a task is a note with `status IS NOT NULL`)
- LLM chat via Ollama with SSE streaming responses - LLM chat via Ollama with SSE streaming responses