feat(journal): LLM tools (record_moment, search_journal) + system prompt wiring
- services/tools/journal.py — record_moment + search_journal tool handlers - services/tools/_registry.py: add `journal` flag on ToolDef + tool() decorator - get_tools_for_user(user_id, conversation_type='chat'|'journal') — exclude journal-only tools from chat sessions; exclude set_rag_scope from journal sessions - services/tools/__init__.py: register the new journal module; drop the unused get_briefing_tools export - services/llm.py build_context: short-circuit for journal conversations, using journal_pipeline.build_journal_system_prompt and skipping all notes-RAG injection (preserves the journal/notes isolation invariant) - services/generation_task.py: pass conversation_type into get_tools_for_user Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -254,8 +254,15 @@ async def run_generation(
|
|||||||
|
|
||||||
buf.append_event("status", {"status": "Building context..."})
|
buf.append_event("status", {"status": "Building context..."})
|
||||||
|
|
||||||
# Phase 1: Resolve the tools list for this user.
|
# Phase 1: Resolve the tools list for this user, scoped to conversation type.
|
||||||
tools = await get_tools_for_user(user_id)
|
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(
|
logger.info(
|
||||||
"Starting generation for conv %d: model=%s, tools=%d",
|
"Starting generation for conv %d: model=%s, tools=%d",
|
||||||
|
|||||||
@@ -573,6 +573,34 @@ async def build_context(
|
|||||||
"""
|
"""
|
||||||
exclude_set = set(exclude_note_ids or [])
|
exclude_set = set(exclude_note_ids or [])
|
||||||
from datetime import date as date_type
|
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")
|
assistant_name = await get_setting(user_id, "assistant_name", "Fable")
|
||||||
today = date_type.today().isoformat()
|
today = date_type.today().isoformat()
|
||||||
has_caldav = await is_caldav_configured(user_id)
|
has_caldav = await is_caldav_configured(user_id)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ of the app depends on.
|
|||||||
from fabledassistant.services.tools import ( # noqa: F401
|
from fabledassistant.services.tools import ( # noqa: F401
|
||||||
calendar,
|
calendar,
|
||||||
entities,
|
entities,
|
||||||
|
journal,
|
||||||
notes,
|
notes,
|
||||||
profile,
|
profile,
|
||||||
projects,
|
projects,
|
||||||
@@ -22,12 +23,10 @@ from fabledassistant.services.tools import ( # noqa: F401
|
|||||||
)
|
)
|
||||||
from fabledassistant.services.tools._registry import (
|
from fabledassistant.services.tools._registry import (
|
||||||
execute_tool,
|
execute_tool,
|
||||||
get_briefing_tools,
|
|
||||||
get_tools_for_user,
|
get_tools_for_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"execute_tool",
|
"execute_tool",
|
||||||
"get_briefing_tools",
|
|
||||||
"get_tools_for_user",
|
"get_tools_for_user",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class ToolDef:
|
|||||||
handler: ToolHandler
|
handler: ToolHandler
|
||||||
read_only: bool = False
|
read_only: bool = False
|
||||||
briefing: bool = False
|
briefing: bool = False
|
||||||
|
journal: bool = False
|
||||||
requires: str | None = None
|
requires: str | None = None
|
||||||
required_params: list[str] = field(default_factory=list)
|
required_params: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ def tool(
|
|||||||
required: list[str] | None = None,
|
required: list[str] | None = None,
|
||||||
read_only: bool = False,
|
read_only: bool = False,
|
||||||
briefing: bool = False,
|
briefing: bool = False,
|
||||||
|
journal: bool = False,
|
||||||
requires: str | None = None,
|
requires: str | None = None,
|
||||||
) -> Callable[[ToolHandler], ToolHandler]:
|
) -> Callable[[ToolHandler], ToolHandler]:
|
||||||
"""Register an async tool handler with its schema metadata."""
|
"""Register an async tool handler with its schema metadata."""
|
||||||
@@ -71,6 +73,7 @@ def tool(
|
|||||||
handler=fn,
|
handler=fn,
|
||||||
read_only=read_only,
|
read_only=read_only,
|
||||||
briefing=briefing,
|
briefing=briefing,
|
||||||
|
journal=journal,
|
||||||
requires=requires,
|
requires=requires,
|
||||||
required_params=required or [],
|
required_params=required or [],
|
||||||
)
|
)
|
||||||
@@ -90,27 +93,29 @@ async def _check_requires(user_id: int, requires: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def get_tools_for_user(user_id: int) -> list[dict]:
|
async def get_tools_for_user(
|
||||||
"""Build the tool schema list for a user based on configured integrations."""
|
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] = []
|
tools: list[dict] = []
|
||||||
for td in _REGISTRY.values():
|
for td in _REGISTRY.values():
|
||||||
if td.requires and not await _check_requires(user_id, td.requires):
|
if td.requires and not await _check_requires(user_id, td.requires):
|
||||||
continue
|
continue
|
||||||
|
if conversation_type == "journal":
|
||||||
|
if td.name == "set_rag_scope":
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if td.journal:
|
||||||
|
continue
|
||||||
tools.append(td.schema())
|
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(
|
logger.debug(
|
||||||
"Briefing tools for user %d: %d of %d selected",
|
"User %d / %s: %d tools available", user_id, conversation_type, len(tools)
|
||||||
user_id, len(filtered), len(all_tools),
|
|
||||||
)
|
)
|
||||||
return filtered
|
return tools
|
||||||
|
|
||||||
|
|
||||||
async def execute_tool(
|
async def execute_tool(
|
||||||
|
|||||||
@@ -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}
|
||||||
Reference in New Issue
Block a user