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