feat(briefing): hard-cut tear-down

Backend:
- Delete briefing services (pipeline, scheduler, conversations, profile, tools)
- Delete routes/briefing.py + remove blueprint registration
- Move _get_temp_unit into services/weather.get_temp_unit (reads top-level temp_unit setting)
- Rename briefing_preferences.py → rss_filtering.py (functions are RSS-specific)
- Strip briefing scheduler hooks from app.py
- Strip briefing scheduler call from routes/settings.py
- Update test imports (test_rss_service, test_tz_helpers)

Frontend:
- Delete BriefingView, BriefingSetupWizard, BriefingToolStatusRow
- Strip /briefing route + nav links (AppHeader, KnowledgeView)
- Strip Settings → Briefing tab + state + functions + imports
- Strip briefing-intermediate handling from ChatMessage
- Hide /news route + nav links (NewsView depended on briefing endpoints; orphaned in tree)
- Drop unused useSettingsStore from AppHeader

The Android BriefingScreen lives in a separate repo and is not touched here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 22:33:37 -04:00
parent d352e9264b
commit 7602bf2293
24 changed files with 28 additions and 4026 deletions
-40
View File
@@ -1,40 +0,0 @@
def test_conversation_has_briefing_fields():
"""Conversation model must declare conversation_type and briefing_date."""
from fabledassistant.models.conversation import Conversation
cols = {c.key for c in Conversation.__table__.columns}
assert "conversation_type" in cols
assert "briefing_date" in cols
def test_rss_models_exist():
from fabledassistant.models.rss_feed import RssFeed, RssItem
feed_cols = {c.key for c in RssFeed.__table__.columns}
item_cols = {c.key for c in RssItem.__table__.columns}
assert {"id", "user_id", "url", "title", "last_fetched_at"} <= feed_cols
assert {"id", "feed_id", "guid", "title", "url", "published_at", "content"} <= item_cols
def test_weather_cache_model_exists():
from fabledassistant.models.weather_cache import WeatherCache
cols = {c.key for c in WeatherCache.__table__.columns}
assert {"id", "user_id", "location_key", "location_label", "forecast_json", "previous_json", "fetched_at"} <= cols
def test_message_metadata_field_exists():
from fabledassistant.models.conversation import Message
# DB column is named 'metadata'; Python attribute is 'msg_metadata'
# (SQLAlchemy reserves 'metadata' as an attribute name on mapped classes)
col_names = {c.name for c in Message.__table__.columns}
assert "metadata" in col_names
m = Message(conversation_id=1, role="assistant", content="hi", msg_metadata={"foo": 1})
assert m.msg_metadata == {"foo": 1}
def test_rss_item_topics_field_exists():
from fabledassistant.models.rss_feed import RssItem
cols = {c.key for c in RssItem.__table__.columns}
assert "topics" in cols
assert "classified_at" in cols
-39
View File
@@ -1,39 +0,0 @@
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 == ""
@pytest.mark.asyncio
async def test_post_message_accepts_metadata():
"""post_message should accept an optional metadata dict and store it."""
from unittest.mock import AsyncMock, patch, MagicMock
mock_msg = MagicMock()
mock_msg.id = 1
with patch("fabledassistant.services.briefing_conversations.async_session") as mock_session_cls:
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_session.add = MagicMock()
mock_session.get = AsyncMock(return_value=None)
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
mock_session_cls.return_value = mock_session
from fabledassistant.services.briefing_conversations import post_message
metadata = {"weather": {"location": "Berlin"}, "rss_item_ids": [1, 2]}
await post_message(1, "assistant", "Hello", metadata=metadata)
# Verify Message was constructed with msg_metadata
call_args = mock_session.add.call_args[0][0]
assert call_args.msg_metadata == metadata
-114
View File
@@ -1,114 +0,0 @@
"""Tests for briefing_scheduler slot gating and work-day gate."""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
def _make_scheduler():
"""Return a mock APScheduler with tracked job IDs."""
sched = MagicMock()
sched.get_job.return_value = None
return sched
def test_add_user_jobs_skips_disabled_slots():
"""_add_user_jobs should not register jobs for slots set to False in config."""
from fabledassistant.services import briefing_scheduler as bs
mock_sched = _make_scheduler()
bs._scheduler = mock_sched
bs._loop = MagicMock()
config = {"slots": {"compilation": True, "morning": False, "midday": False, "afternoon": False}}
bs._add_user_jobs(user_id=1, tz="UTC", config=config)
added_ids = [call.kwargs.get("id") or call.args[3] for call in mock_sched.add_job.call_args_list]
# Only compilation should be scheduled
assert any("compilation" in jid for jid in added_ids)
assert not any("morning" in jid for jid in added_ids)
assert not any("midday" in jid for jid in added_ids)
assert not any("afternoon" in jid for jid in added_ids)
def test_add_user_jobs_schedules_all_when_config_missing():
"""_add_user_jobs with no config should schedule all 4 slots (safe default)."""
from fabledassistant.services import briefing_scheduler as bs
mock_sched = _make_scheduler()
bs._scheduler = mock_sched
bs._loop = MagicMock()
bs._add_user_jobs(user_id=1, tz="UTC", config=None)
added_ids = [
call.kwargs.get("id") or call.args[3]
for call in mock_sched.add_job.call_args_list
]
assert any("compilation" in jid for jid in added_ids)
assert any("morning" in jid for jid in added_ids)
assert any("midday" in jid for jid in added_ids)
assert any("afternoon" in jid for jid in added_ids)
@pytest.mark.asyncio
async def test_run_slot_skips_morning_on_non_work_day():
"""_run_slot_for_user should return early for morning slot when today is not a work day."""
from fabledassistant.services import briefing_scheduler as bs
fake_profile = MagicMock()
fake_profile.work_schedule = {"days": ["Mon", "Tue", "Wed", "Thu", "Fri"]}
# Patch datetime at module level (it's imported at module level in briefing_scheduler)
# Patch get_setting and get_profile at their source modules (they're lazily imported)
with patch("fabledassistant.services.briefing_scheduler.datetime") as mock_dt, \
patch("fabledassistant.services.user_profile.get_profile", new_callable=AsyncMock, return_value=fake_profile), \
patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="UTC"), \
patch("fabledassistant.services.briefing_pipeline.run_slot_injection", new_callable=AsyncMock) as mock_inject:
mock_now = MagicMock()
mock_now.strftime.return_value = "Sat"
mock_dt.now.return_value = mock_now
await bs._run_slot_for_user(user_id=1, slot="morning")
mock_inject.assert_not_called()
@pytest.mark.asyncio
async def test_run_slot_morning_runs_on_work_day():
"""_run_slot_for_user should proceed for morning slot when today is a work day."""
from fabledassistant.services import briefing_scheduler as bs
fake_profile = MagicMock()
fake_profile.work_schedule = {"days": ["Mon", "Tue", "Wed", "Thu", "Fri"]}
with patch("fabledassistant.services.briefing_scheduler.datetime") as mock_dt, \
patch("fabledassistant.services.user_profile.get_profile", new_callable=AsyncMock, return_value=fake_profile), \
patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="UTC"), \
patch("fabledassistant.services.briefing_conversations.get_or_create_today_conversation", new_callable=AsyncMock) as mock_conv, \
patch("fabledassistant.services.briefing_pipeline.run_slot_injection", new_callable=AsyncMock, return_value=("", {})) as mock_inject, \
patch("fabledassistant.services.briefing_conversations.post_message", new_callable=AsyncMock), \
patch("fabledassistant.services.push.send_push_notification", new_callable=AsyncMock):
mock_now = MagicMock()
mock_now.strftime.return_value = "Mon"
mock_dt.now.return_value = mock_now
mock_conv.return_value = MagicMock(id=1)
await bs._run_slot_for_user(user_id=1, slot="morning")
mock_inject.assert_called_once()
@pytest.mark.asyncio
async def test_settings_put_timezone_updates_scheduler():
"""Saving user_timezone should call update_user_schedule when briefing is enabled."""
import json
from fabledassistant.services.briefing_scheduler import update_user_schedule
enabled_config = json.dumps({"enabled": True, "slots": {}, "locations": {}})
config = json.loads(enabled_config)
with patch("fabledassistant.services.briefing_scheduler._add_user_jobs") as mock_add:
update_user_schedule(1, config, tz_override="America/New_York")
mock_add.assert_called_once()
call_args = mock_add.call_args
assert call_args.args[0] == 1 # user_id
assert call_args.args[2] == config # config passed through
+3 -3
View File
@@ -84,7 +84,7 @@ async def test_classify_items_batch_handles_llm_failure():
def test_score_rss_items_excludes_blacklisted_topics():
"""Items with excluded topics should be removed."""
from fabledassistant.services.briefing_preferences import score_and_filter_items
from fabledassistant.services.rss_filtering import score_and_filter_items
items = [
{"id": 1, "title": "Tech news", "topics": ["technology"], "published_at": "2026-03-25T08:00:00"},
@@ -104,7 +104,7 @@ def test_score_rss_items_excludes_blacklisted_topics():
def test_score_rss_items_boosts_included_topics():
"""Items matching include_topics should rank higher than neutral items."""
from fabledassistant.services.briefing_preferences import score_and_filter_items
from fabledassistant.services.rss_filtering import score_and_filter_items
items = [
{"id": 1, "title": "Random news", "topics": ["other"], "published_at": "2026-03-25T07:00:00"},
@@ -122,7 +122,7 @@ def test_score_rss_items_boosts_included_topics():
def test_score_rss_items_no_preferences_returns_all():
"""With no preferences, all items should be returned sorted by recency."""
from fabledassistant.services.briefing_preferences import score_and_filter_items
from fabledassistant.services.rss_filtering import score_and_filter_items
items = [
{"id": 1, "title": "A", "topics": [], "published_at": "2026-03-24T10:00:00"},
+6 -6
View File
@@ -26,12 +26,12 @@ async def test_get_user_tz_falls_back_to_utc_on_bad_input():
@pytest.mark.asyncio
async def test_user_briefing_date_before_4am_returns_yesterday():
"""00:0003:59 local still shows yesterday's briefing day."""
async def test_user_day_date_before_4am_returns_yesterday():
"""00:0003:59 local still shows yesterday's day_date."""
from fabledassistant.services import tz as tz_mod
ny = ZoneInfo("America/New_York")
# 02:00 NY on 2026-04-13 → briefing day = 2026-04-12
# 02:00 NY on 2026-04-13 → day_date = 2026-04-12 (still pre-rollover)
fake_now = datetime(2026, 4, 13, 2, 0, tzinfo=ny)
class _FakeDatetime(datetime):
@@ -41,12 +41,12 @@ async def test_user_briefing_date_before_4am_returns_yesterday():
with patch.object(tz_mod, "get_user_tz", AsyncMock(return_value=ny)), \
patch.object(tz_mod, "datetime", _FakeDatetime):
day = await tz_mod.user_briefing_date(1)
day = await tz_mod.user_day_date(1)
assert day.isoformat() == "2026-04-12"
@pytest.mark.asyncio
async def test_user_briefing_date_after_4am_returns_today():
async def test_user_day_date_after_4am_returns_today():
from fabledassistant.services import tz as tz_mod
ny = ZoneInfo("America/New_York")
@@ -59,5 +59,5 @@ async def test_user_briefing_date_after_4am_returns_today():
with patch.object(tz_mod, "get_user_tz", AsyncMock(return_value=ny)), \
patch.object(tz_mod, "datetime", _FakeDatetime):
day = await tz_mod.user_briefing_date(1)
day = await tz_mod.user_day_date(1)
assert day.isoformat() == "2026-04-13"