Files
CyberGril/tests/test_providers.py
T
mobai 1012d8bb79 chore: project foundation
Contracts (the shared language):
- percept / state / context / utterance / render / audio / capabilities
- Capability model instead of vendor schemas (see below)

Providers (capability-based abstraction):
- LLM + Embedding: OpenAI-compatible shape IS the standard, adopted directly
- ASR: internal contract is an event stream (partial -> final), because
  /v1/audio/transcriptions has no partial and dialogue needs it for
  endpointing. OpenAI adapter degrades to supports_partial=False.
- TTS: internal contract carries tags/emotion/style, because
  /v1/audio/speech has no paralinguistics and [laughter]/[breath] are
  the core of feeling alive. Unsupported providers are stripped AND
  reported via Degradation -- silent degradation is forbidden.
- Avatar: no de-facto standard, custom RenderCmd protocol driven by State.

Core:
- mood: dynamical system, slow variables, negativity bias (grudge),
  numbing after prolonged negativity
- bazi: four pillars -> numeric vector; 'ming' is data, not prose
- projection: phi, system event -> in-world statement; uncovered events
  are counted as projection gaps

Storage:
- SQLite schema, three memory layers, dyad ledger
- accessibility floor 0.03 (not 0): forgettable but still there

Config split:
- models.yaml is the ONLY place model versions live
- character.yaml holds world rules / emotion params, no version numbers

58 tests pass; ruff clean.
2026-10-19 20:08:52 +08:00

161 lines
4.9 KiB
Python

"""Provider 层测试。
核心守的是这条设计判断:
OpenAI 兼容是 adapter,不是标准。
适配器用不了某项能力时,必须**显式降级**,不能静默丢弃。
如果哪天有人把 TTS 的 plan() 改成"静默删掉不支持的标签",
test_plan_reports_missing_paralinguistics 会红。
"""
from __future__ import annotations
import pytest
from cybergril.contracts import AudioChunk, LLMMessage
from cybergril.providers import (
EchoLLM,
HeuristicTurn,
Provider,
ProviderError,
Registry,
ScriptedASR,
SilenceTTS,
SpeechRequest,
TurnDecision,
bootstrap_registry,
registry,
)
from cybergril.providers.asr import ASREventType
# ---------------------------------------------------------------- 注册表
def test_registry_unknown_provider_raises() -> None:
reg = Registry()
with pytest.raises(ProviderError, match="未知的 llm provider"):
reg.create("llm", "nope")
def test_bootstrap_registers_builtins() -> None:
bootstrap_registry()
assert "echo" in registry.available("llm")
assert "scripted" in registry.available("asr")
assert "silence" in registry.available("tts")
# ---------------------------------------------------------------- TTS 规划
def test_plan_keeps_tags_when_supported() -> None:
tts = SilenceTTS(with_tags=True)
req = SpeechRequest(text="真的吗", tags=("laughter", "breath"), emotion="happy")
planned = tts.plan(req)
assert planned.request.tags == ("laughter", "breath")
assert planned.request.emotion == "happy"
assert planned.degradations == ()
assert not planned.lost_paralinguistics
def test_plan_reports_missing_paralinguistics() -> None:
"""关键测试:不支持副语言的 provider 必须上报,不能装没事。"""
tts = SilenceTTS(with_tags=False)
req = SpeechRequest(text="真的吗", tags=("laughter", "breath"), emotion="sad")
planned = tts.plan(req)
# 标签被剥掉
assert planned.request.tags == ()
# 但**必须**有降级记录
assert planned.lost_paralinguistics
caps = {d.capability for d in planned.degradations}
assert "speech_tags" in caps
assert "emotion_control" in caps
def test_plan_partial_tag_support() -> None:
"""只支持一部分标签时,只剥掉不支持的,并上报。"""
tts = SilenceTTS(with_tags=True) # 支持 laughter/breath/sigh/pause
req = SpeechRequest(text="唉", tags=("laughter", "cry")) # cry 不支持
planned = tts.plan(req)
assert planned.request.tags == ("laughter",)
assert any(d.capability == "speech_tags" for d in planned.degradations)
# ---------------------------------------------------------------- LLM
async def test_echo_llm_streams_and_closes() -> None:
llm = EchoLLM(prefix="echo: ")
parts: list[str] = []
done = False
async for d in llm.stream([LLMMessage(role="user", content="你好")]):
if d.done:
done = True
parts.append(d.text)
assert "".join(parts) == "echo: 你好"
assert done
async def test_echo_llm_complete() -> None:
llm = EchoLLM()
resp = await llm.complete([LLMMessage(role="user", content="abc")])
assert resp.text == "abc"
assert resp.finish_reason == "stop"
# ---------------------------------------------------------------- ASR
async def test_scripted_asr_emits_partial_then_final() -> None:
asr = ScriptedASR(["你好呀。"])
events = []
async def _audio():
yield AudioChunk(data=b"\x00" * 320, ts=0)
async for ev in asr.stream(_audio()):
events.append(ev)
assert events[-1].type is ASREventType.FINAL
assert events[-1].text == "你好呀。"
assert any(e.type is ASREventType.PARTIAL for e in events)
async def test_asr_capabilities_claim_partial() -> None:
asr = ScriptedASR()
assert asr.capabilities.supports_partial is True
# ---------------------------------------------------------------- Turn
async def test_turn_waits_on_hesitation() -> None:
turn = HeuristicTurn(min_ms=100, hard_end_ms=800)
sig = await turn.predict("我想想……那个", silence_ms=300)
assert sig.decision is TurnDecision.CONTINUE
sig2 = await turn.predict("我想想……那个", silence_ms=900)
assert sig2.decision is TurnDecision.END # 兜底阈值
async def test_turn_ends_on_sentence_punctuation() -> None:
turn = HeuristicTurn(min_ms=100, hard_end_ms=800)
sig = await turn.predict("今天天气不错。", silence_ms=300)
assert sig.decision is TurnDecision.END
async def test_turn_waits_when_too_short_silence() -> None:
turn = HeuristicTurn(min_ms=150, hard_end_ms=800)
sig = await turn.predict("说完了。", silence_ms=50)
assert sig.decision is TurnDecision.CONTINUE
# ---------------------------------------------------------------- 基类
def test_provider_repr_contains_caps() -> None:
p = EchoLLM()
assert isinstance(p, Provider)
assert "native" in repr(p)