diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py index 8f77a88..31455c4 100644 --- a/src/fabledassistant/services/generation_task.py +++ b/src/fabledassistant/services/generation_task.py @@ -254,8 +254,15 @@ async def run_generation( buf.append_event("status", {"status": "Building context..."}) - # Phase 1: Resolve the tools list for this user. - tools = await get_tools_for_user(user_id) + # Phase 1: Resolve the tools list for this user, scoped to conversation type. + from fabledassistant.models import async_session as _async_session + from fabledassistant.models.conversation import Conversation as _Conversation + async with _async_session() as _sess: + _conv = await _sess.get(_Conversation, conv_id) + _conversation_type = ( + _conv.conversation_type if _conv and _conv.conversation_type else "chat" + ) + tools = await get_tools_for_user(user_id, conversation_type=_conversation_type) logger.info( "Starting generation for conv %d: model=%s, tools=%d", diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index ba8060a..a5c2bd6 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -573,6 +573,34 @@ async def build_context( """ exclude_set = set(exclude_note_ids or []) from datetime import date as date_type + + # --- Journal short-circuit --- + # Journal conversations get a different persona, calibration, and an + # ambient-moments context block. CRUCIALLY, no notes-RAG injection here + # (preserves the notes/journal isolation invariant). + if conv_id is not None: + from fabledassistant.models import async_session as _async_session + from fabledassistant.models.conversation import Conversation as _Conversation + async with _async_session() as _sess: + _conv = await _sess.get(_Conversation, conv_id) + if _conv and getattr(_conv, "conversation_type", None) == "journal": + from fabledassistant.services.journal_pipeline import build_journal_system_prompt + day_date = _conv.day_date or date_type.today() + system_content = await build_journal_system_prompt( + user_id=user_id, + day_date=day_date, + user_timezone=user_timezone or "UTC", + ) + messages_out: list[dict] = [{"role": "system", "content": system_content}] + messages_out.extend(history) + messages_out.append({"role": "user", "content": user_message}) + return messages_out, { + "context_note_id": None, + "context_note_title": None, + "auto_notes": [], + "auto_injected_notes": [], + } + assistant_name = await get_setting(user_id, "assistant_name", "Fable") today = date_type.today().isoformat() has_caldav = await is_caldav_configured(user_id) diff --git a/src/fabledassistant/services/tools/__init__.py b/src/fabledassistant/services/tools/__init__.py index 42e7411..b5a32ae 100644 --- a/src/fabledassistant/services/tools/__init__.py +++ b/src/fabledassistant/services/tools/__init__.py @@ -10,6 +10,7 @@ of the app depends on. from fabledassistant.services.tools import ( # noqa: F401 calendar, entities, + journal, notes, profile, projects, @@ -22,12 +23,10 @@ from fabledassistant.services.tools import ( # noqa: F401 ) from fabledassistant.services.tools._registry import ( execute_tool, - get_briefing_tools, get_tools_for_user, ) __all__ = [ "execute_tool", - "get_briefing_tools", "get_tools_for_user", ] diff --git a/src/fabledassistant/services/tools/_registry.py b/src/fabledassistant/services/tools/_registry.py index d47842d..021cca6 100644 --- a/src/fabledassistant/services/tools/_registry.py +++ b/src/fabledassistant/services/tools/_registry.py @@ -27,6 +27,7 @@ class ToolDef: handler: ToolHandler read_only: bool = False briefing: bool = False + journal: bool = False requires: str | None = None required_params: list[str] = field(default_factory=list) @@ -57,6 +58,7 @@ def tool( required: list[str] | None = None, read_only: bool = False, briefing: bool = False, + journal: bool = False, requires: str | None = None, ) -> Callable[[ToolHandler], ToolHandler]: """Register an async tool handler with its schema metadata.""" @@ -71,6 +73,7 @@ def tool( handler=fn, read_only=read_only, briefing=briefing, + journal=journal, requires=requires, required_params=required or [], ) @@ -90,27 +93,29 @@ async def _check_requires(user_id: int, requires: str) -> bool: return True -async def get_tools_for_user(user_id: int) -> list[dict]: - """Build the tool schema list for a user based on configured integrations.""" +async def get_tools_for_user( + user_id: int, *, conversation_type: str = "chat" +) -> list[dict]: + """Build the tool schema list for a user, scoped by conversation type. + + - 'chat': all tools except those marked journal-only. + - 'journal': all tools except set_rag_scope (scope is implicit in journal). + """ tools: list[dict] = [] for td in _REGISTRY.values(): if td.requires and not await _check_requires(user_id, td.requires): continue + if conversation_type == "journal": + if td.name == "set_rag_scope": + continue + else: + if td.journal: + continue tools.append(td.schema()) - logger.debug("User %d: %d tools available", user_id, len(tools)) - return tools - - -async def get_briefing_tools(user_id: int) -> list[dict]: - """Return only the tool schemas marked ``briefing=True``.""" - all_tools = await get_tools_for_user(user_id) - names = {td.name for td in _REGISTRY.values() if td.briefing} - filtered = [t for t in all_tools if t["function"]["name"] in names] logger.debug( - "Briefing tools for user %d: %d of %d selected", - user_id, len(filtered), len(all_tools), + "User %d / %s: %d tools available", user_id, conversation_type, len(tools) ) - return filtered + return tools async def execute_tool( diff --git a/src/fabledassistant/services/tools/journal.py b/src/fabledassistant/services/tools/journal.py new file mode 100644 index 0000000..d675ec8 --- /dev/null +++ b/src/fabledassistant/services/tools/journal.py @@ -0,0 +1,146 @@ +"""LLM tools for journal conversations: record_moment and search_journal.""" +from __future__ import annotations + +import datetime +import logging +from typing import Any + +from fabledassistant.services.journal_search import search_journal as svc_search_journal +from fabledassistant.services.moments import create_moment +from fabledassistant.services.tools._registry import tool + +logger = logging.getLogger(__name__) + + +@tool( + name="record_moment", + description=( + "Record a meaningful beat from the conversation as a structured Moment. " + "Use freely (no confirmation) for anything significant the user mentions: " + "events, encounters, decisions, observations, feelings worth remembering. " + "Each Moment is one or two sentences distilling the beat — not a full transcript. " + "Link people/places/tasks/notes by ID when the user has mentioned them." + ), + parameters={ + "content": { + "type": "string", + "description": "1-2 sentence distillation of the moment in the user's voice or third-person.", + }, + "occurred_at": { + "type": "string", + "description": "ISO 8601 datetime when the moment happened. Pass 'now' for the present moment; the handler resolves it.", + }, + "raw_excerpt": { + "type": "string", + "description": "Optional: literal user phrase the moment summarizes. Useful for trust UI.", + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional tags (no # prefix).", + }, + "person_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "Person IDs (note IDs with note_type='person') mentioned in this moment.", + }, + "place_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "Place IDs (note IDs with note_type='place') mentioned in this moment.", + }, + "task_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "Task IDs this moment references.", + }, + "note_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "Note IDs this moment references.", + }, + }, + required=["content"], + read_only=False, + journal=True, +) +async def record_moment_tool(*, user_id, arguments, conv_id=None, **_ctx): + content = arguments.get("content", "").strip() + if not content: + return {"success": False, "error": "content is required"} + + occurred_raw = arguments.get("occurred_at") + now = datetime.datetime.now(datetime.timezone.utc) + if occurred_raw and occurred_raw != "now": + try: + occurred_dt = datetime.datetime.fromisoformat(occurred_raw) + if occurred_dt.tzinfo is None: + occurred_dt = occurred_dt.replace(tzinfo=datetime.timezone.utc) + except ValueError: + occurred_dt = now + else: + occurred_dt = now + + moment = await create_moment( + user_id=user_id, + content=content, + occurred_at=occurred_dt, + day_date=occurred_dt.date(), + conversation_id=conv_id, + raw_excerpt=arguments.get("raw_excerpt"), + tags=arguments.get("tags") or [], + person_ids=arguments.get("person_ids") or [], + place_ids=arguments.get("place_ids") or [], + task_ids=arguments.get("task_ids") or [], + note_ids=arguments.get("note_ids") or [], + ) + return { + "success": True, + "type": "moment_recorded", + "moment": moment.to_dict(), + } + + +@tool( + name="search_journal", + description=( + "Search the user's journal: past Moments and (optionally) raw transcripts. " + "Use to recall things mentioned in prior days. Three modes: " + "(a) date filter only -> recent moments in that range; " + "(b) person_id/place_id filter -> moments mentioning that entity; " + "(c) query string -> semantic search, optionally constrained by date/entity." + ), + parameters={ + "query": {"type": "string", "description": "Optional semantic query."}, + "person_id": {"type": "integer", "description": "Filter to this person."}, + "place_id": {"type": "integer", "description": "Filter to this place."}, + "tag": {"type": "string", "description": "Filter to moments with this tag."}, + "date_from": {"type": "string", "description": "ISO date lower bound (inclusive)."}, + "date_to": {"type": "string", "description": "ISO date upper bound (inclusive)."}, + "include_transcripts": { + "type": "boolean", + "description": "If true, also return matching raw transcript excerpts when query is set. Default false.", + }, + "limit": {"type": "integer", "description": "Max results, default 10."}, + }, + required=[], + read_only=True, + journal=True, +) +async def search_journal_tool(*, user_id, arguments, **_ctx) -> dict[str, Any]: + df_raw = arguments.get("date_from") + dt_raw = arguments.get("date_to") + df = datetime.date.fromisoformat(df_raw) if df_raw else None + dt = datetime.date.fromisoformat(dt_raw) if dt_raw else None + results = await svc_search_journal( + user_id=user_id, + query=arguments.get("query"), + person_id=arguments.get("person_id"), + place_id=arguments.get("place_id"), + tag=arguments.get("tag"), + date_from=df, + date_to=dt, + include_transcripts=bool(arguments.get("include_transcripts", False)), + limit=int(arguments.get("limit", 10)), + ) + return {"success": True, "type": "journal_search_results", "count": len(results), "results": results}