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

@@ -11,7 +11,7 @@ import { TransmissionLog } from "../../models/TransmissionLog";
import { getUserFromEvent } from "../../utils/auth";
import { enforceRateLimit, getClientIp } from "../../utils/rateLimit";
import { recordUsage } from "../../utils/usage";
import { DEFAULT_AIRLINE_TELEPHONY } from "../../../shared/utils/radioSpeech";
import { DEFAULT_AIRLINE_TELEPHONY, normalizeRadioPhrase, speakToken } from "../../../shared/utils/radioSpeech";
type AudioFormat = 'wav' | 'mp3' | 'ogg' | 'webm'
@@ -29,12 +29,45 @@ const STT_BIAS_PROMPT = [
"Common phrases: ready for pushback, request taxi, holding point, line up and wait, cleared for takeoff, contact tower, QNH, flight level, squawk, wilco, roger, affirm, negative, say again, runway, heading, descend, climb, maintain.",
].join(" ");
// Whisper keeps only the final ~224 prompt tokens, so the session-specific
// expected readback is appended LAST (after the generic bias) to ensure it
// survives truncation. We add the expected transmission both as written tokens
// (spelling bias for callsigns/SIDs) and in spoken ICAO form (the words the
// pilot actually says) — e.g. "25R" → "two five right", "BIBAX1N" → phonetics.
function buildSttPrompt(expected?: { phrase?: string; tokens?: string[] }): string {
const segments: string[] = [STT_BIAS_PROMPT];
const phrase = expected?.phrase?.trim();
if (phrase) {
segments.push(`Expected pilot transmission: ${phrase}.`);
const spoken = normalizeRadioPhrase(phrase, { expandAirports: true, expandCallsigns: true });
if (spoken && spoken.toLowerCase() !== phrase.toLowerCase()) {
segments.push(`Spoken: ${spoken}.`);
}
}
const tokens = Array.from(
new Set((expected?.tokens ?? []).map(t => `${t ?? ''}`.trim()).filter(Boolean))
);
if (tokens.length) {
segments.push(`Expected values: ${tokens.join(', ')}.`);
const spokenTokens = tokens.map(speakToken).filter(Boolean);
if (spokenTokens.length) segments.push(`Read as: ${spokenTokens.join(', ')}.`);
}
return segments.join(" ");
}
interface PTTRequest {
audio: string; // Base64 encoded audio
moduleId: string;
lessonId: string;
format?: AudioFormat;
sessionId?: string; // Python backend session ID — used for TransmissionLog correlation
expected?: { // Seeds Whisper toward this state's expected readback
phrase?: string; // rendered expected_pilot_template (PM) or joined field values
tokens?: string[]; // discrete expected values (variables / lesson field values)
};
context?: { // Legacy field; kept for backwards compat but not used for routing
state_id?: string;
flags?: Record<string, any>;
@@ -140,7 +173,7 @@ export default defineEventHandler(async (event) => {
model: "whisper-1",
language: "en",
temperature: 0,
prompt: STT_BIAS_PROMPT
prompt: buildSttPrompt(body.expected)
});
const transcribedText = transcription.text.trim();