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

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:
javis-bot
2026-06-09 14:51:05 +09:00
parent a5bf8d1826
commit c4abf63f38
308 changed files with 94135 additions and 1 deletions

264
tests/test_diary_import.py Normal file
View File

@@ -0,0 +1,264 @@
"""Tests for diary-to-graph import feature.
Covers:
- Database.get_all_conversation_summaries() method
- /api/graph/import-diary streaming endpoint (requires flask)
"""
import json
import sqlite3
import sys
import types
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
# Mock modules that may not be available in the test environment
_MOCK_MODULES = [
"PyQt6", "PyQt6.QtWidgets", "PyQt6.QtCore", "PyQt6.QtGui",
"PyQt6.QtWebEngineWidgets", "PyQt6.sip",
"requests", "requests.exceptions",
"psutil",
]
for _mod in _MOCK_MODULES:
if _mod not in sys.modules:
sys.modules[_mod] = MagicMock()
# Ensure requests.exceptions.Timeout is a proper exception class
sys.modules["requests"].exceptions.Timeout = type("Timeout", (Exception,), {})
from src.jarvis.memory.db import Database
# ── Database method tests ─────────────────────────────────────────────
@pytest.fixture
def db_with_summaries(tmp_path):
"""Provide a database pre-populated with conversation summaries."""
db = Database(str(tmp_path / "test.db"), sqlite_vss_path=None)
# Insert some summaries in non-chronological order to test ordering
summaries = [
("2025-03-15", "User discussed work projects and deadlines.", "work,planning", "jarvis"),
("2025-01-10", "User talked about favourite coffee shops.", "food,coffee", "jarvis"),
("2025-06-22", "User mentioned upcoming holiday plans.", "travel,holiday", "jarvis"),
("2025-02-01", "User shared fitness routine details.", "health,fitness", "jarvis"),
]
for date_utc, summary, topics, source_app in summaries:
ts_utc = datetime.now(timezone.utc).isoformat()
db.conn.execute(
"""INSERT INTO conversation_summaries (date_utc, ts_utc, summary, topics, source_app)
VALUES (?, ?, ?, ?, ?)""",
(date_utc, ts_utc, summary, topics, source_app),
)
db.conn.commit()
yield db
db.close()
@pytest.mark.unit
class TestGetAllConversationSummaries:
"""Tests for Database.get_all_conversation_summaries()."""
def test_returns_all_summaries(self, db_with_summaries):
"""Should return every summary in the database."""
rows = db_with_summaries.get_all_conversation_summaries()
assert len(rows) == 4
def test_ordered_by_date_ascending(self, db_with_summaries):
"""Summaries should be ordered oldest-first for chronological import."""
rows = db_with_summaries.get_all_conversation_summaries()
dates = [row["date_utc"] for row in rows]
assert dates == sorted(dates)
assert dates[0] == "2025-01-10"
assert dates[-1] == "2025-06-22"
def test_empty_database(self, db):
"""Should return an empty list when no summaries exist."""
rows = db.get_all_conversation_summaries()
assert rows == []
def test_returns_expected_fields(self, db_with_summaries):
"""Each row should have the standard conversation_summaries fields."""
rows = db_with_summaries.get_all_conversation_summaries()
row = rows[0]
assert "date_utc" in row.keys()
assert "summary" in row.keys()
assert "topics" in row.keys()
assert "source_app" in row.keys()
def test_contains_summary_text(self, db_with_summaries):
"""Summaries should contain the actual text that was stored."""
rows = db_with_summaries.get_all_conversation_summaries()
texts = [row["summary"] for row in rows]
assert any("coffee" in t for t in texts)
assert any("fitness" in t for t in texts)
# ── Import endpoint tests ─────────────────────────────────────────────
try:
import flask as _flask # noqa: F401
_HAS_FLASK = True
except ImportError:
_HAS_FLASK = False
@pytest.mark.unit
@pytest.mark.skipif(not _HAS_FLASK, reason="Flask not available")
class TestImportDiaryEndpoint:
"""Tests for /api/graph/import-diary streaming endpoint."""
@pytest.fixture(autouse=True)
def setup_app(self, tmp_path):
"""Set up Flask test client with a temporary database."""
from src.desktop_app.memory_viewer import app, get_graph_store
self.db_path = str(tmp_path / "test.db")
# Create database with summaries
self.db = Database(self.db_path, sqlite_vss_path=None)
self.db.conn.execute(
"""INSERT INTO conversation_summaries (date_utc, ts_utc, summary, topics, source_app)
VALUES (?, ?, ?, ?, ?)""",
("2025-03-15", "2025-03-15T12:00:00Z", "User likes dark roast coffee.", "food", "jarvis"),
)
self.db.conn.execute(
"""INSERT INTO conversation_summaries (date_utc, ts_utc, summary, topics, source_app)
VALUES (?, ?, ?, ?, ?)""",
("2025-03-16", "2025-03-16T12:00:00Z", "User works at Acme Corp.", "work", "jarvis"),
)
self.db.conn.commit()
app.config["TESTING"] = True
self.client = app.test_client()
yield
self.db.close()
def _parse_ndjson(self, data: bytes) -> list[dict]:
"""Parse newline-delimited JSON from response data."""
lines = data.decode("utf-8").strip().split("\n")
return [json.loads(line) for line in lines if line.strip()]
@patch("src.desktop_app.memory_viewer._get_db_path")
@patch("src.desktop_app.memory_viewer.load_settings")
@patch("src.jarvis.memory.graph_ops.call_llm_direct")
def test_import_streams_progress(self, mock_llm, mock_settings, mock_db_path):
"""Should stream start, progress, and complete messages."""
mock_db_path.return_value = self.db_path
cfg = MagicMock()
cfg.ollama_base_url = "http://localhost:11434"
cfg.ollama_chat_model = "test-model"
cfg.llm_chat_timeout_sec = 10.0
cfg.llm_thinking_enabled = False
mock_settings.return_value = cfg
# LLM returns facts for extraction, NONE for placement (writes to root)
mock_llm.side_effect = [
'["Likes dark roast coffee"]', # extract facts from summary 1
"NONE", # traverse for fact 1 (no children, goes to root)
'["Works at Acme Corp"]', # extract facts from summary 2
"NONE", # traverse for fact 2
]
resp = self.client.post("/api/graph/import-diary")
assert resp.status_code == 200
messages = self._parse_ndjson(resp.data)
types = [m["type"] for m in messages]
assert "start" in types
assert "progress" in types
assert "complete" in types
start_msg = next(m for m in messages if m["type"] == "start")
assert start_msg["total"] == 2
complete_msg = next(m for m in messages if m["type"] == "complete")
assert complete_msg["processed"] == 2
@patch("src.desktop_app.memory_viewer._get_db_path")
@patch("src.desktop_app.memory_viewer.load_settings")
def test_import_empty_diary(self, mock_settings, mock_db_path, tmp_path):
"""Should handle empty diary gracefully."""
empty_db_path = str(tmp_path / "empty.db")
empty_db = Database(empty_db_path, sqlite_vss_path=None)
mock_db_path.return_value = empty_db_path
cfg = MagicMock()
mock_settings.return_value = cfg
resp = self.client.post("/api/graph/import-diary")
messages = self._parse_ndjson(resp.data)
assert len(messages) == 1
assert messages[0]["type"] == "complete"
assert messages[0]["processed"] == 0
empty_db.close()
@patch("src.desktop_app.memory_viewer._get_db_path")
@patch("src.desktop_app.memory_viewer.load_settings")
@patch("src.jarvis.memory.graph_ops.call_llm_direct")
def test_import_continues_on_per_summary_error(self, mock_llm, mock_settings, mock_db_path):
"""If one summary fails, the import should continue with the rest."""
mock_db_path.return_value = self.db_path
cfg = MagicMock()
cfg.ollama_base_url = "http://localhost:11434"
cfg.ollama_chat_model = "test-model"
cfg.llm_chat_timeout_sec = 10.0
cfg.llm_thinking_enabled = False
mock_settings.return_value = cfg
# First summary extraction fails, second succeeds
mock_llm.side_effect = [
None, # extraction fails for summary 1
'["Works at Acme Corp"]', # extract facts from summary 2
"NONE", # traverse
]
resp = self.client.post("/api/graph/import-diary")
messages = self._parse_ndjson(resp.data)
progress_msgs = [m for m in messages if m["type"] == "progress"]
assert len(progress_msgs) == 2 # Both summaries processed
complete_msg = next(m for m in messages if m["type"] == "complete")
assert complete_msg["processed"] == 2
@pytest.mark.unit
@pytest.mark.skipif(not _HAS_FLASK, reason="Flask not available")
class TestImportDialogueDismissal:
"""Regression: after diary import succeeds, loadStats must not re-show the modal."""
def test_html_contains_diary_import_done_guard(self):
"""The loadStats check should be gated by diaryImportDone flag."""
from src.desktop_app.memory_viewer import app
app.config["TESTING"] = True
client = app.test_client()
resp = client.get("/")
html = resp.data.decode("utf-8")
# The flag must be declared
assert "let diaryImportDone = false;" in html
# The flag must be set on import completion
assert "diaryImportDone = true;" in html
# The loadStats check must include the guard
assert "&& !diaryImportDone" in html
# The gate must be based on stored knowledge (total_tokens), not node count.
# Guards against a regression to the old `totalNodes <= 1` condition that kept
# re-prompting after a successful import filled the root node.
assert "totalTokens === 0" in html
assert "totalNodes <= 1" not in html