Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Transform isair/jarvis into a Discord-controlled voice assistant running on the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact. - bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral), voice channel join + voice receive/playback, pluggable VNC screen broadcast (selfbot live / noVNC / screenshot) - bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS behind a thin localhost HTTP API - .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite, docs/language-comparison.md and docs/vnc-xfce-setup.md Language decision: hybrid (Python brain + Node/bun Discord layer) because Discord blocks bot video; native screen broadcast only works via a Node selfbot library.
370 lines
15 KiB
Python
370 lines
15 KiB
Python
"""
|
|
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"
|
|
)
|