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:
@@ -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")
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("/<int:task_id>", 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("/<int:task_id>", 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user