@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.
Files changed (172) hide show
  1. package/AGENTS.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/adapters/codex/README.md +24 -0
  5. package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
  6. package/adapters/pi/README.md +88 -0
  7. package/adapters/pi/browser/agent-browser.mjs +193 -0
  8. package/adapters/pi/lib/invoke.mjs +55 -0
  9. package/adapters/pi/lib/list-models.mjs +29 -0
  10. package/adapters/pi/lib/settings-proposal.mjs +34 -0
  11. package/adapters/pi/lib/subagent.mjs +175 -0
  12. package/adapters/pi/loop-guard/index.js +51 -0
  13. package/adapters/pi/maintenance-policy.json +36 -0
  14. package/adapters/pi/mcp.template.json +4 -0
  15. package/adapters/pi/model-catalog.json +97 -0
  16. package/adapters/pi/models.json +13 -0
  17. package/adapters/pi/models.local.example.json +14 -0
  18. package/adapters/pi/orchestrator-ext/command.mjs +14 -0
  19. package/adapters/pi/orchestrator-ext/index.js +150 -0
  20. package/adapters/pi/settings.template.json +7 -0
  21. package/adapters/pi/shell-gate/index.js +70 -0
  22. package/adapters/pi/sync-pi.ps1 +137 -0
  23. package/agents/README.md +26 -0
  24. package/agents/browser.md +64 -0
  25. package/agents/coder.md +31 -0
  26. package/agents/orchestrator.md +37 -0
  27. package/agents/reviewer.md +32 -0
  28. package/agents/scout.md +35 -0
  29. package/agents/tester.md +28 -0
  30. package/agents/visual.md +28 -0
  31. package/context-pack/SPEC.md +101 -0
  32. package/context-pack/context-pack.schema.json +79 -0
  33. package/context-pack/examples/example-fix.md +44 -0
  34. package/docs/architecture.md +55 -0
  35. package/docs/migration-from-codex-setting.md +44 -0
  36. package/docs/model-maintenance.md +401 -0
  37. package/docs/orchestrator.md +155 -0
  38. package/docs/phase2-report.md +39 -0
  39. package/docs/roadmap.md +27 -0
  40. package/docs/third-party.md +15 -0
  41. package/lib/agents.mjs +79 -0
  42. package/lib/context-pack.mjs +215 -0
  43. package/lib/job.mjs +312 -0
  44. package/lib/language-policy.mjs +27 -0
  45. package/lib/maintenance-exec.mjs +377 -0
  46. package/lib/maintenance-runner.mjs +266 -0
  47. package/lib/maintenance.mjs +422 -0
  48. package/lib/normalize.mjs +101 -0
  49. package/lib/observe/differ.mjs +185 -0
  50. package/lib/observe/observation.mjs +147 -0
  51. package/lib/observe/observers.mjs +134 -0
  52. package/lib/observe/sources.mjs +154 -0
  53. package/lib/orchestrator/activity.mjs +249 -0
  54. package/lib/orchestrator/api.mjs +151 -0
  55. package/lib/orchestrator/contract.mjs +68 -0
  56. package/lib/orchestrator/escalation.mjs +84 -0
  57. package/lib/orchestrator/evaluator.mjs +92 -0
  58. package/lib/orchestrator/failures.mjs +88 -0
  59. package/lib/orchestrator/health.mjs +53 -0
  60. package/lib/orchestrator/orchestrator.mjs +483 -0
  61. package/lib/orchestrator/permissions.mjs +64 -0
  62. package/lib/orchestrator/planner.mjs +194 -0
  63. package/lib/orchestrator/policy.mjs +134 -0
  64. package/lib/orchestrator/router.mjs +45 -0
  65. package/lib/orchestrator/runner.mjs +278 -0
  66. package/lib/orchestrator/shell-policy.mjs +52 -0
  67. package/lib/orchestrator/store.mjs +581 -0
  68. package/lib/orchestrator/task-store.mjs +79 -0
  69. package/lib/orchestrator/turn-budget.mjs +63 -0
  70. package/lib/orchestrator/worktree.mjs +72 -0
  71. package/lib/pipeline.mjs +279 -0
  72. package/lib/registry.mjs +63 -0
  73. package/lib/resolve.mjs +35 -0
  74. package/lib/routing.mjs +137 -0
  75. package/lib/telemetry.mjs +222 -0
  76. package/mcp/README.md +11 -0
  77. package/mcp/servers.json +13 -0
  78. package/orchestration/decision-policy.json +66 -0
  79. package/package.json +56 -0
  80. package/routing/README.md +24 -0
  81. package/routing/routing.json +81 -0
  82. package/routing/routing.schema.json +66 -0
  83. package/rules/README.md +10 -0
  84. package/rules/common.md +52 -0
  85. package/rules/loop-prevention.md +15 -0
  86. package/rules/repo-local.md +6 -0
  87. package/scripts/check-environment.ps1 +22 -0
  88. package/scripts/context-pack.mjs +17 -0
  89. package/scripts/e2e-investigate-repro.mjs +66 -0
  90. package/scripts/model-maintenance-job.mjs +59 -0
  91. package/scripts/observe-models.mjs +97 -0
  92. package/scripts/orchestrate.mjs +137 -0
  93. package/scripts/reevaluate-models.mjs +95 -0
  94. package/scripts/report-model-maintenance.mjs +70 -0
  95. package/scripts/resolve-capabilities.mjs +39 -0
  96. package/scripts/run-pipeline.mjs +56 -0
  97. package/scripts/sync-agents-md.ps1 +10 -0
  98. package/scripts/validate.mjs +71 -0
  99. package/skills/README.md +14 -0
  100. package/skills/pi-workflow/SKILL.md +26 -0
  101. package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
  102. package/skills/pi-workflow/references/research.md +14 -0
  103. package/skills/pi-workflow/references/review.md +11 -0
  104. package/skills/pi-workflow/references/visual-work.md +14 -0
  105. package/skills/project-management/SKILL.md +106 -0
  106. package/skills/project-management/references/operations.md +52 -0
  107. package/skills/visual-verification/SKILL.md +88 -0
  108. package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
  109. package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
  110. package/skills/visual-verification/scripts/common.ps1 +387 -0
  111. package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
  112. package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
  113. package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
  114. package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
  115. package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
  116. package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
  117. package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
  118. package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
  119. package/skills/visual-verification/scripts/record-av.ps1 +102 -0
  120. package/skills/visual-verification/scripts/record.ps1 +72 -0
  121. package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
  122. package/skills/visual-verification/scripts/waveform.ps1 +450 -0
  123. package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
  124. package/tests/activity.test.mjs +252 -0
  125. package/tests/attempt-budget.test.mjs +102 -0
  126. package/tests/browser.test.mjs +121 -0
  127. package/tests/context-pack.test.mjs +98 -0
  128. package/tests/dirty-gate.test.mjs +211 -0
  129. package/tests/e2e-browser.mjs +66 -0
  130. package/tests/e2e-real-orchestrator-resume.mjs +101 -0
  131. package/tests/e2e-real-orchestrator.mjs +41 -0
  132. package/tests/e2e-real-pi.mjs +27 -0
  133. package/tests/e2e-real-tool-orchestrator.mjs +66 -0
  134. package/tests/fixtures/browser-page/index.html +20 -0
  135. package/tests/fixtures/maintenance/availability.txt +5 -0
  136. package/tests/fixtures/maintenance/catalog.json +74 -0
  137. package/tests/fixtures/maintenance/events.json +13 -0
  138. package/tests/fixtures/math-repo/README.md +3 -0
  139. package/tests/fixtures/math-repo/package.json +7 -0
  140. package/tests/fixtures/math-repo/src/math.js +11 -0
  141. package/tests/fixtures/math-repo/test/math.test.js +7 -0
  142. package/tests/fixtures/observe/announcements.json +8 -0
  143. package/tests/fixtures/orch-concurrent-child.mjs +44 -0
  144. package/tests/fixtures/orch-persist-child.mjs +61 -0
  145. package/tests/job.test.mjs +230 -0
  146. package/tests/kit.test.mjs +79 -0
  147. package/tests/language-policy.test.mjs +93 -0
  148. package/tests/loop-guard.test.mjs +60 -0
  149. package/tests/maintenance-exec.test.mjs +218 -0
  150. package/tests/maintenance-runner.test.mjs +222 -0
  151. package/tests/maintenance.test.mjs +195 -0
  152. package/tests/observe.test.mjs +283 -0
  153. package/tests/observer-registry.test.mjs +157 -0
  154. package/tests/orchestrator-cleanup.test.mjs +358 -0
  155. package/tests/orchestrator-command.test.mjs +14 -0
  156. package/tests/orchestrator-persist.test.mjs +375 -0
  157. package/tests/orchestrator-tools.test.mjs +215 -0
  158. package/tests/orchestrator.test.mjs +396 -0
  159. package/tests/package.test.mjs +37 -0
  160. package/tests/pipeline.test.mjs +239 -0
  161. package/tests/planner-classification.test.mjs +81 -0
  162. package/tests/planner-split.test.mjs +67 -0
  163. package/tests/qoder-observer.test.mjs +266 -0
  164. package/tests/reassign-progression.test.mjs +104 -0
  165. package/tests/retry-escalation.test.mjs +120 -0
  166. package/tests/routing.test.mjs +110 -0
  167. package/tests/sqlite-concurrency.test.mjs +178 -0
  168. package/tests/task-global-e2e.test.mjs +63 -0
  169. package/tests/task-global-failed.test.mjs +134 -0
  170. package/tests/telemetry.test.mjs +173 -0
  171. package/tests/test-sync-pi.ps1 +56 -0
  172. 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())