refactor: Phase 8 — backend deletion (chat / voice / push / journal / curator)
Mega-commit. Strips all server-side LLM machinery now that Phase 7 has
removed the corresponding UI surfaces and the MCP HTTP endpoint is the
sole assistant interface.
Deleted (services/):
chat, generation_buffer, generation_log, generation_task, llm, tools/
(entire package), stt, tts, voice_config, voice_library, push,
journal_closeout, journal_pipeline, journal_prep, journal_scheduler,
journal_search, curator, curator_scheduler, consolidation,
tag_suggestions, research, weather, article_fetcher, pending_actions,
moments, assist, wikipedia.
Deleted (routes/):
chat, voice, push, journal, quick_capture, fable_mcp_dist.
Deleted (models/):
conversation, generation_tool_log, push_subscription,
pending_curator_action, moment, weather_cache.
Deleted (tests/):
test_generation_log, test_journal_*, test_consolidation, test_lookup_tool,
test_notes_consolidation_trigger, test_record_moment_guards,
test_research_pipeline, test_tools_*, test_tool_use_fixes,
test_voice_library, test_weather_service, test_calendar_tool_tz,
test_wikipedia.
Deleted (top-level):
fable-mcp/ (legacy standalone stdio package — wheel-build pipeline
also removed from Dockerfile).
app.py:
- blueprint registrations for the 6 deleted routes
- startup hook trimmed: no more Ollama warmup, KV-cache priming,
journal/curator schedulers, voice model loading
- shutdown hook simplified
- httpx import dropped (was for Ollama calls)
pyproject.toml:
- removed deps: pywebpush, feedparser, html2text, trafilatura
- removed [voice] extras entirely
- description updated for the MCP-first architecture
Dockerfile:
- removed faster-whisper / piper-tts install steps
- removed bundled piper voice download stage
- removed fable-mcp wheel build stage
Surviving-file edits:
- services/auth.py: drop Conversation table claim on first-user setup
- services/backup.py: drop conversation / push-subscription export+restore;
v1/v2 restore now silently skip pre-pivot conversation data
- services/notes.py: drop maybe_consolidate trigger on task done/cancelled;
drop _maybe_trigger_project_summary (LLM auto-summary)
- services/projects.py: drop generate_project_summary + backfill_project_summaries
(both LLM-driven)
- services/user_profile.py: drop append_observations / consolidate /
clear_learned_data (curator-tied) and build_profile_context
(was LLM system-prompt builder)
- services/notifications.py: stub out _fire_push_notif (was send_push_notification)
- services/event_scheduler.py: drop event-reminder push + chat-retention
cleanup job; keep CalDAV pull-sync + reminders job (in-app)
- services/diagnostics.py: _curator_busy() always False
- routes/notes.py: drop /assist, /assist/stream, /suggest-tags endpoints
- routes/tasks.py: drop /<id>/consolidate endpoint
- routes/settings.py: drop /models, KV-cache-prime-on-save, journal-schedule
timezone hook, and the SearXNG search-test endpoint; inline _is_private_url
(was in services/llm.py)
- routes/admin.py: drop /voice, /voice/reload endpoints
- routes/profile.py: drop /consolidate, /observations (GET, DELETE)
- models/__init__.py: drop the 6 dead model imports
Frontend cascade:
- stores/push.ts: deleted entirely (no callers after Phase 7)
- stores/settings.ts: drop checkVoiceStatus + voice-status state
- views/SettingsView.vue: drop Locations section + journalConfig state
(was tied to /api/journal/config); drop JournalConfig + journal/voice
api/client imports
- frontend/api/client.ts: orphaned voice/journal/profile-observation/
fable-mcp-dist exports are left as dead but harmless (call them and
they 404; type-check is clean).
Pre-existing v1 backups that contained conversations/messages still
restore — those tables are silently dropped from the import path.
Anyone pulling the new image with a populated database will need the
Phase 9 migration to drop the dead tables (coming next).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,795 +0,0 @@
|
||||
"""Regression tests: calendar tool respects the user's configured timezone."""
|
||||
|
||||
from datetime import timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_all_day_bare_date_interprets_local_midnight():
|
||||
"""Bare date like '2026-09-30' for a NY user = 2026-09-30 00:00 NY,
|
||||
stored as 2026-09-30 04:00 UTC — NOT 2026-09-30 00:00 UTC (which would
|
||||
be 2026-09-29 19:00 NY and land on the wrong day)."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1, **{k: v for k, v in kwargs.items() if not callable(v)}}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "Birthday", "start": "2026-09-30"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
start_dt = captured["start_dt"]
|
||||
assert start_dt.tzinfo is not None
|
||||
# Converted to UTC: 00:00 NY (EDT, UTC-4) == 04:00 UTC on the same day
|
||||
utc = start_dt.astimezone(timezone.utc)
|
||||
assert (utc.year, utc.month, utc.day) == (2026, 9, 30)
|
||||
assert utc.hour == 4 # EDT offset; would be 5 in EST
|
||||
assert captured["all_day"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_naive_datetime_is_user_local():
|
||||
"""'2026-03-15T14:30' for a NY user means 2:30pm NY, not 2:30pm UTC."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "Meeting", "start": "2026-03-15T14:30"},
|
||||
)
|
||||
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
# 14:30 NY on 2026-03-15 (after DST start) = 18:30 UTC
|
||||
assert (utc.year, utc.month, utc.day, utc.hour, utc.minute) == (2026, 3, 15, 18, 30)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_explicit_offset_is_respected():
|
||||
"""Model-supplied timezone offsets must not be overridden by user TZ."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "Meeting", "start": "2026-03-15T14:30+00:00"},
|
||||
)
|
||||
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
assert (utc.hour, utc.minute) == (14, 30)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_bare_date_range_covers_local_day():
|
||||
"""date_from/date_to as bare dates should cover the full LOCAL day."""
|
||||
from fabledassistant.services.tools.calendar import list_events_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_list_events(*, user_id, date_from, date_to):
|
||||
captured["date_from"] = date_from
|
||||
captured["date_to"] = date_to
|
||||
return []
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_list_events",
|
||||
side_effect=fake_list_events,
|
||||
):
|
||||
await list_events_tool(
|
||||
user_id=1,
|
||||
arguments={"date_from": "2026-09-30", "date_to": "2026-09-30"},
|
||||
)
|
||||
|
||||
df = captured["date_from"].astimezone(timezone.utc)
|
||||
dt = captured["date_to"].astimezone(timezone.utc)
|
||||
# 2026-09-30 00:00 NY (EDT) = 04:00 UTC same day
|
||||
assert (df.year, df.month, df.day, df.hour) == (2026, 9, 30, 4)
|
||||
# 2026-09-30 23:59:59 NY (EDT) = 03:59:59 UTC next day
|
||||
assert (dt.year, dt.month, dt.day, dt.hour) == (2026, 10, 1, 3)
|
||||
|
||||
|
||||
# ── Split date/time field tests (durable shape) ──────────────────────────────
|
||||
#
|
||||
# These exercise the start_date + start_time path that the model is now
|
||||
# steered toward. The split-field shape is structurally immune to the
|
||||
# class of bugs where a model emits a TZ-tagged combined datetime that
|
||||
# the parser correctly honors but lands on the wrong calendar day for
|
||||
# negative-offset users.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_fields_friday_8am_no_drift_eastern():
|
||||
"""The reported bug: 'next Friday at 8am' for a NY user must land on
|
||||
2026-05-01 08:00 NY, never 04-30 19:00. With split fields the model
|
||||
can't TZ-tag the date string, so the calendar day is fixed."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Meeting",
|
||||
"start_date": "2026-05-01",
|
||||
"start_time": "08:00",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
# 08:00 NY (EDT, UTC-4) on 2026-05-01 = 12:00 UTC same day
|
||||
assert (utc.year, utc.month, utc.day, utc.hour, utc.minute) == (2026, 5, 1, 12, 0)
|
||||
assert captured["all_day"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_fields_no_drift_pacific():
|
||||
"""Same scenario for a UTC-8 user — the calendar day must be 5/1
|
||||
regardless of how big the offset gets."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/Los_Angeles")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Standup",
|
||||
"start_date": "2026-05-01",
|
||||
"start_time": "08:00",
|
||||
},
|
||||
)
|
||||
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
# 08:00 LA (PDT, UTC-7) = 15:00 UTC same day
|
||||
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 15)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_fields_no_drift_positive_offset():
|
||||
"""A positive-offset user (Tokyo, UTC+9) — 08:00 Tokyo on 2026-05-01
|
||||
is 23:00 UTC on 2026-04-30, but the local calendar day must still be
|
||||
stored as 2026-05-01 from the user's perspective."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("Asia/Tokyo")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Sync",
|
||||
"start_date": "2026-05-01",
|
||||
"start_time": "08:00",
|
||||
},
|
||||
)
|
||||
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
# 08:00 Tokyo (UTC+9) = 23:00 UTC previous day; the round-trip back
|
||||
# to Tokyo TZ recovers 2026-05-01 08:00.
|
||||
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 4, 30, 23)
|
||||
tokyo = captured["start_dt"].astimezone(__import__("zoneinfo").ZoneInfo("Asia/Tokyo"))
|
||||
assert (tokyo.year, tokyo.month, tokyo.day, tokyo.hour) == (2026, 5, 1, 8)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_date_only_is_all_day():
|
||||
"""Omitting start_time means the event is all-day; cache row uses
|
||||
local midnight."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "Birthday", "start_date": "2026-09-30"},
|
||||
)
|
||||
|
||||
assert captured["all_day"] is True
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 9, 30, 4)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_rejects_tz_suffix_in_date():
|
||||
"""The whole point of split fields: a TZ suffix on the date string
|
||||
must be rejected, not silently honored."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "x", "start_date": "2026-05-01Z", "start_time": "08:00"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "YYYY-MM-DD" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_rejects_tz_suffix_in_time():
|
||||
"""A TZ suffix on the time string must also be rejected."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "x", "start_date": "2026-05-01", "start_time": "08:00 UTC"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "HH:MM" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_legacy_combined_start_still_works():
|
||||
"""Backcompat: saved tool-call payloads using the old `start` field
|
||||
must still produce the same UTC datetime as before."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "Legacy", "start": "2026-05-01T08:00"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 12)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_split_fields_reschedule_no_drift():
|
||||
"""update_event with split fields must drift no calendar day either."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_find(*, user_id, query):
|
||||
ev = AsyncMock()
|
||||
ev.id = 99
|
||||
ev.title = "Coffee"
|
||||
return [ev]
|
||||
|
||||
async def fake_update(*, user_id, event_id, **fields):
|
||||
captured.update(fields)
|
||||
ev = AsyncMock()
|
||||
ev.to_dict.return_value = {"id": event_id, **fields}
|
||||
return ev
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.find_events_by_query",
|
||||
side_effect=fake_find,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_update_event",
|
||||
side_effect=fake_update,
|
||||
):
|
||||
result = await update_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"query": "Coffee",
|
||||
"start_date": "2026-05-01",
|
||||
"start_time": "08:00",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 12)
|
||||
|
||||
|
||||
# ── expected_weekday verification (catches "this Friday" → Thursday bugs) ────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_expected_weekday_match_succeeds():
|
||||
"""When expected_weekday agrees with the resolved local date's
|
||||
weekday, the event is created normally."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Meeting",
|
||||
"start_date": "2026-05-01", # is a Friday
|
||||
"start_time": "08:00",
|
||||
"expected_weekday": "friday",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["start_dt"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_expected_weekday_mismatch_rejects_and_names_actual():
|
||||
"""The reported failure mode: model picks Thursday and calls it
|
||||
Friday. With expected_weekday set, the create is rejected and the
|
||||
error names the actual weekday so the model can self-correct."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
create_called = False
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
nonlocal create_called
|
||||
create_called = True
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Dentist",
|
||||
"start_date": "2026-04-30", # Thursday
|
||||
"start_time": "08:00",
|
||||
"expected_weekday": "friday",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Thursday" in result["error"]
|
||||
assert "Friday" in result["error"]
|
||||
# Critical: the event must NOT have been created when the check failed.
|
||||
assert create_called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_expected_weekday_omitted_skips_check():
|
||||
"""Backcompat: when expected_weekday isn't passed, no validation
|
||||
runs — the existing create flow is unchanged."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "x",
|
||||
"start_date": "2026-04-30",
|
||||
"start_time": "08:00",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_expected_weekday_invalid_value_rejects():
|
||||
"""Garbage in expected_weekday produces a clear validation error,
|
||||
not a silent pass."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "x",
|
||||
"start_date": "2026-05-01",
|
||||
"start_time": "08:00",
|
||||
"expected_weekday": "fri", # abbreviation not accepted
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "weekday" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_expected_weekday_mismatch_rejects():
|
||||
"""update_event must enforce the same weekday check on reschedules."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
update_called = False
|
||||
|
||||
async def fake_find(*, user_id, query):
|
||||
ev = AsyncMock()
|
||||
ev.id = 99
|
||||
ev.title = "Coffee"
|
||||
return [ev]
|
||||
|
||||
async def fake_update(*, user_id, event_id, **fields):
|
||||
nonlocal update_called
|
||||
update_called = True
|
||||
ev = AsyncMock()
|
||||
ev.to_dict.return_value = {"id": event_id}
|
||||
return ev
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.find_events_by_query",
|
||||
side_effect=fake_find,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_update_event",
|
||||
side_effect=fake_update,
|
||||
):
|
||||
result = await update_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"query": "Coffee",
|
||||
"start_date": "2026-04-30", # Thursday
|
||||
"start_time": "08:00",
|
||||
"expected_weekday": "friday",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Thursday" in result["error"]
|
||||
assert update_called is False
|
||||
|
||||
|
||||
# ── Multi-match disambiguation (Fable #161) ──────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_refuses_ambiguous_query_with_candidates():
|
||||
"""The reported failure: model called update_event(query='Appointment')
|
||||
when two events matched. Pre-fix: silently mutated matches[0] (the
|
||||
wrong event). Post-fix: returns success=False with a candidates list
|
||||
so the model can pick the right one."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
# Two events both matching "Appointment"
|
||||
ev_a = AsyncMock()
|
||||
ev_a.id = 2
|
||||
ev_a.title = "Appointment"
|
||||
ev_a.start_dt = __import__("datetime").datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
|
||||
ev_a.location = ""
|
||||
ev_b = AsyncMock()
|
||||
ev_b.id = 15
|
||||
ev_b.title = "Appointment"
|
||||
ev_b.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
ev_b.location = "Dentist"
|
||||
|
||||
update_called = False
|
||||
|
||||
async def fake_find(*, user_id, query):
|
||||
return [ev_a, ev_b]
|
||||
|
||||
async def fake_update(*, user_id, event_id, **fields):
|
||||
nonlocal update_called
|
||||
update_called = True
|
||||
return AsyncMock()
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.find_events_by_query",
|
||||
side_effect=fake_find,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_update_event",
|
||||
side_effect=fake_update,
|
||||
):
|
||||
result = await update_event_tool(
|
||||
user_id=1,
|
||||
arguments={"query": "Appointment", "title": "Dentist"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Found 2 events" in result["error"]
|
||||
assert "event_id" in result["error"].lower()
|
||||
assert "candidates" in result
|
||||
candidate_ids = [c["id"] for c in result["candidates"]]
|
||||
assert candidate_ids == [2, 15]
|
||||
# Critical: nothing was mutated.
|
||||
assert update_called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_with_event_id_skips_query_lookup():
|
||||
"""Once the model picks a candidate via event_id, the call proceeds
|
||||
against that exact event — no query, no ambiguity."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
# find_events_by_query should NOT be called when event_id is supplied
|
||||
find_called = False
|
||||
|
||||
async def fake_find(*, user_id, query):
|
||||
nonlocal find_called
|
||||
find_called = True
|
||||
return []
|
||||
|
||||
target = AsyncMock()
|
||||
target.id = 15
|
||||
target.title = "Appointment"
|
||||
target.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
target.location = ""
|
||||
|
||||
async def fake_get_event(*, user_id, event_id):
|
||||
return target if event_id == 15 else None
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_update(*, user_id, event_id, **fields):
|
||||
captured["event_id"] = event_id
|
||||
captured.update(fields)
|
||||
ev = AsyncMock()
|
||||
ev.to_dict.return_value = {"id": event_id, **fields}
|
||||
return ev
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.find_events_by_query",
|
||||
side_effect=fake_find,
|
||||
), patch(
|
||||
"fabledassistant.services.events.get_event",
|
||||
side_effect=fake_get_event,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_update_event",
|
||||
side_effect=fake_update,
|
||||
):
|
||||
result = await update_event_tool(
|
||||
user_id=1,
|
||||
arguments={"event_id": 15, "title": "Dentist: Permanent Crown Fitting"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["event_id"] == 15
|
||||
assert captured["title"] == "Dentist: Permanent Crown Fitting"
|
||||
assert find_called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_with_event_id_not_found_returns_error():
|
||||
"""A bad event_id (non-existent) must surface clearly, not silently
|
||||
fall back to query-based lookup."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
async def fake_get_event(*, user_id, event_id):
|
||||
return None
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.events.get_event",
|
||||
side_effect=fake_get_event,
|
||||
):
|
||||
result = await update_event_tool(
|
||||
user_id=1,
|
||||
arguments={"event_id": 999, "title": "anything"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "999" in result["error"]
|
||||
assert "No event found" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_neither_query_nor_event_id_returns_error():
|
||||
"""Calling update_event with neither identifier is a usage error."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
result = await update_event_tool(user_id=1, arguments={"title": "x"})
|
||||
assert result["success"] is False
|
||||
assert "query or event_id" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_event_refuses_ambiguous_query_with_candidates():
|
||||
"""delete_event must enforce the same disambiguation. Costly to get
|
||||
wrong — silent matches[0] would delete the wrong event entirely."""
|
||||
from fabledassistant.services.tools.calendar import delete_event_tool
|
||||
|
||||
ev_a = AsyncMock()
|
||||
ev_a.id = 2; ev_a.title = "Appointment"
|
||||
ev_a.start_dt = __import__("datetime").datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
|
||||
ev_a.location = ""
|
||||
ev_b = AsyncMock()
|
||||
ev_b.id = 15; ev_b.title = "Appointment"
|
||||
ev_b.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
ev_b.location = "Dentist"
|
||||
|
||||
delete_called = False
|
||||
|
||||
async def fake_find(*, user_id, query):
|
||||
return [ev_a, ev_b]
|
||||
|
||||
async def fake_delete(*, user_id, event_id):
|
||||
nonlocal delete_called
|
||||
delete_called = True
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.find_events_by_query",
|
||||
side_effect=fake_find,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_delete_event",
|
||||
side_effect=fake_delete,
|
||||
):
|
||||
result = await delete_event_tool(
|
||||
user_id=1, arguments={"query": "Appointment"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert len(result["candidates"]) == 2
|
||||
# Most important assertion: nothing was actually deleted.
|
||||
assert delete_called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_weekday_check_uses_local_not_utc():
|
||||
"""The weekday check must use the LOCAL date, not the UTC date.
|
||||
A late-evening Friday event in Tokyo (UTC+9) crosses midnight UTC,
|
||||
so a UTC-day check would call it Saturday — but the user's calendar
|
||||
says Friday. The check must respect the user's local view."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("Asia/Tokyo")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Friday night",
|
||||
"start_date": "2026-05-01", # Friday in Tokyo
|
||||
"start_time": "23:00", # 14:00 UTC same day; safe
|
||||
"expected_weekday": "friday",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
@@ -1,317 +0,0 @@
|
||||
"""Tests for the task-body consolidation pipeline (gate + full pass).
|
||||
|
||||
Design: docs/superpowers/specs/2026-05-13-task-as-durable-record-design.md
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
# ── Gate logic ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_maybe_consolidate_below_threshold_does_not_fire():
|
||||
"""log_added with only 2 logs since last pass → no consolidation."""
|
||||
from fabledassistant.services import consolidation
|
||||
|
||||
with patch.object(
|
||||
consolidation, "_logs_since_last_consolidation",
|
||||
new=AsyncMock(return_value=2),
|
||||
), patch.object(
|
||||
consolidation, "consolidate_task", new=AsyncMock(),
|
||||
) as mock_consolidate, patch.object(
|
||||
consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
|
||||
):
|
||||
await consolidation.maybe_consolidate(
|
||||
user_id=1, task_id=42, reason="log_added",
|
||||
)
|
||||
|
||||
mock_consolidate.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_maybe_consolidate_at_threshold_fires():
|
||||
"""log_added with N logs since last pass → consolidation scheduled."""
|
||||
from fabledassistant.services import consolidation
|
||||
|
||||
# asyncio.create_task is the actual scheduler; replace it so the test
|
||||
# doesn't need an event-loop background task to settle.
|
||||
with patch.object(
|
||||
consolidation, "_logs_since_last_consolidation",
|
||||
new=AsyncMock(return_value=3),
|
||||
), patch.object(
|
||||
consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.asyncio.create_task",
|
||||
) as mock_create_task:
|
||||
await consolidation.maybe_consolidate(
|
||||
user_id=1, task_id=42, reason="log_added",
|
||||
)
|
||||
|
||||
mock_create_task.assert_called_once()
|
||||
|
||||
|
||||
async def test_maybe_consolidate_task_closed_always_fires():
|
||||
"""task_closed bypasses the log-count gate."""
|
||||
from fabledassistant.services import consolidation
|
||||
|
||||
with patch.object(
|
||||
consolidation, "_logs_since_last_consolidation",
|
||||
new=AsyncMock(return_value=0),
|
||||
), patch.object(
|
||||
consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.asyncio.create_task",
|
||||
) as mock_create_task:
|
||||
await consolidation.maybe_consolidate(
|
||||
user_id=1, task_id=42, reason="task_closed",
|
||||
)
|
||||
|
||||
mock_create_task.assert_called_once()
|
||||
|
||||
|
||||
async def test_maybe_consolidate_setting_off_blocks_both_reasons():
|
||||
"""auto_consolidate_tasks=false → neither trigger fires."""
|
||||
from fabledassistant.services import consolidation
|
||||
|
||||
with patch.object(
|
||||
consolidation, "_logs_since_last_consolidation",
|
||||
new=AsyncMock(return_value=5),
|
||||
), patch.object(
|
||||
consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=False),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.asyncio.create_task",
|
||||
) as mock_create_task:
|
||||
await consolidation.maybe_consolidate(
|
||||
user_id=1, task_id=42, reason="log_added",
|
||||
)
|
||||
await consolidation.maybe_consolidate(
|
||||
user_id=1, task_id=42, reason="task_closed",
|
||||
)
|
||||
|
||||
mock_create_task.assert_not_called()
|
||||
|
||||
|
||||
async def test_maybe_consolidate_unknown_reason_is_noop():
|
||||
"""Unknown reasons get logged and skipped — not raised."""
|
||||
from fabledassistant.services import consolidation
|
||||
|
||||
with patch.object(
|
||||
consolidation, "_logs_since_last_consolidation",
|
||||
new=AsyncMock(return_value=99),
|
||||
), patch.object(
|
||||
consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.asyncio.create_task",
|
||||
) as mock_create_task:
|
||||
await consolidation.maybe_consolidate(
|
||||
user_id=1, task_id=42, reason="some_other_reason",
|
||||
)
|
||||
|
||||
mock_create_task.assert_not_called()
|
||||
|
||||
|
||||
# ── Prompt builder ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_build_consolidation_prompt_includes_title_goal_and_logs():
|
||||
from fabledassistant.services.consolidation import _build_consolidation_prompt
|
||||
|
||||
logs = [
|
||||
SimpleNamespace(
|
||||
content="tried REJECT, logs noisy",
|
||||
created_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
SimpleNamespace(
|
||||
content="switched to DROP",
|
||||
created_at=datetime(2026, 5, 13, 11, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
prompt = _build_consolidation_prompt(
|
||||
title="firewall tuning", description="quiet the logs", logs=logs,
|
||||
)
|
||||
assert "firewall tuning" in prompt
|
||||
assert "quiet the logs" in prompt
|
||||
assert "tried REJECT" in prompt
|
||||
assert "switched to DROP" in prompt
|
||||
|
||||
|
||||
def test_build_consolidation_prompt_handles_missing_description():
|
||||
from fabledassistant.services.consolidation import _build_consolidation_prompt
|
||||
|
||||
logs = [
|
||||
SimpleNamespace(
|
||||
content="a", created_at=datetime(2026, 5, 13, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
prompt = _build_consolidation_prompt(title="t", description=None, logs=logs)
|
||||
assert "no goal recorded" in prompt
|
||||
|
||||
|
||||
def test_build_consolidation_prompt_truncates_to_char_budget():
|
||||
from fabledassistant.services.consolidation import (
|
||||
_build_consolidation_prompt, MAX_PROMPT_INPUT_CHARS,
|
||||
)
|
||||
|
||||
big = "x" * (MAX_PROMPT_INPUT_CHARS + 1000)
|
||||
logs = [
|
||||
SimpleNamespace(
|
||||
content=big, created_at=datetime(2026, 5, 13, tzinfo=timezone.utc),
|
||||
),
|
||||
SimpleNamespace(
|
||||
content="should be dropped",
|
||||
created_at=datetime(2026, 5, 13, 1, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
prompt = _build_consolidation_prompt(title="t", description=None, logs=logs)
|
||||
# First log consumes the budget; the second is dropped.
|
||||
assert "should be dropped" not in prompt
|
||||
|
||||
|
||||
# ── consolidate_task orchestration (mocked DB + LLM + embedding) ─────────────
|
||||
|
||||
|
||||
def _make_mock_session(task, logs):
|
||||
"""Return a mock async_session that hands out task and logs to successive
|
||||
.execute() calls. consolidate_task calls execute three times: select task,
|
||||
select logs (same context), then re-select task in the write-back block."""
|
||||
mock_session = AsyncMock()
|
||||
|
||||
task_result = MagicMock()
|
||||
task_result.scalars.return_value.first.return_value = task
|
||||
logs_result = MagicMock()
|
||||
logs_result.scalars.return_value.all.return_value = logs
|
||||
|
||||
mock_session.execute = AsyncMock(
|
||||
side_effect=[task_result, logs_result, task_result],
|
||||
)
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.refresh = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
return mock_session
|
||||
|
||||
|
||||
async def test_consolidate_task_writes_body_and_timestamp_and_reembeds():
|
||||
task = MagicMock()
|
||||
task.id = 42
|
||||
task.title = "Cert renewal"
|
||||
task.description = "renew LE before Nov 30"
|
||||
task.status = "in_progress"
|
||||
task.body = "old body"
|
||||
task.consolidated_at = None
|
||||
logs = [
|
||||
SimpleNamespace(
|
||||
content="tried certbot renew",
|
||||
created_at=datetime(2026, 5, 13, tzinfo=timezone.utc),
|
||||
),
|
||||
SimpleNamespace(
|
||||
content="switched to manual",
|
||||
created_at=datetime(2026, 5, 13, 1, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
fake_summary = (
|
||||
"Started with certbot renew; DNS split-horizon failed. "
|
||||
"Switched to manual flow."
|
||||
)
|
||||
|
||||
# Each consolidate_task call needs its own per-task lock state.
|
||||
# _locks is module-level defaultdict — patch it on the module to avoid
|
||||
# cross-test leakage of the held-lock state.
|
||||
from collections import defaultdict
|
||||
import asyncio as _asyncio
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.consolidation._locks",
|
||||
new=defaultdict(_asyncio.Lock),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.async_session",
|
||||
return_value=_make_mock_session(task, logs),
|
||||
), patch(
|
||||
"fabledassistant.services.llm.generate_completion",
|
||||
new=AsyncMock(return_value=fake_summary),
|
||||
) as mock_llm, patch(
|
||||
"fabledassistant.services.embeddings.upsert_note_embedding",
|
||||
new=AsyncMock(),
|
||||
) as mock_embed, patch(
|
||||
"fabledassistant.services.settings.get_setting",
|
||||
new=AsyncMock(return_value="gemma3:4b"),
|
||||
):
|
||||
from fabledassistant.services.consolidation import consolidate_task
|
||||
await consolidate_task(1, 42)
|
||||
|
||||
mock_llm.assert_awaited_once()
|
||||
mock_embed.assert_awaited_once()
|
||||
assert task.body == fake_summary
|
||||
assert task.consolidated_at is not None
|
||||
|
||||
|
||||
async def test_consolidate_task_llm_failure_leaves_body_untouched():
|
||||
task = MagicMock()
|
||||
task.id = 42
|
||||
task.title = "X"
|
||||
task.description = "y"
|
||||
task.status = "in_progress"
|
||||
task.body = "old body content"
|
||||
task.consolidated_at = None
|
||||
logs = [
|
||||
SimpleNamespace(
|
||||
content="a", created_at=datetime(2026, 5, 13, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
from collections import defaultdict
|
||||
import asyncio as _asyncio
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.consolidation._locks",
|
||||
new=defaultdict(_asyncio.Lock),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.async_session",
|
||||
return_value=_make_mock_session(task, logs),
|
||||
), patch(
|
||||
"fabledassistant.services.llm.generate_completion",
|
||||
new=AsyncMock(side_effect=RuntimeError("ollama down")),
|
||||
), patch(
|
||||
"fabledassistant.services.embeddings.upsert_note_embedding",
|
||||
new=AsyncMock(),
|
||||
) as mock_embed, patch(
|
||||
"fabledassistant.services.settings.get_setting",
|
||||
new=AsyncMock(return_value="gemma3:4b"),
|
||||
):
|
||||
from fabledassistant.services.consolidation import consolidate_task
|
||||
await consolidate_task(1, 42) # must not raise
|
||||
|
||||
assert task.body == "old body content"
|
||||
assert task.consolidated_at is None
|
||||
mock_embed.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_consolidate_task_skips_when_no_logs():
|
||||
task = MagicMock()
|
||||
task.id = 42
|
||||
task.title = "X"
|
||||
task.description = "y"
|
||||
task.status = "in_progress"
|
||||
task.body = "old"
|
||||
task.consolidated_at = None
|
||||
|
||||
from collections import defaultdict
|
||||
import asyncio as _asyncio
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.consolidation._locks",
|
||||
new=defaultdict(_asyncio.Lock),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.async_session",
|
||||
return_value=_make_mock_session(task, []),
|
||||
), patch(
|
||||
"fabledassistant.services.llm.generate_completion", new=AsyncMock(),
|
||||
) as mock_llm, patch(
|
||||
"fabledassistant.services.settings.get_setting",
|
||||
new=AsyncMock(return_value="gemma3:4b"),
|
||||
):
|
||||
from fabledassistant.services.consolidation import consolidate_task
|
||||
await consolidate_task(1, 42)
|
||||
|
||||
mock_llm.assert_not_awaited()
|
||||
assert task.body == "old"
|
||||
@@ -1,180 +0,0 @@
|
||||
"""Unit tests for the tool-call telemetry normalization logic.
|
||||
|
||||
The DB-touching path of `log_tool_outcomes` requires a running PostgreSQL,
|
||||
so we test the pure normalization by stubbing the session. The shape
|
||||
contract that `generation_task.py` actually emits is:
|
||||
|
||||
{"function": <name>, "arguments": ..., "result": <dict>, "status": ...}
|
||||
|
||||
with `result["success"]` being False for failures and `result["error"]`
|
||||
the message. We verify the helper splits these correctly into the
|
||||
attempted / succeeded / failed buckets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_tool_outcomes_splits_success_and_failure():
|
||||
"""A mix of success and error entries should split into the right buckets."""
|
||||
captured: dict = {}
|
||||
|
||||
class _StubSession:
|
||||
def __init__(self):
|
||||
self.added = []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
def add(self, row):
|
||||
self.added.append(row)
|
||||
captured["row"] = row
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
stub = _StubSession()
|
||||
|
||||
from fabledassistant.services import generation_log
|
||||
|
||||
with patch.object(generation_log, "async_session", lambda: stub):
|
||||
await generation_log.log_tool_outcomes(
|
||||
user_id=1,
|
||||
conv_id=42,
|
||||
assistant_message_id=99,
|
||||
model="qwen3:8b",
|
||||
think_enabled=False,
|
||||
tools_available=["record_moment", "search_notes", "create_task"],
|
||||
tool_calls=[
|
||||
{
|
||||
"function": "record_moment",
|
||||
"arguments": {},
|
||||
"result": {"success": True, "moment_id": 17},
|
||||
"status": "success",
|
||||
},
|
||||
{
|
||||
"function": "create_task",
|
||||
"arguments": {},
|
||||
"result": {"success": False, "error": "missing required field 'title'"},
|
||||
"status": "error",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
row = captured["row"]
|
||||
assert row.user_id == 1
|
||||
assert row.conv_id == 42
|
||||
assert row.assistant_message_id == 99
|
||||
assert row.model == "qwen3:8b"
|
||||
assert row.think_enabled is False
|
||||
assert row.tools_available == ["create_task", "record_moment", "search_notes"]
|
||||
assert row.tools_attempted == ["record_moment", "create_task"]
|
||||
assert row.tools_succeeded == ["record_moment"]
|
||||
assert row.tools_failed == [{"name": "create_task", "error": "missing required field 'title'"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_tool_outcomes_handles_empty_tool_calls():
|
||||
"""A turn with no tool calls produces an empty-attempted row, not an exception."""
|
||||
captured: dict = {}
|
||||
|
||||
class _StubSession:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
def add(self, row):
|
||||
captured["row"] = row
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
from fabledassistant.services import generation_log
|
||||
|
||||
with patch.object(generation_log, "async_session", lambda: _StubSession()):
|
||||
await generation_log.log_tool_outcomes(
|
||||
user_id=1,
|
||||
conv_id=42,
|
||||
assistant_message_id=99,
|
||||
model="mistral-small:22b",
|
||||
think_enabled=False,
|
||||
tools_available=["record_moment"],
|
||||
tool_calls=[],
|
||||
)
|
||||
|
||||
row = captured["row"]
|
||||
assert row.tools_attempted == []
|
||||
assert row.tools_succeeded == []
|
||||
assert row.tools_failed == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_tool_outcomes_swallows_exceptions():
|
||||
"""Telemetry failure must NOT propagate — it would break user-facing flow."""
|
||||
from fabledassistant.services import generation_log
|
||||
|
||||
bad_session = MagicMock()
|
||||
bad_session.return_value.__aenter__ = AsyncMock(side_effect=RuntimeError("db down"))
|
||||
|
||||
with patch.object(generation_log, "async_session", bad_session):
|
||||
# Should NOT raise.
|
||||
await generation_log.log_tool_outcomes(
|
||||
user_id=1,
|
||||
conv_id=42,
|
||||
assistant_message_id=99,
|
||||
model="qwen3:8b",
|
||||
think_enabled=False,
|
||||
tools_available=[],
|
||||
tool_calls=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_tool_outcomes_detects_success_false_without_error_field():
|
||||
"""A tool result with success=False but no error string should still go to failed."""
|
||||
captured: dict = {}
|
||||
|
||||
class _StubSession:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
def add(self, row):
|
||||
captured["row"] = row
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
from fabledassistant.services import generation_log
|
||||
|
||||
with patch.object(generation_log, "async_session", lambda: _StubSession()):
|
||||
await generation_log.log_tool_outcomes(
|
||||
user_id=1,
|
||||
conv_id=42,
|
||||
assistant_message_id=99,
|
||||
model="qwen3:8b",
|
||||
think_enabled=False,
|
||||
tools_available=["search_notes"],
|
||||
tool_calls=[
|
||||
{
|
||||
"function": "search_notes",
|
||||
"arguments": {},
|
||||
"result": {"success": False}, # no explicit error field
|
||||
"status": "success", # but status incorrectly says success
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
row = captured["row"]
|
||||
assert row.tools_succeeded == []
|
||||
assert row.tools_failed == [{"name": "search_notes", "error": "unspecified"}]
|
||||
@@ -1,249 +0,0 @@
|
||||
"""Tests for journal closeout extraction helpers."""
|
||||
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _msg(role: str, content: str, kind: str | None = None):
|
||||
"""Build a Message-like stand-in for the filter helpers."""
|
||||
metadata = {"kind": kind} if kind else None
|
||||
return SimpleNamespace(role=role, content=content, msg_metadata=metadata)
|
||||
|
||||
|
||||
def _fake_conv(conv_id: int = 1):
|
||||
return SimpleNamespace(id=conv_id)
|
||||
|
||||
|
||||
def _patch_db(messages, conv=None):
|
||||
"""Build a context manager that fakes async_session() and the two
|
||||
select() calls (conversation lookup + messages query)."""
|
||||
session = AsyncMock()
|
||||
session.execute = AsyncMock()
|
||||
conv_result = MagicMock()
|
||||
conv_result.scalar_one_or_none = MagicMock(return_value=conv)
|
||||
msg_result = MagicMock()
|
||||
scalars = MagicMock()
|
||||
scalars.all = MagicMock(return_value=list(messages))
|
||||
msg_result.scalars = MagicMock(return_value=scalars)
|
||||
session.execute.side_effect = [conv_result, msg_result]
|
||||
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__aenter__ = AsyncMock(return_value=session)
|
||||
session_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
return session_ctx
|
||||
|
||||
|
||||
def test_filter_excludes_daily_prep_messages():
|
||||
from fabledassistant.services.journal_closeout import _filter_messages
|
||||
|
||||
msgs = [
|
||||
_msg("assistant", "Good morning — here is today's plan…", kind="daily_prep"),
|
||||
_msg("user", "I want to focus on the auth refactor today."),
|
||||
_msg("assistant", "Got it. I'll keep tool calls quiet."),
|
||||
]
|
||||
kept = _filter_messages(msgs)
|
||||
contents = [m.content for m in kept]
|
||||
assert "Good morning — here is today's plan…" not in contents
|
||||
assert "I want to focus on the auth refactor today." in contents
|
||||
assert "Got it. I'll keep tool calls quiet." in contents
|
||||
|
||||
|
||||
def test_build_transcript_labels_roles_and_caps_content():
|
||||
from fabledassistant.services.journal_closeout import _build_transcript
|
||||
|
||||
msgs = [
|
||||
_msg("user", "hello"),
|
||||
_msg("assistant", "hi"),
|
||||
_msg("user", "x" * 600),
|
||||
]
|
||||
out = _build_transcript(msgs)
|
||||
lines = out.splitlines()
|
||||
assert lines[0] == "USER: hello"
|
||||
assert lines[1] == "ASSISTANT: hi"
|
||||
# Third line content truncated to 500 chars
|
||||
assert lines[2].startswith("USER: ")
|
||||
assert len(lines[2]) == len("USER: ") + 500
|
||||
|
||||
|
||||
def test_build_transcript_keeps_only_last_20_messages():
|
||||
from fabledassistant.services.journal_closeout import _build_transcript
|
||||
|
||||
msgs = [_msg("user", f"msg-{i}") for i in range(30)]
|
||||
out = _build_transcript(msgs)
|
||||
lines = out.splitlines()
|
||||
assert len(lines) == 20
|
||||
# Newest 20 means msg-10 through msg-29
|
||||
assert lines[0] == "USER: msg-10"
|
||||
assert lines[-1] == "USER: msg-29"
|
||||
|
||||
|
||||
def test_system_prompt_lists_structured_fields_to_exclude():
|
||||
"""The prompt must explicitly tell the LLM not to restate structured
|
||||
fields, so the freeform learned_summary stays a narrow lane."""
|
||||
from fabledassistant.services.journal_closeout import SYSTEM_PROMPT
|
||||
|
||||
text = SYSTEM_PROMPT.lower()
|
||||
for field in ("name", "job title", "industry", "expertise", "response style", "tone", "interests"):
|
||||
assert field in text, f"system prompt should mention '{field}'"
|
||||
assert "(nothing to note)" in SYSTEM_PROMPT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_for_user_happy_path_appends_bullets():
|
||||
from fabledassistant.services import journal_closeout
|
||||
|
||||
yesterday = datetime.date(2026, 5, 11)
|
||||
msgs = [
|
||||
_msg("assistant", "Good morning — here's today.", kind="daily_prep"),
|
||||
_msg("user", "Skip the news section — I never read it."),
|
||||
_msg("assistant", "Noted. Will drop it."),
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs, conv=_fake_conv())),
|
||||
patch.object(journal_closeout, "get_setting", new=AsyncMock(return_value="bg-model")),
|
||||
patch.object(journal_closeout, "generate_completion", new=AsyncMock(return_value="- User skips news section")),
|
||||
patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
|
||||
):
|
||||
await journal_closeout.run_for_user(user_id=42, yesterday=yesterday)
|
||||
|
||||
mock_append.assert_awaited_once_with(42, "- User skips news section")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_for_user_skips_when_no_conversation():
|
||||
from fabledassistant.services import journal_closeout
|
||||
|
||||
with (
|
||||
patch.object(journal_closeout, "async_session", return_value=_patch_db([], conv=None)),
|
||||
patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
|
||||
patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm,
|
||||
):
|
||||
await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
|
||||
|
||||
mock_append.assert_not_awaited()
|
||||
mock_llm.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_for_user_skips_when_only_prep_message_after_filter():
|
||||
"""If only the daily_prep message exists, the in-Python filter pass
|
||||
leaves zero messages and we short-circuit before the LLM."""
|
||||
from fabledassistant.services import journal_closeout
|
||||
|
||||
msgs_post_sql = [
|
||||
_msg("assistant", "Daily prep block.", kind="daily_prep"),
|
||||
]
|
||||
with (
|
||||
patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs_post_sql, conv=_fake_conv())),
|
||||
patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
|
||||
patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm,
|
||||
):
|
||||
await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
|
||||
|
||||
mock_append.assert_not_awaited()
|
||||
mock_llm.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_for_user_respects_nothing_to_note_sentinel():
|
||||
from fabledassistant.services import journal_closeout
|
||||
|
||||
msgs = [_msg("user", "hi"), _msg("assistant", "hi back")]
|
||||
with (
|
||||
patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs, conv=_fake_conv())),
|
||||
patch.object(journal_closeout, "get_setting", new=AsyncMock(return_value="bg-model")),
|
||||
patch.object(journal_closeout, "generate_completion", new=AsyncMock(return_value="(nothing to note)")),
|
||||
patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
|
||||
):
|
||||
await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
|
||||
|
||||
mock_append.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_schedule_registers_closeout_when_enabled(monkeypatch):
|
||||
from fabledassistant.services import journal_scheduler as sched
|
||||
|
||||
fake_scheduler = MagicMock()
|
||||
fake_scheduler.get_job = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(sched, "_scheduler", fake_scheduler)
|
||||
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
||||
"prep_enabled": False, # isolate the closeout job
|
||||
"closeout_enabled": True,
|
||||
"day_rollover_hour": 4,
|
||||
}))
|
||||
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
||||
|
||||
await sched.update_user_schedule(user_id=7)
|
||||
|
||||
added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list]
|
||||
assert "journal_closeout_7" in added
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_schedule_does_not_register_closeout_when_disabled(monkeypatch):
|
||||
from fabledassistant.services import journal_scheduler as sched
|
||||
|
||||
fake_scheduler = MagicMock()
|
||||
fake_scheduler.get_job = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(sched, "_scheduler", fake_scheduler)
|
||||
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
||||
"prep_enabled": False,
|
||||
"closeout_enabled": False,
|
||||
"day_rollover_hour": 4,
|
||||
}))
|
||||
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
||||
|
||||
await sched.update_user_schedule(user_id=7)
|
||||
|
||||
added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list]
|
||||
assert "journal_closeout_7" not in added
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_closeout_catchup_skips_when_already_have_entry(monkeypatch):
|
||||
from fabledassistant.services import journal_scheduler as sched
|
||||
|
||||
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
||||
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
||||
"closeout_enabled": True,
|
||||
"day_rollover_hour": 0, # slot has always passed
|
||||
}))
|
||||
yesterday = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)).date()
|
||||
fake_profile = SimpleNamespace(observations_raw=[{"date": yesterday.isoformat(), "bullets": "..."}])
|
||||
|
||||
from fabledassistant.services import user_profile as up
|
||||
monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile))
|
||||
|
||||
run_mock = AsyncMock()
|
||||
from fabledassistant.services import journal_closeout as jc
|
||||
monkeypatch.setattr(jc, "run_for_user", run_mock)
|
||||
|
||||
await sched._closeout_catchup(user_id=7)
|
||||
run_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_closeout_catchup_runs_when_no_entry_yet(monkeypatch):
|
||||
from fabledassistant.services import journal_scheduler as sched
|
||||
|
||||
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
||||
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
||||
"closeout_enabled": True,
|
||||
"day_rollover_hour": 0,
|
||||
}))
|
||||
fake_profile = SimpleNamespace(observations_raw=[])
|
||||
|
||||
from fabledassistant.services import user_profile as up
|
||||
monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile))
|
||||
|
||||
run_mock = AsyncMock()
|
||||
from fabledassistant.services import journal_closeout as jc
|
||||
monkeypatch.setattr(jc, "run_for_user", run_mock)
|
||||
|
||||
await sched._closeout_catchup(user_id=7)
|
||||
run_mock.assert_awaited_once()
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Regression coverage for the journal `message_count: 0` bug.
|
||||
|
||||
`_day_payload` (routes/journal.py) loads messages in a separate query and
|
||||
serializes the Conversation with `conv.to_dict()`. `Conversation.to_dict()`
|
||||
derives `message_count` from the `messages` relationship, which is NOT
|
||||
eager-loaded on that instance — so it silently fell back to 0 for every
|
||||
journal day (observed via the fable MCP: conversation #291 reported
|
||||
message_count 0 with 9 messages present).
|
||||
|
||||
Full HTTP coverage of `_day_payload` needs a live DB (not available in the
|
||||
unit-test env — see test_events_routes.py). These tests pin the model-level
|
||||
contract that necessitates the route-side override.
|
||||
"""
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
|
||||
|
||||
def _conv() -> Conversation:
|
||||
now = datetime(2026, 5, 19, 9, 0, tzinfo=timezone.utc)
|
||||
return Conversation(
|
||||
user_id=1,
|
||||
conversation_type="journal",
|
||||
day_date=date(2026, 5, 19),
|
||||
title="2026-05-19",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
def test_to_dict_reports_zero_when_messages_relationship_not_loaded():
|
||||
"""The trap: an untouched `messages` relationship is absent from the
|
||||
instance state, so to_dict() reports 0 regardless of DB rows. This is
|
||||
exactly the journal path's situation and why the route must override."""
|
||||
conv = _conv()
|
||||
assert conv.to_dict()["message_count"] == 0
|
||||
|
||||
|
||||
def test_to_dict_counts_when_messages_loaded():
|
||||
"""When the relationship IS populated the count is correct — confirming
|
||||
the model isn't broken, the journal path just never loaded it."""
|
||||
conv = _conv()
|
||||
conv.messages = [
|
||||
Message(conversation_id=1, role="assistant", content="prep"),
|
||||
Message(conversation_id=1, role="user", content="hi"),
|
||||
]
|
||||
assert conv.to_dict()["message_count"] == 2
|
||||
|
||||
|
||||
def test_day_payload_override_yields_true_count():
|
||||
"""Pins the fix contract: _day_payload already holds the real message
|
||||
list and overrides message_count with len(messages), the same way the
|
||||
chat-list path (services/chat.py) supplies its own count."""
|
||||
conv = _conv() # messages relationship deliberately not loaded
|
||||
messages = [object(), object(), object()] # stand-ins for the loaded rows
|
||||
|
||||
conv_dict = conv.to_dict()
|
||||
conv_dict["message_count"] = len(messages)
|
||||
|
||||
assert conv_dict["message_count"] == 3
|
||||
@@ -1,195 +0,0 @@
|
||||
"""Tests for the journal prep filtering helpers added in #159."""
|
||||
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── _task_to_prep_dict ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_task_to_prep_dict_marks_overdue_with_days_count():
|
||||
from fabledassistant.services.journal_prep import _task_to_prep_dict
|
||||
|
||||
today = datetime.date(2026, 4, 29)
|
||||
task = SimpleNamespace(
|
||||
id=2, title="Research Weston's ADHD Evaluation",
|
||||
status="todo", priority="high",
|
||||
due_date=datetime.date(2026, 2, 20),
|
||||
)
|
||||
d = _task_to_prep_dict(task, today)
|
||||
assert d["days_overdue"] == 68
|
||||
assert d["due_date"] == "2026-02-20"
|
||||
|
||||
|
||||
def test_task_to_prep_dict_due_today_no_overdue_marker():
|
||||
from fabledassistant.services.journal_prep import _task_to_prep_dict
|
||||
|
||||
today = datetime.date(2026, 4, 29)
|
||||
task = SimpleNamespace(
|
||||
id=10, title="Pick up dry cleaning",
|
||||
status="todo", priority="none",
|
||||
due_date=today,
|
||||
)
|
||||
d = _task_to_prep_dict(task, today)
|
||||
assert "days_overdue" not in d
|
||||
|
||||
|
||||
def test_task_to_prep_dict_handles_no_due_date():
|
||||
from fabledassistant.services.journal_prep import _task_to_prep_dict
|
||||
|
||||
task = SimpleNamespace(
|
||||
id=11, title="Long-running side project",
|
||||
status="in_progress", priority="medium",
|
||||
due_date=None,
|
||||
)
|
||||
d = _task_to_prep_dict(task, datetime.date(2026, 4, 29))
|
||||
assert d["due_date"] is None
|
||||
assert "days_overdue" not in d
|
||||
|
||||
|
||||
# ── _filter_proximate_events ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_filter_proximate_drops_far_future_recurring_event():
|
||||
"""The reported failure: a recurring Birthday event with start_dt
|
||||
2026-09-29 surfaced in every daily prep. The proximity filter drops it."""
|
||||
from fabledassistant.services.journal_prep import _filter_proximate_events
|
||||
|
||||
events = [
|
||||
{"id": 13, "title": "Birthday", "start_dt": "2026-09-29T00:00:00+00:00"},
|
||||
]
|
||||
kept = _filter_proximate_events(
|
||||
events,
|
||||
day_date=datetime.date(2026, 4, 29),
|
||||
user_tz=ZoneInfo("America/New_York"),
|
||||
)
|
||||
assert kept == []
|
||||
|
||||
|
||||
def test_filter_proximate_keeps_events_within_window():
|
||||
"""Events within ±7 days of the prep date stay."""
|
||||
from fabledassistant.services.journal_prep import _filter_proximate_events
|
||||
|
||||
events = [
|
||||
{"id": 1, "title": "Today", "start_dt": "2026-04-29T13:00:00+00:00"},
|
||||
{"id": 2, "title": "Tomorrow", "start_dt": "2026-04-30T12:00:00+00:00"},
|
||||
{"id": 3, "title": "Next week", "start_dt": "2026-05-05T15:00:00+00:00"},
|
||||
{"id": 4, "title": "Two weeks out", "start_dt": "2026-05-15T15:00:00+00:00"},
|
||||
]
|
||||
kept = _filter_proximate_events(
|
||||
events,
|
||||
day_date=datetime.date(2026, 4, 29),
|
||||
user_tz=ZoneInfo("America/New_York"),
|
||||
)
|
||||
kept_ids = [e["id"] for e in kept]
|
||||
assert 1 in kept_ids
|
||||
assert 2 in kept_ids
|
||||
assert 3 in kept_ids
|
||||
assert 4 not in kept_ids
|
||||
|
||||
|
||||
def test_filter_proximate_uses_local_date_not_utc():
|
||||
"""An event at 04:30 UTC on 2026-04-30 = 00:30 local on 2026-04-30 in
|
||||
NY (EDT, UTC-4). Local date is 2026-04-30, delta from 2026-04-29 = 1
|
||||
day. Must NOT cross-classify based on UTC date alone."""
|
||||
from fabledassistant.services.journal_prep import _filter_proximate_events
|
||||
|
||||
events = [
|
||||
{"id": 99, "title": "Late-night dentist", "start_dt": "2026-04-30T04:30:00+00:00"},
|
||||
]
|
||||
kept = _filter_proximate_events(
|
||||
events,
|
||||
day_date=datetime.date(2026, 4, 29),
|
||||
user_tz=ZoneInfo("America/New_York"),
|
||||
)
|
||||
assert len(kept) == 1
|
||||
|
||||
|
||||
def test_filter_proximate_keeps_unparseable_dates():
|
||||
"""Bad date strings are kept rather than silently suppressing real
|
||||
events on a parser bug."""
|
||||
from fabledassistant.services.journal_prep import _filter_proximate_events
|
||||
|
||||
events = [
|
||||
{"id": 50, "title": "Garbage", "start_dt": "not-a-date"},
|
||||
{"id": 51, "title": "Empty", "start_dt": ""},
|
||||
{"id": 52, "title": "Missing"},
|
||||
]
|
||||
kept = _filter_proximate_events(
|
||||
events,
|
||||
day_date=datetime.date(2026, 4, 29),
|
||||
user_tz=ZoneInfo("America/New_York"),
|
||||
)
|
||||
assert len(kept) == 3
|
||||
|
||||
|
||||
# ── _render_sections_for_prompt — overdue framing ────────────────────────────
|
||||
|
||||
|
||||
def test_render_overdue_includes_staleness_duration():
|
||||
"""The rendered prompt block must surface days_overdue so the LLM
|
||||
can frame stale tasks correctly."""
|
||||
from fabledassistant.services.journal_prep import _render_sections_for_prompt
|
||||
|
||||
sections = {
|
||||
"tasks_due_today": [],
|
||||
"tasks_upcoming": [],
|
||||
"tasks_overdue": [{
|
||||
"id": 2, "title": "Research Weston's ADHD Evaluation",
|
||||
"status": "todo", "priority": "high",
|
||||
"due_date": "2026-02-20", "days_overdue": 68,
|
||||
}],
|
||||
}
|
||||
rendered = _render_sections_for_prompt(sections)
|
||||
assert "OVERDUE TASKS" in rendered
|
||||
# Unambiguous overdue phrasing (replaced the old "68 days ago", which the
|
||||
# model parroted alongside the header into a self-contradiction).
|
||||
assert "68 days overdue" in rendered
|
||||
assert "2026-02-20" in rendered
|
||||
# Crucially, NOT framed as due today.
|
||||
assert "DUE TODAY" not in rendered
|
||||
|
||||
|
||||
def test_render_due_today_no_due_date_repetition():
|
||||
"""Tasks in the DUE TODAY bucket don't need the (due 2026-04-29)
|
||||
parenthetical — the section header already says 'today'."""
|
||||
from fabledassistant.services.journal_prep import _render_sections_for_prompt
|
||||
|
||||
sections = {
|
||||
"tasks_due_today": [{
|
||||
"id": 1, "title": "Pick up dry cleaning",
|
||||
"status": "todo", "priority": "none",
|
||||
"due_date": "2026-04-29",
|
||||
}],
|
||||
"tasks_upcoming": [],
|
||||
"tasks_overdue": [],
|
||||
}
|
||||
rendered = _render_sections_for_prompt(sections)
|
||||
assert "TASKS DUE TODAY" in rendered
|
||||
assert "Pick up dry cleaning" in rendered
|
||||
# The line itself shouldn't repeat the due date.
|
||||
line = next(
|
||||
(l for l in rendered.splitlines() if "Pick up dry cleaning" in l),
|
||||
"",
|
||||
)
|
||||
assert "2026-04-29" not in line
|
||||
|
||||
|
||||
def test_render_three_buckets_in_correct_order():
|
||||
"""When all three buckets have content, the prompt sees them in
|
||||
DUE TODAY → UPCOMING → OVERDUE order so the LLM leads with today."""
|
||||
from fabledassistant.services.journal_prep import _render_sections_for_prompt
|
||||
|
||||
sections = {
|
||||
"tasks_due_today": [{"id": 1, "title": "Today task", "status": "todo", "priority": "none", "due_date": "2026-04-29"}],
|
||||
"tasks_upcoming": [{"id": 2, "title": "Upcoming task", "status": "todo", "priority": "none", "due_date": "2026-05-02"}],
|
||||
"tasks_overdue": [{"id": 3, "title": "Stale task", "status": "todo", "priority": "none", "due_date": "2026-02-20", "days_overdue": 68}],
|
||||
}
|
||||
rendered = _render_sections_for_prompt(sections)
|
||||
today_idx = rendered.index("TASKS DUE TODAY")
|
||||
upcoming_idx = rendered.index("UPCOMING TASKS")
|
||||
overdue_idx = rendered.index("OVERDUE TASKS")
|
||||
assert today_idx < upcoming_idx < overdue_idx
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Coverage for the daily-prep hardening pass.
|
||||
|
||||
Targets the two prep-quality defects observed via the fable MCP on
|
||||
2026-05-19 (conversation #291):
|
||||
|
||||
1. Fabricated weather ("partly cloudy with a high of 68°F and a 15%
|
||||
chance of rain") emitted with an empty weather section.
|
||||
2. Self-contradictory overdue phrasing ("still on the list, not
|
||||
currently due, 88 days ago"; "1 days ago").
|
||||
|
||||
All targets are pure sync functions — no DB / LLM needed.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from fabledassistant.services.journal_prep import (
|
||||
_prose_fabricated_weather,
|
||||
_render_sections_for_prompt,
|
||||
_render_task_line,
|
||||
)
|
||||
|
||||
TODAY = datetime.date(2026, 5, 19)
|
||||
|
||||
|
||||
# ── Overdue phrasing ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_overdue_line_singular_day():
|
||||
line = _render_task_line(
|
||||
{"title": "Change my oil", "due_date": "2026-05-18", "days_overdue": 1},
|
||||
include_due=False, include_overdue=True,
|
||||
)
|
||||
assert "1 day overdue" in line
|
||||
assert "1 days" not in line # the old ungrammatical form is gone
|
||||
|
||||
|
||||
def test_overdue_line_plural_days_no_contradiction():
|
||||
line = _render_task_line(
|
||||
{"title": "Research X", "due_date": "2026-02-20", "days_overdue": 88},
|
||||
include_due=False, include_overdue=True,
|
||||
)
|
||||
assert "88 days overdue" in line
|
||||
assert "was due 2026-02-20" in line
|
||||
assert "days ago" not in line # replaced by unambiguous "overdue"
|
||||
|
||||
|
||||
def test_overdue_section_header_is_unambiguous():
|
||||
out = _render_sections_for_prompt({
|
||||
"tasks_overdue": [
|
||||
{"title": "Research X", "due_date": "2026-02-20", "days_overdue": 88},
|
||||
],
|
||||
})
|
||||
assert "past their due date, still open" in out
|
||||
# The phrase that the model parroted into the self-contradiction is gone.
|
||||
assert "not currently due" not in out
|
||||
|
||||
|
||||
# ── Weather absent-marker ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_weather_emits_explicit_none_marker():
|
||||
out = _render_sections_for_prompt({
|
||||
"tasks_overdue": [
|
||||
{"title": "Change my oil", "due_date": "2026-05-18", "days_overdue": 1},
|
||||
],
|
||||
"weather": [],
|
||||
})
|
||||
assert "WEATHER: none available" in out
|
||||
assert "Do NOT mention weather" in out
|
||||
|
||||
|
||||
def test_present_weather_renders_data_not_marker():
|
||||
out = _render_sections_for_prompt({
|
||||
"tasks_due_today": [{"title": "Ship it"}],
|
||||
"weather": [{
|
||||
"location_label": "Home",
|
||||
"forecast_json": {"daily": {
|
||||
"temperature_2m_max": [70],
|
||||
"temperature_2m_min": [52],
|
||||
"precipitation_probability_max": [10],
|
||||
}},
|
||||
}],
|
||||
})
|
||||
assert "WEATHER:" in out
|
||||
assert "none available" not in out
|
||||
assert "high 70°" in out
|
||||
|
||||
|
||||
def test_quiet_day_returns_fabrication_forbidding_text():
|
||||
out = _render_sections_for_prompt({})
|
||||
assert "quiet day" in out
|
||||
assert "Do not invent weather" in out
|
||||
|
||||
|
||||
# ── Fabricated-weather guard ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_guard_flags_the_real_observed_hallucination():
|
||||
prose = (
|
||||
"You have two overdue tasks. The weather today is partly cloudy "
|
||||
"with a high of 68°F and a 15% chance of rain. What's on your mind?"
|
||||
)
|
||||
assert _prose_fabricated_weather(prose, {"weather": []}) is True
|
||||
|
||||
|
||||
def test_guard_flags_bare_temperature_glyph():
|
||||
assert _prose_fabricated_weather("Expect around 72° later.", {"weather": []}) is True
|
||||
|
||||
|
||||
def test_guard_passes_clean_prose():
|
||||
prose = "Two tasks are overdue and nothing is due today. What's on your mind?"
|
||||
assert _prose_fabricated_weather(prose, {"weather": []}) is False
|
||||
|
||||
|
||||
def test_guard_no_false_positive_on_rain_in_task_title():
|
||||
# Bare "rain" must NOT trip the guard — only "chance of rain" etc. does.
|
||||
prose = "Don't forget to buy rain boots and finish the training module."
|
||||
assert _prose_fabricated_weather(prose, {"weather": []}) is False
|
||||
|
||||
|
||||
def test_guard_allows_weather_when_data_present():
|
||||
prose = "Home is 70°F with a 10% chance of rain."
|
||||
sections = {"weather": [{"location_label": "Home"}]}
|
||||
assert _prose_fabricated_weather(prose, sections) is False
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Unit tests for the cosine helper inside journal_search."""
|
||||
import math
|
||||
|
||||
from fabledassistant.services.journal_search import _cosine
|
||||
|
||||
|
||||
def test_cosine_identical_vectors():
|
||||
v = [1.0, 2.0, 3.0]
|
||||
assert math.isclose(_cosine(v, v), 1.0, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_cosine_orthogonal_vectors():
|
||||
a = [1.0, 0.0]
|
||||
b = [0.0, 1.0]
|
||||
assert math.isclose(_cosine(a, b), 0.0, abs_tol=1e-9)
|
||||
|
||||
|
||||
def test_cosine_zero_vector_returns_zero():
|
||||
assert _cosine([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]) == 0.0
|
||||
|
||||
|
||||
def test_cosine_empty_returns_zero():
|
||||
assert _cosine([], [1.0]) == 0.0
|
||||
@@ -1,111 +0,0 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fabledassistant.services.tools.web import lookup_tool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_wikipedia_hit():
|
||||
wiki_data = {
|
||||
"title": "QUIC",
|
||||
"extract": "QUIC is a transport layer protocol.",
|
||||
"url": "https://en.wikipedia.org/wiki/QUIC",
|
||||
"thumbnail_url": "",
|
||||
}
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=wiki_data), \
|
||||
patch("fabledassistant.config.Config") as mock_config:
|
||||
mock_config.searxng_enabled.return_value = False
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["type"] == "lookup"
|
||||
assert result["data"]["wikipedia"]["title"] == "QUIC"
|
||||
assert result["data"]["web"] == []
|
||||
assert "image" not in result["data"]["wikipedia"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_wikipedia_miss_searxng_fallback():
|
||||
searxng_results = [
|
||||
{"url": "https://example.com/quic", "title": "QUIC Explained", "snippet": "An overview..."},
|
||||
]
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=None), \
|
||||
patch("fabledassistant.config.Config") as mock_config, \
|
||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \
|
||||
patch("fabledassistant.services.article_fetcher.fetch_article_text", new_callable=AsyncMock, return_value="Full article about QUIC..."):
|
||||
mock_config.searxng_enabled.return_value = True
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"]["wikipedia"] is None
|
||||
assert len(result["data"]["web"]) == 1
|
||||
assert result["data"]["web"][0]["url"] == "https://example.com/quic"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_parallel_both_sources():
|
||||
wiki_data = {
|
||||
"title": "President of the United States",
|
||||
"extract": "The president is the head of state.",
|
||||
"url": "https://en.wikipedia.org/wiki/President_of_the_United_States",
|
||||
"thumbnail_url": "",
|
||||
}
|
||||
searxng_results = [
|
||||
{"url": "https://whitehouse.gov", "title": "The White House", "snippet": "Current admin..."},
|
||||
]
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=wiki_data), \
|
||||
patch("fabledassistant.config.Config") as mock_config, \
|
||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \
|
||||
patch("fabledassistant.services.article_fetcher.fetch_article_text", new_callable=AsyncMock, return_value="Current president is..."):
|
||||
mock_config.searxng_enabled.return_value = True
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "president of the united states"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"]["wikipedia"]["title"] == "President of the United States"
|
||||
assert len(result["data"]["web"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_wikipedia_thumbnail_cached():
|
||||
wiki_data = {
|
||||
"title": "Hedgehog",
|
||||
"extract": "Hedgehogs are spiny mammals.",
|
||||
"url": "https://en.wikipedia.org/wiki/Hedgehog",
|
||||
"thumbnail_url": "https://upload.wikimedia.org/.../Hedgehog.jpg",
|
||||
}
|
||||
mock_record = MagicMock()
|
||||
mock_record.id = 42
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=wiki_data), \
|
||||
patch("fabledassistant.config.Config") as mock_config, \
|
||||
patch("fabledassistant.services.images.fetch_and_store_image", new_callable=AsyncMock, return_value=mock_record):
|
||||
mock_config.searxng_enabled.return_value = False
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "hedgehog"})
|
||||
|
||||
assert result["data"]["wikipedia"]["image"]["embed"] == ""
|
||||
assert "Wikipedia" in result["data"]["wikipedia"]["image"]["citation"]
|
||||
assert "image_instructions" in result["data"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_wikipedia_miss_no_searxng():
|
||||
with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=None), \
|
||||
patch("fabledassistant.config.Config") as mock_config:
|
||||
mock_config.searxng_enabled.return_value = False
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "xyznonexistent"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert "message" in result["data"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_always_available():
|
||||
with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock, return_value=False), \
|
||||
patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="false"):
|
||||
from fabledassistant.services.tools import get_tools_for_user
|
||||
tools = await get_tools_for_user(user_id=1)
|
||||
tool_names = {t["function"]["name"] for t in tools}
|
||||
assert "lookup" in tool_names
|
||||
@@ -1,118 +0,0 @@
|
||||
"""Tests for the status-terminal consolidation trigger in update_note."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
def _mock_session_for_update(mock_note):
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = mock_note
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.refresh = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
return mock_session
|
||||
|
||||
|
||||
async def test_update_note_to_done_triggers_consolidation():
|
||||
"""status: in_progress → done should fire maybe_consolidate(task_closed)."""
|
||||
mock_note = MagicMock()
|
||||
mock_note.id = 42
|
||||
mock_note.status = "in_progress" # pre-update state
|
||||
mock_note.body = ""
|
||||
mock_note.title = ""
|
||||
mock_note.tags = []
|
||||
mock_note.started_at = None
|
||||
mock_note.completed_at = None
|
||||
mock_note.recurrence_rule = None
|
||||
mock_note.project_id = None
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.notes.async_session",
|
||||
return_value=_mock_session_for_update(mock_note),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.maybe_consolidate",
|
||||
new=AsyncMock(),
|
||||
) as mock_mc:
|
||||
from fabledassistant.services.notes import update_note
|
||||
await update_note(1, 42, status="done")
|
||||
|
||||
mock_mc.assert_awaited_once_with(1, 42, reason="task_closed")
|
||||
|
||||
|
||||
async def test_update_note_to_cancelled_triggers_consolidation():
|
||||
mock_note = MagicMock()
|
||||
mock_note.id = 42
|
||||
mock_note.status = "in_progress"
|
||||
mock_note.body = ""
|
||||
mock_note.title = ""
|
||||
mock_note.tags = []
|
||||
mock_note.started_at = None
|
||||
mock_note.completed_at = None
|
||||
mock_note.recurrence_rule = None
|
||||
mock_note.project_id = None
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.notes.async_session",
|
||||
return_value=_mock_session_for_update(mock_note),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.maybe_consolidate",
|
||||
new=AsyncMock(),
|
||||
) as mock_mc:
|
||||
from fabledassistant.services.notes import update_note
|
||||
await update_note(1, 42, status="cancelled")
|
||||
|
||||
mock_mc.assert_awaited_once_with(1, 42, reason="task_closed")
|
||||
|
||||
|
||||
async def test_update_note_to_in_progress_does_not_trigger_consolidation():
|
||||
"""Non-terminal status changes don't fire the trigger."""
|
||||
mock_note = MagicMock()
|
||||
mock_note.id = 42
|
||||
mock_note.status = "todo"
|
||||
mock_note.body = ""
|
||||
mock_note.title = ""
|
||||
mock_note.tags = []
|
||||
mock_note.started_at = None
|
||||
mock_note.completed_at = None
|
||||
mock_note.recurrence_rule = None
|
||||
mock_note.project_id = None
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.notes.async_session",
|
||||
return_value=_mock_session_for_update(mock_note),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.maybe_consolidate",
|
||||
new=AsyncMock(),
|
||||
) as mock_mc:
|
||||
from fabledassistant.services.notes import update_note
|
||||
await update_note(1, 42, status="in_progress")
|
||||
|
||||
mock_mc.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_update_note_already_done_does_not_retrigger():
|
||||
"""If the status was already 'done' and update_note(status='done') runs
|
||||
again, no fresh trigger fires — only transitions count."""
|
||||
mock_note = MagicMock()
|
||||
mock_note.id = 42
|
||||
mock_note.status = "done"
|
||||
mock_note.body = ""
|
||||
mock_note.title = ""
|
||||
mock_note.tags = []
|
||||
mock_note.started_at = None
|
||||
mock_note.completed_at = None
|
||||
mock_note.recurrence_rule = None
|
||||
mock_note.project_id = None
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.notes.async_session",
|
||||
return_value=_mock_session_for_update(mock_note),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.maybe_consolidate",
|
||||
new=AsyncMock(),
|
||||
) as mock_mc:
|
||||
from fabledassistant.services.notes import update_note
|
||||
await update_note(1, 42, status="done")
|
||||
|
||||
mock_mc.assert_not_awaited()
|
||||
@@ -1,181 +0,0 @@
|
||||
"""Tests for the record_moment server-side data-hygiene guards.
|
||||
|
||||
These guard against two failure modes observed in real journal usage:
|
||||
|
||||
1. The LLM emits ``task_titles`` referencing a task that's only in the
|
||||
prep context — not actually mentioned by the user. Without a check,
|
||||
every moment ends up linked to whatever's open in the user's queue.
|
||||
|
||||
2. The LLM emits generic placeholder ``place_names`` like ``"work"`` /
|
||||
``"home"`` instead of real place notes. These role-labels aren't
|
||||
places.
|
||||
|
||||
Both are exercised through the pure helpers; full-stack handler tests
|
||||
would require a session-bound DB fixture this suite doesn't have yet.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_content_keywords_drops_stopwords_and_short_tokens():
|
||||
from fabledassistant.services.tools.journal import _content_keywords
|
||||
|
||||
kw = _content_keywords(
|
||||
"I went to the store to pick up milk and bread for the kids."
|
||||
)
|
||||
# Stopwords (the, to, for, and, i) gone; short tokens (kids? 4 chars - kept)
|
||||
# filtered. Real content words kept.
|
||||
assert "store" in kw
|
||||
assert "milk" in kw
|
||||
assert "bread" in kw
|
||||
assert "kids" in kw
|
||||
assert "the" not in kw
|
||||
assert "to" not in kw
|
||||
assert "for" not in kw
|
||||
assert "i" not in kw
|
||||
|
||||
|
||||
def test_content_keywords_extracts_named_entities():
|
||||
"""Place names and project nouns survive tokenization. Short numeric
|
||||
fragments (e.g. '14') get filtered alongside other <3-char tokens —
|
||||
the surrounding alpha keywords carry the overlap weight in practice."""
|
||||
from fabledassistant.services.tools.journal import _content_keywords
|
||||
|
||||
kw = _content_keywords("Migration prep at Branch 14 Bedford.")
|
||||
assert "branch" in kw
|
||||
assert "bedford" in kw
|
||||
assert "migration" in kw
|
||||
|
||||
|
||||
def test_filter_placeholder_places_drops_generic_role_labels():
|
||||
from fabledassistant.services.tools.journal import _filter_placeholder_places
|
||||
|
||||
kept, dropped = _filter_placeholder_places(
|
||||
["work", "Famous Supply", "home", "Branch 14 Bedford", "the office"]
|
||||
)
|
||||
assert "Famous Supply" in kept
|
||||
assert "Branch 14 Bedford" in kept
|
||||
assert "work" in dropped
|
||||
assert "home" in dropped
|
||||
assert "the office" in dropped
|
||||
|
||||
|
||||
def test_filter_placeholder_places_is_case_insensitive():
|
||||
from fabledassistant.services.tools.journal import _filter_placeholder_places
|
||||
|
||||
kept, dropped = _filter_placeholder_places(["WORK", "Home", "OFFICE"])
|
||||
assert kept == []
|
||||
assert set(dropped) == {"WORK", "Home", "OFFICE"}
|
||||
|
||||
|
||||
def test_filter_placeholder_places_preserves_real_places_named_similarly():
|
||||
"""A user-defined place that happens to be ONE word but isn't a generic
|
||||
role-label (e.g. 'Akron') stays."""
|
||||
from fabledassistant.services.tools.journal import _filter_placeholder_places
|
||||
|
||||
kept, dropped = _filter_placeholder_places(["Akron", "Cleveland"])
|
||||
assert kept == ["Akron", "Cleveland"]
|
||||
assert dropped == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_overlap_drops_unrelated_task_link():
|
||||
"""The reported failure mode: model attached Weston's ADHD task to a
|
||||
Docker-swarm moment. With the keyword guard, the link gets dropped."""
|
||||
from fabledassistant.services.tools.journal import (
|
||||
_filter_task_ids_by_keyword_overlap,
|
||||
)
|
||||
|
||||
# Mock the DB lookup to return the offending task title for id=2.
|
||||
fake_session = AsyncMock()
|
||||
fake_session.execute = AsyncMock()
|
||||
fake_session.execute.return_value.all = lambda: [
|
||||
(2, "Research Weston's ADHD Evaluation"),
|
||||
]
|
||||
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
|
||||
fake_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.journal.async_session",
|
||||
return_value=fake_session,
|
||||
):
|
||||
kept = await _filter_task_ids_by_keyword_overlap(
|
||||
user_id=1,
|
||||
content="Working on restaging Docker in the swarm; encountered network breakage on a Windows node.",
|
||||
task_ids=[2],
|
||||
)
|
||||
|
||||
assert kept == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_overlap_keeps_genuinely_referenced_task():
|
||||
"""When the moment content shares a meaningful keyword with the task
|
||||
title, the link is preserved."""
|
||||
from fabledassistant.services.tools.journal import (
|
||||
_filter_task_ids_by_keyword_overlap,
|
||||
)
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.execute = AsyncMock()
|
||||
fake_session.execute.return_value.all = lambda: [
|
||||
(5, "Restage Docker on Fam-dockerwin04"),
|
||||
]
|
||||
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
|
||||
fake_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.journal.async_session",
|
||||
return_value=fake_session,
|
||||
):
|
||||
kept = await _filter_task_ids_by_keyword_overlap(
|
||||
user_id=1,
|
||||
content="Reinstalled Microsoft container support; Docker restage now works.",
|
||||
task_ids=[5],
|
||||
)
|
||||
|
||||
# 'docker' appears in both → keep
|
||||
assert kept == [5]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_overlap_handles_partial_relevance():
|
||||
"""Mixed ids: keep the related one, drop the unrelated one."""
|
||||
from fabledassistant.services.tools.journal import (
|
||||
_filter_task_ids_by_keyword_overlap,
|
||||
)
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.execute = AsyncMock()
|
||||
fake_session.execute.return_value.all = lambda: [
|
||||
(2, "Research Weston's ADHD Evaluation"),
|
||||
(5, "Restage Docker on Fam-dockerwin04"),
|
||||
]
|
||||
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
|
||||
fake_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.journal.async_session",
|
||||
return_value=fake_session,
|
||||
):
|
||||
kept = await _filter_task_ids_by_keyword_overlap(
|
||||
user_id=1,
|
||||
content="Working on restaging Docker in the swarm.",
|
||||
task_ids=[2, 5],
|
||||
)
|
||||
|
||||
assert kept == [5]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_overlap_empty_task_ids_returns_empty():
|
||||
from fabledassistant.services.tools.journal import (
|
||||
_filter_task_ids_by_keyword_overlap,
|
||||
)
|
||||
|
||||
kept = await _filter_task_ids_by_keyword_overlap(
|
||||
user_id=1, content="anything", task_ids=[],
|
||||
)
|
||||
assert kept == []
|
||||
@@ -1,231 +0,0 @@
|
||||
"""Tests for the multi-note research pipeline."""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_parses_valid_json():
|
||||
"""_generate_outline returns parsed sections when model returns valid JSON."""
|
||||
from fabledassistant.services.research import _generate_outline
|
||||
|
||||
outline_json = json.dumps([
|
||||
{"title": "Section One", "focus": "Focus one"},
|
||||
{"title": "Section Two", "focus": "Focus two"},
|
||||
{"title": "Section Three", "focus": "Focus three"},
|
||||
])
|
||||
with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=outline_json):
|
||||
result = await _generate_outline("test topic", [], "test-model")
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0]["title"] == "Section One"
|
||||
assert result[0]["focus"] == "Focus one"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_returns_empty_on_parse_failure():
|
||||
"""_generate_outline returns [] when model output is not parseable JSON."""
|
||||
from fabledassistant.services.research import _generate_outline
|
||||
|
||||
with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value="not json at all"):
|
||||
result = await _generate_outline("test topic", [], "test-model")
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_returns_empty_when_fewer_than_2_sections():
|
||||
"""_generate_outline returns [] when model returns fewer than 2 valid sections."""
|
||||
from fabledassistant.services.research import _generate_outline
|
||||
|
||||
outline_json = json.dumps([{"title": "Only One", "focus": "Focus"}])
|
||||
with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=outline_json):
|
||||
result = await _generate_outline("test topic", [], "test-model")
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_truncates_to_8():
|
||||
"""_generate_outline truncates results to at most 8 sections."""
|
||||
from fabledassistant.services.research import _generate_outline
|
||||
|
||||
sections = [{"title": f"Section {i}", "focus": f"Focus {i}"} for i in range(10)]
|
||||
with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=json.dumps(sections)):
|
||||
result = await _generate_outline("test topic", [], "test-model")
|
||||
|
||||
assert len(result) == 8
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_skips_malformed_entries():
|
||||
"""_generate_outline filters out entries missing title or focus."""
|
||||
from fabledassistant.services.research import _generate_outline
|
||||
|
||||
outline_json = json.dumps([
|
||||
{"title": "Good One", "focus": "Good focus"},
|
||||
{"title": "Missing focus"},
|
||||
{"focus": "Missing title"},
|
||||
{"title": "Good Two", "focus": "Good focus two"},
|
||||
{"title": "Good Three", "focus": "Good focus three"},
|
||||
])
|
||||
with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=outline_json):
|
||||
result = await _generate_outline("test topic", [], "test-model")
|
||||
|
||||
assert len(result) == 3
|
||||
assert all(s["title"] and s["focus"] for s in result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_synthesize_section_returns_title_and_body():
|
||||
"""_synthesize_section returns (section_title, body) from model output."""
|
||||
from fabledassistant.services.research import _synthesize_section
|
||||
|
||||
with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value="The body of this section."):
|
||||
title, body = await _synthesize_section(
|
||||
section_title="Quantum Entanglement: Mechanisms",
|
||||
section_focus="How entanglement works at the particle level",
|
||||
sources=[],
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
assert title == "Quantum Entanglement: Mechanisms"
|
||||
assert body == "The body of this section."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_synthesize_section_strips_whitespace():
|
||||
"""_synthesize_section strips leading/trailing whitespace from body."""
|
||||
from fabledassistant.services.research import _synthesize_section
|
||||
|
||||
with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value="\n\n body text \n\n"):
|
||||
title, body = await _synthesize_section("Title", "Focus", [], "model")
|
||||
|
||||
assert body == "body text"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_creates_section_notes_and_index():
|
||||
"""run_research_pipeline creates N section notes + 1 index note with links, returns index."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
outline = [
|
||||
{"title": "Section A", "focus": "Focus A"},
|
||||
{"title": "Section B", "focus": "Focus B"},
|
||||
{"title": "Section C", "focus": "Focus C"},
|
||||
]
|
||||
|
||||
note_id_counter = iter(range(10, 20))
|
||||
|
||||
def _make_note(user_id, title, body, tags, project_id=None, parent_id=None):
|
||||
n = MagicMock()
|
||||
n.id = next(note_id_counter)
|
||||
n.title = title
|
||||
return n
|
||||
|
||||
with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
|
||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
|
||||
patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
|
||||
patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline), \
|
||||
patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=lambda t, f, s, m: (t, f"Body for {t}")), \
|
||||
patch("fabledassistant.services.research._generate_executive_summary", new_callable=AsyncMock, return_value="Executive summary text."), \
|
||||
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note), \
|
||||
patch("fabledassistant.services.research.update_note", new_callable=AsyncMock) as mock_update:
|
||||
|
||||
from fabledassistant.services.research import run_research_pipeline
|
||||
result = await run_research_pipeline("test topic", user_id=1, model="test-model")
|
||||
|
||||
assert result.title == "Research: test topic"
|
||||
# Index note body should be updated with section links
|
||||
mock_update.assert_called_once()
|
||||
updated_body = mock_update.call_args.kwargs.get("body", "")
|
||||
assert "/notes/" in updated_body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_falls_back_to_single_note_when_outline_empty():
|
||||
"""run_research_pipeline falls back to single-note synthesis when _generate_outline returns []."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
single_note = MagicMock()
|
||||
single_note.id = 99
|
||||
single_note.title = "Research: test topic"
|
||||
|
||||
with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
|
||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
|
||||
patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
|
||||
patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=[]), \
|
||||
patch("fabledassistant.services.research._synthesize_note", new_callable=AsyncMock, return_value=("Research: test topic", "body")), \
|
||||
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, return_value=single_note):
|
||||
|
||||
from fabledassistant.services.research import run_research_pipeline
|
||||
result = await run_research_pipeline("test topic", user_id=1, model="test-model")
|
||||
|
||||
assert result.id == 99
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_falls_back_when_all_sections_fail():
|
||||
"""run_research_pipeline falls back to single note when all section syntheses raise."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
outline = [
|
||||
{"title": "Section A", "focus": "Focus A"},
|
||||
{"title": "Section B", "focus": "Focus B"},
|
||||
{"title": "Section C", "focus": "Focus C"},
|
||||
]
|
||||
fallback_note = MagicMock()
|
||||
fallback_note.id = 77
|
||||
fallback_note.title = "Research: test topic"
|
||||
|
||||
with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
|
||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
|
||||
patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
|
||||
patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline), \
|
||||
patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=RuntimeError("synthesis failed")), \
|
||||
patch("fabledassistant.services.research._synthesize_note", new_callable=AsyncMock, return_value=("Research: test topic", "body")), \
|
||||
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, return_value=fallback_note):
|
||||
|
||||
from fabledassistant.services.research import run_research_pipeline
|
||||
result = await run_research_pipeline("test topic", user_id=1, model="test-model")
|
||||
|
||||
assert result.id == 77
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_includes_wikipedia_sources():
|
||||
"""run_research_pipeline should merge Wikipedia results into the source pool."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
wiki_results = [{"title": "Wiki Article", "extract": "Wikipedia content about the topic.", "url": "https://en.wikipedia.org/wiki/Topic"}]
|
||||
|
||||
outline = [
|
||||
{"title": "Section A", "focus": "Focus A"},
|
||||
{"title": "Section B", "focus": "Focus B"},
|
||||
]
|
||||
|
||||
note_id_counter = iter(range(30, 40))
|
||||
|
||||
def _make_note(user_id, title, body, tags, project_id=None, parent_id=None):
|
||||
n = MagicMock()
|
||||
n.id = next(note_id_counter)
|
||||
n.title = title
|
||||
return n
|
||||
|
||||
with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
|
||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
|
||||
patch("fabledassistant.services.research.wiki_search", new_callable=AsyncMock, return_value=wiki_results), \
|
||||
patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
|
||||
patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline) as mock_outline, \
|
||||
patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=lambda t, f, s, m: (t, f"Body for {t}")), \
|
||||
patch("fabledassistant.services.research._generate_executive_summary", new_callable=AsyncMock, return_value="Summary."), \
|
||||
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note), \
|
||||
patch("fabledassistant.services.research.update_note", new_callable=AsyncMock):
|
||||
|
||||
from fabledassistant.services.research import run_research_pipeline
|
||||
await run_research_pipeline("test topic", user_id=1, model="test-model")
|
||||
|
||||
# The sources passed to _generate_outline should include the Wikipedia article
|
||||
sources_arg = mock_outline.call_args[0][1] # second positional arg
|
||||
source_urls = [s["url"] for s in sources_arg]
|
||||
assert "https://en.wikipedia.org/wiki/Topic" in source_urls
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Tests for the tool-use fixes from the 2026-05-08 journal session."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _project(pid: int, title: str, description: str = "", auto_summary: str = ""):
|
||||
return SimpleNamespace(
|
||||
id=pid, title=title, description=description, auto_summary=auto_summary,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_projects_tool_returns_success_true():
|
||||
"""The dispatcher sets status from result['success']; without this key
|
||||
the call gets labeled 'error' even when data is returned."""
|
||||
from fabledassistant.services.tools import projects as projects_tool
|
||||
|
||||
fake_projects = [_project(5, "Famous-Supply Work topics", "AT&T fiber circuit")]
|
||||
|
||||
# `list_projects` is imported locally inside search_projects_tool, so we
|
||||
# patch the source module rather than the consumer.
|
||||
with patch("fabledassistant.services.projects.list_projects", new=AsyncMock(return_value=fake_projects)):
|
||||
result = await projects_tool.search_projects_tool(
|
||||
user_id=1, arguments={"query": "famous supply"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["type"] == "projects_list"
|
||||
assert len(result["data"]["projects"]) == 1
|
||||
|
||||
|
||||
def test_strip_type_nouns_removes_task_word():
|
||||
from fabledassistant.services.notes import _strip_type_nouns
|
||||
assert _strip_type_nouns("sebring secondary task") == ["sebring", "secondary"]
|
||||
|
||||
|
||||
def test_strip_type_nouns_removes_all_variants():
|
||||
from fabledassistant.services.notes import _strip_type_nouns
|
||||
assert _strip_type_nouns("project notes task") == []
|
||||
|
||||
|
||||
def test_strip_type_nouns_case_insensitive():
|
||||
from fabledassistant.services.notes import _strip_type_nouns
|
||||
assert _strip_type_nouns("Sebring Task NOTES") == ["Sebring"]
|
||||
|
||||
|
||||
def test_strip_type_nouns_preserves_real_content_words():
|
||||
from fabledassistant.services.notes import _strip_type_nouns
|
||||
assert _strip_type_nouns("circuit configuration") == ["circuit", "configuration"]
|
||||
|
||||
|
||||
def test_strip_type_nouns_handles_empty_string():
|
||||
from fabledassistant.services.notes import _strip_type_nouns
|
||||
assert _strip_type_nouns("") == []
|
||||
assert _strip_type_nouns(" ") == []
|
||||
|
||||
|
||||
def test_score_project_match_exact_title_returns_1():
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
p = _project(5, "Famous-Supply Work topics")
|
||||
assert score_project_match("Famous-Supply Work topics", p) == 1.0
|
||||
|
||||
|
||||
def test_score_project_match_colloquial_substring_at_least_85():
|
||||
"""'famous supply' is a substring of normalized 'famous supply work topics'
|
||||
after stripping the hyphen. Substring match returns 0.85."""
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
p = _project(5, "Famous-Supply Work topics", auto_summary="AT&T fiber circuit")
|
||||
score = score_project_match("famous supply project", p)
|
||||
assert score >= 0.85, f"expected substring tier (>=0.85), got {score}"
|
||||
|
||||
|
||||
def test_score_project_match_query_in_summary_returns_70():
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
p = _project(12, "Minstrel", auto_summary="self-hosted music server")
|
||||
assert score_project_match("music server", p) == 0.70
|
||||
|
||||
|
||||
def test_score_project_match_unrelated_returns_low():
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
p = _project(12, "Minstrel", auto_summary="self-hosted music server")
|
||||
assert score_project_match("garden renovation", p) < 0.5
|
||||
|
||||
|
||||
def test_score_project_match_empty_query_returns_zero():
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
p = _project(5, "Famous-Supply Work topics")
|
||||
assert score_project_match("", p) == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_projects_tool_ranks_substring_match_above_others():
|
||||
"""Substring hits (score 0.85) must rank above SequenceMatcher misses
|
||||
for unrelated projects."""
|
||||
from fabledassistant.services.tools import projects as projects_tool
|
||||
|
||||
fake_projects = [
|
||||
_project(12, "Minstrel", auto_summary="self-hosted music server"),
|
||||
_project(5, "Famous-Supply Work topics", auto_summary="AT&T fiber circuit"),
|
||||
_project(13, "ImageRepo", auto_summary="self-hosted Flask app"),
|
||||
]
|
||||
|
||||
with patch("fabledassistant.services.projects.list_projects", new=AsyncMock(return_value=fake_projects)):
|
||||
result = await projects_tool.search_projects_tool(
|
||||
user_id=1, arguments={"query": "famous supply project"},
|
||||
)
|
||||
|
||||
top = result["data"]["projects"][0]
|
||||
assert top["id"] == 5, f"expected Famous-Supply to rank first, got {top}"
|
||||
assert top["score"] >= 0.85
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_finds_colloquial_match(monkeypatch):
|
||||
"""resolve_project must surface 'Famous-Supply Work topics' when the
|
||||
user passes 'famous supply project' — substring match via the shared
|
||||
score helper, score 0.85 ≥ 0.55 threshold."""
|
||||
from fabledassistant.services.tools import _helpers
|
||||
|
||||
fake_projects = [
|
||||
_project(12, "Minstrel", auto_summary="self-hosted music server"),
|
||||
_project(5, "Famous-Supply Work topics", auto_summary="AT&T fiber circuit"),
|
||||
]
|
||||
|
||||
async def fake_get_project_by_title(uid, name):
|
||||
return None # force the scored path
|
||||
|
||||
async def fake_list_projects(uid):
|
||||
return fake_projects
|
||||
|
||||
monkeypatch.setattr(
|
||||
"fabledassistant.services.projects.get_project_by_title",
|
||||
fake_get_project_by_title,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"fabledassistant.services.projects.list_projects",
|
||||
fake_list_projects,
|
||||
)
|
||||
|
||||
result = await _helpers.resolve_project(user_id=1, project_name="famous supply project")
|
||||
assert result is not None
|
||||
assert result.id == 5
|
||||
@@ -1,200 +0,0 @@
|
||||
"""Tests for the create_note / update_note LLM tools.
|
||||
|
||||
Verifies the new task-as-durable-record contract:
|
||||
- create_note drops `body` when a task is being created (status set).
|
||||
- create_note forwards `description` to the service.
|
||||
- update_note rejects `body` writes when the target is a task.
|
||||
- update_note accepts `description` updates on tasks.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
async def test_create_note_tool_ignores_body_when_creating_task():
|
||||
"""Status present → is_task=True → body must be dropped before the
|
||||
create_note service call; description must be forwarded."""
|
||||
from fabledassistant.services.tools import notes as notes_tool
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_create_note(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
id=1,
|
||||
title=kwargs["title"],
|
||||
status=kwargs.get("status"),
|
||||
priority=kwargs.get("priority"),
|
||||
due_date=None,
|
||||
project_id=None,
|
||||
milestone_id=None,
|
||||
parent_id=None,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.notes.create_note", new=fake_create_note,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.check_duplicate",
|
||||
new=AsyncMock(return_value=None),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.suggest_tags",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.schedule_embedding",
|
||||
):
|
||||
await notes_tool.create_note_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "renew cert",
|
||||
"status": "todo",
|
||||
"description": "the goal text",
|
||||
"body": "this should be dropped on tasks",
|
||||
},
|
||||
)
|
||||
|
||||
assert captured.get("description") == "the goal text"
|
||||
# Body dropped because is_task=True. consolidation owns the body field.
|
||||
assert not captured.get("body")
|
||||
|
||||
|
||||
async def test_create_note_tool_preserves_body_for_knowledge_notes():
|
||||
"""No status → is_task=False → body is preserved as today."""
|
||||
from fabledassistant.services.tools import notes as notes_tool
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_create_note(**kwargs):
|
||||
captured.update(kwargs)
|
||||
# Knowledge-note return path reads note.project_id; SimpleNamespace
|
||||
# needs the attribute even when it's None.
|
||||
return SimpleNamespace(
|
||||
id=1, title=kwargs["title"], status=None, project_id=None,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.notes.create_note", new=fake_create_note,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.check_duplicate",
|
||||
new=AsyncMock(return_value=None),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.suggest_tags",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.schedule_embedding",
|
||||
):
|
||||
await notes_tool.create_note_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "runbook",
|
||||
"body": "preserved markdown content",
|
||||
},
|
||||
)
|
||||
|
||||
assert captured.get("body") == "preserved markdown content"
|
||||
|
||||
|
||||
async def test_update_note_tool_rejects_body_on_tasks():
|
||||
"""When the resolved note has is_task=True, providing body must error."""
|
||||
from fabledassistant.services.tools import notes as notes_tool
|
||||
|
||||
# SimpleNamespace can't fake a @property; build is_task as a real attr.
|
||||
fake_task = SimpleNamespace(
|
||||
id=42, title="t", status="in_progress",
|
||||
body="", tags=[], project_id=None, milestone_id=None,
|
||||
)
|
||||
fake_task.is_task = True
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.notes.get_note_by_title",
|
||||
new=AsyncMock(return_value=fake_task),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.update_note", new=AsyncMock(),
|
||||
) as mock_update:
|
||||
result = await notes_tool.update_note_tool(
|
||||
user_id=1,
|
||||
arguments={"query": "t", "body": "trying to overwrite"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
err = result.get("error", "").lower()
|
||||
assert "body" in err or "log_work" in err
|
||||
mock_update.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_update_note_tool_accepts_description_on_tasks():
|
||||
"""description updates flow through to the service even on tasks."""
|
||||
from fabledassistant.services.tools import notes as notes_tool
|
||||
|
||||
fake_task = SimpleNamespace(
|
||||
id=42, title="t", status="in_progress",
|
||||
body="", tags=[], project_id=None, milestone_id=None,
|
||||
description=None,
|
||||
)
|
||||
fake_task.is_task = True
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_update_note(uid, nid, **kwargs):
|
||||
captured.update(kwargs)
|
||||
# Apply the description so the post-update inspection makes sense.
|
||||
for k, v in kwargs.items():
|
||||
setattr(fake_task, k, v)
|
||||
return fake_task
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.notes.get_note_by_title",
|
||||
new=AsyncMock(return_value=fake_task),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.update_note",
|
||||
new=fake_update_note,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.suggest_tags",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.schedule_embedding",
|
||||
):
|
||||
result = await notes_tool.update_note_tool(
|
||||
user_id=1,
|
||||
arguments={"query": "t", "description": "updated goal"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured.get("description") == "updated goal"
|
||||
|
||||
|
||||
async def test_update_note_tool_accepts_body_on_knowledge_notes():
|
||||
"""Body writes are still allowed on non-task notes."""
|
||||
from fabledassistant.services.tools import notes as notes_tool
|
||||
|
||||
fake_note = SimpleNamespace(
|
||||
id=10, title="n", status=None,
|
||||
body="old", tags=[], project_id=None, milestone_id=None,
|
||||
)
|
||||
fake_note.is_task = False
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_update_note(uid, nid, **kwargs):
|
||||
captured.update(kwargs)
|
||||
for k, v in kwargs.items():
|
||||
setattr(fake_note, k, v)
|
||||
return fake_note
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.notes.get_note_by_title",
|
||||
new=AsyncMock(return_value=fake_note),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.update_note",
|
||||
new=fake_update_note,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.suggest_tags",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.schedule_embedding",
|
||||
):
|
||||
result = await notes_tool.update_note_tool(
|
||||
user_id=1,
|
||||
arguments={"query": "n", "body": "new body content"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured.get("body") == "new body content"
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Tests for the task tools — log_work in particular wires into the
|
||||
consolidation pipeline after every successful log."""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
async def test_log_work_tool_invokes_maybe_consolidate():
|
||||
"""log_work must call maybe_consolidate(user_id, task_id, reason='log_added')
|
||||
after a successful task_logs.create_log."""
|
||||
from fabledassistant.services.tools import tasks as tasks_tool
|
||||
|
||||
fake_task = SimpleNamespace(id=42, title="X", status="in_progress")
|
||||
fake_log = SimpleNamespace(
|
||||
to_dict=lambda: {"id": 1, "content": "did stuff"},
|
||||
)
|
||||
|
||||
# get_note_by_title is imported at the top of tools/tasks.py — patch the
|
||||
# consumer module's bound symbol, not the source.
|
||||
with patch(
|
||||
"fabledassistant.services.tools.tasks.get_note_by_title",
|
||||
new=AsyncMock(return_value=fake_task),
|
||||
), patch(
|
||||
"fabledassistant.services.task_logs.create_log",
|
||||
new=AsyncMock(return_value=fake_log),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.maybe_consolidate",
|
||||
new=AsyncMock(),
|
||||
) as mock_mc:
|
||||
result = await tasks_tool.log_work_tool(
|
||||
user_id=1, arguments={"task": "X", "content": "did stuff"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_mc.assert_awaited_once_with(1, 42, reason="log_added")
|
||||
|
||||
|
||||
async def test_log_work_tool_does_not_trigger_on_create_log_failure():
|
||||
"""If create_log raises ValueError, maybe_consolidate must not be called."""
|
||||
from fabledassistant.services.tools import tasks as tasks_tool
|
||||
|
||||
fake_task = SimpleNamespace(id=42, title="X", status="in_progress")
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.tasks.get_note_by_title",
|
||||
new=AsyncMock(return_value=fake_task),
|
||||
), patch(
|
||||
"fabledassistant.services.task_logs.create_log",
|
||||
new=AsyncMock(side_effect=ValueError("bad input")),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.maybe_consolidate",
|
||||
new=AsyncMock(),
|
||||
) as mock_mc:
|
||||
result = await tasks_tool.log_work_tool(
|
||||
user_id=1, arguments={"task": "X", "content": "did stuff"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
mock_mc.assert_not_awaited()
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Unit tests for the voice library catalog shaper + ID validation.
|
||||
|
||||
DB- and network-touching paths (fetch_catalog, install_voice) need a
|
||||
real environment, so we test only the pure transformations here:
|
||||
|
||||
- Voice ID regex accepts valid piper IDs and rejects path-traversal attempts.
|
||||
- shape_catalog_for_ui projects an HF-shaped catalog dict into the UI list.
|
||||
- _resolve_file_urls picks the right .onnx and .onnx.json paths.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from fabledassistant.services import voice_library as lib
|
||||
|
||||
|
||||
def test_voice_id_accepts_real_piper_ids():
|
||||
for vid in [
|
||||
"en_US-amy-medium",
|
||||
"en_US-ryan-medium",
|
||||
"en_GB-alan-low",
|
||||
"fr_FR-siwis-medium",
|
||||
"de_DE-thorsten-high",
|
||||
"es_AR-daniela-high",
|
||||
"zh_CN-huayan-medium",
|
||||
"ar_JO-kareem-low",
|
||||
"ca_ES-upc_ona-x_low",
|
||||
]:
|
||||
assert lib._is_valid_voice_id(vid), f"should accept {vid!r}"
|
||||
|
||||
|
||||
def test_voice_id_rejects_path_traversal_and_garbage():
|
||||
# Regex purpose is path-traversal + structurally-malformed prevention,
|
||||
# NOT enforcement of the HF catalog's lowercase-language convention.
|
||||
# Uppercase variants like `EN_US-amy-medium` pass the regex but would
|
||||
# 404 at install time against HF — harmless, no need to over-tighten.
|
||||
for vid in [
|
||||
"../../etc/passwd",
|
||||
"en_US-amy-medium/../something",
|
||||
"../en_US-amy-medium",
|
||||
"",
|
||||
"en_US-amy", # missing quality
|
||||
"amy-medium", # missing language
|
||||
"en_US--medium", # empty dataset
|
||||
"en_US-amy-large", # invalid quality
|
||||
".onnx",
|
||||
"en_US-amy-medium.onnx", # has extension; we want the bare id
|
||||
]:
|
||||
assert not lib._is_valid_voice_id(vid), f"should reject {vid!r}"
|
||||
|
||||
|
||||
def test_shape_catalog_projects_expected_fields(monkeypatch, tmp_path):
|
||||
"""Catalog dict → list of UI cards, with install state from disk scan."""
|
||||
# Redirect the install-scan paths to a tmp dir; mark en_US-amy-medium
|
||||
# as installed under /data so we can verify the field flips.
|
||||
user_dir = tmp_path / "data_voices"
|
||||
bundled_dir = tmp_path / "opt_voices"
|
||||
user_dir.mkdir()
|
||||
bundled_dir.mkdir()
|
||||
(user_dir / "en_US-amy-medium.onnx").write_text("model")
|
||||
(user_dir / "en_US-amy-medium.onnx.json").write_text("{}")
|
||||
|
||||
monkeypatch.setattr(lib, "_USER_VOICES_DIR", user_dir)
|
||||
monkeypatch.setattr(lib, "_BUNDLED_VOICES_DIR", bundled_dir)
|
||||
|
||||
catalog = {
|
||||
"en_US-amy-medium": {
|
||||
"name": "amy",
|
||||
"language": {
|
||||
"code": "en_US",
|
||||
"name_native": "English",
|
||||
"country_english": "United States",
|
||||
},
|
||||
"quality": "medium",
|
||||
"num_speakers": 1,
|
||||
"files": {
|
||||
"en/en_US/amy/medium/en_US-amy-medium.onnx": {
|
||||
"size_bytes": 63_500_000,
|
||||
},
|
||||
"en/en_US/amy/medium/en_US-amy-medium.onnx.json": {
|
||||
"size_bytes": 4_300,
|
||||
},
|
||||
},
|
||||
},
|
||||
"fr_FR-siwis-medium": {
|
||||
"name": "siwis",
|
||||
"language": {
|
||||
"code": "fr_FR",
|
||||
"name_native": "Français",
|
||||
"country_english": "France",
|
||||
},
|
||||
"quality": "medium",
|
||||
"num_speakers": 1,
|
||||
"files": {
|
||||
"fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx": {
|
||||
"size_bytes": 60_000_000,
|
||||
},
|
||||
"fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx.json": {
|
||||
"size_bytes": 3_900,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out = lib.shape_catalog_for_ui(catalog)
|
||||
assert len(out) == 2
|
||||
by_id = {v["id"]: v for v in out}
|
||||
|
||||
amy = by_id["en_US-amy-medium"]
|
||||
assert amy["name"] == "amy"
|
||||
assert amy["language_code"] == "en_US"
|
||||
assert amy["quality"] == "medium"
|
||||
assert amy["size_bytes"] == 63_500_000 + 4_300
|
||||
assert amy["installed"] is True
|
||||
assert amy["installed_source"] == "user"
|
||||
|
||||
siwis = by_id["fr_FR-siwis-medium"]
|
||||
assert siwis["language_code"] == "fr_FR"
|
||||
assert siwis["installed"] is False
|
||||
assert siwis["installed_source"] is None
|
||||
|
||||
# Sorted by language_code, then name — en_US before fr_FR.
|
||||
assert out[0]["id"] == "en_US-amy-medium"
|
||||
assert out[1]["id"] == "fr_FR-siwis-medium"
|
||||
|
||||
|
||||
def test_resolve_file_urls_picks_onnx_and_sidecar():
|
||||
catalog = {
|
||||
"en_US-amy-medium": {
|
||||
"files": {
|
||||
"en/en_US/amy/medium/en_US-amy-medium.onnx": {"size_bytes": 1},
|
||||
"en/en_US/amy/medium/en_US-amy-medium.onnx.json": {"size_bytes": 2},
|
||||
},
|
||||
}
|
||||
}
|
||||
urls = lib._resolve_file_urls(catalog, "en_US-amy-medium")
|
||||
assert urls["onnx"].endswith("en_US-amy-medium.onnx")
|
||||
assert urls["onnx.json"].endswith("en_US-amy-medium.onnx.json")
|
||||
assert urls["onnx"].startswith("https://huggingface.co/rhasspy/piper-voices/resolve/main/")
|
||||
|
||||
|
||||
def test_resolve_file_urls_raises_when_voice_missing():
|
||||
with pytest.raises(KeyError):
|
||||
lib._resolve_file_urls({}, "en_US-amy-medium")
|
||||
|
||||
|
||||
def test_resolve_file_urls_raises_when_files_incomplete():
|
||||
catalog = {
|
||||
"en_US-amy-medium": {
|
||||
"files": {
|
||||
# Only the .onnx, missing the sidecar — that's malformed.
|
||||
"en/en_US/amy/medium/en_US-amy-medium.onnx": {"size_bytes": 1},
|
||||
}
|
||||
}
|
||||
}
|
||||
with pytest.raises(ValueError, match="missing"):
|
||||
lib._resolve_file_urls(catalog, "en_US-amy-medium")
|
||||
@@ -1,166 +0,0 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
|
||||
def test_parse_open_meteo_response():
|
||||
"""parse_forecast() should extract daily data into a clean list."""
|
||||
from fabledassistant.services.weather import parse_forecast
|
||||
raw = {
|
||||
"daily": {
|
||||
"time": ["2026-03-11", "2026-03-12"],
|
||||
"temperature_2m_max": [15.0, 12.0],
|
||||
"temperature_2m_min": [8.0, 6.0],
|
||||
"precipitation_sum": [0.0, 3.2],
|
||||
"weathercode": [0, 61],
|
||||
"windspeed_10m_max": [10.0, 25.0],
|
||||
}
|
||||
}
|
||||
days = parse_forecast(raw)
|
||||
assert len(days) == 2
|
||||
assert days[0]["date"] == "2026-03-11"
|
||||
assert days[0]["temp_max"] == 15.0
|
||||
assert days[0]["temp_min"] == 8.0
|
||||
assert days[0]["precip_mm"] == 0.0
|
||||
assert days[0]["weathercode"] == 0
|
||||
assert days[1]["date"] == "2026-03-12"
|
||||
assert days[1]["precip_mm"] == 3.2
|
||||
|
||||
|
||||
def test_describe_weathercode():
|
||||
"""describe_weathercode() should return human-readable strings."""
|
||||
from fabledassistant.services.weather import describe_weathercode
|
||||
assert describe_weathercode(0) == "Clear sky"
|
||||
assert describe_weathercode(61) == "Rain: slight"
|
||||
assert describe_weathercode(95) == "Thunderstorm"
|
||||
assert "Unknown" in describe_weathercode(999)
|
||||
|
||||
|
||||
def test_detect_forecast_changes_no_change():
|
||||
"""detect_changes() should return empty list when forecasts are identical."""
|
||||
from fabledassistant.services.weather import detect_changes
|
||||
day = {"date": "2026-03-12", "temp_max": 12.0, "temp_min": 6.0,
|
||||
"precip_mm": 3.2, "weathercode": 61}
|
||||
assert detect_changes([day], [day]) == []
|
||||
|
||||
|
||||
def test_detect_forecast_changes_rain_added():
|
||||
"""detect_changes() should flag when precipitation appears."""
|
||||
from fabledassistant.services.weather import detect_changes
|
||||
old = [{"date": "2026-03-12", "temp_max": 14.0, "temp_min": 8.0,
|
||||
"precip_mm": 0.0, "weathercode": 2}]
|
||||
new = [{"date": "2026-03-12", "temp_max": 12.0, "temp_min": 7.0,
|
||||
"precip_mm": 4.5, "weathercode": 61}]
|
||||
changes = detect_changes(old, new)
|
||||
assert len(changes) == 1
|
||||
assert "2026-03-12" in changes[0]
|
||||
assert "rain" in changes[0].lower() or "precip" in changes[0].lower()
|
||||
|
||||
|
||||
def test_parse_weather_card_data_returns_none_when_stale():
|
||||
"""Should return None when cache is older than 24 hours."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from fabledassistant.services.weather import parse_weather_card_data
|
||||
|
||||
cache = MagicMock()
|
||||
cache.fetched_at = datetime.now(timezone.utc) - timedelta(hours=25)
|
||||
cache.forecast_json = {}
|
||||
assert parse_weather_card_data(cache) is None
|
||||
|
||||
|
||||
def test_parse_weather_card_data_returns_card_schema():
|
||||
"""Should return the correct metadata.weather schema for fresh cache."""
|
||||
from datetime import datetime, timezone, timedelta, date
|
||||
from fabledassistant.services.weather import parse_weather_card_data
|
||||
|
||||
today = date.today().isoformat()
|
||||
yesterday = (date.today() - timedelta(days=1)).isoformat()
|
||||
tomorrow = (date.today() + timedelta(days=1)).isoformat()
|
||||
|
||||
cache = MagicMock()
|
||||
cache.fetched_at = datetime.now(timezone.utc)
|
||||
cache.location_label = "Berlin, DE"
|
||||
cache.forecast_json = {
|
||||
"current_weather": {"temperature": 12.0, "weathercode": 2},
|
||||
"daily": {
|
||||
"time": [yesterday, today, tomorrow],
|
||||
"temperature_2m_max": [14.0, 16.0, 18.0],
|
||||
"temperature_2m_min": [9.0, 8.0, 10.0],
|
||||
"precipitation_sum": [0.0, 0.0, 1.5],
|
||||
"weathercode": [1, 2, 61],
|
||||
"windspeed_10m_max": [10.0, 12.0, 8.0],
|
||||
}
|
||||
}
|
||||
|
||||
card = parse_weather_card_data(cache)
|
||||
assert card is not None
|
||||
assert card["current_temp"] == 12
|
||||
assert card["condition"] == "Partly cloudy"
|
||||
assert card["today_high"] == 16
|
||||
assert card["today_low"] == 8
|
||||
assert card["yesterday_high"] == 14
|
||||
assert card["yesterday_low"] == 9
|
||||
assert len(card["forecast"]) >= 1
|
||||
assert card["location"] == "Berlin, DE"
|
||||
assert card["precip_summary"] is None
|
||||
|
||||
|
||||
def test_summarize_precip_dry():
|
||||
from fabledassistant.services.weather import _summarize_precip
|
||||
assert _summarize_precip([(h, 10) for h in range(24)]) is None
|
||||
|
||||
|
||||
def test_summarize_precip_all_day():
|
||||
from fabledassistant.services.weather import _summarize_precip
|
||||
result = _summarize_precip([(h, 60) for h in range(24)])
|
||||
assert result is not None
|
||||
assert "all day" in result.lower()
|
||||
|
||||
|
||||
def test_summarize_precip_window():
|
||||
from fabledassistant.services.weather import _summarize_precip
|
||||
hourly = [(h, 5) for h in range(24)]
|
||||
for h in range(14, 18):
|
||||
hourly[h] = (h, 65)
|
||||
result = _summarize_precip(hourly)
|
||||
assert result is not None
|
||||
assert "2 PM" in result
|
||||
assert "65%" in result
|
||||
|
||||
|
||||
def test_parse_weather_card_includes_hourly_precip_summary():
|
||||
from datetime import datetime, timezone, timedelta, date
|
||||
from fabledassistant.services.weather import parse_weather_card_data
|
||||
|
||||
today = date.today().isoformat()
|
||||
yesterday = (date.today() - timedelta(days=1)).isoformat()
|
||||
tomorrow = (date.today() + timedelta(days=1)).isoformat()
|
||||
|
||||
hourly_times = [f"{today}T{h:02d}:00" for h in range(24)]
|
||||
hourly_probs = [5] * 24
|
||||
for h in range(14, 18):
|
||||
hourly_probs[h] = 70
|
||||
|
||||
cache = MagicMock()
|
||||
cache.fetched_at = datetime.now(timezone.utc)
|
||||
cache.location_label = "Berlin, DE"
|
||||
cache.forecast_json = {
|
||||
"current_weather": {"temperature": 12.0, "weathercode": 61},
|
||||
"daily": {
|
||||
"time": [yesterday, today, tomorrow],
|
||||
"temperature_2m_max": [14.0, 16.0, 18.0],
|
||||
"temperature_2m_min": [9.0, 8.0, 10.0],
|
||||
"precipitation_sum": [0.0, 5.0, 1.5],
|
||||
"precipitation_probability_max": [0, 70, 30],
|
||||
"weathercode": [1, 61, 61],
|
||||
"windspeed_10m_max": [10.0, 12.0, 8.0],
|
||||
},
|
||||
"hourly": {
|
||||
"time": hourly_times,
|
||||
"precipitation_probability": hourly_probs,
|
||||
},
|
||||
}
|
||||
|
||||
card = parse_weather_card_data(cache)
|
||||
assert card is not None
|
||||
assert card["precip_summary"] is not None
|
||||
assert "2 PM" in card["precip_summary"]
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Tests for the Wikipedia service module."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fabledassistant.services.wikipedia import wiki_summary, wiki_search
|
||||
|
||||
|
||||
def _make_mock_response(json_data: dict, status_code: int = 200) -> MagicMock:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = status_code
|
||||
mock_resp.json.return_value = json_data
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
return mock_resp
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_summary_returns_extract():
|
||||
"""Successful lookup returns a dict with title, extract, and url."""
|
||||
payload = {
|
||||
"type": "standard",
|
||||
"title": "Python (programming language)",
|
||||
"extract": "Python is a high-level programming language.",
|
||||
"content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Python_(programming_language)"}},
|
||||
}
|
||||
mock_resp = _make_mock_response(payload)
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await wiki_summary("Python programming language")
|
||||
|
||||
assert result is not None
|
||||
assert result["title"] == "Python (programming language)"
|
||||
assert "Python" in result["extract"]
|
||||
assert result["url"].startswith("https://")
|
||||
assert result["thumbnail_url"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_summary_includes_thumbnail():
|
||||
"""Thumbnail URL is extracted when the article has an image."""
|
||||
payload = {
|
||||
"type": "standard",
|
||||
"title": "Hedgehog",
|
||||
"extract": "Hedgehogs are spiny mammals.",
|
||||
"content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Hedgehog"}},
|
||||
"thumbnail": {"source": "https://upload.wikimedia.org/foo-320px.jpg"},
|
||||
"originalimage": {"source": "https://upload.wikimedia.org/foo.jpg"},
|
||||
}
|
||||
mock_resp = _make_mock_response(payload)
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await wiki_summary("Hedgehog")
|
||||
|
||||
assert result is not None
|
||||
assert result["thumbnail_url"] == "https://upload.wikimedia.org/foo-320px.jpg"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_summary_returns_none_on_404():
|
||||
"""HTTP error (e.g. 404) causes wiki_summary to return None."""
|
||||
import httpx
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"404", request=MagicMock(), response=MagicMock()
|
||||
)
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await wiki_summary("NonExistentPageXYZ123")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_summary_returns_none_on_disambiguation():
|
||||
"""Disambiguation pages cause wiki_summary to return None."""
|
||||
payload = {
|
||||
"type": "disambiguation",
|
||||
"title": "Mercury",
|
||||
"extract": "Mercury may refer to:",
|
||||
"content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Mercury"}},
|
||||
}
|
||||
mock_resp = _make_mock_response(payload)
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await wiki_summary("Mercury")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_search_returns_results():
|
||||
"""wiki_search returns a list of result dicts for a successful query."""
|
||||
search_payload = {
|
||||
"query": {
|
||||
"search": [
|
||||
{"title": "Quantum mechanics"},
|
||||
{"title": "Quantum field theory"},
|
||||
]
|
||||
}
|
||||
}
|
||||
summary_payloads = [
|
||||
{
|
||||
"type": "standard",
|
||||
"title": "Quantum mechanics",
|
||||
"extract": "Quantum mechanics is a fundamental theory in physics.",
|
||||
"content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Quantum_mechanics"}},
|
||||
},
|
||||
{
|
||||
"type": "standard",
|
||||
"title": "Quantum field theory",
|
||||
"extract": "Quantum field theory is a framework in theoretical physics.",
|
||||
"content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Quantum_field_theory"}},
|
||||
},
|
||||
]
|
||||
|
||||
search_resp = _make_mock_response(search_payload)
|
||||
summary_resp_1 = _make_mock_response(summary_payloads[0])
|
||||
summary_resp_2 = _make_mock_response(summary_payloads[1])
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(side_effect=[search_resp, summary_resp_1, summary_resp_2])
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
results = await wiki_search("quantum physics", limit=2)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0]["title"] == "Quantum mechanics"
|
||||
assert "quantum" in results[0]["extract"].lower()
|
||||
assert results[1]["title"] == "Quantum field theory"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_search_returns_empty_on_failure():
|
||||
"""wiki_search returns an empty list when the network request fails."""
|
||||
import httpx
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("connection refused"))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
results = await wiki_search("anything")
|
||||
|
||||
assert results == []
|
||||
Reference in New Issue
Block a user