[测试、性能、安全、验收与归档]:完成全量测试验收与 OpenSpec 归档,包含 CLI 验收、NewAPI smoke、安全扫描和主 spec 更新
This commit is contained in:
+1
-1
@@ -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=
|
||||
|
||||
@@ -8,6 +8,7 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.serena/
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
+6
-6
@@ -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 分钟。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,8 +86,10 @@ 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:
|
||||
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
|
||||
@@ -100,6 +102,10 @@ class OpenAICompatibleLlmProvider:
|
||||
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:
|
||||
raise ProviderError(
|
||||
ErrorCode.LLM_EMPTY_REPLY,
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user