feat(stt): seed Whisper prompt with expected readback + per-field debug UI

Whisper prompt seeding (per request):
- ptt.post.ts builds the prompt as generic ICAO bias + this state's expected
  readback appended LAST (survives the 224-token truncation), in both raw token
  form and spoken ICAO form via new radioSpeech.speakToken().
- pm.vue passes the expected phrase + active variable values; classroom.vue
  passes the lesson's expected field values.

Per-field readback debug:
- sttMatch.matchTranscriptionToFields returns fields[] (matched/missing + which
  view matched) plus normalized/denormalized transcription views.
- useRadioBackend types readback_report on the transmit response.
- pm.vue renders a "Readback check" panel in the right log rail; classroom.vue
  renders per-field rows under the STT panel.

Radio-pronunciation fixes (radioSpeech.ts):
- callsign expander handles multi-letter suffixes (DLH6RK -> Lufthansa six Romeo
  Kilo).
- toRadioSpeech now expands airports (EDDC -> Echo Delta Delta Charlie).
- bare altitudes >=1000 in a clearance context are spoken ("climb initially
  5000" -> "climb initially five thousand feet"); speeds/headings untouched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leubeem
2026-06-17 14:12:54 +02:00
parent 453b04881f
commit b80feb80d6
8 changed files with 373 additions and 18 deletions

View File

@@ -2,14 +2,62 @@ import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
DEFAULT_AIRLINE_TELEPHONY,
normalizeAtisForSpeech,
normalizeMetarPhrase,
normalizeRadioPhrase,
speakToken,
spellIcaoDigits,
spellIcaoLetters,
toIcaoPhonetic,
} from '~~/shared/utils/radioSpeech'
describe('normalizeRadioPhrase — PM radio pronunciation', () => {
const opts = {
expandCallsigns: true,
expandAirports: true,
airlineMap: DEFAULT_AIRLINE_TELEPHONY,
sidSuffixIcao: true,
}
it('expands a callsign with a multi-letter suffix (DLH6RK)', () => {
assert.equal(normalizeRadioPhrase('DLH6RK', opts), 'Lufthansa six Romeo Kilo')
})
it('spells a 4-letter ICAO airport code', () => {
assert.equal(normalizeRadioPhrase('EDDC', opts), spellIcaoLetters('EDDC'))
})
it('speaks a bare altitude in a clearance context', () => {
assert.match(normalizeRadioPhrase('climb initially 5000', opts), /thousand feet$/)
})
it('leaves sub-1000 numbers (speeds/headings) untouched', () => {
assert.equal(normalizeRadioPhrase('maintain 250 knots', opts), 'maintain 250 knots')
})
})
describe('speakToken', () => {
it('speaks a runway with its side', () => {
assert.equal(speakToken('25R'), `${spellIcaoDigits('25')} right`)
})
it('speaks a frequency with decimal', () => {
assert.equal(speakToken('121.800'), `${spellIcaoDigits('121')} decimal ${spellIcaoDigits('800')}`)
})
it('speaks a flight level', () => {
assert.equal(speakToken('FL150'), `flight level ${spellIcaoDigits('150')}`)
})
it('spells a pure number digit by digit', () => {
assert.equal(speakToken('2341'), spellIcaoDigits('2341'))
})
it('spells an alphanumeric identifier phonetically', () => {
assert.equal(speakToken('BIBAX1N'), toIcaoPhonetic('BIBAX1N'))
})
it('returns empty for plain words (caller keeps raw)', () => {
assert.equal(speakToken('west'), '')
})
})
describe('radioSpeech', () => {
it('spells ICAO digits and letters', () => {
assert.equal(spellIcaoDigits('120'), 'wun too zero')

View File

@@ -120,6 +120,31 @@ describe('fuzzyContains', () => {
})
})
describe('matchTranscriptionToFields — per-field report', () => {
it('reports matched and missing fields with the view that matched', () => {
const result = matchTranscriptionToFields('runway two five right squawk seven five zero zero', [
{ key: 'runway', expected: '25R' },
{ key: 'squawk', expected: '7500' },
{ key: 'altitude', expected: '5000' },
])
const byKey = Object.fromEntries(result.fields.map(f => [f.key, f]))
assert.equal(byKey.runway!.matched, true)
assert.equal(byKey.runway!.view, 'spoken') // folded from "two five right"
assert.equal(byKey.squawk!.matched, true)
assert.equal(byKey.altitude!.matched, false) // never spoken
assert.equal(byKey.altitude!.matchedVia, null)
assert.match(result.denormalized, /25r/)
})
it('preserves original field order in the report', () => {
const result = matchTranscriptionToFields('nothing here', [
{ key: 'a', expected: '111' },
{ key: 'b', expected: '222' },
])
assert.deepEqual(result.fields.map(f => f.key), ['a', 'b'])
})
})
describe('looksLikeCallsignKey', () => {
it('detects common callsign field keys', () => {
assert.equal(looksLikeCallsignKey('callsign'), true)