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:
@@ -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