S.H.O.N.A.R._Desktop_Companion/backend/tests/test_ai_adapters.py
avi 61f2124b1c AI progress: honest milestone bar from closed JSON contract units
Replace the char-count estimate (EST_OUTPUT_CHARS) with ContractProgress:
points are earned only when real output units complete — first token (5),
each closed contract key / array item (5-90), root closed (95), stored (100).
Reasoning/thinking streams no longer fake progress: the first thinking
delta fires the first-token milestone ('the model is alive') and holds.
Surface the job's tone through ProcessingJobOut/JobInfo so the bar can
label itself 'Summarizing dry wit…'. Test rewritten to pin the new
honest-thinking semantics.
2026-09-18 15:00:04 -05:00

251 lines
9.9 KiB
Python

"""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_streams_with_progress():
"""stream:true replies (SSE deltas) reassemble and tick progress."""
import json as _json
def sse(request: httpx.Request) -> httpx.Response:
payload = _json.loads(request.content.decode())
assert payload["stream"] is True
pieces = ['{"short": "Stand', 'up.", "detailed": "The team met."',
', "key_points": ["a"]', "}"]
lines = [f"data: {_json.dumps({'choices': [{'delta': {'content': p}}]})}"
for p in pieces]
lines.append("data: [DONE]")
return httpx.Response(
200, content=("\n\n".join(lines) + "\n\n").encode(),
headers={"content-type": "text/event-stream"})
p = OpenAICompatProvider("http://llm:8000", model="qwen",
http_client=mock_client(sse))
seen_pcts = []
res = await p.summarize("t", on_progress=seen_pcts.append)
assert res.short == "Standup."
assert res.key_points == ("a",)
assert seen_pcts and seen_pcts == sorted(seen_pcts)
assert all(0 <= p <= 99 for p in seen_pcts) # never claims 100 early
async def test_openai_compat_thinking_earns_only_first_token():
"""qwen3-on-llama.cpp streams reasoning_content for most of the run.
Thinking is real work but NOT contract output: it fires the 5%
first-token milestone once ("the model is alive") and then holds.
Progress only climbs when JSON contract units actually close — a
continuous percent over the thinking span would be a fake estimate."""
import json as _json
def sse(request: httpx.Request) -> httpx.Response:
think = [f"reasoning chunk {i} " for i in range(40)] # ~800 chars
lines = [f"data: {_json.dumps({'choices': [{'delta': {'reasoning_content': p}}]})}"
for p in think]
answer = _json.dumps({"short": "Done.", "key_points": []})
lines.append(f"data: {_json.dumps({'choices': [{'delta': {'content': answer}}]})}")
lines.append("data: [DONE]")
return httpx.Response(
200, content=("\n\n".join(lines) + "\n\n").encode(),
headers={"content-type": "text/event-stream"})
p = OpenAICompatProvider("http://llm:8000", model="qwen",
http_client=mock_client(sse))
seen_pcts = []
res = await p.summarize("t", on_progress=seen_pcts.append)
assert res.short == "Done." # reasoning never enters the reply
# Thinking earned exactly one tick: the first-token milestone. No
# fake crawl across the reasoning span.
assert seen_pcts[0] == 5
assert seen_pcts.count(5) == 1
assert max(seen_pcts) <= 99 # never claims 100 before storage
# Contract units closing does move the bar to the root milestone.
assert seen_pcts[-1] == 95
assert seen_pcts == sorted(seen_pcts)
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 True
body = "\n".join([
_json.dumps({"message": {"content": "{\"short\": \"S."}}),
_json.dumps({"message": {"content": "\", \"detailed\": \"D.\"}"}}),
_json.dumps({"done": True}),
])
return httpx.Response(
200, json=None, content=body.encode(),
headers={"content-type": "application/x-ndjson"})
p = OllamaProvider("http://ollama:11434", model="llama3",
http_client=mock_client(ok))
seen_pcts = []
res = await p.summarize("meeting notes", on_progress=seen_pcts.append)
assert res.short == "S." and res.model == "llama3"
assert seen_pcts and seen_pcts == sorted(seen_pcts) # monotonic progress
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")