85b212fbf2
Chat and background model roles effectively swapped during the conversation+curator pivot, but call sites still used OLD routing. This commit re-routes each call to the model whose new role fits. Moved to background_model (worker — heavy, deliberate): - services/journal_prep.py: daily prep generation. - services/user_profile.py: observation consolidation. Moved to default_model (chat — small, fast): - services/chat.py save_response_as_note: note title generation. - services/tag_suggestions.py: tag suggestions. Already routed correctly (unchanged): curator, closeout, consolidation, project summaries, history summarization. SettingsView.vue: help text rewritten for both model fields to describe new roles. Background Model UI label renamed to Worker Model so the heavier role is visible from the picker. Warning copy updated to recommend OLLAMA_MAX_LOADED_MODELS=2+ so chat and worker can stay loaded simultaneously. Schema names default_model and background_model unchanged on purpose (renaming requires migration + touches ~50 call sites for UX-only gain). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
303 lines
10 KiB
Python
303 lines
10 KiB
Python
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import func, select, delete as sa_delete
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from fabledassistant.models import async_session
|
|
from fabledassistant.models.conversation import Conversation, Message
|
|
from fabledassistant.config import Config
|
|
from fabledassistant.services.llm import generate_completion
|
|
from fabledassistant.services.notes import create_note
|
|
from fabledassistant.services.settings import get_setting
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def create_conversation(
|
|
user_id: int, title: str = "", model: str = "", conversation_type: str = "chat"
|
|
) -> Conversation:
|
|
async with async_session() as session:
|
|
conv = Conversation(user_id=user_id, title=title, model=model, conversation_type=conversation_type)
|
|
session.add(conv)
|
|
await session.commit()
|
|
# Re-fetch with messages eagerly loaded to avoid lazy-load in async context
|
|
result = await session.execute(
|
|
select(Conversation)
|
|
.options(selectinload(Conversation.messages))
|
|
.where(Conversation.id == conv.id)
|
|
)
|
|
return result.scalars().first()
|
|
|
|
|
|
async def get_conversation(
|
|
user_id: int, conversation_id: int
|
|
) -> Conversation | None:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Conversation)
|
|
.options(selectinload(Conversation.messages))
|
|
.where(
|
|
Conversation.id == conversation_id,
|
|
Conversation.user_id == user_id,
|
|
)
|
|
)
|
|
return result.scalars().first()
|
|
|
|
|
|
async def list_conversations(
|
|
user_id: int, limit: int = 50, offset: int = 0, conv_type: str = "chat"
|
|
) -> tuple[list[dict], int]:
|
|
async with async_session() as session:
|
|
total = await session.scalar(
|
|
select(func.count(Conversation.id)).where(
|
|
Conversation.user_id == user_id,
|
|
Conversation.conversation_type == conv_type,
|
|
)
|
|
) 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, msg_count.label("message_count"))
|
|
.where(Conversation.user_id == user_id, Conversation.conversation_type == conv_type)
|
|
.order_by(Conversation.updated_at.desc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
)
|
|
|
|
conversations = []
|
|
for row in result.all():
|
|
conv = row[0]
|
|
d = {
|
|
"id": conv.id,
|
|
"title": conv.title,
|
|
"model": conv.model,
|
|
"conversation_type": conv.conversation_type,
|
|
"day_date": conv.day_date.isoformat() if conv.day_date else None,
|
|
"rag_project_id": conv.rag_project_id,
|
|
"message_count": row[1],
|
|
"created_at": conv.created_at.isoformat(),
|
|
"updated_at": conv.updated_at.isoformat(),
|
|
}
|
|
conversations.append(d)
|
|
|
|
return conversations, total
|
|
|
|
|
|
async def delete_conversation(user_id: int, conversation_id: int) -> bool:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Conversation).where(
|
|
Conversation.id == conversation_id,
|
|
Conversation.user_id == user_id,
|
|
)
|
|
)
|
|
conv = result.scalars().first()
|
|
if conv is None:
|
|
return False
|
|
await session.delete(conv)
|
|
await session.commit()
|
|
return True
|
|
|
|
|
|
async def bulk_delete_conversations(user_id: int, ids: list[int]) -> int:
|
|
"""Delete multiple conversations by ID for a user. Returns count deleted."""
|
|
if not ids:
|
|
return 0
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
sa_delete(Conversation)
|
|
.where(Conversation.user_id == user_id, Conversation.id.in_(ids))
|
|
.returning(Conversation.id)
|
|
)
|
|
await session.commit()
|
|
return len(result.fetchall())
|
|
|
|
|
|
async def cleanup_old_conversations(user_id: int, days: int) -> int:
|
|
"""Delete conversations older than `days` days. Returns count deleted."""
|
|
if days <= 0:
|
|
return 0
|
|
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
sa_delete(Conversation)
|
|
.where(
|
|
Conversation.user_id == user_id,
|
|
Conversation.updated_at < cutoff,
|
|
Conversation.conversation_type != "mcp", # preserve MCP audit trail
|
|
Conversation.conversation_type != "voice", # voice convs managed separately
|
|
Conversation.conversation_type != "briefing", # briefing history managed by briefing system
|
|
)
|
|
.returning(Conversation.id)
|
|
)
|
|
await session.commit()
|
|
return len(result.fetchall())
|
|
|
|
|
|
_UNSET = object()
|
|
|
|
|
|
async def update_conversation(
|
|
user_id: int,
|
|
conversation_id: int,
|
|
title: str | None = None,
|
|
model: str | None = None,
|
|
rag_project_id: object = _UNSET,
|
|
) -> Conversation | None:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Conversation).where(
|
|
Conversation.id == conversation_id,
|
|
Conversation.user_id == user_id,
|
|
)
|
|
)
|
|
conv = result.scalars().first()
|
|
if conv is None:
|
|
return None
|
|
if title is not None:
|
|
conv.title = title
|
|
if model is not None:
|
|
conv.model = model
|
|
if rag_project_id is not _UNSET:
|
|
conv.rag_project_id = rag_project_id # type: ignore[assignment]
|
|
conv.updated_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
await session.refresh(conv)
|
|
return conv
|
|
|
|
|
|
async def update_conversation_title(
|
|
user_id: int, conversation_id: int, title: str
|
|
) -> Conversation | None:
|
|
return await update_conversation(user_id, conversation_id, title=title)
|
|
|
|
|
|
async def add_message(
|
|
conversation_id: int,
|
|
role: str,
|
|
content: str,
|
|
context_note_id: int | None = None,
|
|
status: str | None = None,
|
|
tool_calls: list | None = None,
|
|
msg_metadata: dict | None = None,
|
|
) -> Message:
|
|
async with async_session() as session:
|
|
kwargs: dict = dict(
|
|
conversation_id=conversation_id,
|
|
role=role,
|
|
content=content,
|
|
context_note_id=context_note_id,
|
|
)
|
|
if status is not None:
|
|
kwargs["status"] = status
|
|
if tool_calls is not None:
|
|
kwargs["tool_calls"] = tool_calls
|
|
if msg_metadata is not None:
|
|
kwargs["msg_metadata"] = msg_metadata
|
|
msg = Message(**kwargs)
|
|
session.add(msg)
|
|
# Touch conversation updated_at
|
|
conv = await session.get(Conversation, conversation_id)
|
|
if conv:
|
|
conv.updated_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
await session.refresh(msg)
|
|
return msg
|
|
|
|
|
|
async def get_message(message_id: int) -> Message | None:
|
|
async with async_session() as session:
|
|
return await session.get(Message, message_id)
|
|
|
|
|
|
async def save_response_as_note(user_id: int, message_id: int) -> dict:
|
|
"""Create a note from an assistant message. Returns the new note dict."""
|
|
msg = await get_message(message_id)
|
|
if msg is None:
|
|
raise ValueError("Message not found")
|
|
if msg.role != "assistant":
|
|
raise ValueError("Can only save assistant messages as notes")
|
|
|
|
conv = await get_conversation(user_id, msg.conversation_id)
|
|
|
|
# Generate title via LLM using the assistant message content
|
|
title = ""
|
|
if conv:
|
|
try:
|
|
prompt_messages = [
|
|
{
|
|
"role": "system",
|
|
"content": (
|
|
"Generate a concise 3-8 word title for a note based on "
|
|
"this content. Reply with ONLY the title, no quotes or "
|
|
"punctuation."
|
|
),
|
|
},
|
|
{"role": "user", "content": msg.content[:2000]},
|
|
]
|
|
# 3-8 word title generation is the kind of trivial small-input
|
|
# / small-output task the chat model (default_model) handles in
|
|
# ~1s. Routing here so the worker (background_model) isn't
|
|
# interrupted from heavier curator / prep / closeout passes.
|
|
chat_model = (
|
|
await get_setting(user_id, "default_model", "")
|
|
or Config.OLLAMA_MODEL
|
|
)
|
|
title = await generate_completion(prompt_messages, chat_model)
|
|
title = title.strip().strip('"\'').strip()[:100]
|
|
except Exception:
|
|
logger.warning("Failed to generate note title, using fallback", exc_info=True)
|
|
|
|
if not title:
|
|
lines = msg.content.strip().split("\n", 1)
|
|
title = lines[0].strip().lstrip("# ")[:100]
|
|
|
|
note = await create_note(user_id, title=title, body=msg.content, tags=["chat"])
|
|
return note.to_dict()
|
|
|
|
|
|
async def summarize_conversation_as_note(
|
|
user_id: int, conversation_id: int, model: str
|
|
) -> dict:
|
|
"""Summarize a conversation using the LLM and save as a note."""
|
|
conv = await get_conversation(user_id, conversation_id)
|
|
if conv is None:
|
|
raise ValueError("Conversation not found")
|
|
|
|
# Build the conversation text
|
|
conv_text = []
|
|
for msg in conv.messages:
|
|
if msg.role == "system":
|
|
continue
|
|
label = "User" if msg.role == "user" else "Assistant"
|
|
conv_text.append(f"{label}: {msg.content}")
|
|
|
|
prompt_messages = [
|
|
{
|
|
"role": "system",
|
|
"content": (
|
|
"Summarize the following conversation into a concise note. "
|
|
"Include key points, decisions, and any action items. "
|
|
"Format the summary in markdown."
|
|
),
|
|
},
|
|
{"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"
|
|
title = f"Summary: {title}"
|
|
|
|
note = await create_note(user_id, title=title, body=summary, tags=["chat"])
|
|
return note.to_dict()
|