From 1b884ac6a9e49d1cda0aabacd728d7a425d3bae6 Mon Sep 17 00:00:00 2001 From: mkbk Date: Wed, 17 Jun 2026 18:42:11 +0800 Subject: [PATCH] =?UTF-8?q?[=E6=B5=8B=E8=AF=95=E3=80=81=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E3=80=81=E5=AE=89=E5=85=A8=E3=80=81=E9=AA=8C=E6=94=B6=E4=B8=8E?= =?UTF-8?q?=E5=BD=92=E6=A1=A3]=EF=BC=9A=E5=AE=8C=E6=88=90=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E6=B5=8B=E8=AF=95=E9=AA=8C=E6=94=B6=E4=B8=8E=20OpenSp?= =?UTF-8?q?ec=20=E5=BD=92=E6=A1=A3=EF=BC=8C=E5=8C=85=E5=90=AB=20CLI=20?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E3=80=81NewAPI=20smoke=E3=80=81=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E6=89=AB=E6=8F=8F=E5=92=8C=E4=B8=BB=20spec=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- .gitignore | 1 + .../.openspec.yaml | 0 .../README.md | 0 .../design.md | 0 .../proposal.md | 0 .../specs/voice-pet-pipeline/spec.md | 0 .../tasks.md | 12 +- openspec/specs/voice-pet-pipeline/spec.md | 268 ++++++++++++++++++ src/owner_voice_pet/cli.py | 115 ++++++++ src/owner_voice_pet/config.py | 20 +- src/owner_voice_pet/llm.py | 63 +++- tests/test_cli_acceptance.py | 37 +++ tests/test_models_config.py | 1 + tests/test_pipeline_llm_tts.py | 31 +- 15 files changed, 522 insertions(+), 28 deletions(-) rename openspec/changes/{add-voice-pet-pipeline => archive/2026-06-17-add-voice-pet-pipeline}/.openspec.yaml (100%) rename openspec/changes/{add-voice-pet-pipeline => archive/2026-06-17-add-voice-pet-pipeline}/README.md (100%) rename openspec/changes/{add-voice-pet-pipeline => archive/2026-06-17-add-voice-pet-pipeline}/design.md (100%) rename openspec/changes/{add-voice-pet-pipeline => archive/2026-06-17-add-voice-pet-pipeline}/proposal.md (100%) rename openspec/changes/{add-voice-pet-pipeline => archive/2026-06-17-add-voice-pet-pipeline}/specs/voice-pet-pipeline/spec.md (100%) rename openspec/changes/{add-voice-pet-pipeline => archive/2026-06-17-add-voice-pet-pipeline}/tasks.md (96%) create mode 100644 openspec/specs/voice-pet-pipeline/spec.md create mode 100644 tests/test_cli_acceptance.py diff --git a/.env.example b/.env.example index 6bac1a5..3b3fcf4 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ OWNER_LLM_BASE_URL=https://newapi.mkbk.shop OWNER_LLM_API_KEY= -OWNER_LLM_MODEL=gpt-4o-mini +OWNER_LLM_MODEL=gpt-5.4-mini OWNER_LLM_API_STYLE=chat_completions OWNER_AUDIO_INPUT_DEVICE= OWNER_AUDIO_OUTPUT_DEVICE= diff --git a/.gitignore b/.gitignore index 5b51adb..06a60a3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ .pytest_cache/ .mypy_cache/ .ruff_cache/ +.serena/ build/ dist/ *.egg-info/ diff --git a/openspec/changes/add-voice-pet-pipeline/.openspec.yaml b/openspec/changes/archive/2026-06-17-add-voice-pet-pipeline/.openspec.yaml similarity index 100% rename from openspec/changes/add-voice-pet-pipeline/.openspec.yaml rename to openspec/changes/archive/2026-06-17-add-voice-pet-pipeline/.openspec.yaml diff --git a/openspec/changes/add-voice-pet-pipeline/README.md b/openspec/changes/archive/2026-06-17-add-voice-pet-pipeline/README.md similarity index 100% rename from openspec/changes/add-voice-pet-pipeline/README.md rename to openspec/changes/archive/2026-06-17-add-voice-pet-pipeline/README.md diff --git a/openspec/changes/add-voice-pet-pipeline/design.md b/openspec/changes/archive/2026-06-17-add-voice-pet-pipeline/design.md similarity index 100% rename from openspec/changes/add-voice-pet-pipeline/design.md rename to openspec/changes/archive/2026-06-17-add-voice-pet-pipeline/design.md diff --git a/openspec/changes/add-voice-pet-pipeline/proposal.md b/openspec/changes/archive/2026-06-17-add-voice-pet-pipeline/proposal.md similarity index 100% rename from openspec/changes/add-voice-pet-pipeline/proposal.md rename to openspec/changes/archive/2026-06-17-add-voice-pet-pipeline/proposal.md diff --git a/openspec/changes/add-voice-pet-pipeline/specs/voice-pet-pipeline/spec.md b/openspec/changes/archive/2026-06-17-add-voice-pet-pipeline/specs/voice-pet-pipeline/spec.md similarity index 100% rename from openspec/changes/add-voice-pet-pipeline/specs/voice-pet-pipeline/spec.md rename to openspec/changes/archive/2026-06-17-add-voice-pet-pipeline/specs/voice-pet-pipeline/spec.md diff --git a/openspec/changes/add-voice-pet-pipeline/tasks.md b/openspec/changes/archive/2026-06-17-add-voice-pet-pipeline/tasks.md similarity index 96% rename from openspec/changes/add-voice-pet-pipeline/tasks.md rename to openspec/changes/archive/2026-06-17-add-voice-pet-pipeline/tasks.md index a2130e2..1be3e9b 100644 --- a/openspec/changes/add-voice-pet-pipeline/tasks.md +++ b/openspec/changes/archive/2026-06-17-add-voice-pet-pipeline/tasks.md @@ -43,9 +43,9 @@ ## 6. 测试、性能、安全、验收与归档 -- [ ] 6.1 完成自动化测试套件;前置条件:所有模块已实现;验收标准:覆盖核心模型、配置、Transport、Wake/VAD/STT、Context、LLM/TTS、Pipeline、UI、资产;测试要点:`python3.11 -m unittest discover -s tests` 通过;优先级:P0;预计:60 分钟。 -- [ ] 6.2 完成端到端验收命令;前置条件:Pipeline 可用;验收标准:fixture 音频链路可从“小杰小杰”走到播放输出和上下文更新;测试要点:CLI acceptance 命令退出码为 0;优先级:P0;预计:45 分钟。 -- [ ] 6.3 完成 OpenAI/NewAPI smoke 验收;前置条件:提供临时 API key 环境变量;验收标准:调用配置的 base URL 获得非空 LLM 回复,失败时输出结构化诊断且不泄露 key;测试要点:真实 smoke 或可解释的网络失败证据;优先级:P0;预计:45 分钟。 -- [ ] 6.4 完成安全检查;前置条件:全部代码已实现;验收标准:仓库中不包含 API key,日志脱敏,默认不持久化原始音频;测试要点:secret grep、配置测试和日志脱敏测试通过;优先级:P0;预计:30 分钟。 -- [ ] 6.5 完成 OpenSpec archive;前置条件:所有任务完成并验证通过;验收标准:`openspec archive add-voice-pet-pipeline` 后主 spec 更新,change 进入 archive;测试要点:`openspec validate --all --strict` 通过;优先级:P0;预计:30 分钟。 -- [ ] 6.6 完成最终验收提交;前置条件:6.1 至 6.5 已完成;验收标准:完整测试、OpenSpec strict 校验、git status 审计通过后立即 commit;测试要点:提交信息使用“`[测试、性能、安全、验收与归档]:完成[具体功能描述],包含[关键变更]`”格式;优先级:P0;预计:20 分钟。 +- [x] 6.1 完成自动化测试套件;前置条件:所有模块已实现;验收标准:覆盖核心模型、配置、Transport、Wake/VAD/STT、Context、LLM/TTS、Pipeline、UI、资产;测试要点:`python3.11 -m unittest discover -s tests` 通过;优先级:P0;预计:60 分钟。 +- [x] 6.2 完成端到端验收命令;前置条件:Pipeline 可用;验收标准:fixture 音频链路可从“小杰小杰”走到播放输出和上下文更新;测试要点:CLI acceptance 命令退出码为 0;优先级:P0;预计:45 分钟。 +- [x] 6.3 完成 OpenAI/NewAPI smoke 验收;前置条件:提供临时 API key 环境变量;验收标准:调用配置的 base URL 获得非空 LLM 回复,失败时输出结构化诊断且不泄露 key;测试要点:真实 smoke 或可解释的网络失败证据;优先级:P0;预计:45 分钟。 +- [x] 6.4 完成安全检查;前置条件:全部代码已实现;验收标准:仓库中不包含 API key,日志脱敏,默认不持久化原始音频;测试要点:secret grep、配置测试和日志脱敏测试通过;优先级:P0;预计:30 分钟。 +- [x] 6.5 完成 OpenSpec archive;前置条件:所有任务完成并验证通过;验收标准:`openspec archive add-voice-pet-pipeline` 后主 spec 更新,change 进入 archive;测试要点:`openspec validate --all --strict` 通过;优先级:P0;预计:30 分钟。 +- [x] 6.6 完成最终验收提交;前置条件:6.1 至 6.5 已完成;验收标准:完整测试、OpenSpec strict 校验、git status 审计通过后立即 commit;测试要点:提交信息使用“`[测试、性能、安全、验收与归档]:完成[具体功能描述],包含[关键变更]`”格式;优先级:P0;预计:20 分钟。 diff --git a/openspec/specs/voice-pet-pipeline/spec.md b/openspec/specs/voice-pet-pipeline/spec.md new file mode 100644 index 0000000..806dbd8 --- /dev/null +++ b/openspec/specs/voice-pet-pipeline/spec.md @@ -0,0 +1,268 @@ +# voice-pet-pipeline Specification + +## Purpose +TBD - created by archiving change add-voice-pet-pipeline. Update Purpose after archive. +## Requirements +### Requirement: Python desktop pet runtime +The system SHALL be specified as a local Python desktop pet application that owns the voice pipeline, desktop pet state, local audio input, and local audio output in the first version. + +#### Scenario: App starts in local desktop mode +- **WHEN** the user starts the future application on macOS +- **THEN** the system SHALL initialize as a local desktop pet process rather than a remote service or browser-only tool + +#### Scenario: Implementation follows OpenSpec artifacts +- **WHEN** this OpenSpec change is implemented +- **THEN** the repository SHALL contain Python source code, automated tests, validation commands, and project-local pet assets aligned with the planning artifacts + +### Requirement: Local microphone and speaker transport +The system SHALL use the local microphone as the first-version input Transport and the local system speaker as the first-version output Transport. + +#### Scenario: Microphone input is available +- **WHEN** the configured microphone is available and permitted +- **THEN** the Transport SHALL provide streaming audio frames to the wakeword and VAD stages + +#### Scenario: Speaker output is available +- **WHEN** TTS returns a valid audio segment and the configured speaker is available +- **THEN** the Transport SHALL play the audio segment through the local speaker + +#### Scenario: Audio device is unavailable +- **WHEN** the microphone or speaker is missing, denied, or unsupported +- **THEN** the system SHALL expose a recoverable Transport error with a stable error code + +### Requirement: Wake word detection +The system SHALL listen locally for the Chinese wake word “小杰小杰” before accepting user speech for a conversation turn. + +#### Scenario: Wake word is detected +- **WHEN** the user says “小杰小杰” and the wakeword provider returns confidence above the configured threshold +- **THEN** the pipeline SHALL transition from wake listening to speech detection + +#### Scenario: Wake word is not detected +- **WHEN** background speech or noise does not match “小杰小杰” +- **THEN** the pipeline SHALL remain in wake listening and SHALL NOT invoke STT, LLM, or TTS + +#### Scenario: Wake model fails +- **WHEN** the wakeword provider cannot load or process audio +- **THEN** the system SHALL report a wakeword error and SHALL NOT crash the desktop pet process + +### Requirement: VAD speech endpoint detection +After wakeword detection, the system SHALL use VAD to identify when the user starts and stops speaking. + +#### Scenario: User begins speaking +- **WHEN** VAD detects continuous speech above the configured start threshold +- **THEN** the pipeline SHALL begin recording the current utterance + +#### Scenario: User stops speaking +- **WHEN** VAD detects continuous silence above the configured end threshold +- **THEN** the pipeline SHALL close the current audio segment and send it to STT + +#### Scenario: User says nothing after wakeword +- **WHEN** no speech is detected before the configured no-speech timeout +- **THEN** the pipeline SHALL return to wake listening without invoking STT or LLM + +#### Scenario: Recording exceeds maximum duration +- **WHEN** speech continues beyond the configured maximum utterance duration +- **THEN** the pipeline SHALL end the segment, mark the end reason, and continue to STT with the captured audio + +### Requirement: Local STT transcription +The system SHALL transcribe the captured user utterance through a local STT provider, with `sherpa-onnx` as the recommended first implementation candidate. + +#### Scenario: STT succeeds +- **WHEN** STT returns non-empty text for a captured audio segment +- **THEN** the pipeline SHALL add the trimmed text as a user message to the conversation context + +#### Scenario: STT returns empty text +- **WHEN** STT returns empty text, punctuation-only text, or an invalid transcript +- **THEN** the pipeline SHALL skip LLM invocation and return to wake listening with a recoverable status + +#### Scenario: STT provider fails +- **WHEN** the STT provider raises an error or cannot load its local model +- **THEN** the system SHALL emit an STT error code and SHALL recover to a state where future wake attempts are possible + +### Requirement: Conversation context management +The system SHALL maintain an in-memory conversation context for the active desktop pet session. + +#### Scenario: User transcript is accepted +- **WHEN** a valid STT transcript is produced +- **THEN** the system SHALL append it to the context as a user message before invoking the LLM + +#### Scenario: Assistant reply completes +- **WHEN** the LLM and TTS stages complete a reply +- **THEN** the system SHALL append the assistant text to the context + +#### Scenario: Context exceeds configured budget +- **WHEN** the context exceeds the configured message, character, or token budget +- **THEN** the system SHALL preserve the system prompt and most recent conversation turns while removing older ordinary messages + +### Requirement: Cloud LLM streaming reply +The system SHALL use a cloud LLM provider for reply generation, with OpenAI Responses API streaming as the recommended first implementation. + +#### Scenario: LLM stream begins +- **WHEN** the LLM provider receives valid context messages and configuration +- **THEN** it SHALL stream reply deltas rather than waiting for the full reply before producing output + +#### Scenario: LLM model is configured +- **WHEN** the system starts +- **THEN** the LLM model name SHALL be read from configuration and SHALL NOT be hard-coded in pipeline logic + +#### Scenario: LLM request fails +- **WHEN** the LLM provider times out, is rate limited, lacks credentials, or encounters a network error +- **THEN** the pipeline SHALL emit a structured LLM error and transition through error recovery to wake listening + +### Requirement: Local TTS synthesis and playback +The system SHALL synthesize assistant replies through a local TTS provider, with `sherpa-onnx` as the recommended first implementation candidate. + +#### Scenario: Sentence is ready for speech +- **WHEN** the LLM stream produces a complete sentence or configured text chunk +- **THEN** the TTS provider SHALL synthesize that text into an audio segment + +#### Scenario: First TTS segment is ready +- **WHEN** the first synthesized audio segment is available +- **THEN** the output Transport SHALL begin playback without waiting for every remaining LLM delta + +#### Scenario: TTS fails +- **WHEN** TTS returns empty audio or raises an error +- **THEN** the pipeline SHALL emit a structured TTS error and SHALL recover without terminating the application + +### Requirement: Pipeline state machine +The system SHALL expose deterministic pipeline states for `idle`, `wake_listening`, `speech_detecting`, `recording`, `transcribing`, `thinking`, `speaking`, `interrupted`, and `error_recovering`. + +#### Scenario: Normal conversation turn +- **WHEN** wakeword, VAD, STT, LLM, TTS, and playback all succeed +- **THEN** the state sequence SHALL progress through wake listening, speech detection, recording, transcribing, thinking, speaking, and back to wake listening + +#### Scenario: Recoverable provider error +- **WHEN** any provider fails during a conversation turn +- **THEN** the state machine SHALL enter error recovery and then return to wake listening after cleanup + +#### Scenario: UI subscribes to state +- **WHEN** the pipeline state changes +- **THEN** the desktop pet UI SHALL be able to update its visible state without directly invoking audio or model providers + +### Requirement: Desktop pet visual states +The system SHALL define desktop pet visual states for idle, listening, recording, thinking, speaking, and error feedback. + +#### Scenario: Wakeword is detected +- **WHEN** the pipeline enters speech detection or recording +- **THEN** the desktop pet SHALL show a listening or recording visual state + +#### Scenario: LLM is generating +- **WHEN** the pipeline enters thinking +- **THEN** the desktop pet SHALL show a thinking visual state + +#### Scenario: TTS is playing +- **WHEN** the pipeline enters speaking +- **THEN** the desktop pet SHALL show a speaking visual state + +#### Scenario: Error occurs +- **WHEN** the pipeline enters error recovery +- **THEN** the desktop pet SHALL show a concise visible error state and then return to idle or wake listening when recovered + +### Requirement: Generated pet asset specification +The system SHALL specify that production desktop pet images are generated with the image generation skill when usable, or with a reproducible project-local fallback when image generation output is unavailable or fails validation, and saved inside the project as transparent PNG assets. + +#### Scenario: Image generation output is usable +- **WHEN** pet images are generated with `imagegen` and pass role and transparency validation +- **THEN** the images SHALL be processed for transparency, validated, and saved to a project asset directory + +#### Scenario: Image generation output is unavailable or invalid +- **WHEN** image generation output is unavailable, inaccessible as a project file, or fails role/transparency validation +- **THEN** the system SHALL use a reproducible project-local fallback to create transparent PNG pet state assets and SHALL validate them before use + +#### Scenario: Planning phase is executed +- **WHEN** this OpenSpec change is implemented +- **THEN** generated or generated-derived pet assets SHALL be saved inside the project and SHALL NOT be referenced from a temporary generation directory + +### Requirement: Audio feedback suppression +The system SHALL prevent the desktop pet's own TTS playback from being treated as a new user wakeword or speech input. + +#### Scenario: TTS playback is active +- **WHEN** the output Transport is playing synthesized assistant speech +- **THEN** wakeword detection and VAD input processing SHALL be suppressed or ignored for that playback window by default + +#### Scenario: User speaks during playback +- **WHEN** the user speaks while TTS playback is active in the first version +- **THEN** the system SHALL ignore that speech unless a future interruption feature is explicitly specified + +### Requirement: Structured errors and logging +The system SHALL represent stage failures with structured error codes and log state transitions, latency metrics, and provider names. + +#### Scenario: Stage transition occurs +- **WHEN** the pipeline changes state +- **THEN** the system SHALL log the turn ID, old state, new state, timestamp, and triggering event + +#### Scenario: Provider call completes +- **WHEN** a provider call succeeds or fails +- **THEN** the system SHALL log provider name, stage, duration, and error code if present + +#### Scenario: Sensitive data is present +- **WHEN** logs are written +- **THEN** the system SHALL NOT log API keys, authorization headers, or raw credentials + +### Requirement: Performance targets +The system SHALL define measurable first-version latency and reliability targets for wakeword response, endpoint detection, LLM first output, and speech playback. + +#### Scenario: Wakeword latency is measured +- **WHEN** wakeword detection succeeds in a normal local environment +- **THEN** the system SHALL target visible listening feedback within 800 ms + +#### Scenario: User stops speaking +- **WHEN** the user stops speaking after a normal utterance +- **THEN** VAD endpoint detection SHALL target transition to transcription within 900 ms + +#### Scenario: LLM begins responding +- **WHEN** a valid transcript has been sent to the LLM provider +- **THEN** the system SHALL target first playable reply text within 3.5 seconds + +#### Scenario: Normal reply is spoken +- **WHEN** a normal Chinese question under 10 seconds is processed +- **THEN** the system SHALL target the start of spoken playback within 5 seconds P95 after the user stops speaking + +### Requirement: Security and privacy +The system SHALL protect credentials and minimize audio persistence. + +#### Scenario: API key is required +- **WHEN** the LLM provider needs an OpenAI API key +- **THEN** the key SHALL be loaded from environment or local uncommitted configuration and SHALL NOT be committed + +#### Scenario: Audio is processed +- **WHEN** user speech is captured for STT +- **THEN** raw audio SHALL NOT be persisted by default + +#### Scenario: Cloud LLM is invoked +- **WHEN** the system sends data to the cloud LLM +- **THEN** it SHALL send transcript text and necessary conversation context, not raw microphone audio + +### Requirement: Testability +The system SHALL be designed so each stage can be tested with mock providers and file-based audio fixtures. + +#### Scenario: Pipeline is unit tested +- **WHEN** mock providers produce deterministic events +- **THEN** tests SHALL verify state transitions, context updates, and error recovery + +#### Scenario: Audio fixture is replayed +- **WHEN** a file-based audio fixture containing “小杰小杰” and user speech is replayed in a test Transport +- **THEN** the pipeline SHALL be testable without using a live microphone + +#### Scenario: OpenSpec planning validation runs +- **WHEN** this planning change is complete +- **THEN** `openspec validate add-voice-pet-pipeline --strict` and `openspec validate --all --strict` SHALL pass + +### Requirement: Module-level git commit gates +The system implementation process SHALL require an immediate Git commit after each major module or milestone is completed, where a major module means a top-level task group from `tasks.md` or a phase milestone from `proposal.md`. + +#### Scenario: Major module is completed +- **WHEN** an implementer completes a top-level task group or milestone phase +- **THEN** the implementer SHALL run the applicable build, tests, lint/type checks, and OpenSpec validation before committing + +#### Scenario: Build validation fails before commit +- **WHEN** the applicable build, tests, lint/type checks, or OpenSpec validation fail +- **THEN** the implementer SHALL NOT create the module commit until the failure is fixed and validation passes + +#### Scenario: Module commit is created +- **WHEN** validation passes for the completed module +- **THEN** the implementer SHALL immediately create a Git commit using the Chinese message format “[模块名]:完成[具体功能描述],包含[关键变更]” + +#### Scenario: Intermediate work remains after module completion +- **WHEN** module-related changes remain unstaged or uncommitted after the module is completed +- **THEN** the implementer SHALL include those changes in the module commit or explicitly separate unrelated external changes before continuing to the next module + diff --git a/src/owner_voice_pet/cli.py b/src/owner_voice_pet/cli.py index 4473302..4869b7f 100644 --- a/src/owner_voice_pet/cli.py +++ b/src/owner_voice_pet/cli.py @@ -2,13 +2,33 @@ from __future__ import annotations import argparse import json +import re +import subprocess +from pathlib import Path +from .assets import validate_pet_assets from .config import AppConfig +from .conversation import ConversationContext +from .llm import MockLlmProvider, OpenAICompatibleLlmProvider +from .models import AudioFrame, ProviderError +from .pipeline import VoicePipeline +from .stt import MetadataSttProvider +from .transport import MemoryAudioTransport +from .tts import SineTtsProvider +from .vad import EnergyVadProvider, VadRecorder +from .wakeword import KeywordWakeWordProvider def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="owner-voice-pet") parser.add_argument("--show-config", action="store_true", help="Print non-secret config summary") + subparsers = parser.add_subparsers(dest="command") + subparsers.add_parser("acceptance", help="Run deterministic end-to-end pipeline acceptance") + subparsers.add_parser("validate-assets", help="Validate project pet assets") + subparsers.add_parser("security-check", help="Scan tracked files for leaked API keys") + 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") args = parser.parse_args(argv) if args.show_config: @@ -22,6 +42,7 @@ def main(argv: list[str] | None = None) -> int: "llm_base_url": config.llm_base_url, "llm_model": config.llm_model, "llm_api_style": config.llm_api_style, + "llm_stream": config.llm_stream, "llm_api_key_present": bool(config.llm_api_key), "asset_dir": str(config.asset_dir), }, @@ -31,5 +52,99 @@ def main(argv: list[str] | None = None) -> int: ) return 0 + if args.command == "validate-assets": + infos = validate_pet_assets(AppConfig.from_env().asset_dir) + print(json.dumps({"assets": len(infos), "valid": True}, ensure_ascii=False, sort_keys=True)) + return 0 + + if args.command == "security-check": + leaks = find_secret_leaks(Path.cwd()) + print(json.dumps({"secret_leaks": leaks, "valid": not leaks}, ensure_ascii=False, sort_keys=True)) + return 1 if leaks else 0 + + if args.command == "acceptance": + result = run_acceptance() + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + return 0 if result["success"] else 1 + + if args.command == "llm-smoke": + config = AppConfig.from_env() + if args.no_stream: + config = AppConfig( + wake_word=config.wake_word, + sample_rate=config.sample_rate, + channels=config.channels, + llm_base_url=config.llm_base_url, + llm_api_key=config.llm_api_key, + llm_model=config.llm_model, + llm_api_style=config.llm_api_style, + llm_stream=False, + audio_input_device=config.audio_input_device, + audio_output_device=config.audio_output_device, + asset_dir=config.asset_dir, + log_dir=config.log_dir, + context_max_messages=config.context_max_messages, + context_max_chars=config.context_max_chars, + ) + try: + provider = OpenAICompatibleLlmProvider(config, timeout_s=30) + messages = [ConversationContext().build_llm_messages()[0]] + messages.append(__import__("owner_voice_pet.models", fromlist=["Message"]).Message("user", args.message, 1.0)) + text = "".join(delta.text_delta for delta in provider.stream_reply(messages)).strip() + print(json.dumps({"ok": bool(text), "reply_preview": text[:80]}, ensure_ascii=False, sort_keys=True)) + return 0 if text else 1 + except ProviderError as exc: + print(json.dumps({"ok": False, "code": exc.code.value, "message": exc.message}, ensure_ascii=False, sort_keys=True)) + return 1 + parser.print_help() return 0 + + +def run_acceptance() -> dict[str, object]: + validate_pet_assets(AppConfig.from_env().asset_dir) + frames = [ + AudioFrame(b"\x80\x80", 16000, 1, 0, 0, {"duration_ms": 20, "wake_word": "小杰小杰", "wake_confidence": 0.99}), + AudioFrame(b"\xff\xff", 16000, 1, 20, 1, {"duration_ms": 20, "speech": True, "transcript": "你是谁"}), + AudioFrame(b"\xff\xff", 16000, 1, 40, 2, {"duration_ms": 20, "speech": True}), + AudioFrame(b"\x80\x80", 16000, 1, 60, 3, {"duration_ms": 20, "speech": False}), + AudioFrame(b"\x80\x80", 16000, 1, 80, 4, {"duration_ms": 20, "speech": False}), + ] + transport = MemoryAudioTransport(frames) + pipeline = VoicePipeline( + transport=transport, + wakeword=KeywordWakeWordProvider(), + vad_recorder=VadRecorder(EnergyVadProvider(), min_duration_ms=40, end_silence_ms=40), + stt=MetadataSttProvider(), + context=ConversationContext(), + llm=MockLlmProvider(["我是小杰桌宠,已经在线。"]), + tts=SineTtsProvider(), + ) + pipeline.load() + result = pipeline.run_once() + return { + "success": result.success, + "transcript": result.transcript, + "assistant_text": result.assistant_text, + "played_segments": result.played_segments, + "states": [state.value for state in result.states], + } + + +def find_secret_leaks(root: Path) -> list[str]: + completed = subprocess.run(["git", "ls-files"], cwd=root, check=True, stdout=subprocess.PIPE, text=True) + pattern = re.compile(r"sk-[A-Za-z0-9_\\-]{16,}") + leaks: list[str] = [] + for rel in completed.stdout.splitlines(): + path = root / rel + if not path.exists(): + continue + if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}: + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + if pattern.search(text): + leaks.append(rel) + return leaks diff --git a/src/owner_voice_pet/config.py b/src/owner_voice_pet/config.py index 362642a..5f9fdcd 100644 --- a/src/owner_voice_pet/config.py +++ b/src/owner_voice_pet/config.py @@ -12,10 +12,11 @@ class AppConfig: wake_word: str = "小杰小杰" sample_rate: int = 16000 channels: int = 1 - llm_base_url: str = "https://api.openai.com" + llm_base_url: str = "https://newapi.mkbk.shop" llm_api_key: str | None = None - llm_model: str = "gpt-4o-mini" + llm_model: str = "gpt-5.4-mini" llm_api_style: str = "chat_completions" + llm_stream: bool = True audio_input_device: str | None = None audio_output_device: str | None = None asset_dir: Path = Path("assets/pet") @@ -33,10 +34,11 @@ class AppConfig: wake_word=get("WAKE_WORD", "小杰小杰") or "小杰小杰", sample_rate=int(get("SAMPLE_RATE", "16000") or "16000"), channels=int(get("CHANNELS", "1") or "1"), - llm_base_url=(get("LLM_BASE_URL", "https://api.openai.com") or "").rstrip("/"), + llm_base_url=(get("LLM_BASE_URL", "https://newapi.mkbk.shop") or "").rstrip("/"), llm_api_key=get("LLM_API_KEY"), - llm_model=get("LLM_MODEL", "gpt-4o-mini") or "gpt-4o-mini", + llm_model=get("LLM_MODEL", "gpt-5.4-mini") or "gpt-5.4-mini", llm_api_style=get("LLM_API_STYLE", "chat_completions") or "chat_completions", + llm_stream=(get("LLM_STREAM", "1") or "1").lower() not in {"0", "false", "no"}, audio_input_device=get("AUDIO_INPUT_DEVICE"), audio_output_device=get("AUDIO_OUTPUT_DEVICE"), asset_dir=Path(get("ASSET_DIR", "assets/pet") or "assets/pet"), @@ -87,4 +89,14 @@ class AppConfig: "startup", ) ) + if not self.llm_base_url.startswith(("http://", "https://")): + errors.append( + ProviderError( + ErrorCode.CONFIG_MISSING_VALUE, + "OWNER_LLM_BASE_URL must start with http:// or https://", + False, + "config", + "startup", + ) + ) return errors diff --git a/src/owner_voice_pet/llm.py b/src/owner_voice_pet/llm.py index e771161..f322c52 100644 --- a/src/owner_voice_pet/llm.py +++ b/src/owner_voice_pet/llm.py @@ -48,7 +48,7 @@ class OpenAICompatibleLlmProvider: payload = { "model": self.config.llm_model, "messages": [{"role": m.role, "content": m.content} for m in messages], - "stream": True, + "stream": self.config.llm_stream, } yield from self._post_stream( f"{self.config.llm_base_url}/v1/chat/completions", @@ -60,7 +60,7 @@ class OpenAICompatibleLlmProvider: payload = { "model": self.config.llm_model, "input": [{"role": m.role, "content": m.content} for m in messages], - "stream": True, + "stream": self.config.llm_stream, } yield from self._post_stream( f"{self.config.llm_base_url}/v1/responses", @@ -86,18 +86,24 @@ class OpenAICompatibleLlmProvider: ) try: with self.urlopen(request, timeout=self.timeout_s) as response: + raw_body = _read_response_body(response) emitted = False - for raw_line in response: - line = raw_line.decode("utf-8", errors="replace").strip() - if not line or not line.startswith("data:"): - continue - data = line.removeprefix("data:").strip() - if data == "[DONE]": - yield ReplyDelta("", finish_reason="stop") - return - event = json.loads(data) - delta = parser(event) - if delta is not None: + if raw_body.lstrip().startswith(b"data:"): + for raw_line in raw_body.splitlines(): + line = raw_line.decode("utf-8", errors="replace").strip() + if not line or not line.startswith("data:"): + continue + data = line.removeprefix("data:").strip() + if data == "[DONE]": + yield ReplyDelta("", finish_reason="stop") + return + event = json.loads(data) + delta = parser(event) + if delta is not None: + emitted = True + yield delta + else: + for delta in _parse_non_stream_json(json.loads(raw_body.decode("utf-8"))): emitted = True yield delta if not emitted: @@ -145,5 +151,36 @@ def _parse_responses_sse(event: dict[str, Any]) -> ReplyDelta | None: return None +def _parse_non_stream_json(event: dict[str, Any]) -> Iterable[ReplyDelta]: + if "choices" in event: + choices = event.get("choices") or [] + if choices: + message = choices[0].get("message") or {} + text = str(message.get("content") or "") + if text: + yield ReplyDelta(text, is_sentence_boundary=_ends_sentence(text), finish_reason="stop") + return + if "output_text" in event: + text = str(event.get("output_text") or "") + if text: + yield ReplyDelta(text, is_sentence_boundary=_ends_sentence(text), finish_reason="stop") + return + output = event.get("output") or [] + parts: list[str] = [] + for item in output: + for content in item.get("content", []): + if content.get("type") in {"output_text", "text"}: + parts.append(str(content.get("text") or "")) + text = "".join(parts) + if text: + yield ReplyDelta(text, is_sentence_boundary=_ends_sentence(text), finish_reason="stop") + + +def _read_response_body(response: Any) -> bytes: + if hasattr(response, "read"): + return response.read() + return b"".join(response) + + def _ends_sentence(text: str) -> bool: return text.endswith(("。", "!", "?", ".", "!", "?", "\n")) diff --git a/tests/test_cli_acceptance.py b/tests/test_cli_acceptance.py new file mode 100644 index 0000000..4a9592b --- /dev/null +++ b/tests/test_cli_acceptance.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import io +import json +import unittest +from contextlib import redirect_stdout + +from owner_voice_pet.cli import main + + +class CliAcceptanceTests(unittest.TestCase): + def call(self, *args: str) -> tuple[int, dict[str, object]]: + buffer = io.StringIO() + with redirect_stdout(buffer): + code = main(list(args)) + return code, json.loads(buffer.getvalue()) + + def test_acceptance_command_runs_end_to_end(self) -> None: + code, data = self.call("acceptance") + self.assertEqual(code, 0) + self.assertTrue(data["success"]) + self.assertEqual(data["transcript"], "你是谁") + self.assertGreaterEqual(data["played_segments"], 1) + + def test_validate_assets_command(self) -> None: + code, data = self.call("validate-assets") + self.assertEqual(code, 0) + self.assertTrue(data["valid"]) + + def test_security_check_command_has_no_leaks(self) -> None: + code, data = self.call("security-check") + self.assertEqual(code, 0) + self.assertEqual(data["secret_leaks"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_models_config.py b/tests/test_models_config.py index dad368e..e49f79f 100644 --- a/tests/test_models_config.py +++ b/tests/test_models_config.py @@ -62,6 +62,7 @@ class ModelsConfigTests(unittest.TestCase): self.assertEqual(config.llm_base_url, "https://newapi.mkbk.shop") self.assertEqual(config.llm_api_key, "secret-value") self.assertEqual(config.llm_model, "test-model") + self.assertTrue(config.llm_stream) self.assertEqual(config.validate_basic(), []) finally: os.environ.clear() diff --git a/tests/test_pipeline_llm_tts.py b/tests/test_pipeline_llm_tts.py index af3ec0a..2725e9d 100644 --- a/tests/test_pipeline_llm_tts.py +++ b/tests/test_pipeline_llm_tts.py @@ -120,12 +120,14 @@ class PipelineLlmTtsTests(unittest.TestCase): def __exit__(self, *args: object) -> None: return None - def __iter__(self): + def read(self): event = {"choices": [{"delta": {"content": "你好。"}, "finish_reason": None}]} done = {"choices": [{"delta": {}, "finish_reason": "stop"}]} - yield f"data: {json.dumps(event, ensure_ascii=False)}\n".encode() - yield f"data: {json.dumps(done, ensure_ascii=False)}\n".encode() - yield b"data: [DONE]\n" + return ( + f"data: {json.dumps(event, ensure_ascii=False)}\n" + f"data: {json.dumps(done, ensure_ascii=False)}\n" + "data: [DONE]\n" + ).encode() requests = [] @@ -146,6 +148,27 @@ class PipelineLlmTtsTests(unittest.TestCase): self.assertTrue(body["stream"]) self.assertEqual(body["model"], "test-model") + def test_openai_non_stream_json_parser(self) -> None: + class FakeResponse: + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *args: object) -> None: + return None + + def read(self): + return json.dumps({"choices": [{"message": {"content": "非流式回复。"}}]}).encode() + + config = AppConfig( + llm_base_url="https://newapi.mkbk.shop", + llm_api_key="secret", + llm_model="test-model", + llm_api_style="chat_completions", + llm_stream=False, + ) + provider = OpenAICompatibleLlmProvider(config, urlopen=lambda request, timeout: FakeResponse()) + self.assertEqual(list(provider.stream_reply([Message("user", "你好", 1.0)]))[0].text_delta, "非流式回复。") + if __name__ == "__main__": unittest.main()