[测试、性能、安全、验收与归档]:完成全量测试验收与 OpenSpec 归档,包含 CLI 验收、NewAPI smoke、安全扫描和主 spec 更新
This commit is contained in:
@@ -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
|
||||
|
||||
+50
-13
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user