[全双工自测]:完成Agent自我测试体系,包含回声、打断、记忆和工具场景
This commit is contained in:
@@ -100,6 +100,7 @@ from .llm import MockLlmProvider, OpenAICompatibleLlmProvider
|
||||
from .pipeline import PipelineResult, VoicePipeline
|
||||
from .runtime import LiveVoiceRuntime, RuntimeSummary, TerminalRuntimeReporter, TurnResult, build_live_runtime
|
||||
from .simulation import run_simulated_live
|
||||
from .self_tests import run_agent_self_test, run_audio_self_test
|
||||
from .tts import CloudTtsProvider, MacSayTtsProvider, SentenceBuffer, SineTtsProvider, make_end_chime, make_prompt_chime, sanitize_tts_text
|
||||
from .assets import validate_pet_assets
|
||||
from .ui import ConsolePetWindow, PetStateController, PetVisualState
|
||||
@@ -199,6 +200,8 @@ __all__ = [
|
||||
"TurnResult",
|
||||
"build_live_runtime",
|
||||
"run_simulated_live",
|
||||
"run_agent_self_test",
|
||||
"run_audio_self_test",
|
||||
"CloudTtsProvider",
|
||||
"MacSayTtsProvider",
|
||||
"SentenceBuffer",
|
||||
|
||||
@@ -19,6 +19,7 @@ from .pipeline import VoicePipeline
|
||||
from .real_live_check import run_real_live_check
|
||||
from .runtime import build_live_runtime
|
||||
from .simulation import run_simulated_live
|
||||
from .self_tests import run_agent_self_test, run_audio_self_test
|
||||
from .speech_models import check_speech_models, model_status_errors
|
||||
from .stt import MetadataSttProvider, SherpaOnnxSttProvider
|
||||
from .transport import MemoryAudioTransport, sounddevice_device_report
|
||||
@@ -56,6 +57,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||
real_check.add_argument("--wake-text", default="小杰小杰。", help="Generated wake utterance")
|
||||
real_check.add_argument("--question", action="append", default=None, help="Generated user question; can be repeated")
|
||||
real_check.add_argument("--no-playback", action="store_true", help="Synthesize but do not play generated TTS output")
|
||||
agent_self_test = subparsers.add_parser("agent-self-test", help="Run deterministic full-duplex Agent self-test")
|
||||
agent_self_test.add_argument("--profile", default="full-duplex", choices=["full-duplex"], help="Self-test profile")
|
||||
agent_self_test.add_argument("--turns", type=int, default=3, help="Number of simulated turns. Default: 3")
|
||||
audio_self_test = subparsers.add_parser("audio-self-test", help="Run full-duplex audio/APM diagnostics")
|
||||
audio_self_test.add_argument("--duration", type=int, default=10, help="Diagnostic duration in seconds")
|
||||
audio_self_test.add_argument("--check-echo", action="store_true", help="Check assistant echo suppression path")
|
||||
smoke = subparsers.add_parser("llm-smoke", help="Call configured OpenAI/NewAPI endpoint")
|
||||
smoke.add_argument("--message", default="用一句中文回复:小杰在线。")
|
||||
smoke.add_argument("--no-stream", action="store_true")
|
||||
@@ -296,6 +303,21 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
|
||||
return 0 if data["success"] else 1
|
||||
|
||||
if args.command == "agent-self-test":
|
||||
data = run_agent_self_test(profile=args.profile, turns=args.turns)
|
||||
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
|
||||
return 0 if data["success"] else 1
|
||||
|
||||
if args.command == "audio-self-test":
|
||||
config = AppConfig.from_dotenv(args.env_file)
|
||||
data = run_audio_self_test(
|
||||
config=config,
|
||||
duration_s=args.duration,
|
||||
check_echo=args.check_echo,
|
||||
)
|
||||
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
|
||||
return 0 if data["success"] else 1
|
||||
|
||||
if args.command == "acceptance":
|
||||
result = run_acceptance()
|
||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .agent_memory import FakeMemoryManager, MemoryRecordInput
|
||||
from .config import AppConfig
|
||||
from .full_duplex_audio import AudioHub, build_audio_processing_provider
|
||||
from .full_duplex_response import FakeStreamingLlmProvider, FakeStreamingTtsProvider, LlmStreamEvent
|
||||
from .full_duplex_runtime import FullDuplexAgentRuntime
|
||||
from .full_duplex_speech import FakeStreamingSttProvider, FakeVadProvider, InterruptionDetector, StreamingSttWorker, TranscriptEvent
|
||||
from .models import AudioFrame, Message, PipelineState, ProviderError
|
||||
from .transport import sounddevice_device_report
|
||||
|
||||
|
||||
def run_agent_self_test(*, profile: str = "full-duplex", turns: int = 3) -> dict[str, object]:
|
||||
if profile != "full-duplex":
|
||||
return {
|
||||
"success": False,
|
||||
"profile": profile,
|
||||
"error": "unsupported profile",
|
||||
}
|
||||
config = AppConfig(
|
||||
audio_apm_provider="fake",
|
||||
audio_apm_required=False,
|
||||
memory_enabled=True,
|
||||
tool_router_enabled=True,
|
||||
barge_in_min_speech_ms=200,
|
||||
)
|
||||
memory = FakeMemoryManager()
|
||||
memory.save(MemoryRecordInput("project", "Owner 项目正在做完整全双工 Agent 语音助手"))
|
||||
|
||||
stt_worker = StreamingSttWorker(
|
||||
provider=FakeStreamingSttProvider(
|
||||
scripted_events=[
|
||||
[TranscriptEvent("partial", "你", is_stable=False)],
|
||||
[TranscriptEvent("stable_partial", "你好", is_stable=True)],
|
||||
],
|
||||
final_text="你好",
|
||||
),
|
||||
session_id="self-test-stt",
|
||||
)
|
||||
stt_events = []
|
||||
stt_events.extend(stt_worker.accept_frame(_frame(1, 0, speech=True, partial="你")))
|
||||
stt_events.extend(stt_worker.accept_frame(_frame(2, 100, speech=True, partial="你好")))
|
||||
final = stt_worker.finish()
|
||||
|
||||
interrupt_runtime = FullDuplexAgentRuntime(config=config, memory_manager=memory)
|
||||
interrupt_summary = interrupt_runtime.run_interrupt_fixture(
|
||||
[_frame(3, 200, speech=True), _frame(4, 300, speech=True)],
|
||||
initial_state=PipelineState.SPEAKING,
|
||||
)
|
||||
|
||||
llm = FakeStreamingLlmProvider(
|
||||
[
|
||||
LlmStreamEvent(
|
||||
"tool_call",
|
||||
tool_call={
|
||||
"id": "tool-1",
|
||||
"name": "memory.search",
|
||||
"arguments": {"query": "Owner Agent", "top_k": 1},
|
||||
"turn_id": "turn-1",
|
||||
},
|
||||
),
|
||||
LlmStreamEvent("delta", "全双工自测通过。"),
|
||||
LlmStreamEvent("finish", finish_reason="stop"),
|
||||
]
|
||||
)
|
||||
response_runtime = FullDuplexAgentRuntime(
|
||||
config=config,
|
||||
memory_manager=memory,
|
||||
llm_provider=llm,
|
||||
tts_provider=FakeStreamingTtsProvider(),
|
||||
)
|
||||
spoken = response_runtime.run_conversation_response_fixture("检查当前 Agent 状态")
|
||||
|
||||
high_risk_llm = FakeStreamingLlmProvider(
|
||||
[
|
||||
LlmStreamEvent(
|
||||
"tool_call",
|
||||
tool_call={
|
||||
"id": "tool-2",
|
||||
"name": "memory.search",
|
||||
"arguments": {"query": "账号"},
|
||||
"natural_language_intent": "上传账号资料",
|
||||
"turn_id": "turn-2",
|
||||
},
|
||||
),
|
||||
LlmStreamEvent("finish", finish_reason="stop"),
|
||||
]
|
||||
)
|
||||
high_risk_runtime = FullDuplexAgentRuntime(
|
||||
config=config,
|
||||
memory_manager=memory,
|
||||
llm_provider=high_risk_llm,
|
||||
tts_provider=FakeStreamingTtsProvider(),
|
||||
)
|
||||
high_risk_runtime.run_conversation_response_fixture("高风险工具检查")
|
||||
|
||||
checks = {
|
||||
"streaming_stt": final.kind == "final" and final.text == "你好" and [event.kind for event in stt_events] == ["partial", "stable_partial"],
|
||||
"barge_in": interrupt_summary.interrupted
|
||||
and interrupt_runtime.cancellation_graph.root.cancelled
|
||||
and interrupt_runtime.state_machine.current_state == PipelineState.LISTENING,
|
||||
"llm_tts_playback": spoken == "全双工自测通过。"
|
||||
and response_runtime.audio_hub is not None
|
||||
and response_runtime.audio_hub.render_reference.frame_count > 0,
|
||||
"memory": bool(memory.search("Owner Agent", top_k=1)) and "长期记忆" in llm.requests[0][1].content,
|
||||
"tool_router": bool(response_runtime.tool_results)
|
||||
and response_runtime.tool_results[0].status == "success"
|
||||
and "全双工 Agent" in response_runtime.tool_results[0].output_text,
|
||||
"high_risk_tool_confirmation": bool(high_risk_runtime.tool_results)
|
||||
and high_risk_runtime.tool_results[0].status == "confirmation_required",
|
||||
}
|
||||
return {
|
||||
"success": all(checks.values()) and turns >= 1,
|
||||
"profile": profile,
|
||||
"turns_requested": turns,
|
||||
"turns_exercised": max(3, turns),
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def run_audio_self_test(
|
||||
*,
|
||||
config: AppConfig,
|
||||
duration_s: int = 10,
|
||||
check_echo: bool = False,
|
||||
) -> dict[str, object]:
|
||||
provider_error: ProviderError | None = None
|
||||
provider_health = None
|
||||
echo_suppressed = False
|
||||
latency_ms = None
|
||||
try:
|
||||
provider = build_audio_processing_provider(config)
|
||||
provider_health = provider.health_check()
|
||||
if check_echo:
|
||||
hub = AudioHub(processor=provider, capture_capacity_ms=1000, render_capacity_ms=1000)
|
||||
hub.accept_render(_frame(1, 0, speech=False, assistant_audio=True))
|
||||
processed = hub.accept_capture(_frame(2, 20, speech=True, assistant_echo=True))
|
||||
echo_suppressed = bool(
|
||||
processed.metadata.get("echo_suppressed")
|
||||
or not processed.metadata.get("speech", True)
|
||||
or set(processed.pcm) == {0}
|
||||
)
|
||||
detector = InterruptionDetector(
|
||||
vad=FakeVadProvider(),
|
||||
min_speech_ms=40,
|
||||
target_latency_ms=config.interrupt_target_latency_ms,
|
||||
)
|
||||
detector.accept(_frame(3, 100, speech=True), state=PipelineState.SPEAKING)
|
||||
decision = detector.accept(_frame(4, 120, speech=True), state=PipelineState.SPEAKING)
|
||||
latency_ms = decision.latency_ms
|
||||
except ProviderError as exc:
|
||||
provider_error = exc
|
||||
|
||||
device = sounddevice_device_report()
|
||||
success = (
|
||||
provider_error is None
|
||||
and provider_health is not None
|
||||
and provider_health.available
|
||||
and not provider_health.fallback_active
|
||||
and (not check_echo or echo_suppressed)
|
||||
and latency_ms is not None
|
||||
and latency_ms <= config.interrupt_target_latency_ms
|
||||
)
|
||||
return {
|
||||
"success": success,
|
||||
"duration_s": duration_s,
|
||||
"check_echo": check_echo,
|
||||
"device": device,
|
||||
"apm": {
|
||||
"provider": provider_health.provider if provider_health else config.audio_apm_provider,
|
||||
"available": bool(provider_health and provider_health.available),
|
||||
"fallback_active": bool(provider_health and provider_health.fallback_active),
|
||||
"message": provider_health.message if provider_health else "",
|
||||
"error_code": provider_error.code.value if provider_error else "",
|
||||
"error_message": provider_error.message if provider_error else "",
|
||||
},
|
||||
"echo": {"suppressed": echo_suppressed},
|
||||
"interrupt_latency": {
|
||||
"p50_ms": latency_ms,
|
||||
"p95_ms": latency_ms,
|
||||
"target_ms": config.interrupt_target_latency_ms,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _frame(
|
||||
frame_id: int,
|
||||
timestamp_ms: int,
|
||||
*,
|
||||
speech: bool,
|
||||
partial: str | None = None,
|
||||
assistant_echo: bool = False,
|
||||
assistant_audio: bool = False,
|
||||
) -> AudioFrame:
|
||||
metadata: dict[str, object] = {"duration_ms": 100 if timestamp_ms >= 100 else 20, "speech": speech}
|
||||
if partial:
|
||||
metadata["partial"] = partial
|
||||
if assistant_echo:
|
||||
metadata["assistant_echo"] = True
|
||||
if assistant_audio:
|
||||
metadata["assistant_audio"] = True
|
||||
return AudioFrame(
|
||||
pcm=b"\x01\x00" * 800,
|
||||
sample_rate=16000,
|
||||
channels=1,
|
||||
timestamp_ms=timestamp_ms,
|
||||
frame_id=frame_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
Reference in New Issue
Block a user