sable-sphynx 0.7.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sable_sphynx-0.7.0.dev0.dist-info/METADATA +152 -0
- sable_sphynx-0.7.0.dev0.dist-info/RECORD +174 -0
- sable_sphynx-0.7.0.dev0.dist-info/WHEEL +5 -0
- sable_sphynx-0.7.0.dev0.dist-info/top_level.txt +1 -0
- sphynx/__init__.py +469 -0
- sphynx/__init__.pyi +376 -0
- sphynx/_analytical_client.py +77 -0
- sphynx/_analytical_values.py +252 -0
- sphynx/_audit_client.py +173 -0
- sphynx/_comparison_adapters.py +113 -0
- sphynx/_componentized_full_rag.py +34 -0
- sphynx/_composed_pipeline.py +109 -0
- sphynx/_full_rag_grading.py +218 -0
- sphynx/_full_rag_trace.py +221 -0
- sphynx/_local_eval.py +268 -0
- sphynx/_pipeline_core.py +243 -0
- sphynx/_pr_client.py +237 -0
- sphynx/_rag_diagnostics_calibration.py +285 -0
- sphynx/_rag_diagnostics_catalog.py +39 -0
- sphynx/_rag_diagnostics_common.py +26 -0
- sphynx/_rag_diagnostics_contracts.py +186 -0
- sphynx/_rag_diagnostics_engine.py +243 -0
- sphynx/_rag_diagnostics_importers.py +242 -0
- sphynx/_rag_diagnostics_ranking.py +81 -0
- sphynx/_rag_diagnostics_semantic.py +325 -0
- sphynx/_rag_ranking.py +149 -0
- sphynx/_speech_benchmark_scoring.py +140 -0
- sphynx/_speech_benchmark_sources.py +275 -0
- sphynx/_speech_complete_scoring.py +218 -0
- sphynx/_speech_complete_source.py +118 -0
- sphynx/_speech_kido.py +294 -0
- sphynx/_speech_scoring.py +77 -0
- sphynx/_track_capture.py +213 -0
- sphynx/analytical_views.py +515 -0
- sphynx/assessment_contracts.py +378 -0
- sphynx/assessment_selection.py +57 -0
- sphynx/audit_access.py +245 -0
- sphynx/audit_contracts.py +204 -0
- sphynx/audit_requests.py +180 -0
- sphynx/authoring.py +188 -0
- sphynx/benchmarks.py +568 -0
- sphynx/candidates.py +62 -0
- sphynx/catalog.py +272 -0
- sphynx/chunking.py +372 -0
- sphynx/clickhouse_reader.py +169 -0
- sphynx/client.py +486 -0
- sphynx/comparison_core.py +149 -0
- sphynx/comparison_views.py +223 -0
- sphynx/components.py +1661 -0
- sphynx/contracts.py +514 -0
- sphynx/customer_rerank.py +299 -0
- sphynx/customer_retrieval.py +146 -0
- sphynx/data/benchmark-releases.v1.json +164 -0
- sphynx/data/public-benchmark-release.v1.json +354 -0
- sphynx/data/rag-diagnostics-synthetic.v1.json +6982 -0
- sphynx/data/rag-paper-references.v1.json +176 -0
- sphynx/data/rag-paper-schemas.v1.json +1069 -0
- sphynx/data/rag-rerank-v1.json +507 -0
- sphynx/dataset_catalog_data.py +1214 -0
- sphynx/dataset_readiness.py +138 -0
- sphynx/dataset_v3.py +999 -0
- sphynx/demo.py +130 -0
- sphynx/demo_candidates/__init__.py +1 -0
- sphynx/demo_candidates/dense.py +11 -0
- sphynx/demo_candidates/lexical.py +21 -0
- sphynx/diagnostics.py +207 -0
- sphynx/documentation.py +737 -0
- sphynx/documentation_candidate.py +939 -0
- sphynx/evals.py +538 -0
- sphynx/evaluation_protocols.py +124 -0
- sphynx/evaluator_bindings.py +370 -0
- sphynx/evidence.py +352 -0
- sphynx/execution_capabilities.py +109 -0
- sphynx/fast_search.py +285 -0
- sphynx/full_rag.py +341 -0
- sphynx/hosted/__init__.py +18 -0
- sphynx/hosted/benchmark_intake.py +218 -0
- sphynx/hosted/contracts.py +361 -0
- sphynx/hosted/model_admission.py +130 -0
- sphynx/hosted/model_artifacts.py +298 -0
- sphynx/hosted/model_contracts.py +63 -0
- sphynx/hosted/model_gcs.py +181 -0
- sphynx/hosted/model_history.py +84 -0
- sphynx/hosted/model_session.py +233 -0
- sphynx/hosted/public_suite.py +74 -0
- sphynx/hosted/registry.py +97 -0
- sphynx/hosted/revisions.py +126 -0
- sphynx/hosted/sandbox.py +105 -0
- sphynx/hosted/submission.py +287 -0
- sphynx/hosted_workflow.py +451 -0
- sphynx/http_transport.py +86 -0
- sphynx/integrated_rag.py +352 -0
- sphynx/integrated_workflow.py +552 -0
- sphynx/integration_contracts.py +474 -0
- sphynx/invocation.py +791 -0
- sphynx/invocation_candidates.py +392 -0
- sphynx/invocation_catalog_data.py +40 -0
- sphynx/learned_catalog_data.py +30 -0
- sphynx/learned_rerank.py +267 -0
- sphynx/legacy_full_rag.py +67 -0
- sphynx/lifecycle.py +885 -0
- sphynx/lifecycle_workflow.py +490 -0
- sphynx/metric_views.py +474 -0
- sphynx/metrics.py +282 -0
- sphynx/model_adapters.py +438 -0
- sphynx/model_catalog_data.py +30 -0
- sphynx/official_benchmarks.py +385 -0
- sphynx/packs.py +258 -0
- sphynx/pipeline.py +313 -0
- sphynx/pipeline_workflow.py +551 -0
- sphynx/pr_evaluations.py +560 -0
- sphynx/progress.py +89 -0
- sphynx/public_bundle.py +173 -0
- sphynx/public_eval.py +62 -0
- sphynx/public_rerank.py +355 -0
- sphynx/py.typed +0 -0
- sphynx/pytest_plugin.py +62 -0
- sphynx/question_robustness.py +437 -0
- sphynx/rag.py +329 -0
- sphynx/rag_diagnostics.py +309 -0
- sphynx/rag_papers/__init__.py +29 -0
- sphynx/rag_papers/__main__.py +45 -0
- sphynx/rag_papers/candidates.py +19 -0
- sphynx/rag_papers/contracts.py +282 -0
- sphynx/rag_papers/datasets.py +291 -0
- sphynx/rag_papers/experiments.py +116 -0
- sphynx/rag_papers/judges.py +308 -0
- sphynx/rag_papers/registry.py +33 -0
- sphynx/rag_papers/runner.py +176 -0
- sphynx/rag_papers/sable.py +135 -0
- sphynx/rag_papers/schemas.py +12 -0
- sphynx/rag_papers/scoring.py +249 -0
- sphynx/rag_papers/semantic.py +288 -0
- sphynx/rag_papers/trec.py +80 -0
- sphynx/recorded_scores.py +352 -0
- sphynx/records.py +124 -0
- sphynx/reflex_traces.py +193 -0
- sphynx/report_rendering.py +71 -0
- sphynx/rerank_family_contracts.py +150 -0
- sphynx/rerank_workflow.py +424 -0
- sphynx/research_candidate.py +190 -0
- sphynx/results.py +852 -0
- sphynx/retrieval.py +750 -0
- sphynx/retrieval_candidates.py +295 -0
- sphynx/retrieval_catalog_data.py +19 -0
- sphynx/retrieval_recordings.py +125 -0
- sphynx/reviewer_audit.py +266 -0
- sphynx/routing_policies.py +375 -0
- sphynx/runner.py +544 -0
- sphynx/sable_rerank.py +489 -0
- sphynx/score_reliability.py +393 -0
- sphynx/speech_benchmarks.py +204 -0
- sphynx/speech_campaigns.py +378 -0
- sphynx/speech_complete.py +236 -0
- sphynx/speech_contracts.py +225 -0
- sphynx/speech_evidence.py +429 -0
- sphynx/speech_listening.py +115 -0
- sphynx/speech_multilingual.py +71 -0
- sphynx/speech_tts.py +479 -0
- sphynx/sql/001_sync_v1.sql +30 -0
- sphynx/stage_contracts.py +199 -0
- sphynx/statistical_catalog_data.py +53 -0
- sphynx/statistical_rerank.py +232 -0
- sphynx/sync.py +797 -0
- sphynx/workbench.py +378 -0
- sphynx/workbench_catalog_data.py +13 -0
- sphynx/workbench_tracks.py +78 -0
- sphynx/workflow_async.py +197 -0
- sphynx/workflow_contracts.py +594 -0
- sphynx/workflow_decisions.py +211 -0
- sphynx/workflow_defaults.py +16 -0
- sphynx/workflow_extensions.py +212 -0
- sphynx/workflow_registry.py +513 -0
- sphynx/workflows.py +792 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Versioned speech candidate wire contracts and provider-neutral serving helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
TTS_REQUEST = "sphynx.speech.tts.request.v1"
|
|
13
|
+
STT_REQUEST = "sphynx.speech.stt.request.v1"
|
|
14
|
+
EVENT_SCHEMA = "sphynx.speech.stream-event.v1"
|
|
15
|
+
FORMAT = {"encoding": "pcm_s16le", "sample_rate": 16000, "channels": 1}
|
|
16
|
+
ERRORS = {"adapter_error", "missing_credential", "provider_http_error", "provider_timeout",
|
|
17
|
+
"empty_audio", "audio_limit", "invalid_pcm", "invalid_request"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SpeechContractError(ValueError):
|
|
21
|
+
"""A speech request, configuration or stream is invalid."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def require(condition, message):
|
|
25
|
+
if not condition:
|
|
26
|
+
raise SpeechContractError(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def encode(value):
|
|
30
|
+
return (json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + "\n").encode()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sha(raw):
|
|
34
|
+
return hashlib.sha256(raw).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse(raw):
|
|
38
|
+
def pairs(items):
|
|
39
|
+
result = {}
|
|
40
|
+
for key, value in items:
|
|
41
|
+
require(key not in result, "duplicate speech field")
|
|
42
|
+
result[key] = value
|
|
43
|
+
return result
|
|
44
|
+
def floating(value):
|
|
45
|
+
result = float(value)
|
|
46
|
+
require(math.isfinite(result), "nonfinite speech number")
|
|
47
|
+
return result
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(raw, object_pairs_hook=pairs, parse_float=floating,
|
|
50
|
+
parse_constant=lambda _: require(False, "nonfinite speech number"))
|
|
51
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
52
|
+
raise SpeechContractError("invalid speech JSON") from None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fields(value, keys):
|
|
56
|
+
require(isinstance(value, dict) and set(value) == set(keys), "speech fields differ from contract")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def identifier(value):
|
|
60
|
+
require(isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", value), "invalid speech identifier")
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def checksum(value):
|
|
65
|
+
require(isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value), "invalid speech digest")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_method(method):
|
|
70
|
+
fields(method, ("mode", "provider", "model", "voice_id", "api_version", "fixture_samples", "fixture_chunk_samples"))
|
|
71
|
+
require(method["mode"] in ("fixture", "provider"), "unsupported speech execution mode")
|
|
72
|
+
identifier(method["model"])
|
|
73
|
+
if method["mode"] == "fixture":
|
|
74
|
+
require(method["provider"] == "fixture" and method["voice_id"] is None and method["api_version"] is None,
|
|
75
|
+
"fixture cannot declare a live provider")
|
|
76
|
+
require(all(type(method[k]) is int and 1 <= method[k] <= 16000 for k in ("fixture_samples", "fixture_chunk_samples")),
|
|
77
|
+
"fixture sample limits are invalid")
|
|
78
|
+
else:
|
|
79
|
+
require(method["provider"] in ("elevenlabs", "cartesia"), "unsupported provider transport")
|
|
80
|
+
require(method["fixture_samples"] is None and method["fixture_chunk_samples"] is None, "provider cannot contain fixture settings")
|
|
81
|
+
require(isinstance(method["voice_id"], str) and re.fullmatch(r"[A-Za-z0-9_-]{1,128}", method["voice_id"]), "invalid voice ID")
|
|
82
|
+
require(method["api_version"] == ("2026-08-14" if method["provider"] == "cartesia" else None), "unsupported API version")
|
|
83
|
+
return method
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def validate_request(request):
|
|
87
|
+
fields(request, ("schema_version", "request_id", "case_id", "method_id", "config", "config_sha256", "input", "format", "limits"))
|
|
88
|
+
require(request["schema_version"] in (TTS_REQUEST, STT_REQUEST), "unsupported speech request")
|
|
89
|
+
for key in ("request_id", "case_id", "method_id"):
|
|
90
|
+
identifier(request[key])
|
|
91
|
+
fields(request["format"], FORMAT)
|
|
92
|
+
require(request["format"] == FORMAT and type(request["format"]["sample_rate"]) is int
|
|
93
|
+
and type(request["format"]["channels"]) is int, "unsupported speech audio format")
|
|
94
|
+
fields(request["limits"], ("max_audio_bytes", "timeout_seconds"))
|
|
95
|
+
require(type(request["limits"]["max_audio_bytes"]) is int and 2 <= request["limits"]["max_audio_bytes"] <= 8_000_000,
|
|
96
|
+
"invalid speech audio byte limit")
|
|
97
|
+
require(type(request["limits"]["timeout_seconds"]) is int and 1 <= request["limits"]["timeout_seconds"] <= 120,
|
|
98
|
+
"invalid speech timeout")
|
|
99
|
+
require(checksum(request["config_sha256"]) == sha(encode(request["config"])), "speech configuration digest differs")
|
|
100
|
+
if request["schema_version"] == TTS_REQUEST:
|
|
101
|
+
validate_method(request["config"])
|
|
102
|
+
fields(request["input"], ("text", "language"))
|
|
103
|
+
require(isinstance(request["input"]["text"], str) and 0 < len(request["input"]["text"]) <= 10000,
|
|
104
|
+
"TTS text is empty or oversized")
|
|
105
|
+
else:
|
|
106
|
+
fields(request["config"], ("text_mode",))
|
|
107
|
+
require(request["config"]["text_mode"] in ("delta", "snapshot"), "declare STT transcript semantics")
|
|
108
|
+
fields(request["input"], ("audio_base64", "audio_sha256", "language"))
|
|
109
|
+
raw = decode_audio(request["input"]["audio_base64"])
|
|
110
|
+
require(raw and len(raw) % 2 == 0 and len(raw) <= request["limits"]["max_audio_bytes"], "invalid STT PCM input")
|
|
111
|
+
require(sha(raw) == checksum(request["input"]["audio_sha256"]), "STT input audio digest differs")
|
|
112
|
+
require(isinstance(request["input"]["language"], str) and re.fullmatch(r"[a-z]{2}(?:-[A-Za-z]{2,4})?", request["input"]["language"]), "invalid speech language")
|
|
113
|
+
return request
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def decode_audio(value):
|
|
117
|
+
require(isinstance(value, str) and len(value) <= 11_000_000, "invalid speech chunk")
|
|
118
|
+
try:
|
|
119
|
+
return base64.b64decode(value, validate=True)
|
|
120
|
+
except ValueError:
|
|
121
|
+
raise SpeechContractError("invalid speech base64") from None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class StreamValidator:
|
|
125
|
+
"""Strict sequencing, request binding and terminal audio integrity."""
|
|
126
|
+
def __init__(self, request):
|
|
127
|
+
self.request = validate_request(request)
|
|
128
|
+
self.count = 0
|
|
129
|
+
self.last_ns = -1
|
|
130
|
+
self.terminal = None
|
|
131
|
+
self.total_bytes = 0
|
|
132
|
+
self.audio_hash = hashlib.sha256()
|
|
133
|
+
self.transcript = ""
|
|
134
|
+
self.final_seen = False
|
|
135
|
+
|
|
136
|
+
def accept(self, event):
|
|
137
|
+
common = {"schema_version", "request_id", "config_sha256", "seq", "t_ns", "type"}
|
|
138
|
+
kind = event.get("type") if isinstance(event, dict) else None
|
|
139
|
+
extras = {"start": set(), "audio": {"data_base64"}, "transcript": {"text", "is_final"},
|
|
140
|
+
"terminal": {"status", "error_code", "audio_sha256", "audio_bytes", "identity_evidence"}}
|
|
141
|
+
require(isinstance(kind, str) and kind in extras and self.terminal is None, "unexpected speech event or event after terminal")
|
|
142
|
+
fields(event, common | extras[kind])
|
|
143
|
+
require(event["schema_version"] == EVENT_SCHEMA and event["request_id"] == self.request["request_id"]
|
|
144
|
+
and event["config_sha256"] == self.request["config_sha256"], "speech event request/config binding differs")
|
|
145
|
+
require(type(event["seq"]) is int and event["seq"] == self.count, "speech event sequence gap or duplicate")
|
|
146
|
+
require(type(event["t_ns"]) is int and 0 <= event["t_ns"] and self.last_ns <= event["t_ns"] <= self.request["limits"]["timeout_seconds"] * 1_000_000_000,
|
|
147
|
+
"speech event clock is invalid")
|
|
148
|
+
require((self.count == 0) == (kind == "start"), "speech stream must start exactly once")
|
|
149
|
+
self.count += 1
|
|
150
|
+
self.last_ns = event["t_ns"]
|
|
151
|
+
audio = b""
|
|
152
|
+
if kind == "audio":
|
|
153
|
+
require(self.request["schema_version"] == TTS_REQUEST, "STT output cannot contain synthesis audio")
|
|
154
|
+
audio = decode_audio(event["data_base64"])
|
|
155
|
+
require(audio and len(audio) <= 8192, "speech audio event is empty or oversized")
|
|
156
|
+
self.total_bytes += len(audio)
|
|
157
|
+
require(self.total_bytes <= self.request["limits"]["max_audio_bytes"], "speech audio byte limit exceeded")
|
|
158
|
+
self.audio_hash.update(audio)
|
|
159
|
+
elif kind == "transcript":
|
|
160
|
+
require(self.request["schema_version"] == STT_REQUEST and not self.final_seen, "unexpected transcript after final")
|
|
161
|
+
require(isinstance(event["text"], str) and len(event["text"]) <= 20000 and type(event["is_final"]) is bool, "invalid transcript event")
|
|
162
|
+
if self.request["config"]["text_mode"] == "delta":
|
|
163
|
+
self.transcript += event["text"]
|
|
164
|
+
else:
|
|
165
|
+
self.transcript = event["text"]
|
|
166
|
+
require(len(self.transcript) <= 20000, "STT output exceeds transcript limit")
|
|
167
|
+
self.final_seen = event["is_final"]
|
|
168
|
+
elif kind == "terminal":
|
|
169
|
+
require(event["status"] in ("success", "error") and type(event["audio_bytes"]) is int and
|
|
170
|
+
event["audio_bytes"] == self.total_bytes and event["audio_sha256"] == self.audio_hash.hexdigest(),
|
|
171
|
+
"terminal speech status or audio identity differs")
|
|
172
|
+
require(event["error_code"] is None if event["status"] == "success" else isinstance(event["error_code"], str) and event["error_code"] in ERRORS,
|
|
173
|
+
"invalid terminal speech error code")
|
|
174
|
+
fields(event["identity_evidence"], ("endpoint", "observed_model", "observed_voice", "cache_status"))
|
|
175
|
+
require(event["identity_evidence"]["cache_status"] in ("fixture", "unknown"), "unsupported speech cache attestation")
|
|
176
|
+
require(all(value is None or (isinstance(value, str) and len(value) <= 256)
|
|
177
|
+
for value in event["identity_evidence"].values()), "invalid provider identity evidence")
|
|
178
|
+
if event["status"] == "success":
|
|
179
|
+
require((self.total_bytes > 0 and self.total_bytes % 2 == 0) if self.request["schema_version"] == TTS_REQUEST else self.final_seen,
|
|
180
|
+
"successful speech stream has no complete output")
|
|
181
|
+
self.terminal = event
|
|
182
|
+
return audio
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def serve_tts(adapter):
|
|
186
|
+
"""Serve one validated request; adapter yields bytes, never grader data."""
|
|
187
|
+
raw = sys.stdin.buffer.read(65537)
|
|
188
|
+
try:
|
|
189
|
+
require(len(raw) <= 65536, "request exceeds limit")
|
|
190
|
+
request = validate_request(parse(raw))
|
|
191
|
+
require(request["schema_version"] == TTS_REQUEST, "TTS adapter requires a TTS request")
|
|
192
|
+
except (SpeechContractError, KeyError, TypeError):
|
|
193
|
+
raise SystemExit(2) from None
|
|
194
|
+
start = time.monotonic_ns()
|
|
195
|
+
seq, count, checksum_ = 0, 0, hashlib.sha256()
|
|
196
|
+
def emit(kind, **extra):
|
|
197
|
+
nonlocal seq
|
|
198
|
+
event = {"schema_version": EVENT_SCHEMA, "request_id": request["request_id"],
|
|
199
|
+
"config_sha256": request["config_sha256"], "seq": seq, "t_ns": time.monotonic_ns() - start,
|
|
200
|
+
"type": kind, **extra}
|
|
201
|
+
seq += 1
|
|
202
|
+
sys.stdout.buffer.write(encode(event)); sys.stdout.buffer.flush()
|
|
203
|
+
emit("start")
|
|
204
|
+
error = None
|
|
205
|
+
try:
|
|
206
|
+
for chunk in adapter(request):
|
|
207
|
+
require(isinstance(chunk, bytes) and 0 < len(chunk) <= 8192, "invalid adapter chunk")
|
|
208
|
+
if count + len(chunk) > request["limits"]["max_audio_bytes"]:
|
|
209
|
+
error = "audio_limit"; break
|
|
210
|
+
count += len(chunk); checksum_.update(chunk)
|
|
211
|
+
emit("audio", data_base64=base64.b64encode(chunk).decode("ascii"))
|
|
212
|
+
if error is None and (count == 0 or count % 2):
|
|
213
|
+
error = "empty_audio" if count == 0 else "invalid_pcm"
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
# Only allow stable codes; exception text can contain credentials.
|
|
216
|
+
error = getattr(exc, "speech_error_code", "adapter_error")
|
|
217
|
+
if error not in ERRORS:
|
|
218
|
+
error = "adapter_error"
|
|
219
|
+
method = request["config"]
|
|
220
|
+
endpoint = {"elevenlabs": "https://api.elevenlabs.io/v1/text-to-speech/stream",
|
|
221
|
+
"cartesia": "https://api.cartesia.ai/tts/bytes"}.get(method["provider"])
|
|
222
|
+
emit("terminal", status="error" if error else "success", error_code=error,
|
|
223
|
+
audio_sha256=checksum_.hexdigest(), audio_bytes=count,
|
|
224
|
+
identity_evidence={"endpoint": endpoint, "observed_model": None, "observed_voice": None,
|
|
225
|
+
"cache_status": "fixture" if method["mode"] == "fixture" else "unknown"})
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Independent offline speech suite, import invocation, and saved regrading.
|
|
2
|
+
|
|
3
|
+
Run ``python -m sphynx.speech_evidence --help``. This module never starts a
|
|
4
|
+
candidate, contacts a provider, executes imported code, or uses RAG invocation.
|
|
5
|
+
Owner-held source/bundles/reports contain private text and must stay private.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import csv
|
|
11
|
+
from collections import Counter, defaultdict
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
import re
|
|
16
|
+
import stat
|
|
17
|
+
import uuid
|
|
18
|
+
|
|
19
|
+
from ._speech_kido import (SpeechEvidenceError, csv_rows, digest, encoded,
|
|
20
|
+
json_value, normalize_kido, require)
|
|
21
|
+
from ._speech_scoring import (NORMALIZATION, TOKEN_SCORER, score_transcript,
|
|
22
|
+
timing_summary)
|
|
23
|
+
|
|
24
|
+
SUITE_ID = "speech.saved-evidence.v1"
|
|
25
|
+
INTERFACE_VERSION = "sphynx.speech.saved-evidence.v1"
|
|
26
|
+
BUNDLE_SCHEMA = "sphynx.speech.saved-evidence.bundle.v1"
|
|
27
|
+
INVOCATION_SCHEMA = "sphynx.speech.saved-evidence.invocation.v1"
|
|
28
|
+
REPORT_SCHEMA = "sphynx.speech.saved-evidence.report.v1"
|
|
29
|
+
GRADER_VERSION = "speech.saved-evidence.grader.v1"
|
|
30
|
+
SOURCE_LOCK_SHA256 = "b2cd81f87658c9b8b9f8a618657bf6e64fd513fb510dcc8da81e101055e3c70e"
|
|
31
|
+
_MAX_FILE = 16 * 1024 * 1024
|
|
32
|
+
_MAX_TOTAL = 64 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _source_lock(root):
|
|
36
|
+
"""Reconstruct the reviewed inventory; a fixed digest admits only exact bytes.
|
|
37
|
+
|
|
38
|
+
Keep internal session/file inventories out of distributable package resources.
|
|
39
|
+
The inventory is metadata derived from the owner-supplied source export, and
|
|
40
|
+
its digest must equal the independently reviewed constant before admission.
|
|
41
|
+
"""
|
|
42
|
+
names = {"results/isolated/official-v1/combined/trials.csv",
|
|
43
|
+
"provenance/manifests/combined-manifest.json", "results/human-eval/mapping.json",
|
|
44
|
+
"results/human-eval/scores.csv", "scripts/manifests/official-v1-live-sessions.json",
|
|
45
|
+
"scripts/wer_cer.py", "scripts/live_telemetry.py", "provenance/audio-evidence-manifest.csv",
|
|
46
|
+
"results/isolated/official-v1/analysis/per-provider-ttfa.csv",
|
|
47
|
+
"results/live-agent/official-v1/analysis/live-tts-ttfa-fresh-only-post-1756.csv",
|
|
48
|
+
"results/live-agent/official-v1/analysis/success-coverage-post-1756.csv"}
|
|
49
|
+
for relative in ("configs", "provenance/run-jsons"):
|
|
50
|
+
directory = root / relative
|
|
51
|
+
_plain(directory)
|
|
52
|
+
names.update(p.relative_to(root).as_posix() for p in directory.glob("*.json"))
|
|
53
|
+
for folder in ("hands-on-v1", "hands-on-ink2-v1"):
|
|
54
|
+
for name in ("corpus.json", "raw_results.json", "final_analysis.json"):
|
|
55
|
+
names.add("results/exploratory/cartesia-ink-2-stt/" + folder + "/" + name)
|
|
56
|
+
sessions = json_value(_read(root, "scripts/manifests/official-v1-live-sessions.json"))
|
|
57
|
+
directory = root / "results/live-agent/official-v1/raw-events"
|
|
58
|
+
_plain(directory)
|
|
59
|
+
require(isinstance(sessions, list) and len(sessions) <= 100, "invalid speech session inventory")
|
|
60
|
+
for session in sessions:
|
|
61
|
+
room = session.get("room_sid")
|
|
62
|
+
require(isinstance(room, str) and re.fullmatch(r"[A-Za-z0-9_-]{1,128}", room), "invalid session identity")
|
|
63
|
+
names.update(p.relative_to(root).as_posix() for p in directory.glob(room + "-*")
|
|
64
|
+
if p.suffix in (".json", ".jsonl"))
|
|
65
|
+
require(len(names) <= 1000, "speech source has too many files")
|
|
66
|
+
pins, total = {}, 0
|
|
67
|
+
for name in sorted(names):
|
|
68
|
+
raw = _read(root, name)
|
|
69
|
+
total += len(raw)
|
|
70
|
+
require(total <= _MAX_TOTAL, "speech source exceeds total byte limit")
|
|
71
|
+
pins[name] = {"sha256": digest(raw), "size_bytes": len(raw)}
|
|
72
|
+
lock = {"schema_version": "sphynx.speech.source-lock.v1",
|
|
73
|
+
"repository": "https://github.com/sable-inc/sable-tts-eval",
|
|
74
|
+
"source_commit": "71ec7e89805360479ea19c5bb43fae7707229ce9", "files": pins}
|
|
75
|
+
raw = (json.dumps(lock, indent=2) + "\n").encode("utf-8")
|
|
76
|
+
require(digest(raw) == SOURCE_LOCK_SHA256, "speech source inventory differs from reviewed identity")
|
|
77
|
+
return lock
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _sha(value):
|
|
81
|
+
require(isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value), "expected a complete speech SHA256")
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _plain(path):
|
|
86
|
+
"""Refuse symlinks and Windows junction/reparse paths, including parents."""
|
|
87
|
+
for part in (path, *path.parents):
|
|
88
|
+
if part.exists() or part.is_symlink():
|
|
89
|
+
info = part.lstat()
|
|
90
|
+
require(not stat.S_ISLNK(info.st_mode) and
|
|
91
|
+
not getattr(info, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400),
|
|
92
|
+
"speech evidence paths cannot contain links or reparse points")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _read(root, relative):
|
|
96
|
+
require(isinstance(relative, str) and "\\" not in relative and ":" not in relative,
|
|
97
|
+
"unsafe speech artifact path")
|
|
98
|
+
rel = PurePosixPath(relative)
|
|
99
|
+
require(not rel.is_absolute() and all(p not in ("..", ".", "") for p in rel.parts)
|
|
100
|
+
and str(rel) == relative, "unsafe speech artifact path")
|
|
101
|
+
path = root.joinpath(*rel.parts)
|
|
102
|
+
_plain(path)
|
|
103
|
+
require(path.is_file() and path.stat().st_size <= _MAX_FILE, "speech artifact missing or oversized")
|
|
104
|
+
raw = path.read_bytes()
|
|
105
|
+
require(len(raw) <= _MAX_FILE, "speech artifact exceeds read limit")
|
|
106
|
+
return raw
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _source_bytes(root, lock):
|
|
110
|
+
data, total = {}, 0
|
|
111
|
+
for path, receipt in lock["files"].items():
|
|
112
|
+
raw = _read(root, path)
|
|
113
|
+
require(len(raw) == receipt["size_bytes"] and digest(raw) == receipt["sha256"],
|
|
114
|
+
"speech source bytes differ from pinned identity: " + path)
|
|
115
|
+
total += len(raw)
|
|
116
|
+
require(total <= _MAX_TOTAL, "speech source exceeds total byte limit")
|
|
117
|
+
data[path] = raw
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _new_directory(path):
|
|
122
|
+
path = Path(path).absolute()
|
|
123
|
+
_plain(path)
|
|
124
|
+
path = path.resolve()
|
|
125
|
+
require(not path.exists(), "speech output already exists")
|
|
126
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
path.mkdir()
|
|
128
|
+
return path
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _lines(rows):
|
|
132
|
+
return b"".join(encoded(row) for row in rows)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _write_files(root, payloads):
|
|
136
|
+
receipts = {}
|
|
137
|
+
for name, raw in payloads.items():
|
|
138
|
+
target = root.joinpath(*PurePosixPath(name).parts)
|
|
139
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
with target.open("xb") as stream:
|
|
141
|
+
stream.write(raw)
|
|
142
|
+
receipts[name] = {"sha256": digest(raw), "size_bytes": len(raw)}
|
|
143
|
+
return receipts
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def import_kido_speech_evidence(source, output_dir) -> dict:
|
|
147
|
+
"""Import only the installed, byte-pinned source revision, entirely offline.
|
|
148
|
+
|
|
149
|
+
Raw selected files are retained as inert evidence. No audio is copied and
|
|
150
|
+
external paths from source metadata are never opened. Failure leaves no
|
|
151
|
+
finalized manifest. An existing destination is never overwritten.
|
|
152
|
+
"""
|
|
153
|
+
root = Path(source).absolute()
|
|
154
|
+
lock = _source_lock(root)
|
|
155
|
+
raw = _source_bytes(root, lock)
|
|
156
|
+
observations, references, audit = normalize_kido(raw)
|
|
157
|
+
destination = _new_directory(output_dir)
|
|
158
|
+
payloads = {"source/" + name: data for name, data in raw.items()}
|
|
159
|
+
payloads.update({"observations.jsonl": _lines(observations),
|
|
160
|
+
"references.jsonl": _lines(references)})
|
|
161
|
+
receipts = _write_files(destination, payloads)
|
|
162
|
+
manifest = {"schema_version": BUNDLE_SCHEMA, "suite_id": SUITE_ID,
|
|
163
|
+
"source_repository": lock["repository"], "source_commit": lock["source_commit"],
|
|
164
|
+
"source_lock_sha256": SOURCE_LOCK_SHA256,
|
|
165
|
+
"counts": dict(Counter(r["track"] for r in observations)),
|
|
166
|
+
"reference_count": len(references), "audit": audit, "files": receipts,
|
|
167
|
+
"execution_mode": "saved_evidence_import", "fresh_model_execution": False,
|
|
168
|
+
"candidate_execution_available": False, "hosted_admitted": False}
|
|
169
|
+
raw_manifest = encoded(manifest)
|
|
170
|
+
(destination / "manifest.json").write_bytes(raw_manifest) # commit marker LAST
|
|
171
|
+
return {"path": str(destination), "suite_id": SUITE_ID,
|
|
172
|
+
"manifest_sha256": digest(raw_manifest), "counts": manifest["counts"], "audit": audit}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True)
|
|
176
|
+
class SpeechEvidence:
|
|
177
|
+
path: Path
|
|
178
|
+
manifest_sha256: str
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def manifest(self):
|
|
182
|
+
return _verified(self)[0]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _verified(bundle):
|
|
186
|
+
raw_manifest = _read(bundle.path, "manifest.json")
|
|
187
|
+
require(digest(raw_manifest) == _sha(bundle.manifest_sha256), "speech bundle manifest changed")
|
|
188
|
+
manifest = json_value(raw_manifest)
|
|
189
|
+
require(manifest.get("schema_version") == BUNDLE_SCHEMA and manifest.get("suite_id") == SUITE_ID,
|
|
190
|
+
"not a saved speech evidence bundle")
|
|
191
|
+
lock = _source_lock(bundle.path / "source")
|
|
192
|
+
require(manifest.get("source_lock_sha256") == SOURCE_LOCK_SHA256 and
|
|
193
|
+
manifest.get("source_commit") == lock["source_commit"] and
|
|
194
|
+
manifest.get("source_repository") == lock["repository"], "speech source identity mismatch")
|
|
195
|
+
expected_paths = {"source/" + p for p in lock["files"]} | {"observations.jsonl", "references.jsonl"}
|
|
196
|
+
require(isinstance(manifest.get("files"), dict) and set(manifest["files"]) == expected_paths,
|
|
197
|
+
"speech bundle file census changed")
|
|
198
|
+
payloads, total = {}, 0
|
|
199
|
+
for path, receipt in manifest["files"].items():
|
|
200
|
+
raw = _read(bundle.path, path)
|
|
201
|
+
require(isinstance(receipt, dict) and receipt.get("size_bytes") == len(raw)
|
|
202
|
+
and receipt.get("sha256") == digest(raw), "speech bundle artifact changed")
|
|
203
|
+
total += len(raw)
|
|
204
|
+
require(total <= _MAX_TOTAL, "speech bundle exceeds total byte limit")
|
|
205
|
+
payloads[path] = raw
|
|
206
|
+
source = {p: payloads["source/" + p] for p in lock["files"]}
|
|
207
|
+
require(all(digest(source[p]) == r["sha256"] and len(source[p]) == r["size_bytes"]
|
|
208
|
+
for p, r in lock["files"].items()), "speech source archive differs from lock")
|
|
209
|
+
observations, references, audit = normalize_kido(source)
|
|
210
|
+
require(payloads["observations.jsonl"] == _lines(observations) and
|
|
211
|
+
payloads["references.jsonl"] == _lines(references), "normalized speech records differ from source")
|
|
212
|
+
require(manifest.get("counts") == dict(Counter(r["track"] for r in observations)) and
|
|
213
|
+
manifest.get("reference_count") == len(references) and manifest.get("audit") == audit,
|
|
214
|
+
"speech census or audit changed")
|
|
215
|
+
require(manifest.get("execution_mode") == "saved_evidence_import" and
|
|
216
|
+
manifest.get("fresh_model_execution") is False and
|
|
217
|
+
manifest.get("candidate_execution_available") is False and manifest.get("hosted_admitted") is False,
|
|
218
|
+
"saved speech execution boundary changed")
|
|
219
|
+
return manifest, observations, references
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def load_speech_evidence(path, *, expected_manifest_sha256) -> SpeechEvidence:
|
|
223
|
+
bundle = SpeechEvidence(Path(path).absolute(), _sha(expected_manifest_sha256))
|
|
224
|
+
_verified(bundle)
|
|
225
|
+
return bundle
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def describe_speech_suite():
|
|
229
|
+
"""Dedicated local definition; deliberately separate from RAG discovery."""
|
|
230
|
+
return {"suite_id": SUITE_ID, "interface_version": INTERFACE_VERSION,
|
|
231
|
+
"invocation_schema": INVOCATION_SCHEMA, "grader_version": GRADER_VERSION,
|
|
232
|
+
"execution_mode": "saved_evidence_regrade", "runnable_locally": True,
|
|
233
|
+
"hosted_admitted": False, "fresh_model_execution": False,
|
|
234
|
+
"tracks": ["tts_isolated", "tts_live", "tts_live_session", "stt_recognition", "tts_listening"],
|
|
235
|
+
"normalization": NORMALIZATION, "structured_token_scorer": TOKEN_SCORER,
|
|
236
|
+
"implementation_sha256": {name: digest(Path(__file__).with_name(name).read_bytes()) for name in
|
|
237
|
+
("speech_evidence.py", "_speech_kido.py", "_speech_scoring.py")}}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def speech_candidate_view(bundle: SpeechEvidence, *, task: str) -> dict:
|
|
241
|
+
"""Return an explicit future-input view; never transcripts, gold or ratings.
|
|
242
|
+
|
|
243
|
+
TTS has source text. STT has no bundled audio, so it remains non-executable.
|
|
244
|
+
This view is not permission, an execution adapter, or hosted admission.
|
|
245
|
+
"""
|
|
246
|
+
require(task in ("stt", "tts"), "select exactly one speech input task")
|
|
247
|
+
manifest, observations, _ = _verified(bundle)
|
|
248
|
+
cases = {}
|
|
249
|
+
for row in observations:
|
|
250
|
+
if task == "tts" and row["track"] == "tts_isolated":
|
|
251
|
+
key = "tts/" + row["case_id"]
|
|
252
|
+
case = {"id": key, "task": "tts", "text": row["input_text"], "language": row["language"]}
|
|
253
|
+
require(key not in cases or cases[key] == case, "TTS input identity collision")
|
|
254
|
+
cases[key] = case
|
|
255
|
+
elif task == "stt" and row["track"] == "stt_recognition":
|
|
256
|
+
key = "stt/" + row["case_id"]
|
|
257
|
+
cases[key] = {"id": key, "task": "stt", "audio_available": False, "audio_sha256": None}
|
|
258
|
+
return {"suite_id": SUITE_ID, "task": task, "bundle_sha256": bundle.manifest_sha256,
|
|
259
|
+
"source_commit": manifest["source_commit"], "candidate_execution_available": False,
|
|
260
|
+
"cases": sorted(cases.values(), key=lambda r: r["id"])}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _grade_rows(observations, references):
|
|
264
|
+
references = {r["id"]: r for r in references}
|
|
265
|
+
groups = defaultdict(list)
|
|
266
|
+
for row in observations:
|
|
267
|
+
groups[(row["track"], row["cohort"], row["model"])].append(row)
|
|
268
|
+
summaries, per_case = [], []
|
|
269
|
+
for (track, cohort, model), rows in sorted(groups.items()):
|
|
270
|
+
summary = {"track": track, "cohort": cohort, "model": model, "observations": len(rows),
|
|
271
|
+
"clusters": len({r["cluster_id"] for r in rows}), "confidence_interval": None,
|
|
272
|
+
"uncertainty_status": "descriptive-dependent-historical-samples"}
|
|
273
|
+
if track == "stt_recognition":
|
|
274
|
+
scored = []
|
|
275
|
+
for row in rows:
|
|
276
|
+
ref = references.get(row["id"])
|
|
277
|
+
eligible = row["success"] and row["hypothesis"] is not None and ref is not None
|
|
278
|
+
score = score_transcript(ref["reference"], row["hypothesis"]) if eligible else None
|
|
279
|
+
if score is not None:
|
|
280
|
+
scored.append(score)
|
|
281
|
+
per_case.append({"id": row["id"], "track": track, "cluster_id": row["cluster_id"],
|
|
282
|
+
"status": "scored" if eligible else "unavailable", "score": score})
|
|
283
|
+
words = sum(r["reference_words"] for r in scored)
|
|
284
|
+
chars = sum(r["reference_characters"] for r in scored)
|
|
285
|
+
word_errors = sum(r["word_errors"] for r in scored if r["reference_words"])
|
|
286
|
+
char_errors = sum(r["character_errors"] for r in scored if r["reference_characters"])
|
|
287
|
+
complete = len(scored) == len(rows)
|
|
288
|
+
summary.update({"scored": len(scored), "unavailable": len(rows) - len(scored),
|
|
289
|
+
"reference_words": words, "word_errors": word_errors, "reference_characters": chars,
|
|
290
|
+
"character_errors": char_errors,
|
|
291
|
+
"wer": word_errors / words if words and complete else None,
|
|
292
|
+
"cer": char_errors / chars if chars and complete else None,
|
|
293
|
+
"observed_subset_wer": word_errors / words if words else None,
|
|
294
|
+
"observed_subset_cer": char_errors / chars if chars else None,
|
|
295
|
+
"silence_inserted_words": sum(r["silence_inserted_words"] for r in scored),
|
|
296
|
+
"normalization": NORMALIZATION, "structured_token_scorer": TOKEN_SCORER})
|
|
297
|
+
summary["structured_tokens"] = {}
|
|
298
|
+
for token in (scored[0]["structured_tokens"] if scored else {}):
|
|
299
|
+
count = sum(r["structured_tokens"][token]["reference_tokens"] for r in scored)
|
|
300
|
+
matched = sum(r["structured_tokens"][token]["matched"] for r in scored)
|
|
301
|
+
summary["structured_tokens"][token] = {"matched": matched, "reference_tokens": count,
|
|
302
|
+
"accuracy": matched / count if count and complete else None}
|
|
303
|
+
elif track in ("tts_isolated", "tts_live"):
|
|
304
|
+
success = [r for r in rows if r["success"]]
|
|
305
|
+
summary.update({"successful": len(success), "failed_or_cancelled": len(rows) - len(success)})
|
|
306
|
+
if track == "tts_live":
|
|
307
|
+
fresh = [r for r in success if not r["cache_hit"] and r["observed_provider"] == r["provider"]]
|
|
308
|
+
summary["successful_cache_replays"] = sum(r["cache_hit"] for r in success)
|
|
309
|
+
summary["provider_mismatch_attempts"] = sum(r["observed_provider"] != r["provider"] for r in rows)
|
|
310
|
+
summary["cancelled_with_audio"] = sum(r["cancelled"] and r["audio_bytes"] > 0 for r in rows)
|
|
311
|
+
summary["model_identity_observed"] = sum(r["observed_model"] is not None for r in rows)
|
|
312
|
+
summary["model_identity_mismatch"] = sum(r["observed_model"] is not None and r["observed_model"] != r["model"] for r in rows)
|
|
313
|
+
summary["missing_session_metadata_attempts"] = sum(not r["session_metadata_present"] for r in rows)
|
|
314
|
+
convention = "kido.live-median-rounded-index.v1"
|
|
315
|
+
summary["timing_scope"] = "server-synthesis-start-to-first-audio-bytes-not-audible-response"
|
|
316
|
+
else:
|
|
317
|
+
fresh, convention = success, "kido.isolated-rounded-index.v1"
|
|
318
|
+
summary["timing_scope"] = "source-Stream-call-to-first-nonempty-Read-not-audible-response"
|
|
319
|
+
values = [r["ttfa_ms"] for r in fresh if r["ttfa_ms"] is not None]
|
|
320
|
+
summary["fresh_valid_ttfa_ms"] = timing_summary(values, convention=convention)
|
|
321
|
+
summary["fresh_timing_missing"] = len(fresh) - len(values)
|
|
322
|
+
summary["perceived_latency_ms"] = None
|
|
323
|
+
elif track == "tts_listening":
|
|
324
|
+
scores = [references[r["id"]]["overall_quality"] for r in rows]
|
|
325
|
+
available = [s for s in scores if s is not None]
|
|
326
|
+
summary.update({"rated": len(available), "pending": len(rows) - len(available),
|
|
327
|
+
"overall_quality_mean": sum(available) / len(available) if available and len(available) == len(rows) else None,
|
|
328
|
+
"observed_subset_mean": sum(available) / len(available) if available else None,
|
|
329
|
+
"naturalness": None, "perceived_waiting": None, "historical_study_rater_count": 1,
|
|
330
|
+
"review_scope": "historical-single-rater-overall-only"})
|
|
331
|
+
elif track == "tts_live_session":
|
|
332
|
+
summary.update({"legacy_provider_gate_passed": sum(r["legacy_provider_gate"] for r in rows),
|
|
333
|
+
"fallback_sessions": sum(r["fallback_observed"] for r in rows),
|
|
334
|
+
"sessions_without_synthesis": sum(r["attempts"] == 0 for r in rows),
|
|
335
|
+
"full_model_voice_verification": False})
|
|
336
|
+
summaries.append(summary)
|
|
337
|
+
return summaries, per_case
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def grade_speech_evidence(bundle: SpeechEvidence, output_dir) -> dict:
|
|
341
|
+
"""Regrade saved evidence into a new speech-specific invocation and report."""
|
|
342
|
+
manifest, observations, references = _verified(bundle)
|
|
343
|
+
invocation_id = "speech-invocation-" + uuid.uuid4().hex
|
|
344
|
+
definition = describe_speech_suite()
|
|
345
|
+
invocation = {"schema_version": INVOCATION_SCHEMA, "id": invocation_id,
|
|
346
|
+
"suite_id": SUITE_ID, "definition_sha256": digest(encoded(definition)),
|
|
347
|
+
"implementation_sha256": definition["implementation_sha256"],
|
|
348
|
+
"bundle_manifest_sha256": bundle.manifest_sha256, "source_commit": manifest["source_commit"],
|
|
349
|
+
"grader_version": GRADER_VERSION, "mode": "saved_evidence_regrade", "candidate_executed": False,
|
|
350
|
+
"provider_called": False, "hosted_admitted": False}
|
|
351
|
+
summaries, per_case = _grade_rows(observations, references)
|
|
352
|
+
report = {"schema_version": REPORT_SCHEMA, "suite_id": SUITE_ID,
|
|
353
|
+
"invocation_id": invocation_id, "invocation_sha256": digest(encoded(invocation)),
|
|
354
|
+
"bundle_manifest_sha256": bundle.manifest_sha256, "grader_version": GRADER_VERSION,
|
|
355
|
+
"summaries": summaries, "per_case": per_case, "audit": manifest["audit"],
|
|
356
|
+
"limits": {"historical_observations_only": True, "fresh_performance_measured": False,
|
|
357
|
+
"perceived_latency_measured": False, "population_inference_validated": False}}
|
|
358
|
+
output = _new_directory(output_dir)
|
|
359
|
+
receipts = _write_files(output, {"invocation.json": encoded(invocation), "report.json": encoded(report)})
|
|
360
|
+
receipt = {"schema_version": "sphynx.speech.saved-evidence.receipt.v1", "suite_id": SUITE_ID,
|
|
361
|
+
"invocation_id": invocation_id, "files": receipts}
|
|
362
|
+
raw = encoded(receipt)
|
|
363
|
+
(output / "receipt.json").write_bytes(raw)
|
|
364
|
+
return {"path": str(output), "suite_id": SUITE_ID, "invocation_id": invocation_id,
|
|
365
|
+
"receipt_sha256": digest(raw), "report_sha256": receipts["report.json"]["sha256"]}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def load_speech_evaluation(path, *, expected_receipt_sha256) -> dict:
|
|
369
|
+
root = Path(path).absolute()
|
|
370
|
+
raw = _read(root, "receipt.json")
|
|
371
|
+
require(digest(raw) == _sha(expected_receipt_sha256), "speech evaluation receipt changed")
|
|
372
|
+
receipt = json_value(raw)
|
|
373
|
+
require(receipt.get("schema_version") == "sphynx.speech.saved-evidence.receipt.v1" and
|
|
374
|
+
receipt.get("suite_id") == SUITE_ID and set(receipt.get("files", {})) == {"invocation.json", "report.json"},
|
|
375
|
+
"not a saved speech evaluation")
|
|
376
|
+
results = {}
|
|
377
|
+
for name, entry in receipt["files"].items():
|
|
378
|
+
data = _read(root, name)
|
|
379
|
+
require(entry.get("sha256") == digest(data) and entry.get("size_bytes") == len(data), "speech evaluation artifact changed")
|
|
380
|
+
results[name] = json_value(data)
|
|
381
|
+
invocation, report = results["invocation.json"], results["report.json"]
|
|
382
|
+
require(invocation.get("schema_version") == INVOCATION_SCHEMA and report.get("schema_version") == REPORT_SCHEMA
|
|
383
|
+
and invocation.get("suite_id") == report.get("suite_id") == SUITE_ID
|
|
384
|
+
and invocation.get("id") == receipt.get("invocation_id") == report.get("invocation_id")
|
|
385
|
+
and report.get("invocation_sha256") == digest(encoded(invocation))
|
|
386
|
+
and report.get("bundle_manifest_sha256") == invocation.get("bundle_manifest_sha256"),
|
|
387
|
+
"speech report and invocation identities differ")
|
|
388
|
+
require(invocation.get("mode") == "saved_evidence_regrade" and
|
|
389
|
+
invocation.get("candidate_executed") is False and invocation.get("provider_called") is False and
|
|
390
|
+
invocation.get("hosted_admitted") is False and
|
|
391
|
+
invocation.get("grader_version") == report.get("grader_version") == GRADER_VERSION,
|
|
392
|
+
"speech evaluation execution scope differs")
|
|
393
|
+
_sha(invocation.get("definition_sha256"))
|
|
394
|
+
implementations = invocation.get("implementation_sha256", {})
|
|
395
|
+
require(isinstance(implementations, dict) and set(implementations) ==
|
|
396
|
+
{"speech_evidence.py", "_speech_kido.py", "_speech_scoring.py"}, "missing speech grader source identities")
|
|
397
|
+
for value in implementations.values():
|
|
398
|
+
_sha(value)
|
|
399
|
+
return report
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def main(argv=None):
|
|
403
|
+
parser = argparse.ArgumentParser(description="Independent offline speech.saved-evidence.v1 suite; no provider calls")
|
|
404
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
405
|
+
commands.add_parser("describe")
|
|
406
|
+
prepare = commands.add_parser("import-kido")
|
|
407
|
+
prepare.add_argument("--source", required=True)
|
|
408
|
+
prepare.add_argument("--output", required=True)
|
|
409
|
+
grade = commands.add_parser("grade")
|
|
410
|
+
grade.add_argument("--bundle", required=True)
|
|
411
|
+
grade.add_argument("--expected-manifest-sha256", required=True)
|
|
412
|
+
grade.add_argument("--output", required=True)
|
|
413
|
+
args = parser.parse_args(argv)
|
|
414
|
+
try:
|
|
415
|
+
if args.command == "describe":
|
|
416
|
+
result = describe_speech_suite()
|
|
417
|
+
elif args.command == "import-kido":
|
|
418
|
+
result = import_kido_speech_evidence(args.source, args.output)
|
|
419
|
+
else:
|
|
420
|
+
result = grade_speech_evidence(load_speech_evidence(args.bundle,
|
|
421
|
+
expected_manifest_sha256=args.expected_manifest_sha256), args.output)
|
|
422
|
+
except (SpeechEvidenceError, OSError, KeyError, TypeError, csv.Error) as exc:
|
|
423
|
+
# No source text, provider error payload, reference label or secret in CLI diagnostics.
|
|
424
|
+
parser.exit(2, "speech evidence operation failed (" + type(exc).__name__ + "); check source pins and output path\n")
|
|
425
|
+
print(json.dumps(result, indent=2))
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
if __name__ == "__main__":
|
|
429
|
+
main()
|