""" Tests for ``optimise_diary_topics`` — the LLM-driven bulk sweep that normalises topic tags across every row in ``conversation_summaries``. Merges near-synonyms, splits compound tags, and normalises casing. Mirrors the shape of ``rewrite_all_diary_summaries``: generator contract, fail-open semantics, audit-trail preservation, and privacy constraints. """ from __future__ import annotations import json import pytest from jarvis.memory.db import Database import jarvis.memory.conversation as cmod from jarvis.memory.conversation import optimise_diary_topics # ── Fixtures ────────────────────────────────────────────────────────────── @pytest.fixture() def db(tmp_path) -> Database: instance = Database(tmp_path / "jarvis.db") yield instance def _seed(db: Database, rows: list[tuple[str, str, str | None]]) -> None: """Seed conversation_summaries with (date_utc, summary, topics) triples.""" for date_utc, summary, topics in rows: db.upsert_conversation_summary( date_utc=date_utc, summary=summary, topics=topics, source_app="jarvis", ) def _fake_llm(mapping: dict): """Return a monkeypatch-compatible fake call_llm_direct that emits ``mapping``.""" def _call(base_url, model, system_prompt, user_content, **kwargs): return json.dumps(mapping) return _call # ── Generator contract ──────────────────────────────────────────────────── class TestOptimiseContract: def test_yields_nothing_for_empty_db(self, db): events = list(optimise_diary_topics( db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3", )) assert events == [] def test_yields_one_event_per_row(self, db, monkeypatch): _seed(db, [ ("2026-04-10", "User discussed Python.", "python"), ("2026-04-15", "User cooked dinner.", "cooking"), ("2026-04-27", "User went running.", "fitness"), ]) monkeypatch.setattr(cmod, "call_llm_direct", _fake_llm({ "python": "python", "cooking": "cooking", "fitness": "fitness", })) events = list(optimise_diary_topics( db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3", )) assert len(events) == 3 def test_event_shape(self, db, monkeypatch): _seed(db, [("2026-04-10", "User discussed Python.", "python")]) monkeypatch.setattr(cmod, "call_llm_direct", _fake_llm({"python": "python"})) events = list(optimise_diary_topics( db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3", )) ev = events[0] assert "date_utc" in ev assert "topics_changed" in ev assert isinstance(ev["topics_changed"], bool) def test_event_payload_contains_no_raw_topic_strings(self, db, monkeypatch): """Progress events must not echo tag values — counts and date only.""" _seed(db, [("2026-04-10", "User cooked carbonara.", "cooking, carbonara, pasta")]) monkeypatch.setattr(cmod, "call_llm_direct", _fake_llm({ "cooking": "cooking", "carbonara": "cooking", "pasta": "cooking", })) events = list(optimise_diary_topics( db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3", )) sentinel = "carbonara" for ev in events: blob = json.dumps(ev).lower() assert sentinel not in blob, ( f"topic value {sentinel!r} leaked into event: {ev}" ) # ── Core behaviour ──────────────────────────────────────────────────────── class TestOptimiseMerge: def test_merges_synonym_topics_in_db(self, db, monkeypatch): """'cook' and 'cooking' should both be normalised to 'cooking'.""" _seed(db, [ ("2026-04-10", "User made pasta.", "cook, pasta"), ("2026-04-15", "User baked bread.", "cooking, baking"), ]) monkeypatch.setattr(cmod, "call_llm_direct", _fake_llm({ "cook": "cooking", "pasta": "pasta", "cooking": "cooking", "baking": "baking", })) list(optimise_diary_topics( db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3", )) rows = {r["date_utc"]: r["topics"] for r in db.get_all_conversation_summaries()} topics_10 = [t.strip() for t in rows["2026-04-10"].split(",")] topics_15 = [t.strip() for t in rows["2026-04-15"].split(",")] assert "cook" not in topics_10, "raw 'cook' must be normalised" assert "cooking" in topics_10 assert "cooking" in topics_15 def test_rows_with_no_change_are_not_written(self, db, monkeypatch): """Rows already using canonical tags must not trigger a write-back.""" _seed(db, [("2026-04-10", "User went running.", "fitness")]) # Identity mapping — no change needed. monkeypatch.setattr(cmod, "call_llm_direct", _fake_llm({"fitness": "fitness"})) # Track write-back by counting upserts. upserts = [] original_upsert = db.upsert_conversation_summary def counting_upsert(**kwargs): upserts.append(kwargs) return original_upsert(**kwargs) db.upsert_conversation_summary = counting_upsert list(optimise_diary_topics( db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3", )) assert len(upserts) == 0, "identity mapping must not trigger a write-back" def test_changed_event_flag_reflects_actual_change(self, db, monkeypatch): _seed(db, [ ("2026-04-10", "User made pasta.", "cook"), ("2026-04-15", "User did yoga.", "fitness"), ]) monkeypatch.setattr(cmod, "call_llm_direct", _fake_llm({ "cook": "cooking", # changes "fitness": "fitness", # no change })) events = { e["date_utc"]: e for e in optimise_diary_topics( db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3", ) } assert events["2026-04-10"]["topics_changed"] is True assert events["2026-04-15"]["topics_changed"] is False class TestOptimiseSplit: def test_splits_compound_topic_into_two(self, db, monkeypatch): """A compound tag mapped to a list must expand into multiple tags.""" _seed(db, [("2026-04-10", "User worked out and ate well.", "fitness and nutrition")]) monkeypatch.setattr(cmod, "call_llm_direct", _fake_llm({ "fitness and nutrition": ["fitness", "nutrition"], })) list(optimise_diary_topics( db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3", )) row = db.get_all_conversation_summaries()[0] tags = [t.strip() for t in row["topics"].split(",")] assert "fitness and nutrition" not in tags, "compound tag must be split" assert "fitness" in tags assert "nutrition" in tags def test_split_event_is_marked_as_changed(self, db, monkeypatch): _seed(db, [("2026-04-10", "User worked out and ate well.", "fitness and nutrition")]) monkeypatch.setattr(cmod, "call_llm_direct", _fake_llm({ "fitness and nutrition": ["fitness", "nutrition"], })) events = list(optimise_diary_topics( db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3", )) assert events[0]["topics_changed"] is True class TestOptimiseDeduplicate: def test_deduplicates_when_merge_creates_duplicate(self, db, monkeypatch): """'cook, cooking' → both become 'cooking'; result must not be 'cooking, cooking'.""" _seed(db, [("2026-04-10", "User cooked dinner.", "cook, cooking, pasta")]) monkeypatch.setattr(cmod, "call_llm_direct", _fake_llm({ "cook": "cooking", "cooking": "cooking", "pasta": "pasta", })) list(optimise_diary_topics( db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3", )) row = db.get_all_conversation_summaries()[0] tags = [t.strip() for t in row["topics"].split(",")] assert tags.count("cooking") == 1, "merged duplicates must appear only once" # ── Audit trail ─────────────────────────────────────────────────────────── class TestOptimiseAuditTrail: def test_preserves_ts_utc_on_rewrite(self, db, monkeypatch): """A maintenance pass must not stomp the original write timestamp.""" original_ts = "2026-03-01T12:00:00+00:00" db.upsert_conversation_summary( date_utc="2026-04-10", summary="User made pasta.", topics="cook", source_app="jarvis", ts_utc=original_ts, ) monkeypatch.setattr(cmod, "call_llm_direct", _fake_llm({"cook": "cooking"})) list(optimise_diary_topics( db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3", )) row = db.get_all_conversation_summaries()[0] assert row["ts_utc"] == original_ts, ( "rewrite must preserve original ts_utc; a maintenance pass must not look like a new write" ) # ── Fail-open semantics ─────────────────────────────────────────────────── class TestOptimiseFailOpen: def test_fails_open_when_llm_returns_none(self, db, monkeypatch): """LLM failure → no rows changed; events still yielded.""" _seed(db, [("2026-04-10", "User ran 5 km.", "fitness")]) monkeypatch.setattr(cmod, "call_llm_direct", lambda *a, **k: None) events = list(optimise_diary_topics( db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3", )) rows = db.get_all_conversation_summaries() assert rows[0]["topics"] == "fitness", "topics must be unchanged on LLM failure" # At minimum the caller should get a non-empty response (either events or nothing). # The sweep is fail-open: it continues with unchanged rows. # Events may carry an 'error' flag or be empty — either is acceptable. def test_fails_open_when_llm_returns_malformed_json(self, db, monkeypatch): """Malformed JSON from LLM must not crash the sweep.""" _seed(db, [("2026-04-10", "User ran 5 km.", "fitness")]) monkeypatch.setattr(cmod, "call_llm_direct", lambda *a, **k: "not json at all") events = list(optimise_diary_topics( db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3", )) rows = db.get_all_conversation_summaries() assert rows[0]["topics"] == "fitness", "topics must be unchanged on parse failure" def test_rows_without_topics_are_skipped(self, db, monkeypatch): """Rows with no topics field must not cause errors and are left unchanged.""" _seed(db, [("2026-04-10", "User ran 5 km.", None)]) monkeypatch.setattr(cmod, "call_llm_direct", _fake_llm({})) events = list(optimise_diary_topics( db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3", )) rows = db.get_all_conversation_summaries() assert rows[0]["topics"] is None def test_fails_open_when_write_back_raises_mid_sweep(self, db, monkeypatch): """A per-row write failure must not abort the sweep. The first row's write raises; the sweep must continue and the second row must be processed normally. The failed row's event carries the exception class name only (no message text). """ _seed(db, [ ("2026-04-10", "User made pasta.", "cook"), ("2026-04-15", "User went running.", "fitness"), ]) monkeypatch.setattr(cmod, "call_llm_direct", _fake_llm({ "cook": "cooking", "fitness": "fitness", })) original_upsert = db.upsert_conversation_summary call_count = [0] def failing_upsert(**kwargs): call_count[0] += 1 if call_count[0] == 1: raise RuntimeError("disk full") return original_upsert(**kwargs) db.upsert_conversation_summary = failing_upsert events = { e["date_utc"]: e for e in optimise_diary_topics( db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3", ) } # First row: write failed → event flagged with error, no change persisted. assert events["2026-04-10"]["error"] == "RuntimeError" assert events["2026-04-10"]["topics_changed"] is False # Second row: sweep continued and applied the mapping normally. assert "error" not in events["2026-04-15"] assert events["2026-04-15"]["topics_changed"] is False # identity mapping # ── Idempotence ─────────────────────────────────────────────────────────── class TestOptimiseIdempotence: def test_second_run_produces_no_further_changes(self, db, monkeypatch): _seed(db, [ ("2026-04-10", "User made pasta.", "cook, pasta"), ("2026-04-15", "User worked out.", "workout"), ]) mapping = {"cook": "cooking", "pasta": "pasta", "workout": "fitness", "cooking": "cooking", "fitness": "fitness"} monkeypatch.setattr(cmod, "call_llm_direct", _fake_llm(mapping)) list(optimise_diary_topics(db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3")) second_events = list(optimise_diary_topics(db, ollama_base_url="http://localhost:11434", ollama_chat_model="llama3")) assert all(not e["topics_changed"] for e in second_events), ( "second run must not change any rows — sweep must be idempotent" )