Add Discord-native hybrid front-end for Jarvis (bot + bridge)
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
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.
This commit is contained in:
369
tests/test_diary_topic_optimise.py
Normal file
369
tests/test_diary_topic_optimise.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
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"
|
||||
)
|
||||
Reference in New Issue
Block a user