From bc2119c06749625824f9326005b5ebab7ef39bd3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 11 Mar 2026 20:07:05 -0400 Subject: [PATCH] feat: briefing profile note service Co-Authored-By: Claude Sonnet 4.6 --- .../services/briefing_profile.py | 80 +++++++++++++++++++ tests/test_briefing_pipeline.py | 29 +++++++ 2 files changed, 109 insertions(+) create mode 100644 src/fabledassistant/services/briefing_profile.py create mode 100644 tests/test_briefing_pipeline.py diff --git a/src/fabledassistant/services/briefing_profile.py b/src/fabledassistant/services/briefing_profile.py new file mode 100644 index 0000000..4eb980b --- /dev/null +++ b/src/fabledassistant/services/briefing_profile.py @@ -0,0 +1,80 @@ +"""Briefing profile note: stores learned user preferences for the briefing assistant.""" + +import logging + +from fabledassistant.services.notes import create_note, list_notes, update_note + +logger = logging.getLogger(__name__) + +PROFILE_TAG = "briefing-profile" +PROFILE_TITLE = "Briefing Profile" + + +async def _find_profile_note(user_id: int) -> dict | None: + """Find the user's briefing profile note by tag.""" + notes, _total = await list_notes(user_id, tags=[PROFILE_TAG], limit=1) + if not notes: + return None + note = notes[0] + return { + "id": note.id, + "body": note.body or "", + "title": note.title, + } + + +async def get_profile_body(user_id: int) -> str: + """Return the body of the briefing profile note, or '' if none exists.""" + note = await _find_profile_note(user_id) + return note["body"] if note else "" + + +async def get_profile_note_id(user_id: int) -> int | None: + note = await _find_profile_note(user_id) + return note["id"] if note else None + + +async def ensure_profile_note(user_id: int) -> int: + """ + Get or create the briefing profile note. + Returns the note id. + """ + note = await _find_profile_note(user_id) + if note: + return note["id"] + created = await create_note( + user_id=user_id, + title=PROFILE_TITLE, + body=( + "# Briefing Profile\n\n" + "This note is maintained by the briefing assistant. " + "It stores your preferences, patterns, and work schedule.\n\n" + "## Work Schedule\n\n" + "Office days: (not yet configured)\n\n" + "## Locations\n\n" + "(configured via Settings → Briefing)\n\n" + "## Preferences\n\n" + "(the assistant will add observations here over time)\n" + ), + tags=[PROFILE_TAG], + ) + return created.id + + +async def append_observations(user_id: int, observations: str) -> None: + """ + Append the assistant's end-of-day observations to the profile note. + Creates the note if it doesn't exist. + """ + if not observations.strip(): + return + note_id = await ensure_profile_note(user_id) + note = await _find_profile_note(user_id) + if not note: + return + current_body = note.get("body", "") + from datetime import date + date_str = date.today().isoformat() + new_body = current_body.rstrip() + f"\n\n## Observations — {date_str}\n\n{observations.strip()}\n" + await update_note(user_id, note_id, body=new_body) + logger.info("Briefing profile updated for user %d", user_id) diff --git a/tests/test_briefing_pipeline.py b/tests/test_briefing_pipeline.py new file mode 100644 index 0000000..80a284e --- /dev/null +++ b/tests/test_briefing_pipeline.py @@ -0,0 +1,29 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock + + +@pytest.mark.asyncio +async def test_get_or_create_profile_note_returns_body(): + """get_profile_body() should return empty string when no profile note exists.""" + with patch("fabledassistant.services.briefing_profile.list_notes", new_callable=AsyncMock) as mock_list: + mock_list.return_value = ([], 0) + from fabledassistant.services.briefing_profile import get_profile_body + body = await get_profile_body(user_id=1) + assert body == "" + + +def test_format_task_for_briefing(): + """format_task() should return a compact single-line string.""" + from fabledassistant.services.briefing_pipeline import format_task + task = {"title": "Write design doc", "status": "in_progress", "due_date": "2026-03-12", "priority": "high"} + result = format_task(task) + assert "Write design doc" in result + assert "in_progress" in result or "In Progress" in result + + +def test_slot_greeting(): + """slot_greeting() should return different strings for different slot names.""" + from fabledassistant.services.briefing_pipeline import slot_greeting + assert "morning" in slot_greeting("compilation").lower() or slot_greeting("compilation") + assert slot_greeting("morning") != slot_greeting("midday") + assert slot_greeting("midday") != slot_greeting("afternoon")