[测试、性能、安全、验收与归档]:完成全量测试验收与 OpenSpec 归档,包含 CLI 验收、NewAPI smoke、安全扫描和主 spec 更新

This commit is contained in:
mkbk
2026-06-17 18:42:11 +08:00
parent 667c8941f2
commit 1b884ac6a9
15 changed files with 522 additions and 28 deletions
+1 -1
View File
@@ -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=
+1
View File
@@ -8,6 +8,7 @@ __pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.serena/
build/
dist/
*.egg-info/
@@ -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 分钟。
+268
View File
@@ -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
+115
View File
@@ -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
+16 -4
View File
@@ -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
+50 -13
View File
@@ -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"))
+37
View File
@@ -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()
+1
View File
@@ -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()
+27 -4
View File
@@ -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()