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:
331
tests/test_diary_enrichment_flow.py
Normal file
331
tests/test_diary_enrichment_flow.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
Diary-to-Enrichment Flow Integration Tests
|
||||
|
||||
Tests the critical flow where dialogue memory is saved to the diary, cleaned
|
||||
up from in-memory, and then retrieved via FTS search on a follow-up query.
|
||||
|
||||
This validates that after the unified RECENT_WINDOW_SEC = MAX_UNSAVED_AGE_SEC
|
||||
change, context is not lost when messages are cleaned from memory — the
|
||||
FTS pipeline successfully retrieves just-saved diary entries.
|
||||
"""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDiaryToEnrichmentFlow:
|
||||
"""Test the full diary save → cleanup → enrichment retrieval pipeline."""
|
||||
|
||||
def _create_dialogue_memory(self, timeout: float = 5.0):
|
||||
"""Create a DialogueMemory with a short timeout for testing."""
|
||||
from jarvis.memory.conversation import DialogueMemory
|
||||
return DialogueMemory(inactivity_timeout=timeout)
|
||||
|
||||
def _force_messages_old(self, dm, age_seconds: float):
|
||||
"""Make all messages in dialogue memory appear old."""
|
||||
with dm._lock:
|
||||
now = time.time()
|
||||
dm._messages = [
|
||||
(now - age_seconds, role, content)
|
||||
for _, role, content in dm._messages
|
||||
]
|
||||
dm._last_activity_time = now - age_seconds
|
||||
|
||||
def test_diary_save_then_enrichment_retrieval_fts(self, db):
|
||||
"""After diary save + cleanup, FTS enrichment finds the saved context.
|
||||
|
||||
This is the core scenario: user discusses a topic, diary update fires,
|
||||
messages are cleaned from memory, then a follow-up query successfully
|
||||
retrieves the context from the diary via FTS search.
|
||||
"""
|
||||
from jarvis.memory.conversation import (
|
||||
DialogueMemory,
|
||||
update_diary_from_dialogue_memory,
|
||||
search_conversation_memory_by_keywords,
|
||||
)
|
||||
|
||||
dm = self._create_dialogue_memory(timeout=5.0)
|
||||
|
||||
# Step 1: Simulate a conversation about a specific topic
|
||||
dm.add_message("user", "I've been working on a Python migration to async/await")
|
||||
dm.add_message("assistant", "That's a big refactor. Are you using asyncio or trio?")
|
||||
dm.add_message("user", "asyncio, and we're converting the database layer first")
|
||||
dm.add_message("assistant", "Good approach — the database layer benefits most from async")
|
||||
|
||||
assert dm.has_pending_chunks(), "Should have pending chunks"
|
||||
|
||||
# Step 2: Force diary update with mocked LLM summarisation
|
||||
mock_summary = (
|
||||
"User is working on migrating a Python codebase to async/await "
|
||||
"using asyncio. They are starting with the database layer conversion. "
|
||||
"The assistant recommended this approach as the database layer benefits "
|
||||
"most from async patterns."
|
||||
)
|
||||
mock_topics = "python, asyncio, async/await, database, migration, refactoring"
|
||||
|
||||
with patch(
|
||||
"jarvis.memory.conversation.generate_conversation_summary",
|
||||
return_value=(mock_summary, mock_topics),
|
||||
):
|
||||
summary_id = update_diary_from_dialogue_memory(
|
||||
db=db,
|
||||
dialogue_memory=dm,
|
||||
ollama_base_url="http://localhost:11434",
|
||||
ollama_chat_model="test",
|
||||
ollama_embed_model="test",
|
||||
force=True,
|
||||
timeout_sec=5.0,
|
||||
)
|
||||
|
||||
assert summary_id is not None, "Diary update should succeed"
|
||||
print(f"\n 📝 Diary entry saved with ID: {summary_id}")
|
||||
|
||||
# Step 3: Force messages old and trigger cleanup
|
||||
self._force_messages_old(dm, dm.RECENT_WINDOW_SEC + 60)
|
||||
dm.mark_saved_up_to(time.time())
|
||||
|
||||
# Verify messages were cleaned up
|
||||
recent = dm.get_recent_messages()
|
||||
assert len(recent) == 0, "Messages should be cleaned from memory after save"
|
||||
print(" 🧹 In-memory messages cleaned up")
|
||||
|
||||
# Step 4: Search via FTS (no embeddings — simulates fallback path)
|
||||
results = search_conversation_memory_by_keywords(
|
||||
db=db,
|
||||
keywords=["asyncio", "database", "migration"],
|
||||
max_results=5,
|
||||
)
|
||||
|
||||
print(f" 🔍 FTS search results: {len(results)} found")
|
||||
for i, r in enumerate(results):
|
||||
preview = r[:120] + "..." if len(r) > 120 else r
|
||||
print(f" {i + 1}. {preview}")
|
||||
|
||||
# Step 5: Verify enrichment finds the diary entry
|
||||
assert len(results) > 0, (
|
||||
"Enrichment should find the just-saved diary entry via FTS. "
|
||||
"This means context is NOT lost after cleanup."
|
||||
)
|
||||
|
||||
# Verify the content is relevant
|
||||
combined = " ".join(results).lower()
|
||||
assert any(kw in combined for kw in ["asyncio", "async", "database", "migration"]), (
|
||||
f"Search results should contain relevant keywords. Got: {combined[:200]}"
|
||||
)
|
||||
print(" ✅ Enrichment successfully retrieved diary context after cleanup")
|
||||
|
||||
def test_followup_query_finds_recent_diary_entry(self, db):
|
||||
"""Simulate the exact flow: conversation → diary save → follow-up query.
|
||||
|
||||
The follow-up query exercises the enrichment keyword extraction
|
||||
(mocked) and diary search (real FTS) to verify the full pipeline.
|
||||
"""
|
||||
from jarvis.memory.conversation import (
|
||||
DialogueMemory,
|
||||
update_diary_from_dialogue_memory,
|
||||
search_conversation_memory_by_keywords,
|
||||
)
|
||||
|
||||
dm = self._create_dialogue_memory(timeout=5.0)
|
||||
|
||||
# User discusses their holiday plans
|
||||
dm.add_message("user", "I'm planning a trip to Tokyo in November")
|
||||
dm.add_message("assistant", "November is a great time for Tokyo — autumn foliage season!")
|
||||
dm.add_message("user", "I want to visit Shibuya and Akihabara")
|
||||
dm.add_message("assistant", "Both excellent choices. Shibuya for the crossing and shopping, Akihabara for electronics and anime culture.")
|
||||
|
||||
# Save to diary
|
||||
mock_summary = (
|
||||
"User is planning a trip to Tokyo in November during autumn foliage season. "
|
||||
"They want to visit Shibuya for the famous crossing and shopping, and "
|
||||
"Akihabara for electronics and anime culture."
|
||||
)
|
||||
mock_topics = "tokyo, travel, japan, november, shibuya, akihabara, autumn"
|
||||
|
||||
with patch(
|
||||
"jarvis.memory.conversation.generate_conversation_summary",
|
||||
return_value=(mock_summary, mock_topics),
|
||||
):
|
||||
summary_id = update_diary_from_dialogue_memory(
|
||||
db=db,
|
||||
dialogue_memory=dm,
|
||||
ollama_base_url="http://localhost:11434",
|
||||
ollama_chat_model="test",
|
||||
ollama_embed_model="test",
|
||||
force=True,
|
||||
)
|
||||
|
||||
assert summary_id is not None
|
||||
|
||||
# Clean up in-memory messages (simulates the unified window expiry)
|
||||
self._force_messages_old(dm, dm.RECENT_WINDOW_SEC + 60)
|
||||
dm.mark_saved_up_to(time.time())
|
||||
assert len(dm.get_recent_messages()) == 0, "Memory should be empty"
|
||||
|
||||
# User comes back and asks a follow-up
|
||||
# (Enrichment would extract keywords like: tokyo, trip, travel)
|
||||
followup_keywords = ["tokyo", "trip", "travel"]
|
||||
|
||||
results = search_conversation_memory_by_keywords(
|
||||
db=db,
|
||||
keywords=followup_keywords,
|
||||
max_results=5,
|
||||
)
|
||||
|
||||
print(f"\n 🗣️ Follow-up: 'what were my Tokyo plans again?'")
|
||||
print(f" 🔍 Enrichment keywords: {followup_keywords}")
|
||||
print(f" 📋 Results: {len(results)} found")
|
||||
|
||||
assert len(results) > 0, (
|
||||
"Follow-up query should find the Tokyo trip diary entry via enrichment"
|
||||
)
|
||||
|
||||
combined = " ".join(results).lower()
|
||||
assert "tokyo" in combined, "Results should mention Tokyo"
|
||||
assert any(kw in combined for kw in ["shibuya", "akihabara", "november"]), (
|
||||
"Results should include specific trip details"
|
||||
)
|
||||
print(" ✅ Follow-up successfully retrieved trip plans from diary")
|
||||
|
||||
def test_multiple_diary_entries_searchable(self, db):
|
||||
"""Multiple diary entries from different conversations are all searchable."""
|
||||
from jarvis.memory.conversation import (
|
||||
DialogueMemory,
|
||||
update_diary_from_dialogue_memory,
|
||||
search_conversation_memory_by_keywords,
|
||||
)
|
||||
|
||||
dm = self._create_dialogue_memory(timeout=5.0)
|
||||
|
||||
# First conversation: cooking
|
||||
dm.add_message("user", "Can you suggest a good pasta recipe?")
|
||||
dm.add_message("assistant", "Try a carbonara — eggs, pecorino, guanciale, and black pepper.")
|
||||
|
||||
with patch(
|
||||
"jarvis.memory.conversation.generate_conversation_summary",
|
||||
return_value=(
|
||||
"User asked for a pasta recipe. Suggested carbonara with eggs, pecorino, guanciale, and black pepper.",
|
||||
"cooking, pasta, carbonara, recipe",
|
||||
),
|
||||
):
|
||||
id1 = update_diary_from_dialogue_memory(
|
||||
db=db, dialogue_memory=dm,
|
||||
ollama_base_url="http://localhost:11434",
|
||||
ollama_chat_model="test", ollama_embed_model="test",
|
||||
force=True,
|
||||
)
|
||||
|
||||
assert id1 is not None
|
||||
self._force_messages_old(dm, dm.RECENT_WINDOW_SEC + 60)
|
||||
dm.mark_saved_up_to(time.time())
|
||||
|
||||
# Second conversation: fitness
|
||||
dm.add_message("user", "What's a good strength training routine for beginners?")
|
||||
dm.add_message("assistant", "Start with compound lifts: squats, deadlifts, bench press, and overhead press.")
|
||||
|
||||
# Second summary includes first conversation (LLM appends to previous)
|
||||
with patch(
|
||||
"jarvis.memory.conversation.generate_conversation_summary",
|
||||
return_value=(
|
||||
"User asked for a pasta recipe. Suggested carbonara with eggs, pecorino, guanciale, and black pepper. "
|
||||
"Later, user asked about beginner strength training. Recommended compound lifts: squats, deadlifts, bench press, and overhead press.",
|
||||
"cooking, pasta, carbonara, recipe, fitness, strength training, exercise, beginner, workout",
|
||||
),
|
||||
):
|
||||
id2 = update_diary_from_dialogue_memory(
|
||||
db=db, dialogue_memory=dm,
|
||||
ollama_base_url="http://localhost:11434",
|
||||
ollama_chat_model="test", ollama_embed_model="test",
|
||||
force=True,
|
||||
)
|
||||
|
||||
assert id2 is not None
|
||||
self._force_messages_old(dm, dm.RECENT_WINDOW_SEC + 60)
|
||||
dm.mark_saved_up_to(time.time())
|
||||
|
||||
# Both should be empty from memory
|
||||
assert len(dm.get_recent_messages()) == 0
|
||||
|
||||
# Search for cooking — should find first entry
|
||||
cooking_results = search_conversation_memory_by_keywords(
|
||||
db=db, keywords=["pasta", "recipe", "cooking"], max_results=5,
|
||||
)
|
||||
assert len(cooking_results) > 0, "Should find cooking diary entry"
|
||||
assert "carbonara" in " ".join(cooking_results).lower()
|
||||
|
||||
# Search for fitness — should find second entry
|
||||
fitness_results = search_conversation_memory_by_keywords(
|
||||
db=db, keywords=["strength", "training", "exercise"], max_results=5,
|
||||
)
|
||||
assert len(fitness_results) > 0, "Should find fitness diary entry"
|
||||
assert any(kw in " ".join(fitness_results).lower() for kw in ["squat", "deadlift", "bench"])
|
||||
|
||||
print(f"\n 📝 Saved 2 diary entries (IDs: {id1}, {id2})")
|
||||
print(f" 🔍 Cooking search: {len(cooking_results)} results")
|
||||
print(f" 🔍 Fitness search: {len(fitness_results)} results")
|
||||
print(" ✅ Multiple diary entries independently searchable after cleanup")
|
||||
|
||||
def test_concurrent_message_during_diary_update_preserved(self, db):
|
||||
"""Messages arriving during diary update are NOT lost.
|
||||
|
||||
While the diary update (slow LLM call) is processing, new messages
|
||||
arrive. These must survive cleanup and appear in the next diary update.
|
||||
"""
|
||||
from jarvis.memory.conversation import (
|
||||
DialogueMemory,
|
||||
update_daily_conversation_summary,
|
||||
)
|
||||
|
||||
dm = self._create_dialogue_memory(timeout=5.0)
|
||||
|
||||
# Add initial messages
|
||||
dm.add_message("user", "Tell me about quantum computing")
|
||||
dm.add_message("assistant", "Quantum computing uses qubits instead of classical bits.")
|
||||
|
||||
# Simulate the diary update flow manually to inject a concurrent message
|
||||
snapshot_timestamp = time.time()
|
||||
pending_chunks = dm.get_pending_chunks()
|
||||
assert len(pending_chunks) > 0
|
||||
|
||||
# Simulate a new message arriving DURING the slow LLM summarisation
|
||||
time.sleep(0.01) # Ensure timestamp differs
|
||||
dm.add_message("user", "What about quantum error correction?")
|
||||
|
||||
# Mock the LLM summarisation result
|
||||
with patch(
|
||||
"jarvis.memory.conversation.generate_conversation_summary",
|
||||
return_value=(
|
||||
"Discussed quantum computing basics — qubits vs classical bits.",
|
||||
"quantum, computing, qubits",
|
||||
),
|
||||
):
|
||||
summary_id = update_daily_conversation_summary(
|
||||
db=db,
|
||||
new_chunks=pending_chunks,
|
||||
ollama_base_url="http://localhost:11434",
|
||||
ollama_chat_model="test",
|
||||
ollama_embed_model="test",
|
||||
)
|
||||
|
||||
assert summary_id is not None
|
||||
|
||||
# Mark saved up to the snapshot (NOT the current time)
|
||||
dm.mark_saved_up_to(snapshot_timestamp)
|
||||
|
||||
# The concurrent message should still be pending
|
||||
assert dm.has_pending_chunks(), (
|
||||
"Message that arrived during diary update should still be pending"
|
||||
)
|
||||
|
||||
new_pending = dm.get_pending_chunks()
|
||||
combined = " ".join(new_pending).lower()
|
||||
assert "quantum error correction" in combined, (
|
||||
"The concurrent message about error correction should be preserved"
|
||||
)
|
||||
|
||||
print("\n 📝 Diary saved initial conversation")
|
||||
print(" ⏱️ New message arrived during save")
|
||||
print(f" 📋 Pending after save: {len(new_pending)} chunks")
|
||||
print(" ✅ Concurrent message preserved — no data loss")
|
||||
Reference in New Issue
Block a user