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.
161 lines
4.9 KiB
Python
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)
|