@ludi-uni/ludi-agent-kit 0.1.0
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.
- package/AGENTS.md +55 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/adapters/codex/README.md +24 -0
- package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
- package/adapters/pi/README.md +88 -0
- package/adapters/pi/browser/agent-browser.mjs +193 -0
- package/adapters/pi/lib/invoke.mjs +55 -0
- package/adapters/pi/lib/list-models.mjs +29 -0
- package/adapters/pi/lib/settings-proposal.mjs +34 -0
- package/adapters/pi/lib/subagent.mjs +175 -0
- package/adapters/pi/loop-guard/index.js +51 -0
- package/adapters/pi/maintenance-policy.json +36 -0
- package/adapters/pi/mcp.template.json +4 -0
- package/adapters/pi/model-catalog.json +97 -0
- package/adapters/pi/models.json +13 -0
- package/adapters/pi/models.local.example.json +14 -0
- package/adapters/pi/orchestrator-ext/command.mjs +14 -0
- package/adapters/pi/orchestrator-ext/index.js +150 -0
- package/adapters/pi/settings.template.json +7 -0
- package/adapters/pi/shell-gate/index.js +70 -0
- package/adapters/pi/sync-pi.ps1 +137 -0
- package/agents/README.md +26 -0
- package/agents/browser.md +64 -0
- package/agents/coder.md +31 -0
- package/agents/orchestrator.md +37 -0
- package/agents/reviewer.md +32 -0
- package/agents/scout.md +35 -0
- package/agents/tester.md +28 -0
- package/agents/visual.md +28 -0
- package/context-pack/SPEC.md +101 -0
- package/context-pack/context-pack.schema.json +79 -0
- package/context-pack/examples/example-fix.md +44 -0
- package/docs/architecture.md +55 -0
- package/docs/migration-from-codex-setting.md +44 -0
- package/docs/model-maintenance.md +401 -0
- package/docs/orchestrator.md +155 -0
- package/docs/phase2-report.md +39 -0
- package/docs/roadmap.md +27 -0
- package/docs/third-party.md +15 -0
- package/lib/agents.mjs +79 -0
- package/lib/context-pack.mjs +215 -0
- package/lib/job.mjs +312 -0
- package/lib/language-policy.mjs +27 -0
- package/lib/maintenance-exec.mjs +377 -0
- package/lib/maintenance-runner.mjs +266 -0
- package/lib/maintenance.mjs +422 -0
- package/lib/normalize.mjs +101 -0
- package/lib/observe/differ.mjs +185 -0
- package/lib/observe/observation.mjs +147 -0
- package/lib/observe/observers.mjs +134 -0
- package/lib/observe/sources.mjs +154 -0
- package/lib/orchestrator/activity.mjs +249 -0
- package/lib/orchestrator/api.mjs +151 -0
- package/lib/orchestrator/contract.mjs +68 -0
- package/lib/orchestrator/escalation.mjs +84 -0
- package/lib/orchestrator/evaluator.mjs +92 -0
- package/lib/orchestrator/failures.mjs +88 -0
- package/lib/orchestrator/health.mjs +53 -0
- package/lib/orchestrator/orchestrator.mjs +483 -0
- package/lib/orchestrator/permissions.mjs +64 -0
- package/lib/orchestrator/planner.mjs +194 -0
- package/lib/orchestrator/policy.mjs +134 -0
- package/lib/orchestrator/router.mjs +45 -0
- package/lib/orchestrator/runner.mjs +278 -0
- package/lib/orchestrator/shell-policy.mjs +52 -0
- package/lib/orchestrator/store.mjs +581 -0
- package/lib/orchestrator/task-store.mjs +79 -0
- package/lib/orchestrator/turn-budget.mjs +63 -0
- package/lib/orchestrator/worktree.mjs +72 -0
- package/lib/pipeline.mjs +279 -0
- package/lib/registry.mjs +63 -0
- package/lib/resolve.mjs +35 -0
- package/lib/routing.mjs +137 -0
- package/lib/telemetry.mjs +222 -0
- package/mcp/README.md +11 -0
- package/mcp/servers.json +13 -0
- package/orchestration/decision-policy.json +66 -0
- package/package.json +56 -0
- package/routing/README.md +24 -0
- package/routing/routing.json +81 -0
- package/routing/routing.schema.json +66 -0
- package/rules/README.md +10 -0
- package/rules/common.md +52 -0
- package/rules/loop-prevention.md +15 -0
- package/rules/repo-local.md +6 -0
- package/scripts/check-environment.ps1 +22 -0
- package/scripts/context-pack.mjs +17 -0
- package/scripts/e2e-investigate-repro.mjs +66 -0
- package/scripts/model-maintenance-job.mjs +59 -0
- package/scripts/observe-models.mjs +97 -0
- package/scripts/orchestrate.mjs +137 -0
- package/scripts/reevaluate-models.mjs +95 -0
- package/scripts/report-model-maintenance.mjs +70 -0
- package/scripts/resolve-capabilities.mjs +39 -0
- package/scripts/run-pipeline.mjs +56 -0
- package/scripts/sync-agents-md.ps1 +10 -0
- package/scripts/validate.mjs +71 -0
- package/skills/README.md +14 -0
- package/skills/pi-workflow/SKILL.md +26 -0
- package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
- package/skills/pi-workflow/references/research.md +14 -0
- package/skills/pi-workflow/references/review.md +11 -0
- package/skills/pi-workflow/references/visual-work.md +14 -0
- package/skills/project-management/SKILL.md +106 -0
- package/skills/project-management/references/operations.md +52 -0
- package/skills/visual-verification/SKILL.md +88 -0
- package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
- package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
- package/skills/visual-verification/scripts/common.ps1 +387 -0
- package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
- package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
- package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
- package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
- package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
- package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
- package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
- package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
- package/skills/visual-verification/scripts/record-av.ps1 +102 -0
- package/skills/visual-verification/scripts/record.ps1 +72 -0
- package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
- package/skills/visual-verification/scripts/waveform.ps1 +450 -0
- package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
- package/tests/activity.test.mjs +252 -0
- package/tests/attempt-budget.test.mjs +102 -0
- package/tests/browser.test.mjs +121 -0
- package/tests/context-pack.test.mjs +98 -0
- package/tests/dirty-gate.test.mjs +211 -0
- package/tests/e2e-browser.mjs +66 -0
- package/tests/e2e-real-orchestrator-resume.mjs +101 -0
- package/tests/e2e-real-orchestrator.mjs +41 -0
- package/tests/e2e-real-pi.mjs +27 -0
- package/tests/e2e-real-tool-orchestrator.mjs +66 -0
- package/tests/fixtures/browser-page/index.html +20 -0
- package/tests/fixtures/maintenance/availability.txt +5 -0
- package/tests/fixtures/maintenance/catalog.json +74 -0
- package/tests/fixtures/maintenance/events.json +13 -0
- package/tests/fixtures/math-repo/README.md +3 -0
- package/tests/fixtures/math-repo/package.json +7 -0
- package/tests/fixtures/math-repo/src/math.js +11 -0
- package/tests/fixtures/math-repo/test/math.test.js +7 -0
- package/tests/fixtures/observe/announcements.json +8 -0
- package/tests/fixtures/orch-concurrent-child.mjs +44 -0
- package/tests/fixtures/orch-persist-child.mjs +61 -0
- package/tests/job.test.mjs +230 -0
- package/tests/kit.test.mjs +79 -0
- package/tests/language-policy.test.mjs +93 -0
- package/tests/loop-guard.test.mjs +60 -0
- package/tests/maintenance-exec.test.mjs +218 -0
- package/tests/maintenance-runner.test.mjs +222 -0
- package/tests/maintenance.test.mjs +195 -0
- package/tests/observe.test.mjs +283 -0
- package/tests/observer-registry.test.mjs +157 -0
- package/tests/orchestrator-cleanup.test.mjs +358 -0
- package/tests/orchestrator-command.test.mjs +14 -0
- package/tests/orchestrator-persist.test.mjs +375 -0
- package/tests/orchestrator-tools.test.mjs +215 -0
- package/tests/orchestrator.test.mjs +396 -0
- package/tests/package.test.mjs +37 -0
- package/tests/pipeline.test.mjs +239 -0
- package/tests/planner-classification.test.mjs +81 -0
- package/tests/planner-split.test.mjs +67 -0
- package/tests/qoder-observer.test.mjs +266 -0
- package/tests/reassign-progression.test.mjs +104 -0
- package/tests/retry-escalation.test.mjs +120 -0
- package/tests/routing.test.mjs +110 -0
- package/tests/sqlite-concurrency.test.mjs +178 -0
- package/tests/task-global-e2e.test.mjs +63 -0
- package/tests/task-global-failed.test.mjs +134 -0
- package/tests/telemetry.test.mjs +173 -0
- package/tests/test-sync-pi.ps1 +56 -0
- package/tests/turn-budget.test.mjs +106 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
[CmdletBinding()]
|
|
2
|
+
param(
|
|
3
|
+
[Parameter(Mandatory)]
|
|
4
|
+
[ValidateNotNullOrEmpty()]
|
|
5
|
+
[string]$Input,
|
|
6
|
+
[string]$Language,
|
|
7
|
+
[string]$Model = 'base',
|
|
8
|
+
[string]$Backend = 'whisperx',
|
|
9
|
+
[string]$Device = 'cuda',
|
|
10
|
+
[string]$OutputJson,
|
|
11
|
+
[string]$WhisperXVenvPath = (Join-Path $env:USERPROFILE '.cache/agent-verification-lab/whisperx-venv'),
|
|
12
|
+
[string]$ModelCachePath = (Join-Path $env:USERPROFILE '.cache/agent-verification-lab/whisperx-models')
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
Set-StrictMode -Version Latest
|
|
16
|
+
$ErrorActionPreference = 'Stop'
|
|
17
|
+
|
|
18
|
+
function New-BackendResult {
|
|
19
|
+
param(
|
|
20
|
+
[Parameter(Mandatory)][string]$Status,
|
|
21
|
+
[Parameter(Mandatory)][string]$Message,
|
|
22
|
+
[Parameter(Mandatory)][string]$BackendName,
|
|
23
|
+
[Parameter(Mandatory)][string]$RequestedDevice,
|
|
24
|
+
[Parameter(Mandatory)][string]$RequestedModel
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return [pscustomobject]@{
|
|
28
|
+
schema_version = 'agent-verification-lab.speech.v1'
|
|
29
|
+
status = $Status
|
|
30
|
+
language = $null
|
|
31
|
+
backend = [pscustomobject]@{
|
|
32
|
+
name = $BackendName
|
|
33
|
+
version = $null
|
|
34
|
+
device = $RequestedDevice
|
|
35
|
+
model = $RequestedModel
|
|
36
|
+
}
|
|
37
|
+
segments = @()
|
|
38
|
+
words = @()
|
|
39
|
+
error = $Message
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function Write-BackendUnavailable {
|
|
44
|
+
param([Parameter(Mandatory)][string]$Message)
|
|
45
|
+
|
|
46
|
+
$result = New-BackendResult -Status 'REQUIRES_BACKEND' -Message $Message -BackendName $Backend -RequestedDevice $Device -RequestedModel $Model
|
|
47
|
+
Write-Output ($result | ConvertTo-Json -Depth 6 -Compress)
|
|
48
|
+
exit 3
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function Get-RequiredStringProperty {
|
|
52
|
+
param([Parameter(Mandatory)][object]$Object, [Parameter(Mandatory)][string]$Name)
|
|
53
|
+
|
|
54
|
+
$property = $Object.PSObject.Properties[$Name]
|
|
55
|
+
if ($null -eq $property -or [string]::IsNullOrWhiteSpace([string]$property.Value)) {
|
|
56
|
+
throw "Speech backend result is missing required '$Name'."
|
|
57
|
+
}
|
|
58
|
+
return [string]$property.Value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Test-FiniteNumber {
|
|
62
|
+
param([AllowNull()][object]$Value)
|
|
63
|
+
|
|
64
|
+
if ($null -eq $Value) {
|
|
65
|
+
return $false
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
$number = [double]$Value
|
|
69
|
+
return -not [double]::IsNaN($number) -and -not [double]::IsInfinity($number)
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return $false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function Assert-NormalizedSpeechResult {
|
|
77
|
+
param([Parameter(Mandatory)][object]$Result)
|
|
78
|
+
|
|
79
|
+
if ((Get-RequiredStringProperty -Object $Result -Name 'schema_version') -ne 'agent-verification-lab.speech.v1') {
|
|
80
|
+
throw "Speech backend result does not use schema_version 'agent-verification-lab.speech.v1'."
|
|
81
|
+
}
|
|
82
|
+
if ((Get-RequiredStringProperty -Object $Result -Name 'status') -ne 'ok') {
|
|
83
|
+
throw "Speech backend result has non-success status '$($Result.status)'."
|
|
84
|
+
}
|
|
85
|
+
[void](Get-RequiredStringProperty -Object $Result -Name 'language')
|
|
86
|
+
$backendResult = $Result.PSObject.Properties['backend']
|
|
87
|
+
if ($null -eq $backendResult -or $null -eq $backendResult.Value) {
|
|
88
|
+
throw 'Speech backend result is missing backend provenance.'
|
|
89
|
+
}
|
|
90
|
+
foreach ($field in @('name', 'version', 'device', 'model')) {
|
|
91
|
+
[void](Get-RequiredStringProperty -Object $backendResult.Value -Name $field)
|
|
92
|
+
}
|
|
93
|
+
foreach ($collectionName in @('segments', 'words')) {
|
|
94
|
+
$collection = $Result.PSObject.Properties[$collectionName]
|
|
95
|
+
if ($null -eq $collection) {
|
|
96
|
+
throw "Speech backend result is missing '$collectionName'."
|
|
97
|
+
}
|
|
98
|
+
if (@($collection.Value).Count -lt 1) {
|
|
99
|
+
throw "Speech backend result has no normalized $collectionName."
|
|
100
|
+
}
|
|
101
|
+
foreach ($item in @($collection.Value)) {
|
|
102
|
+
if ($null -eq $item) {
|
|
103
|
+
throw "Speech backend result contains a null $collectionName item."
|
|
104
|
+
}
|
|
105
|
+
[void](Get-RequiredStringProperty -Object $item -Name 'text')
|
|
106
|
+
if (-not (Test-FiniteNumber -Value $item.start) -or -not (Test-FiniteNumber -Value $item.end)) {
|
|
107
|
+
throw "Speech backend result contains a $collectionName item without finite numeric start/end timestamps."
|
|
108
|
+
}
|
|
109
|
+
if ([double]$item.end -lt [double]$item.start) {
|
|
110
|
+
throw "Speech backend result contains a $collectionName item whose end precedes start."
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
$alignment = $backendResult.Value.PSObject.Properties['alignment']
|
|
115
|
+
if ($null -eq $alignment -or $null -eq $alignment.Value -or $alignment.Value.status -ne 'aligned') {
|
|
116
|
+
throw 'Speech backend result is not word-aligned.'
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function Test-PathWithin {
|
|
121
|
+
param(
|
|
122
|
+
[Parameter(Mandatory)][string]$Child,
|
|
123
|
+
[Parameter(Mandatory)][string]$Parent
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
$childPath = [IO.Path]::GetFullPath($Child).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
|
|
127
|
+
$parentPath = [IO.Path]::GetFullPath($Parent).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
|
|
128
|
+
return $childPath.Equals($parentPath, [StringComparison]::OrdinalIgnoreCase) -or $childPath.StartsWith($parentPath + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function Resolve-PhysicalExistingPath {
|
|
132
|
+
param([Parameter(Mandatory)][string]$ExistingPath)
|
|
133
|
+
|
|
134
|
+
$logicalPath = [IO.Path]::GetFullPath($ExistingPath)
|
|
135
|
+
$providerPath = (Resolve-Path -LiteralPath $logicalPath -ErrorAction Stop).ProviderPath
|
|
136
|
+
$item = Get-Item -LiteralPath $logicalPath -Force -ErrorAction Stop
|
|
137
|
+
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
|
138
|
+
try {
|
|
139
|
+
$target = $item.ResolveLinkTarget($true)
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw "Could not resolve reparse-point target for '$logicalPath': $($_.Exception.Message)"
|
|
143
|
+
}
|
|
144
|
+
if ($null -eq $target) {
|
|
145
|
+
throw "Could not resolve reparse-point target for '$logicalPath'."
|
|
146
|
+
}
|
|
147
|
+
return [IO.Path]::GetFullPath($target.FullName)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
$parent = Split-Path -Parent $logicalPath
|
|
151
|
+
$leaf = Split-Path -Leaf $logicalPath
|
|
152
|
+
if ([string]::IsNullOrWhiteSpace($parent) -or [string]::IsNullOrWhiteSpace($leaf) -or $parent -eq $logicalPath) {
|
|
153
|
+
return [IO.Path]::GetFullPath($providerPath)
|
|
154
|
+
}
|
|
155
|
+
return (Join-Path (Resolve-PhysicalExistingPath -ExistingPath $parent) $leaf)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function Resolve-PhysicalPath {
|
|
159
|
+
param([Parameter(Mandatory)][string]$Path)
|
|
160
|
+
|
|
161
|
+
$logicalPath = [IO.Path]::GetFullPath($Path)
|
|
162
|
+
$unresolvedTail = [System.Collections.Generic.List[string]]::new()
|
|
163
|
+
$existingAncestor = $logicalPath
|
|
164
|
+
while (-not (Test-Path -LiteralPath $existingAncestor)) {
|
|
165
|
+
$leaf = Split-Path -Leaf $existingAncestor
|
|
166
|
+
$parent = Split-Path -Parent $existingAncestor
|
|
167
|
+
if ([string]::IsNullOrWhiteSpace($leaf) -or [string]::IsNullOrWhiteSpace($parent) -or $parent -eq $existingAncestor) {
|
|
168
|
+
throw "No existing ancestor is available for physical path resolution: $logicalPath"
|
|
169
|
+
}
|
|
170
|
+
[void]$unresolvedTail.Add($leaf)
|
|
171
|
+
$existingAncestor = $parent
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
$physicalPath = Resolve-PhysicalExistingPath -ExistingPath $existingAncestor
|
|
175
|
+
for ($index = $unresolvedTail.Count - 1; $index -ge 0; $index--) {
|
|
176
|
+
$physicalPath = Join-Path $physicalPath $unresolvedTail[$index]
|
|
177
|
+
}
|
|
178
|
+
return [IO.Path]::GetFullPath($physicalPath)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function Write-NewSpeechOutputJson {
|
|
182
|
+
param(
|
|
183
|
+
[Parameter(Mandatory)][string]$Path,
|
|
184
|
+
[Parameter(Mandatory)][string]$Json
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
$bytes = [Text.UTF8Encoding]::new($false).GetBytes($Json)
|
|
188
|
+
$stream = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
|
189
|
+
try {
|
|
190
|
+
$stream.Write($bytes, 0, $bytes.Length)
|
|
191
|
+
$stream.Flush($true)
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
$stream.Dispose()
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function Set-ScopedModelCacheEnvironment {
|
|
199
|
+
param([Parameter(Mandatory)][string]$CachePath)
|
|
200
|
+
|
|
201
|
+
$resolvedCachePath = [IO.Path]::GetFullPath($CachePath)
|
|
202
|
+
$values = [ordered]@{
|
|
203
|
+
HF_HOME = $resolvedCachePath
|
|
204
|
+
HF_HUB_CACHE = Join-Path $resolvedCachePath 'hub'
|
|
205
|
+
TRANSFORMERS_CACHE = Join-Path $resolvedCachePath 'transformers'
|
|
206
|
+
}
|
|
207
|
+
$previous = @{}
|
|
208
|
+
foreach ($name in $values.Keys) {
|
|
209
|
+
$existing = Get-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue
|
|
210
|
+
$previous[$name] = [pscustomobject]@{ exists = $null -ne $existing; value = if ($null -ne $existing) { $existing.Value } else { $null } }
|
|
211
|
+
Set-Item -LiteralPath "Env:$name" -Value $values[$name]
|
|
212
|
+
}
|
|
213
|
+
return [pscustomobject]@{ cache_path = $resolvedCachePath; previous = $previous; names = @($values.Keys) }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function Restore-ScopedModelCacheEnvironment {
|
|
217
|
+
param([Parameter(Mandatory)][pscustomobject]$Scope)
|
|
218
|
+
|
|
219
|
+
foreach ($name in $Scope.names) {
|
|
220
|
+
if ($Scope.previous[$name].exists) {
|
|
221
|
+
Set-Item -LiteralPath "Env:$name" -Value $Scope.previous[$name].value
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
$inputPath = $PSBoundParameters['Input']
|
|
230
|
+
if (-not (Test-Path -LiteralPath $inputPath -PathType Leaf)) {
|
|
231
|
+
throw "Input media file does not exist or is not a file: $inputPath"
|
|
232
|
+
}
|
|
233
|
+
$resolvedInputPath = [IO.Path]::GetFullPath($inputPath)
|
|
234
|
+
$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
|
|
235
|
+
|
|
236
|
+
if ($Backend -cne 'whisperx') {
|
|
237
|
+
Write-BackendUnavailable -Message "Unsupported speech backend '$Backend'."
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
$resolvedVenvPath = [IO.Path]::GetFullPath($WhisperXVenvPath)
|
|
241
|
+
$resolvedModelCachePath = [IO.Path]::GetFullPath($ModelCachePath)
|
|
242
|
+
try {
|
|
243
|
+
$physicalRepoRoot = Resolve-PhysicalPath -Path $repoRoot
|
|
244
|
+
$physicalVenvPath = Resolve-PhysicalPath -Path $resolvedVenvPath
|
|
245
|
+
$physicalModelCachePath = Resolve-PhysicalPath -Path $resolvedModelCachePath
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
Write-BackendUnavailable -Message "ISOLATION_PATH_RESOLUTION_FAILED: $($_.Exception.Message)"
|
|
249
|
+
}
|
|
250
|
+
if (Test-PathWithin -Child $physicalVenvPath -Parent $physicalRepoRoot) {
|
|
251
|
+
Write-BackendUnavailable -Message "VENV_PATH_INSIDE_REPOSITORY: $physicalVenvPath"
|
|
252
|
+
}
|
|
253
|
+
if (Test-PathWithin -Child $physicalModelCachePath -Parent $physicalRepoRoot) {
|
|
254
|
+
Write-BackendUnavailable -Message "MODEL_CACHE_PATH_INSIDE_REPOSITORY: $physicalModelCachePath"
|
|
255
|
+
}
|
|
256
|
+
$pythonPath = Join-Path $resolvedVenvPath 'Scripts\python.exe'
|
|
257
|
+
if (-not (Test-Path -LiteralPath $pythonPath -PathType Leaf)) {
|
|
258
|
+
Write-BackendUnavailable -Message "Dedicated WhisperX interpreter is unavailable: $pythonPath"
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
$adapterPath = Join-Path $PSScriptRoot 'backends\whisperx_backend.py'
|
|
262
|
+
if (-not (Test-Path -LiteralPath $adapterPath -PathType Leaf)) {
|
|
263
|
+
Write-BackendUnavailable -Message "WhisperX backend adapter is unavailable: $adapterPath"
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
$resolvedOutputJson = $null
|
|
267
|
+
if (-not [string]::IsNullOrWhiteSpace($OutputJson)) {
|
|
268
|
+
$resolvedOutputJson = [IO.Path]::GetFullPath($OutputJson)
|
|
269
|
+
$outputDirectory = Split-Path -Parent $resolvedOutputJson
|
|
270
|
+
if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
|
|
271
|
+
throw "OutputJson directory does not exist: $outputDirectory"
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
$ffmpeg = Get-Command ffmpeg -CommandType Application -ErrorAction Stop
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
throw 'ffmpeg was not found on PATH.'
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
$temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) 'agent-verification-lab\speech'
|
|
283
|
+
New-Item -ItemType Directory -Path $temporaryDirectory -Force | Out-Null
|
|
284
|
+
$temporaryWav = Join-Path $temporaryDirectory ('speech-' + [Guid]::NewGuid().ToString('N') + '.wav')
|
|
285
|
+
$cacheScope = $null
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
$ffmpegOutput = & $ffmpeg.Source '-hide_banner' '-loglevel' 'error' '-nostdin' '-y' '-i' $resolvedInputPath '-map' '0:a:0' '-vn' '-ac' '1' '-ar' '16000' '-c:a' 'pcm_s16le' $temporaryWav 2>&1
|
|
289
|
+
$ffmpegExitCode = $LASTEXITCODE
|
|
290
|
+
if ($ffmpegExitCode -ne 0 -or -not (Test-Path -LiteralPath $temporaryWav -PathType Leaf)) {
|
|
291
|
+
$diagnostics = ($ffmpegOutput | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine
|
|
292
|
+
throw "FFmpeg audio extraction failed with exit code $ffmpegExitCode for '$resolvedInputPath'.$([Environment]::NewLine)$diagnostics"
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
$cacheScope = Set-ScopedModelCacheEnvironment -CachePath $ModelCachePath
|
|
296
|
+
$adapterArguments = @(
|
|
297
|
+
$adapterPath,
|
|
298
|
+
'--input-wav', $temporaryWav,
|
|
299
|
+
'--model', $Model,
|
|
300
|
+
'--device', $Device,
|
|
301
|
+
'--model-cache-path', $cacheScope.cache_path
|
|
302
|
+
)
|
|
303
|
+
if (-not [string]::IsNullOrWhiteSpace($Language)) {
|
|
304
|
+
$adapterArguments += @('--language', $Language)
|
|
305
|
+
}
|
|
306
|
+
$adapterOutput = & $pythonPath @adapterArguments 2>&1
|
|
307
|
+
$adapterExitCode = $LASTEXITCODE
|
|
308
|
+
$jsonLine = @($adapterOutput | Where-Object { $_.ToString().TrimStart().StartsWith('{') } | Select-Object -Last 1)
|
|
309
|
+
if ($jsonLine.Count -ne 1) {
|
|
310
|
+
$diagnostics = ($adapterOutput | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine
|
|
311
|
+
throw "WhisperX adapter did not return normalized JSON (exit $adapterExitCode).$([Environment]::NewLine)$diagnostics"
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
$result = $jsonLine[0].ToString() | ConvertFrom-Json -ErrorAction Stop
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
throw "WhisperX adapter returned invalid JSON: $($_.Exception.Message)"
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if ($adapterExitCode -ne 0 -or $result.status -eq 'REQUIRES_BACKEND') {
|
|
321
|
+
if ($result.schema_version -ne 'agent-verification-lab.speech.v1' -or $result.status -ne 'REQUIRES_BACKEND') {
|
|
322
|
+
throw "WhisperX adapter failed with exit code $adapterExitCode without a normalized REQUIRES_BACKEND result."
|
|
323
|
+
}
|
|
324
|
+
Write-Output ($result | ConvertTo-Json -Depth 8 -Compress)
|
|
325
|
+
exit 3
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
Assert-NormalizedSpeechResult -Result $result
|
|
329
|
+
$normalizedJson = $result | ConvertTo-Json -Depth 8
|
|
330
|
+
if ($null -ne $resolvedOutputJson) {
|
|
331
|
+
Write-NewSpeechOutputJson -Path $resolvedOutputJson -Json $normalizedJson
|
|
332
|
+
}
|
|
333
|
+
Write-Output $normalizedJson
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
try {
|
|
337
|
+
if ($null -ne $cacheScope) {
|
|
338
|
+
Restore-ScopedModelCacheEnvironment -Scope $cacheScope
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
if (Test-Path -LiteralPath $temporaryWav -PathType Leaf) {
|
|
343
|
+
Remove-Item -LiteralPath $temporaryWav -Force -ErrorAction SilentlyContinue
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Normalize WhisperX transcription results behind the speech v1 contract."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import importlib.metadata
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SCHEMA_VERSION = "agent-verification-lab.speech.v1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def finite_number(value: Any) -> float | None:
|
|
20
|
+
if isinstance(value, bool):
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
number = float(value)
|
|
24
|
+
except (TypeError, ValueError):
|
|
25
|
+
return None
|
|
26
|
+
return number if math.isfinite(number) else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def clean_text(value: Any) -> str:
|
|
30
|
+
return value.strip() if isinstance(value, str) else ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def normalize_segments(raw_segments: Any) -> list[dict[str, Any]]:
|
|
34
|
+
normalized: list[dict[str, Any]] = []
|
|
35
|
+
if not isinstance(raw_segments, list):
|
|
36
|
+
return normalized
|
|
37
|
+
for position, segment in enumerate(raw_segments):
|
|
38
|
+
if not isinstance(segment, dict):
|
|
39
|
+
continue
|
|
40
|
+
text = clean_text(segment.get("text"))
|
|
41
|
+
start = finite_number(segment.get("start"))
|
|
42
|
+
end = finite_number(segment.get("end"))
|
|
43
|
+
if not text or start is None or end is None or end < start:
|
|
44
|
+
continue
|
|
45
|
+
segment_id = segment.get("id", position)
|
|
46
|
+
normalized.append({"id": segment_id if isinstance(segment_id, int) else position, "text": text, "start": start, "end": end})
|
|
47
|
+
return normalized
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def normalize_words(raw_segments: Any) -> list[dict[str, Any]]:
|
|
51
|
+
normalized: list[dict[str, Any]] = []
|
|
52
|
+
if not isinstance(raw_segments, list):
|
|
53
|
+
return normalized
|
|
54
|
+
for segment in raw_segments:
|
|
55
|
+
if not isinstance(segment, dict):
|
|
56
|
+
continue
|
|
57
|
+
raw_words = segment.get("words")
|
|
58
|
+
if not isinstance(raw_words, list):
|
|
59
|
+
continue
|
|
60
|
+
for word in raw_words:
|
|
61
|
+
if not isinstance(word, dict):
|
|
62
|
+
continue
|
|
63
|
+
text = clean_text(word.get("word", word.get("text")))
|
|
64
|
+
start = finite_number(word.get("start"))
|
|
65
|
+
end = finite_number(word.get("end"))
|
|
66
|
+
if not text or start is None or end is None or end < start:
|
|
67
|
+
continue
|
|
68
|
+
item: dict[str, Any] = {"text": text, "start": start, "end": end}
|
|
69
|
+
score = finite_number(word.get("score"))
|
|
70
|
+
if score is not None:
|
|
71
|
+
item["score"] = score
|
|
72
|
+
normalized.append(item)
|
|
73
|
+
return normalized
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def package_version() -> str | None:
|
|
77
|
+
try:
|
|
78
|
+
return importlib.metadata.version("whisperx")
|
|
79
|
+
except importlib.metadata.PackageNotFoundError:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def backend_provenance(args: argparse.Namespace, version: str | None, alignment: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
84
|
+
provenance: dict[str, Any] = {
|
|
85
|
+
"name": "whisperx",
|
|
86
|
+
"version": version,
|
|
87
|
+
"device": args.device,
|
|
88
|
+
"model": args.model,
|
|
89
|
+
"model_cache_path": str(Path(args.model_cache_path).resolve()),
|
|
90
|
+
}
|
|
91
|
+
if alignment is not None:
|
|
92
|
+
provenance["alignment"] = alignment
|
|
93
|
+
return provenance
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def emit_failure(
|
|
97
|
+
args: argparse.Namespace,
|
|
98
|
+
message: str,
|
|
99
|
+
version: str | None = None,
|
|
100
|
+
language: str | None = None,
|
|
101
|
+
alignment: dict[str, Any] | None = None,
|
|
102
|
+
segments: list[dict[str, Any]] | None = None,
|
|
103
|
+
words: list[dict[str, Any]] | None = None,
|
|
104
|
+
) -> int:
|
|
105
|
+
print(json.dumps({
|
|
106
|
+
"schema_version": SCHEMA_VERSION,
|
|
107
|
+
"status": "REQUIRES_BACKEND",
|
|
108
|
+
"language": language,
|
|
109
|
+
"backend": backend_provenance(args, version, alignment),
|
|
110
|
+
"segments": segments or [],
|
|
111
|
+
"words": words or [],
|
|
112
|
+
"error": message,
|
|
113
|
+
}, ensure_ascii=True, separators=(",", ":")))
|
|
114
|
+
return 3
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_args() -> argparse.Namespace:
|
|
118
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
119
|
+
parser.add_argument("--input-wav", required=True)
|
|
120
|
+
parser.add_argument("--language")
|
|
121
|
+
parser.add_argument("--model", required=True)
|
|
122
|
+
parser.add_argument("--device", required=True)
|
|
123
|
+
parser.add_argument("--model-cache-path", required=True)
|
|
124
|
+
return parser.parse_args()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main() -> int:
|
|
128
|
+
args = parse_args()
|
|
129
|
+
input_wav = Path(args.input_wav)
|
|
130
|
+
if not input_wav.is_file():
|
|
131
|
+
return emit_failure(args, f"Temporary WAV is unavailable: {input_wav}")
|
|
132
|
+
|
|
133
|
+
cache_path = Path(args.model_cache_path).resolve()
|
|
134
|
+
os.environ["HF_HOME"] = str(cache_path)
|
|
135
|
+
os.environ["HF_HUB_CACHE"] = str(cache_path / "hub")
|
|
136
|
+
os.environ["TRANSFORMERS_CACHE"] = str(cache_path / "transformers")
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
import whisperx
|
|
140
|
+
except Exception as error: # The adapter must degrade instead of importing a global fallback.
|
|
141
|
+
return emit_failure(args, f"WhisperX import failed: {type(error).__name__}: {error}", package_version())
|
|
142
|
+
|
|
143
|
+
version = getattr(whisperx, "__version__", None) or package_version()
|
|
144
|
+
try:
|
|
145
|
+
cache_path.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
compute_type = "float16" if args.device.lower().startswith("cuda") else "int8"
|
|
147
|
+
model = whisperx.load_model(
|
|
148
|
+
args.model,
|
|
149
|
+
args.device,
|
|
150
|
+
compute_type=compute_type,
|
|
151
|
+
language=args.language or None,
|
|
152
|
+
download_root=str(cache_path),
|
|
153
|
+
)
|
|
154
|
+
audio = whisperx.load_audio(str(input_wav))
|
|
155
|
+
transcription = model.transcribe(audio, batch_size=4, language=args.language or None)
|
|
156
|
+
except Exception as error:
|
|
157
|
+
return emit_failure(args, f"WhisperX transcription failed: {type(error).__name__}: {error}", version)
|
|
158
|
+
|
|
159
|
+
raw_segments = transcription.get("segments", []) if isinstance(transcription, dict) else []
|
|
160
|
+
language = clean_text(transcription.get("language")) if isinstance(transcription, dict) else ""
|
|
161
|
+
language = language or (args.language or "und")
|
|
162
|
+
transcribed_segments = normalize_segments(raw_segments)
|
|
163
|
+
if not transcribed_segments:
|
|
164
|
+
return emit_failure(
|
|
165
|
+
args,
|
|
166
|
+
"No usable word timestamps/speech detected: no normalized speech segments were detected.",
|
|
167
|
+
version,
|
|
168
|
+
language,
|
|
169
|
+
{"status": "not_attempted", "reason": "No speech segments to align"},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
aligned_segments = raw_segments
|
|
173
|
+
try:
|
|
174
|
+
try:
|
|
175
|
+
align_model, metadata = whisperx.load_align_model(language_code=language, device=args.device, model_dir=str(cache_path))
|
|
176
|
+
except TypeError:
|
|
177
|
+
align_model, metadata = whisperx.load_align_model(language_code=language, device=args.device)
|
|
178
|
+
aligned = whisperx.align(raw_segments, align_model, metadata, audio, args.device, return_char_alignments=False)
|
|
179
|
+
candidate_segments = aligned.get("segments", []) if isinstance(aligned, dict) else []
|
|
180
|
+
if not isinstance(candidate_segments, list):
|
|
181
|
+
return emit_failure(
|
|
182
|
+
args,
|
|
183
|
+
"WhisperX alignment returned no segments.",
|
|
184
|
+
version,
|
|
185
|
+
language,
|
|
186
|
+
{"status": "unavailable", "reason": "WhisperX alignment returned no segments"},
|
|
187
|
+
transcribed_segments,
|
|
188
|
+
)
|
|
189
|
+
aligned_segments = candidate_segments
|
|
190
|
+
except Exception as error:
|
|
191
|
+
return emit_failure(
|
|
192
|
+
args,
|
|
193
|
+
f"WhisperX alignment failed: {type(error).__name__}: {error}",
|
|
194
|
+
version,
|
|
195
|
+
language,
|
|
196
|
+
{"status": "unavailable", "reason": f"{type(error).__name__}: {error}"},
|
|
197
|
+
transcribed_segments,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
normalized_segments = normalize_segments(aligned_segments)
|
|
201
|
+
normalized_words = normalize_words(aligned_segments)
|
|
202
|
+
if not normalized_segments:
|
|
203
|
+
return emit_failure(
|
|
204
|
+
args,
|
|
205
|
+
"WhisperX alignment returned no normalized speech segments.",
|
|
206
|
+
version,
|
|
207
|
+
language,
|
|
208
|
+
{"status": "unavailable", "reason": "No normalized aligned segments"},
|
|
209
|
+
transcribed_segments,
|
|
210
|
+
)
|
|
211
|
+
if not normalized_words:
|
|
212
|
+
return emit_failure(
|
|
213
|
+
args,
|
|
214
|
+
"WhisperX alignment returned no usable word timestamps/speech detected.",
|
|
215
|
+
version,
|
|
216
|
+
language,
|
|
217
|
+
{"status": "unavailable", "reason": "No finite normalized word timestamps"},
|
|
218
|
+
normalized_segments,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
result = {
|
|
222
|
+
"schema_version": SCHEMA_VERSION,
|
|
223
|
+
"status": "ok",
|
|
224
|
+
"language": language,
|
|
225
|
+
"backend": backend_provenance(args, version, {"status": "aligned"}),
|
|
226
|
+
"segments": normalized_segments,
|
|
227
|
+
"words": normalized_words,
|
|
228
|
+
}
|
|
229
|
+
print(json.dumps(result, ensure_ascii=True, separators=(",", ":")))
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
sys.exit(main())
|