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:
2026-05-27 17:47:18 -04:00
parent 8bec68abc0
commit 91bafb641f
123 changed files with 161 additions and 19312 deletions
-795
View File
@@ -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
-317
View File
@@ -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"
-180
View File
@@ -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"}]
-249
View File
@@ -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()
-60
View File
@@ -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
-195
View File
@@ -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
-123
View File
@@ -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
-23
View File
@@ -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
-111
View File
@@ -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"] == "![Hedgehog](/api/images/42)"
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
-118
View File
@@ -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()
-181
View File
@@ -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 == []
-231
View File
@@ -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
-145
View File
@@ -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
-200
View File
@@ -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"
-58
View File
@@ -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()
-156
View File
@@ -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")
-166
View File
@@ -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"]
-173
View File
@@ -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 == []