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:
@@ -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)
|
||||
Reference in New Issue
Block a user