Whisper hallucinates short real words ("Test", "Thank you", "Okay") on
near-silent or noisy audio. Unfiltered these reached the backend as a wrong
readback attempt — counting toward the 3x-skip — and triggered paid
LLM-router calls. Drop voice (PTT) transcripts below NUXT_PUBLIC_PTT_MIN_WORDS
(default 2); text input is exempt so deliberate short commands still work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backend counterpart to the Python engine's semantic router.
- POST /api/decision/route: service-secret-guarded endpoint the Python
backend calls on regex-miss. Calls gpt-5-mini (ROUTER_LLM_MODEL),
validates the chosen id against the candidate set, and writes both a
UsageEvent (central cost ledger) and a routing-review record — including
timeouts, with timeoutMs + actual latencyMs — so the budget can be tuned
- LlmRoutingDecision model + GET /api/admin/llm-routing (paginated,
status-filtered, per-status counts)
- admin "LLM Routing" tab: transcript vs expected phrase, candidate chips
with the chosen one highlighted, latency/budget chip, model reason
- serviceAuth util (mirrors CRON_SECRET pattern)
- .env.example: ROUTER_LLM_MODEL, SERVICE_SECRET
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First-run overlay explaining how /pm works (tune frequency, push-to-talk,
read back, emergency, bug button), reopenable via a new "?" button in the
HUD. Shows one language with a DE/EN toggle; the choice and "seen" state
persist in localStorage so it auto-opens only on first use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- VFR scenarios get a German D-registration (e.g. D-EKLM) and its
abbreviated form (callsign_short, D-EKLM -> D-LM) instead of the airline
callsign; the pilot's first call uses the full registration, ATC uses the
short form thereafter.
- radioSpeech: spell aircraft registrations phonetically for TTS
("D-EKLM" -> "Delta Echo Kilo Lima Mike", "D-LM" -> "Delta Lima Mike").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New standalone "Drills" section in the scenario picker, with the
Rejected Take-Off drill (rto-v1). Drills are surfaced separately from
the journey chains via DRILL_IDS / drillScenarios.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The wrong-frequency gate now accepts any of the frequencies a logical
position publishes (e.g. EDDM has two Tower frequencies, 118.700 and
120.500); expectedFrequencyForState() still returns the primary for the
"contact X on <freq>" prompt. Falls back to the single expected value
when no frequency list resolves.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From /pm tester bug reports:
- pre-tune COM1 to the opening pilot state's frequency on scenario start,
so the first call isn't met with a "wrong frequency" rejection (#5/#6/#21)
- taxi-route phonetics no longer stop at the first comma: "via A, V" now
speaks "via Alfa, Victor" (#31)
- barge-in: keying the mic stops any ATC speech still playing
- ignore empty / punctuation-only transmissions (silence, stray PTT taps)
- PTT pad turns green while transmitting (was red)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Standby used change-detection like active, so a locally tuned standby
lingered as "the last" channel. While the bridge is connected, standby now
always reflects COM_STANDBY_FREQUENCY from telemetry on every poll. Active
keeps its change-detection anchor so flow/manual tuning isn't overridden.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bridge now sends latitude_deg/longitude_deg/heading_deg; map them to
PLANE_LATITUDE/PLANE_LONGITUDE/PLANE_HEADING_DEGREES_TRUE so position and
course land in the telemetry store and surface in /api/bridge/live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unauthenticated /pm visits pushed to /login without a redirect query, so
after sign-in they landed on the classroom fallback instead of returning
to /pm. Pass route.fullPath as ?redirect= so the bridge link (incl.
?token=…) survives the login round-trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sync both COM_ACTIVE_FREQUENCY and COM_STANDBY_FREQUENCY from bridge
telemetry into the radio, each tracked independently so they only retune
on an actual sim change. Active retune still cuts in-progress ATC speech;
standby has no audio side effects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When /pm is opened with ?token=<bridge-token>, poll /api/bridge/live for
fresh telemetry. While the bridge keeps posting, mirror the sim's COM1
active frequency into the radio (only on actual sim change, so manual/flow
tuning isn't clobbered) and show a "Bridge connected" badge in the HUD.
Telemetry now carries COM_ACTIVE_FREQUENCY/COM_STANDBY_FREQUENCY from the
bridge's com_active_frequency/com_standby_frequency fields.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Strip the overloaded two-step grid, status cards and elaborate success
overlay down to a single centered card with clear states: ask for code,
prompt login, link (auto), and a state-aware success view. Keep the
background photo (more visible) and the server log as a collapsible panel.
Attempt to auto-close the tab once linked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Explain plainly that testers drag an arrow to the broken spot (mouse or finger),
add an on-image hint until the first arrow is drawn, and switch the annotation
canvas to pointer events so touch dragging works as the instructions promise.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- .githooks/pre-push: runs vue-tsc before every push; blocks TypeScript
regressions locally without any manual developer setup
- postinstall: git config core.hooksPath .githooks activates the hook
automatically on yarn install (yarn 4, enableScripts: true)
- tests/smoke/apiHandlers.smoke.test.ts: import-level smoke tests for all
bug-report handlers + 3 core admin handlers — catches broken exports and
top-level runtime errors without a DB or running server
- tests/server/bugReport.test.ts: 16 unit tests covering comment validation,
contact-string building, model schema fields, status enum, and patch logic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Screenshots: html2canvas 1.4.1 only parses rgb/hsl and throws on color-mix(),
which the app uses app-wide; the throw was swallowed so no screenshot was ever
captured. Swap to modern-screenshot (native SVG foreignObject — supports
color-mix/oklch), capture before opening the dialog, surface failures instead of
hiding them, and show a capture spinner on the Bug button.
State restore: the old handler only patched local vars/flags and loaded the tree
— it never entered the monitor screen or created a backend session, so nothing
happened. New restoreBugReportState() reuses startMonitoring() to spin up a real
session for the same flight/scenario from the snapshot, overlays saved
variables/flags, and replays the captured comm log. The Python backend has no
mid-session resume, so the session restarts at the flow start and the captured
state id is surfaced in a banner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New BugReport MongoDB model (comment, contact, userId, screenshot, pmState, status)
- POST /api/bug-reports — authenticated submit; emails emanuel@faktorxmensch.com on receipt
- GET/PATCH /api/admin/bug-reports + /[id] — admin list, detail with screenshot, status toggle
- /pm: "Bug" button in HUD captures viewport screenshot (html2canvas), shows annotation
canvas where testers can draw arrows; submits comment + contact + state snapshot
- /admin: new "Bug Reports" tab with open-count badge, screenshot expand, "Erledigt" toggle,
and "In /pm öffnen" link that restores captured engine state via ?restoreBugReport=<id>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
backendVariables was a departure-shaped subset, so arrival flows (IFR and VFR)
ignored the selected flight and ran on YAML defaults for runway, QNH, surface
wind, aircraft type, cruise level and assigned squawk — even though the engine
generates them. Forward those (with name mapping qnh_hpa→qnh, acf_type→
aircraft_type, cruise_flight_level→cruise_level). Also fixes taxi/tower on
departure, which use runway/qnh/surface_wind.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reverses the auto-tune: tuning to the first controller is part of the exercise.
Each scenario now starts from a fixed baseline frequency (121.900), and the
first call is rejected until the pilot dials the correct one — we do NOT silently
put them on the expected frequency.
The wrong-frequency reply now names the position and frequency to switch to,
e.g. "DLH6RK, you are on the wrong frequency. Contact Approach on 119.900."
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The initial active frequency was only set via a hardcoded EDDF special-case
(121.900); every other airport — and any arrival that begins on Center/Approach
rather than Delivery — kept a stale/default active frequency. That triggered an
immediate "check frequency" on the pilot's very first call (e.g. IFR enroute
start: on 121.900 but Center expects 121.800).
Now the radio is tuned to expectedFrequencyForState() for the start position
when the session begins, so the first call is always on the right frequency and
subsequent handoffs change to the correct next-position frequency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reworked the scenario picker so it's clear which single-phase practice belongs
to which chain. Scenarios are grouped by Departure / Arrival; each complete
chain renders as a left-to-right flow of its phases with arrows
(e.g. Clearance → Startup & Taxi → Tower → Departure). Tap any step to practise
just that phase, or "Fly full" to run the whole chain.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the IFR arrival to the PM scenario picker:
- Complete chain "IFR Arrival" (Enroute → Approach → Landing → Taxi-in),
starting at ifr-enroute-arrival-v1.
- Individual phases: Enroute Descent, IFR Approach, IFR Landing
(taxi-in already present). All use the arrival airport for frequencies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
SEC-07 — committed secrets:
- Replace real-looking defaults in .env.example (JWT_SECRET/JWT_REFRESH_SECRET
"changeme", MANUAL_INVITE_PASSWORD "pm.local@zghl.de") with CHANGE_ME
placeholders, and drop the personal DOME_LIGHT_WEBHOOK_URL default.
- Add a Nitro startup plugin (server/plugins/validate-secrets.ts) that refuses
to boot in production when JWT_SECRET is unset, looks like a placeholder, or
is shorter than 32 chars (warns only in development).
OPS-02 / SEC-09 — cron endpoints:
- requireCronSecret now fails closed: when no CRON_SECRET/KPI_CRON_SECRET is
configured the endpoint returns 503 instead of being publicly callable
(previously it allowed the request with a warning). Both cron routes already
call the guard. Prefer the x-cron-secret header over the loggable ?secret=
query param; document CRON_SECRET in .env.example.
Operational note: production deployments must now set JWT_SECRET (>=32 chars)
and CRON_SECRET, or the server won't start / crons return 503.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous `vue-tsc --noEmit` step was a no-op: the root tsconfig uses
`files: []` with project references, so without `--build` it checks zero files
and always passes. Switch to `vue-tsc --build` (new `yarn typecheck` script)
and make the job blocking.
Fix the one error this surfaced: UsageEventDocument extended mongoose.Document,
whose `model` method collides with the `model: string` field. Use the
recommended pattern — a plain attrs interface passed to the Schema/Model
generics (hydrated docs still expose Document methods). Typecheck is now clean.
Bump actions/checkout@v5 and actions/setup-node@v5 to silence the Node.js 20
runtime deprecation (forced to Node 24 from 2026-06-16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the generic one-line Whisper prompt with a vocabulary-rich bias prompt
(phonetic alphabet, aviation number words, common phraseology, and the airline
telephony names pulled live from DEFAULT_AIRLINE_TELEPHONY) and set
temperature: 0. Aviation R/T is a constrained domain, so seeding the expected
vocabulary improves transcription of callsigns, runways, and readbacks — the
upstream input to the decision engine. Stays within the ~224-token prompt cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The suite was red on main: the SID test still expected the old
letter-by-letter spelling ("Mike Alfa Romeo...") after the pronunciation
change to speak named waypoints as words ("Marun seven Foxtrot"). Update the
stale assertion.
Add a GitHub Actions workflow: yarn install + yarn test as the required gate,
plus a non-blocking vue-tsc job (TS strict mode is still off — promote to
required after that cleanup). Suite now 80/80.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The LLM decision routing moved to the external Python backend
(OpenSquawk-LiveATC-api), but the core docs still described the old in-Nuxt
path (routeDecision(), /api/llm/decide, a Nuxt /api/decision-flows/runtime
route) — none of which exist. Every agent session started with a wrong mental
model.
- Rewrite CLAUDE.md to describe the real /pm flow (useRadioBackend -> Python
backend owns authoritative state; Nuxt owns STT/TTS/audio/auth/editor) and
fix stale commands (bun -> yarn).
- Fix the two stale AGENTS.md lines (openai.ts is getOpenAIClient() only;
decisionFlowService is editor-only, no Nuxt runtime route).
- README: note the Python backend is required for /pm; correct server/ desc.
- Remove dead shared/utils/openaiDecision.ts (called the non-existent
/api/llm/decide) and the now-orphaned LLM decision contract types in
shared/types/llm.ts. Trace types used by pm.vue are kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The radio backend now expires sessions idle for 5+ hours and may restart
without in-memory state. A 404 on transmit previously left the monitor
screen stuck; now it clears the session, returns to the scenario picker,
and shows a dismissible "session expired" alert there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Usage tracking:
- new UsageEvent collection records every STT/TTS/LLM call per user with
provider, model, volume (audio seconds, characters, tokens) and an
estimated USD cost; self-hosted providers (Speaches/Piper) and cache
hits record at $0
- pricing table for whisper-1, tts-1, gpt-5-nano & co. in server/utils/usage.ts
- weekly KPI mail gains an "AI-Nutzung & Kosten" section: weekly and
rolling 30-day cost, per-kind breakdown, top 5 users by cost
- quota alert mail when rolling 30-day cost exceeds USAGE_ALERT_USD
(default $5), at most once per calendar month (UsageAlertDelivery)
Hardening:
- /api/atc/say now requires an authenticated session (middleware
exemption removed); useFlightLabAudio sends the bearer token
- /api/service/tools/latency requires auth (was a public LLM endpoint)
- per-user rate limits: PTT 20/min, say 60/min, latency 5/min
- cron endpoints (waitlist-drip, weekly-kpi-report) require a shared
secret via ?secret= or x-cron-secret (CRON_SECRET, falls back to
KPI_CRON_SECRET); allowed with a warning while unset so existing
deployments keep working
- PTT records the actual transcribed audio duration for billing accuracy
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clients that already ran the full radiotelephony normalizer (see
normalizeATCText) pass preNormalized: true so the server skips
normalizeATC — double-normalizing corrupts the text, e.g. expandAirports
spells the city name "MAIN" letter-by-letter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- per-airport ATIS loop keyed by station with a virtual start epoch, so
re-tuning resumes where the broadcast would be instead of restarting
- refetch airport data at :23/:53 to follow VATSIM ATIS regeneration from
real-world METAR publication, with faster retries while no ATIS is on
the feed; prefetch audio when the info letter changes
- support separate arrival/departure ATIS stations on different frequencies
- cancel the deferred audio teardown on retune so a fresh broadcast is not
killed by the previous stop()'s fade-out timer (atisAudioLoop)
- comm log shows newest entries first
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- SID basenames and 5-letter waypoints (ANEKI 7S, SULUS) are pronounceable
by design and are now spoken as words instead of letter-by-letter phonetics
- skip acronyms (ATIS, RNAV, MAIN, ...) when spelling 4-letter ICAO codes
- expand stand/gate designators, ATIS information letter, and surface wind
groups for TTS
- normalizeATCText now runs full client-side radiotelephony expansion
(callsigns, airports) since preNormalized texts skip the server normalizer
Note: tests/radioSpeech "normalizes SID suffix and METAR data" still expects
the old spelled-out SID behavior and fails until updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Scenario picker & completion:
- Login → scenario selection screen with complete chains + individual phases
- Completion screen with "fly again / try opposite / back to scenarios"
- Scenario.airport ('dep'|'arr') drives which airport frequencies to fetch;
arrival scenarios (vfr-arrival, circuit-landing, taxi-in) use arr ICAO
Backend session integration:
- createSession forwards no_chain; response carries active_flow + session_complete
- Pass all six airport frequency variables to session so every chained flow
has the real airport values from creation
- fetchAirportFrequencies now runs before session creation so resolved
frequencies are included in backendVariables
Wrong-frequency check:
- airportFreqMap computed (from airportFrequencies, always up-to-date)
used as primary source in expectedFrequencyForState — immune to flow
snapshot switches
- setActiveFlow called when response.active_flow changes so local engine
cursor moves to the correct flow's states after a chain
- Wrong-freq ATC reply appended to communication log (offSchema entry)
Engine fixes (communicationsEngine.ts):
- patchVariables / patchFlags: write directly to the internal reactive
store, bypassing readonly(ref) which silently blocked all (vars as any)
.value[k] = v mutations
- appendLogEntry: push ATC speech (and wrong-freq replies) into comm log
- ATC controller_say_rendered appended to comm log after every transmission
- Stop in-progress ATC speech (HTTP request + audio playback) when the
active frequency changes via preset select or swap button. Prevents the
pilot "hearing" a controller on a frequency they have already left.
- Fix RangeError crash for long PTT recordings: btoa(String.fromCharCode
(...largeArray)) overflows the call stack above ~2 s of audio and
silently drops the PTT request. Replaced with chunked 8 KB conversion.
- Add 30 s PTT auto-stop timer so runaway holds are submitted rather than
silently lost.
- Add AbortSignal support to useApi so fetch requests can be cancelled.
ATIS text often arrives in raw METAR form (e.g.
"METAR EDDF 281050Z AUTO 02008KT 320V070 CAVOK 24/02 Q1025 NOSIG"),
which TTS reads as letter-by-letter spelling. The normalizer now expands
the full WMO Code Form FM 15-XV vocabulary inline: DDHHMMZ date stamps,
compressed wind (with gusts, VRB, calm), wind variability ranges, RVR
(R25L/1500N), wind shear, slash-form temp/dewpoint, Q/A pressure,
NSC/SKC/CLR/NCD/VV cloud codes, weather phenomena (with intensity and
descriptors), recent-weather RE prefix, BECMG/TEMPO/FM/TL/AT trend
codes, and strips RMK remarks. Plus ATIS/METAR/SPECI get lowercased
so TTS pronounces them as words (pilots SPELL ILS/QNH/VOR so those
stay uppercase).
Airport ICAO codes are substituted with their OpenAIP name when the
frequencies endpoint returns one. New `airportName` field added to
the FrequencyResponse for that. Adds 7 test cases covering the user-
reported EDDF sample plus calm/VRB/gust winds, RVR, weather codes,
cloud specials, trend codes, and RMK stripping.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tuning to the ATIS frequency now plays carrier noise immediately so the
pilot gets feedback before the TTS comes back (synthesis takes a moment
on cold cache). When the ATIS audio is ready, the carrier ducks down to
a subtle bed level and stays underneath the announcement — mimicking
how a real radio channel always carries some noise floor.
Switches the loop from HTMLAudioElement (whose seek on data: URLs gets
quantized by some browsers) to a Web-Audio AudioBufferSourceNode.
`source.start(0, offset)` is sample-accurate per spec, so the
virtual-clock entry point lands exactly where computed. `window.__atisDebug`
exposes ctx/source/state for manual inspection, and pm.vue logs the
requestedOffset/duration/epochAge on each loop start.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>