G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
554
scripts/check-g7-external-proof.py
Normal file
554
scripts/check-g7-external-proof.py
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fail-closed validator for the complete G7 external evidence gate.
|
||||
|
||||
All four artifacts are required: authenticated public physical-microphone soak,
|
||||
process-local voice runtime sampling, pinned Linux topology sampling, and an
|
||||
independent human-held-out voice-gain pack. No artifact may contain raw audio or
|
||||
transcripts, and synthetic evidence cannot satisfy this gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
API_ROOT = REPO_ROOT / "apps/api"
|
||||
if str(API_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(API_ROOT))
|
||||
|
||||
from app.contracts.g7_external_evidence import G7HumanVoiceGainEvidencePack # noqa: E402
|
||||
from app.services.g7_voice_gain_evidence import evaluate_human_voice_gain # noqa: E402
|
||||
|
||||
|
||||
MINIMUM_SOAK_SECONDS = 3_000.0
|
||||
|
||||
# 이 게이트는 특정 벤더가 아니라 **운영하기로 결정한 provider** 를 강제한다.
|
||||
# 2026-08-08 소유자 결정: STT 는 노트북 상주 faster-whisper(`local_whisper`),
|
||||
# TTS 는 노트북 Higgs(`higgs`). 이전 값은 벤더 하나(`deepgram`/`openai`)가
|
||||
# 하드코딩돼 있었을 뿐 결정 기록이 아니었다.
|
||||
#
|
||||
# 목록은 닫혀 있다. 배치 STT(`openai`)는 interim/final 계약을 만족할 수 없어
|
||||
# 여기 들어오지 못한다. 어떤 경우에도 `expected_* == ready_*` 결속은 유지되므로
|
||||
# 선언한 provider 와 실제로 돈 provider 가 다르면 계속 실패한다.
|
||||
ALLOWED_STT_PROVIDERS = ("local_whisper", "deepgram")
|
||||
ALLOWED_TTS_PROVIDERS = ("higgs", "openai")
|
||||
|
||||
|
||||
def load_json(path: Path, errors: list[str], label: str) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
errors.append(f"{label}:missing")
|
||||
return {}
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append(f"{label}:unreadable")
|
||||
return {}
|
||||
if not isinstance(payload, dict):
|
||||
errors.append(f"{label}:invalid_shape")
|
||||
return {}
|
||||
return payload
|
||||
|
||||
|
||||
def _number(value: object) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
result = float(value)
|
||||
return result if math.isfinite(result) else None
|
||||
|
||||
|
||||
def _require(errors: list[str], condition: bool, code: str) -> None:
|
||||
if not condition:
|
||||
errors.append(code)
|
||||
|
||||
|
||||
def _iso(value: object) -> datetime | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC)
|
||||
|
||||
|
||||
def validate_public_soak(payload: dict[str, Any], errors: list[str]) -> None:
|
||||
prefix = "voice_soak"
|
||||
_require(
|
||||
errors,
|
||||
payload.get("schema_version") == "vignette.g7-public-voice-soak.v4",
|
||||
f"{prefix}:schema",
|
||||
)
|
||||
_require(errors, payload.get("mode") == "public_soak", f"{prefix}:mode")
|
||||
_require(errors, payload.get("status") == "passed", f"{prefix}:status")
|
||||
requested = _number(payload.get("requested_duration_seconds"))
|
||||
elapsed = _number(payload.get("elapsed_seconds"))
|
||||
_require(
|
||||
errors,
|
||||
requested is not None and requested >= MINIMUM_SOAK_SECONDS,
|
||||
f"{prefix}:duration_requested",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
elapsed is not None and requested is not None and elapsed >= requested,
|
||||
f"{prefix}:duration_elapsed",
|
||||
)
|
||||
for field in (
|
||||
"microphone_device_enumerated",
|
||||
"physical_capture_confirmed",
|
||||
"physical_microphone_used",
|
||||
"public_wss_used",
|
||||
"authenticated_public_wss_ready",
|
||||
"real_database_session_binding_required",
|
||||
"ready_provider_metadata_validated",
|
||||
"cookie_present",
|
||||
"session_id_present",
|
||||
"cloudflare_ray_present",
|
||||
"unauthenticated_handshake_accepted",
|
||||
):
|
||||
_require(errors, payload.get(field) is True, f"{prefix}:{field}")
|
||||
for field in (
|
||||
"raw_audio_retained",
|
||||
"provider_overrides_used",
|
||||
"cookie_value_logged",
|
||||
):
|
||||
_require(errors, payload.get(field) is False, f"{prefix}:{field}")
|
||||
_require(errors, payload.get("tls_version") == "TLSv1.3", f"{prefix}:tls")
|
||||
_require(
|
||||
errors,
|
||||
payload.get("unauthenticated_close_code") == 1008,
|
||||
f"{prefix}:unauth_close",
|
||||
)
|
||||
_require(errors, payload.get("close_code") == 1000, f"{prefix}:auth_close")
|
||||
_require(
|
||||
errors,
|
||||
payload.get("expected_stt_provider") in ALLOWED_STT_PROVIDERS,
|
||||
f"{prefix}:stt_provider_not_operated",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
payload.get("expected_tts_provider") in ALLOWED_TTS_PROVIDERS,
|
||||
f"{prefix}:tts_provider_not_operated",
|
||||
)
|
||||
for expected, ready in (
|
||||
("expected_stt_provider", "ready_stt_provider"),
|
||||
("expected_stt_model", "ready_stt_model"),
|
||||
("expected_tts_provider", "ready_tts_provider"),
|
||||
("expected_tts_model", "ready_tts_model"),
|
||||
):
|
||||
_require(
|
||||
errors,
|
||||
bool(payload.get(expected)) and payload.get(expected) == payload.get(ready),
|
||||
f"{prefix}:{ready}_mismatch",
|
||||
)
|
||||
|
||||
attempted = payload.get("turns_attempted")
|
||||
succeeded = payload.get("turns_succeeded")
|
||||
metrics = payload.get("turn_transcript_metrics")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(attempted, int) and attempted >= 10,
|
||||
f"{prefix}:turns_attempted",
|
||||
)
|
||||
_require(errors, succeeded == attempted, f"{prefix}:turns_succeeded")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(metrics, list) and len(metrics) == succeeded,
|
||||
f"{prefix}:turn_metrics_count",
|
||||
)
|
||||
if isinstance(metrics, list):
|
||||
interim_total = 0
|
||||
final_total = 0
|
||||
seen_turns: set[int] = set()
|
||||
for item in metrics:
|
||||
if not isinstance(item, dict):
|
||||
errors.append(f"{prefix}:turn_metric_shape")
|
||||
continue
|
||||
turn = item.get("turn_number")
|
||||
interim = item.get("interim_transcript_frames")
|
||||
final = item.get("speech_final_transcript_frames")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(turn, int) and turn > 0 and turn not in seen_turns,
|
||||
f"{prefix}:turn_number",
|
||||
)
|
||||
if isinstance(turn, int):
|
||||
seen_turns.add(turn)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(interim, int) and interim >= 1,
|
||||
f"{prefix}:interim_missing",
|
||||
)
|
||||
_require(errors, final == 1, f"{prefix}:speech_final_not_exact")
|
||||
_require(
|
||||
errors,
|
||||
_number(item.get("first_interim_latency_ms")) is not None,
|
||||
f"{prefix}:interim_latency",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
_number(item.get("speech_final_latency_ms")) is not None,
|
||||
f"{prefix}:final_latency",
|
||||
)
|
||||
interim_total += interim if isinstance(interim, int) else 0
|
||||
final_total += final if isinstance(final, int) else 0
|
||||
_require(
|
||||
errors,
|
||||
payload.get("interim_transcript_frames") == interim_total,
|
||||
f"{prefix}:interim_total",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
payload.get("speech_final_transcript_frames") == final_total,
|
||||
f"{prefix}:final_total",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(succeeded, int) and payload.get("reply_frames", 0) >= succeeded,
|
||||
f"{prefix}:reply_frames",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(succeeded, int) and payload.get("tts_end_frames", 0) >= succeeded,
|
||||
f"{prefix}:tts_end_frames",
|
||||
)
|
||||
_require(errors, payload.get("tts_binary_bytes", 0) > 0, f"{prefix}:tts_audio")
|
||||
_require(errors, payload.get("captured_pcm_bytes", 0) > 0, f"{prefix}:captured_pcm")
|
||||
_require(errors, not payload.get("blockers"), f"{prefix}:blockers")
|
||||
_require(errors, payload.get("failure_type") is None, f"{prefix}:failure")
|
||||
|
||||
|
||||
def validate_runtime(payload: dict[str, Any], errors: list[str]) -> None:
|
||||
prefix = "runtime"
|
||||
_require(
|
||||
errors,
|
||||
payload.get("schema_version") == "vignette.g7-runtime-sampling.v1",
|
||||
f"{prefix}:schema",
|
||||
)
|
||||
_require(errors, payload.get("status") == "passed", f"{prefix}:status")
|
||||
_require(
|
||||
errors,
|
||||
payload.get("privacy_boundary")
|
||||
== "metadata_only_no_audio_transcript_session_or_cookie_values",
|
||||
f"{prefix}:privacy",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
payload.get("cookie_present") is True
|
||||
and payload.get("cookie_value_logged") is False,
|
||||
f"{prefix}:cookie_boundary",
|
||||
)
|
||||
requested = payload.get("requested_samples")
|
||||
completed = payload.get("samples_completed")
|
||||
interval = _number(payload.get("interval_seconds"))
|
||||
_require(
|
||||
errors,
|
||||
isinstance(requested, int) and requested == completed and requested > 1,
|
||||
f"{prefix}:sample_count",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
interval is not None
|
||||
and isinstance(completed, int)
|
||||
and (completed - 1) * interval >= MINIMUM_SOAK_SECONDS,
|
||||
f"{prefix}:coverage",
|
||||
)
|
||||
samples = payload.get("samples")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(samples, list) and len(samples) == completed,
|
||||
f"{prefix}:samples",
|
||||
)
|
||||
high = payload.get("high_water")
|
||||
_require(errors, isinstance(high, dict), f"{prefix}:high_water")
|
||||
if isinstance(high, dict):
|
||||
for field in (
|
||||
"process_peak_rss_bytes",
|
||||
"process_threads_max",
|
||||
"websocket_high_water",
|
||||
"streaming_provider_session_high_water",
|
||||
"route_audio_buffer_high_water_bytes",
|
||||
"streaming_event_queue_high_water_items",
|
||||
):
|
||||
_require(errors, (_number(high.get(field)) or 0) > 0, f"{prefix}:{field}")
|
||||
capture_started = _iso(payload.get("started_at_utc"))
|
||||
worker_started = _iso(payload.get("worker_started_at_utc"))
|
||||
_require(
|
||||
errors,
|
||||
capture_started is not None
|
||||
and worker_started is not None
|
||||
and 0 <= (capture_started - worker_started).total_seconds() <= 120,
|
||||
f"{prefix}:fresh_worker_required",
|
||||
)
|
||||
if isinstance(samples, list) and samples:
|
||||
final = samples[-1].get("snapshot") if isinstance(samples[-1], dict) else None
|
||||
counters = final.get("counters") if isinstance(final, dict) else None
|
||||
limits = final.get("limits") if isinstance(final, dict) else None
|
||||
_require(
|
||||
errors,
|
||||
isinstance(final, dict)
|
||||
and final.get("schema_version") == "vignette.voice-runtime.v1",
|
||||
f"{prefix}:snapshot_schema",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(final, dict) and final.get("scope") == "single_api_worker",
|
||||
f"{prefix}:snapshot_scope",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(final, dict)
|
||||
and final.get("privacy_boundary")
|
||||
== "metadata_only_no_audio_transcript_or_session_ids",
|
||||
f"{prefix}:snapshot_privacy",
|
||||
)
|
||||
_require(errors, isinstance(counters, dict), f"{prefix}:final_counters")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(limits, dict) and limits.get("uvicorn_ws_max_queue") == 4,
|
||||
f"{prefix}:uvicorn_queue_cap",
|
||||
)
|
||||
if isinstance(counters, dict):
|
||||
for field in (
|
||||
"provider_fallback_total",
|
||||
"websocket_error_total",
|
||||
"audio_overflow_rejections_total",
|
||||
):
|
||||
_require(errors, counters.get(field) == 0, f"{prefix}:{field}")
|
||||
|
||||
|
||||
def validate_topology(payload: dict[str, Any], errors: list[str]) -> None:
|
||||
prefix = "topology"
|
||||
_require(
|
||||
errors,
|
||||
payload.get("schema_version") == "vignette.g7-topology-evidence.v1",
|
||||
f"{prefix}:schema",
|
||||
)
|
||||
_require(errors, payload.get("status") == "passed", f"{prefix}:status")
|
||||
scope = payload.get("scope")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(scope, dict) and scope.get("metadata_only") is True,
|
||||
f"{prefix}:privacy",
|
||||
)
|
||||
if isinstance(scope, dict):
|
||||
for field in (
|
||||
"raw_command_output_retained",
|
||||
"socket_endpoints_retained",
|
||||
"request_payloads_retained",
|
||||
"audio_retained",
|
||||
"transcripts_retained",
|
||||
):
|
||||
_require(errors, scope.get(field) is False, f"{prefix}:{field}")
|
||||
edge = scope.get("cloudflare_edge")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(edge, dict)
|
||||
and edge.get("internal_queue_measured") is False
|
||||
and edge.get("evidence_boundary") == "separate_external_artifact_required",
|
||||
f"{prefix}:edge_boundary",
|
||||
)
|
||||
requested = payload.get("requested")
|
||||
completed = payload.get("samples_completed")
|
||||
_require(errors, isinstance(requested, dict), f"{prefix}:requested")
|
||||
if isinstance(requested, dict):
|
||||
sample_count = requested.get("samples")
|
||||
interval = _number(requested.get("interval_seconds"))
|
||||
_require(
|
||||
errors,
|
||||
isinstance(sample_count, int)
|
||||
and sample_count == completed
|
||||
and sample_count > 1,
|
||||
f"{prefix}:sample_count",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
interval is not None
|
||||
and isinstance(completed, int)
|
||||
and (completed - 1) * interval >= MINIMUM_SOAK_SECONDS,
|
||||
f"{prefix}:coverage",
|
||||
)
|
||||
targets = payload.get("targets")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(targets, dict) and set(targets) == {"api", "caddy"},
|
||||
f"{prefix}:targets",
|
||||
)
|
||||
if isinstance(targets, dict):
|
||||
capture_started = _iso(payload.get("started_at_utc"))
|
||||
for role in ("api", "caddy"):
|
||||
target = targets.get(role)
|
||||
_require(errors, isinstance(target, dict), f"{prefix}:{role}_target")
|
||||
if isinstance(target, dict):
|
||||
_require(
|
||||
errors,
|
||||
isinstance(target.get("container_id"), str)
|
||||
and len(target["container_id"]) == 64,
|
||||
f"{prefix}:{role}_container",
|
||||
)
|
||||
digest = target.get("image_digest")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(digest, str)
|
||||
and digest.startswith("sha256:")
|
||||
and len(digest) == 71,
|
||||
f"{prefix}:{role}_image",
|
||||
)
|
||||
target_started = _iso(target.get("started_at"))
|
||||
_require(
|
||||
errors,
|
||||
capture_started is not None
|
||||
and target_started is not None
|
||||
and 0 <= (capture_started - target_started).total_seconds() <= 120,
|
||||
f"{prefix}:{role}_fresh_container_required",
|
||||
)
|
||||
summary = payload.get("summary")
|
||||
_require(errors, isinstance(summary, dict), f"{prefix}:summary")
|
||||
if isinstance(summary, dict):
|
||||
containers = summary.get("containers")
|
||||
host_tcp = summary.get("host_tcp")
|
||||
_require(errors, isinstance(containers, dict), f"{prefix}:container_summary")
|
||||
if isinstance(containers, dict):
|
||||
for role in ("api", "caddy"):
|
||||
values = containers.get(role)
|
||||
_require(errors, isinstance(values, dict), f"{prefix}:{role}_summary")
|
||||
if isinstance(values, dict):
|
||||
for field in (
|
||||
"cgroup_memory_peak_bytes_max",
|
||||
"cgroup_cpu_usage_usec_max",
|
||||
"cgroup_pids_current_max",
|
||||
"proc_vm_hwm_bytes_max",
|
||||
"proc_threads_max",
|
||||
"proc_fd_count_max",
|
||||
):
|
||||
_require(
|
||||
errors,
|
||||
(_number(values.get(field)) or 0) > 0,
|
||||
f"{prefix}:{role}_{field}",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(host_tcp, dict)
|
||||
and (_number(host_tcp.get("connections_max")) or 0) > 0,
|
||||
f"{prefix}:host_tcp",
|
||||
)
|
||||
_require(errors, payload.get("failure_type") is None, f"{prefix}:failure")
|
||||
|
||||
|
||||
def validate_human_gain(payload: dict[str, Any], errors: list[str]) -> dict[str, Any]:
|
||||
try:
|
||||
pack = G7HumanVoiceGainEvidencePack.model_validate(payload)
|
||||
result = evaluate_human_voice_gain(pack)
|
||||
except Exception as exc:
|
||||
errors.append(f"human_gain:invalid:{type(exc).__name__}")
|
||||
return {}
|
||||
if not result.passed:
|
||||
errors.extend(f"human_gain:{reason}" for reason in result.failure_reasons)
|
||||
return result.model_dump(mode="json")
|
||||
|
||||
|
||||
def validate_binding(
|
||||
voice: dict[str, Any],
|
||||
runtime: dict[str, Any],
|
||||
topology: dict[str, Any],
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
voice_host = voice.get("target_host")
|
||||
runtime_host = runtime.get("target_host")
|
||||
requested = topology.get("requested")
|
||||
topology_host = (
|
||||
requested.get("public_host") if isinstance(requested, dict) else None
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
bool(voice_host) and voice_host == runtime_host == topology_host,
|
||||
"binding:public_host",
|
||||
)
|
||||
windows = []
|
||||
for label, payload in (
|
||||
("voice", voice),
|
||||
("runtime", runtime),
|
||||
("topology", topology),
|
||||
):
|
||||
start, end = (
|
||||
_iso(payload.get("started_at_utc")),
|
||||
_iso(payload.get("ended_at_utc")),
|
||||
)
|
||||
if start is None or end is None or end < start:
|
||||
errors.append(f"binding:{label}_time")
|
||||
else:
|
||||
windows.append((start, end))
|
||||
if len(windows) == 3:
|
||||
_require(
|
||||
errors,
|
||||
max(start for start, _ in windows) <= min(end for _, end in windows),
|
||||
"binding:no_concurrent_overlap",
|
||||
)
|
||||
|
||||
|
||||
def artifact_sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
result = argparse.ArgumentParser(description=__doc__)
|
||||
result.add_argument("--voice-soak", type=Path, required=True)
|
||||
result.add_argument("--runtime", type=Path, required=True)
|
||||
result.add_argument("--topology", type=Path, required=True)
|
||||
result.add_argument("--human-voice-gain", type=Path, required=True)
|
||||
result.add_argument("--json", action="store_true")
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
errors: list[str] = []
|
||||
paths = {
|
||||
"voice_soak": args.voice_soak,
|
||||
"runtime": args.runtime,
|
||||
"topology": args.topology,
|
||||
"human_voice_gain": args.human_voice_gain,
|
||||
}
|
||||
payloads = {label: load_json(path, errors, label) for label, path in paths.items()}
|
||||
if payloads["voice_soak"]:
|
||||
validate_public_soak(payloads["voice_soak"], errors)
|
||||
if payloads["runtime"]:
|
||||
validate_runtime(payloads["runtime"], errors)
|
||||
if payloads["topology"]:
|
||||
validate_topology(payloads["topology"], errors)
|
||||
gain_result = (
|
||||
validate_human_gain(payloads["human_voice_gain"], errors)
|
||||
if payloads["human_voice_gain"]
|
||||
else {}
|
||||
)
|
||||
if all(payloads[label] for label in ("voice_soak", "runtime", "topology")):
|
||||
validate_binding(
|
||||
payloads["voice_soak"], payloads["runtime"], payloads["topology"], errors
|
||||
)
|
||||
report = {
|
||||
"schema_version": "vignette.g7-external-proof-result.v1",
|
||||
"passed": not errors,
|
||||
"clinical_claim_allowed": False,
|
||||
"artifact_sha256": {
|
||||
label: artifact_sha256(path)
|
||||
for label, path in paths.items()
|
||||
if path.is_file()
|
||||
},
|
||||
"human_voice_gain_result": gain_result,
|
||||
"errors": errors,
|
||||
}
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
return 0 if not errors else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue