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