Standalone Shonar Desktop: vendor portable sources + local engine; decouple from ~/Projects/Shonar
- shared/ = portable Android-origin sources vendored from deferred/desktop-server (app/build.gradle.kts srcDir repointed; PlaybackController.kt excluded as Android-only) - backend/ = bundled-lite engine (SQLite + inline queue); .venv symlinked from the old checkout, PYTHONPATH pins THIS backend's code over any editable install - repoRoot() resolves this project dir (env SHONAR_REPO still wins); desktop-dev.sh watches shared/ + backend/ - Verified: :app:compileKotlin + :app:test green (23 tests); engine boots on :8010, self-migrates, /healthz ok
This commit is contained in:
commit
76c867fca4
136 changed files with 21099 additions and 0 deletions
184
backend/tests/test_ai_adapters.py
Normal file
184
backend/tests/test_ai_adapters.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"""Adapter wire-protocol tests (M7): JSON shapes in/out, error mapping.
|
||||
|
||||
HTTP adapters take an injectable httpx client; faster-whisper is an
|
||||
optional dependency and is only exercised when installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from shonar.core.config import Settings
|
||||
from shonar.services import ai
|
||||
from shonar.services.ai import ProviderConfigError, SummaryResult
|
||||
from shonar.services.ai.ollama import OllamaProvider
|
||||
from shonar.services.ai.openai_compat import OpenAICompatProvider
|
||||
from shonar.services.ai.whisper_http import WhisperHttpProvider
|
||||
|
||||
|
||||
def mock_client(handler) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
# --- factories -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_factories_none_means_skip():
|
||||
s = Settings(transcription_provider="none", llm_provider="none")
|
||||
assert ai.get_transcription_provider(s) is None
|
||||
assert ai.get_llm_provider(s) is None
|
||||
|
||||
|
||||
def test_factories_unknown_names_raise_config_error():
|
||||
s = Settings(transcription_provider="whisper-9k")
|
||||
with pytest.raises(ProviderConfigError):
|
||||
ai.get_transcription_provider(s)
|
||||
s = Settings(llm_provider="clippy")
|
||||
with pytest.raises(ProviderConfigError):
|
||||
ai.get_llm_provider(s)
|
||||
|
||||
|
||||
def test_factories_missing_urls_raise_config_error():
|
||||
s = Settings(transcription_provider="whisper_http", transcription_base_url="")
|
||||
with pytest.raises(ProviderConfigError):
|
||||
ai.get_transcription_provider(s)
|
||||
s = Settings(llm_provider="openai_compat", llm_base_url="http://x", llm_model="")
|
||||
with pytest.raises(ProviderConfigError):
|
||||
ai.get_llm_provider(s)
|
||||
|
||||
|
||||
def test_faster_whisper_missing_dep_is_config_error():
|
||||
if importlib.util.find_spec("faster_whisper") is not None:
|
||||
pytest.skip("faster-whisper installed; missing-dep path N/A")
|
||||
# Constructor validates eagerly so misconfiguration fails at startup,
|
||||
# not on the first recording.
|
||||
with pytest.raises(ProviderConfigError):
|
||||
ai.get_transcription_provider(Settings(transcription_provider="faster_whisper"))
|
||||
|
||||
|
||||
# --- whisper_http -----------------------------------------------------------------
|
||||
|
||||
|
||||
def whisper_ok(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/v1/audio/transcriptions"
|
||||
assert request.method == "POST"
|
||||
return httpx.Response(200, json={
|
||||
"text": "hello world",
|
||||
"language": "en",
|
||||
"segments": [{"start": 0.0, "end": 1.2, "text": "hello world"}],
|
||||
})
|
||||
|
||||
|
||||
async def test_whisper_http_happy_path():
|
||||
p = WhisperHttpProvider("http://stt:8000", model="small",
|
||||
http_client=mock_client(whisper_ok))
|
||||
res = await p.transcribe(b"\x00" * 16, "audio/wav")
|
||||
assert res.text == "hello world"
|
||||
assert res.language == "en"
|
||||
assert [(s.start, s.end, s.text) for s in res.segments] == [(0.0, 1.2, "hello world")]
|
||||
assert res.model == "small"
|
||||
|
||||
|
||||
async def test_whisper_http_401_is_config_error():
|
||||
async def denied(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(401, json={"detail": "nope"})
|
||||
p = WhisperHttpProvider("http://stt:8000", http_client=mock_client(denied))
|
||||
with pytest.raises(ProviderConfigError):
|
||||
await p.transcribe(b"\x00" * 16, "audio/wav")
|
||||
|
||||
|
||||
async def test_whisper_http_503_is_transient():
|
||||
async def busy(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(503, text="overloaded")
|
||||
from shonar.services.ai import ProviderTransientError
|
||||
|
||||
p = WhisperHttpProvider("http://stt:8000", http_client=mock_client(busy))
|
||||
with pytest.raises(ProviderTransientError):
|
||||
await p.transcribe(b"\x00" * 16, "audio/wav")
|
||||
|
||||
|
||||
# --- openai_compat ------------------------------------------------------------------
|
||||
|
||||
|
||||
def chat_ok(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/v1/chat/completions"
|
||||
body = {
|
||||
"short": "Standup.",
|
||||
"detailed": "The team met.",
|
||||
"key_points": ["a", "b"],
|
||||
"decisions": ["ship"],
|
||||
"action_items": [{"not": "a string"}, "call ana"],
|
||||
"questions": [],
|
||||
"extra_key": "ignored",
|
||||
}
|
||||
import json as _json
|
||||
|
||||
return httpx.Response(200, json={"choices": [{"message": {"content": _json.dumps(body)}}]})
|
||||
|
||||
|
||||
async def test_openai_compat_parses_and_sanitizes():
|
||||
p = OpenAICompatProvider("http://llm:8000", model="qwen",
|
||||
http_client=mock_client(chat_ok))
|
||||
res = await p.summarize("a very long meeting transcript", title="Standup")
|
||||
assert isinstance(res, SummaryResult)
|
||||
assert res.short == "Standup."
|
||||
assert res.action_items == ("call ana",) # non-strings dropped
|
||||
assert res.model == "qwen"
|
||||
|
||||
|
||||
async def test_openai_compat_partial_json_gets_defaults():
|
||||
import json as _json
|
||||
|
||||
async def partial(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={
|
||||
"choices": [{"message": {"content": _json.dumps({"short": "Hi."})}}]
|
||||
})
|
||||
p = OpenAICompatProvider("http://llm:8000", model="qwen",
|
||||
http_client=mock_client(partial))
|
||||
res = await p.summarize("hi")
|
||||
assert res.short == "Hi."
|
||||
assert res.detailed == "" and res.key_points == ()
|
||||
|
||||
|
||||
async def test_openai_compat_non_json_is_transient():
|
||||
async def garbage(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={
|
||||
"choices": [{"message": {"content": "Sure! Here it is..."}}]
|
||||
})
|
||||
from shonar.services.ai import ProviderTransientError
|
||||
|
||||
p = OpenAICompatProvider("http://llm:8000", model="qwen",
|
||||
http_client=mock_client(garbage))
|
||||
with pytest.raises(ProviderTransientError):
|
||||
await p.summarize("hi")
|
||||
|
||||
|
||||
# --- ollama ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_ollama_happy_path():
|
||||
import json as _json
|
||||
|
||||
async def ok(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/api/chat"
|
||||
payload = _json.loads(request.content.decode())
|
||||
assert payload["format"] == "json" and payload["stream"] is False
|
||||
return httpx.Response(200, json={
|
||||
"message": {"content": _json.dumps({"short": "S.", "detailed": "D."})}
|
||||
})
|
||||
p = OllamaProvider("http://ollama:11434", model="llama3",
|
||||
http_client=mock_client(ok))
|
||||
res = await p.summarize("meeting notes")
|
||||
assert res.short == "S." and res.model == "llama3"
|
||||
|
||||
|
||||
async def test_ollama_404_is_config_error():
|
||||
async def missing(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(404, text="model not found")
|
||||
p = OllamaProvider("http://ollama:11434", model="nope",
|
||||
http_client=mock_client(missing))
|
||||
with pytest.raises(ProviderConfigError):
|
||||
await p.summarize("hi")
|
||||
Loading…
Add table
Add a link
Reference in a new issue