"""Tests for TeacherVoice prompt assembly and message building.""" from __future__ import annotations import numpy as np import pytest import soundfile as sf from inference.response_clean import reply_ends_complete_sentence from echocoach.prompts import PITCH_SYSTEM, resolve_aya_preset, system_prompt_for_mode from echocoach.teacher_voice import ( RagContext, append_chat_turn, build_teacher_messages, fetch_rag_context, history_to_messages, ) from echocoach.voiceout import ( extract_message_text, last_assistant_message, split_sentences, strip_references_for_tts, ) _THINK_OPEN = "<" + "think" + ">" _THINK_CLOSE = "" class _MockBackend: def load(self) -> None: pass def chat(self, messages, *, max_tokens=512, temperature=0.7): assert messages[0]["role"] == "system" assert messages[-1]["role"] == "user" return "Plants use sunlight to make food." def generate(self, prompt, *, max_tokens=512, temperature=0.7): return self.chat([{"role": "user", "content": prompt}], max_tokens=max_tokens) def test_append_chat_turn_messages_format(): from echocoach.teacher_voice import append_chat_turn history = append_chat_turn([], "Hi", "Hello") assert history == [ {"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Hello"}, ] extended = append_chat_turn(history, "Next?", "Sure.") assert extended == [ {"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Hello"}, {"role": "user", "content": "Next?"}, {"role": "assistant", "content": "Sure."}, ] def test_append_chat_turn_migrates_legacy_tuples(): from echocoach.teacher_voice import append_chat_turn legacy = [("Old question", "Old answer")] history = append_chat_turn(legacy, "New?", "New reply.") assert history[-2:] == [ {"role": "user", "content": "New?"}, {"role": "assistant", "content": "New reply."}, ] assert history[0] == {"role": "user", "content": "Old question"} def test_append_chat_turn_attaches_voice_to_assistant_message(tmp_path): wav = tmp_path / "reply.wav" wav.write_bytes(b"RIFF") history = append_chat_turn( [], "Hi", "Hello", assistant_display=f"{_THINK_OPEN}plan{_THINK_CLOSE}\n\nHello", voice_path=str(wav), ) assistant = history[-1] assert assistant["role"] == "assistant" assert isinstance(assistant["content"], list) assert assistant["content"][0].startswith(_THINK_OPEN) assert assistant["content"][1] == {"path": str(wav)} def test_history_to_messages_strips_assistant_reasoning(): history = [ {"role": "user", "content": "Hi"}, { "role": "assistant", "content": f"{_THINK_OPEN}planning{_THINK_CLOSE}\n\nHello there.", }, ] messages = history_to_messages(history) assert messages[-1]["content"] == "Hello there." def test_history_to_messages_tuple_pairs(): history = [("Hi", "Hello"), ("What is AI?", "Machine learning.")] messages = history_to_messages(history) assert messages == [ {"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Hello"}, {"role": "user", "content": "What is AI?"}, {"role": "assistant", "content": "Machine learning."}, ] def test_build_teacher_messages_includes_topic_and_rag(): rag = RagContext( context_block="[1] Plants need light.", references_markdown="**References**\n[1] Biology", chunk_count=1, ) messages = build_teacher_messages( mode="lesson", history=[], user_text="How do plants eat?", topic="Photosynthesis", rag=rag, ) assert "TeacherVoice" in messages[0]["content"] assert "lesson-planning" in messages[0]["content"] assert "Photosynthesis" in messages[0]["content"] assert "[1] Plants need light." in messages[-1]["content"] assert "How do plants eat?" in messages[-1]["content"] assert "Reply now in 2-4 complete spoken sentences only" in messages[-1]["content"] def test_coach_model_chain_dedupes(): from echocoach.config import EchoCoachConfig, LanguageOption cfg = EchoCoachConfig( asr_preset="whisper-cpp-tiny", tts_preset="piper-multilingual", realtime_tts_preset=None, coach_model="tiny-aya-global", coach_fallbacks=("minicpm5-1b", "tiny-aya-global"), max_seconds=30, languages=[LanguageOption("en", "English")], asr_presets={}, tts_presets={}, ) assert cfg.coach_model_chain() == ["tiny-aya-global", "minicpm5-1b"] def test_resolve_aya_preset_uses_global_only(): assert resolve_aya_preset("fr", "auto") == "tiny-aya-global" assert resolve_aya_preset("hi", "auto") == "tiny-aya-global" assert resolve_aya_preset("en", "tiny-aya-water") == "tiny-aya-global" def test_build_teacher_messages_includes_language_instruction(): messages = build_teacher_messages( mode="lesson", history=[], user_text="Explique le fine-tuning.", topic="ML", language="fr", ) assert "Target language: French" in messages[0]["content"] assert "Reply ONLY in French" in messages[0]["content"] def test_pitch_mode_system_prompt(): assert "public-speaking coach" in system_prompt_for_mode("pitch") assert PITCH_SYSTEM == system_prompt_for_mode("pitch") def test_split_sentences(): text = "Hello there. How are you? Great!" assert split_sentences(text) == ["Hello there.", "How are you?", "Great!"] def test_extract_message_text(): assert extract_message_text("Hello") == "Hello" assert extract_message_text([{"text": "Hello there."}]) == "Hello there." assert extract_message_text([{"text": "A"}, {"text": "B"}]) == "A\nB" def test_last_assistant_message(): history = [ {"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Hello there."}, ] assert last_assistant_message(history) == "Hello there." assert last_assistant_message([]) is None gradio_history = [ {"role": "user", "content": [{"text": "Hi"}]}, {"role": "assistant", "content": [{"text": "Hello there."}]}, ] assert last_assistant_message(gradio_history) == "Hello there." def test_vibevoice_preset_in_voice_models(): from echocoach.config import get_echo_coach_config config = get_echo_coach_config(reload=True) preset = config.get_tts("vibevoice-realtime-0.5b") assert preset.backend == "vibevoice" assert preset.model_id == "microsoft/VibeVoice-Realtime-0.5B" assert preset.realtime is True assert preset.streaming is True assert "en" in preset.supported_languages assert config.realtime_tts_preset == "vibevoice-realtime-0.5b" def test_strip_references_for_tts(): text = "Answer here.\n\n**References**\n[1] Source" assert strip_references_for_tts(text) == "Answer here." def test_fetch_rag_context_empty_store_warns(research_env): ctx = fetch_rag_context("What is photosynthesis?", session_id="", doc_ids=None) assert ctx is not None assert ctx.chunk_count == 0 assert ctx.warning def test_retrieval_query_exported(): from researchmind.scope import retrieval_query as rm_query assert rm_query("step 2?", topic="Photosynthesis") == "Photosynthesis: step 2?" def test_rag_turn_via_agent_mock(monkeypatch, tmp_path): from agent.models import Citation, ResearchChatResult from echocoach.teacher_voice import _rag_turn_via_agent from agent.trace import TraceRecorder result = ResearchChatResult( answer="Plants use light [1].\n\n**References**\n[1] Bio", citations=[ Citation( index=1, chunk_id="c1", doc_title="Bio", doc_uri="https://example.com", excerpt="Plants use light.", ) ], references_markdown="**References**\n[1] Bio", session_id="", trace_path=str(tmp_path / "trace.json"), ) class _RunnerStub: def run_researchmind_chat(self, **kwargs): return result monkeypatch.setattr("echocoach.teacher_voice.AgentRunner", _RunnerStub) trace = TraceRecorder(skill="teacher-voice", model="test", user_input={}) text, refs, status, display = _rag_turn_via_agent( "How do plants eat?", mode="explain", topic="Photosynthesis", session_id="", doc_ids=None, model_key="test", backend=_MockBackend(), trace=trace, ) assert "Plants use light" in text assert refs assert "1" in status assert display @pytest.fixture def research_env(tmp_path, monkeypatch): from researchmind.config import ResearchMindConfig cfg = ResearchMindConfig( data_dir=tmp_path / "rm", embed_model="test", auto_search=False, top_k=2, max_context_chunks=8, chunk_size=50, chunk_overlap=10, ) monkeypatch.setenv("RESEARCHMIND_DATA_DIR", str(cfg.data_dir)) monkeypatch.setenv("AGENT_OUTPUTS_DIR", str(tmp_path / "outputs")) def test_finalize_voice_reply_compacts_incomplete_sentence(): from echocoach.teacher_voice import _finalize_voice_reply from agent.trace import TraceRecorder class _Backend: def chat(self, messages, *, max_tokens=512, temperature=0.2): return ( "Finetuning adapts a pretrained small model to your task using extra labeled data. " "You keep most of the base weights and train on a focused dataset. " "That usually beats prompting alone for domain-specific work." ) trace = TraceRecorder(skill="teacher-voice", model="test", user_input={}) text, display = _finalize_voice_reply( "The lesson aims to teach how to fine-tune small", mode="lesson", backend=_Backend(), trace=trace, ) assert reply_ends_complete_sentence(text) assert "fine-tune" in text.lower() or "finetun" in text.lower() assert text == display def test_run_teacher_voice_text_turn_mock(monkeypatch, tmp_path): from echocoach.teacher_voice import run_teacher_voice_text_turn class _Tts: def synthesize(self, text, *, language, out_dir=None): out = (out_dir or tmp_path) / "out.wav" out.parent.mkdir(parents=True, exist_ok=True) sf.write(out, np.zeros(8000, dtype=np.float32), 16_000) return str(out), None monkeypatch.setattr("echocoach.voiceout.get_tts_backend", lambda _: _Tts()) result = run_teacher_voice_text_turn( "Tell me about plants.", [], mode="explain", backend=_MockBackend(), use_rag=False, ) assert result.user_text == "Tell me about plants." assert "sunlight" in result.assistant_text assert len(result.history) == 2 assistant = result.history[-1] assert assistant["role"] == "assistant" assert isinstance(assistant["content"], list) assert assistant["content"][0] == "Plants use sunlight to make food." assert assistant["content"][1]["path"] assert result.trace.get("skill") == "teacher-voice" def test_run_teacher_voice_turn_mock_asr(monkeypatch, tmp_path): from echocoach.teacher_voice import run_teacher_voice_turn wav = tmp_path / "turn.wav" sf.write(wav, np.zeros(16_000, dtype=np.float32), 16_000) class _Asr: def transcribe(self, path, *, language="en"): return "Tell me about plants." class _Tts: def synthesize(self, text, *, language, out_dir=None): out = (out_dir or tmp_path) / "out.wav" out.parent.mkdir(parents=True, exist_ok=True) sf.write(out, np.zeros(8000, dtype=np.float32), 16_000) return str(out), None monkeypatch.setattr("echocoach.teacher_voice.get_asr_backend", lambda _: _Asr()) monkeypatch.setattr("echocoach.voiceout.get_tts_backend", lambda _: _Tts()) result = run_teacher_voice_turn( str(wav), [], mode="explain", backend=_MockBackend(), use_rag=False, ) assert result.user_text == "Tell me about plants." assert "sunlight" in result.assistant_text assert len(result.history) == 2 assert result.history[0]["role"] == "user" assert result.history[1]["role"] == "assistant" assert result.trace.get("skill") == "teacher-voice"