[测试、性能、安全、验收与归档]:完成全量测试验收与 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
+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