Make interview engine tuning numbers runtime-configurable - #5
Merged
Conversation
Every threshold in the coverage, pacing, portfolio, session, Gemini and preflight paths was hardcoded, so changing e.g. the minimum probe count per skill required a code change and a deploy. Introduce AppConfig: a registry of 29 settings with code defaults, types and ranges, backed by an app_settings table. Values resolve tenant override -> global override -> legacy ENV -> code default, cached in Redis for 30s and invalidated on write, so a change reaches every Puma and Sidekiq process within seconds without a restart. Lookups fall back to the code default if the DB or cache is unavailable. Writes are exposed per tenant via /api/v1/settings (admin) and globally via rake app_config tasks — the API never writes the global scope, so one tenant's admin cannot change another tenant's behaviour. Probe minimums are also embedded in the compiled system prompt, so assessments now store a digest of the config they were compiled under and recompile lazily when it changes. Candidate preflight thresholds (minimum upload/download speed) are served at /api/v1/preflight/config for the frontend device check; the existing /api/v1/speed_test route is unchanged. Defaults match the previous hardcoded values, so behaviour is unchanged until a setting is overridden. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The frontend blocks a candidate below 4 Mbps upload / 8 Mbps download. Live audio is raw PCM over the WebSocket: the uplink is 16kHz x 16-bit mono (256 kbps, ~341 kbps once base64-encoded) and the downlink is 24kHz (384 kbps, ~512 kbps encoded). The old bar was more than 10x the real requirement and rejected connections that would run the interview fine. Set the defaults to those figures with ~2x headroom (750 kbps up, 1000 kbps down) and add preflight.max_ping_ms so the frontend can stop hardcoding that too. Add GET /api/v1/preflight/download_test, which streams incompressible bytes so the client measures download throughput against this API — the path the interview actually uses — rather than against a public CDN. Throttle the unauthenticated preflight endpoints, and make Rack::Attack survive a Redis outage: short timeouts plus an error handler so an unreachable Redis degrades to "no rate limiting" instead of hanging the candidate's first request. Give the production cache store the same timeouts — AppConfig falls back to code defaults on error, but only if the cache call returns at all. Also load each override scope once in all/describe/public_values instead of twice per key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dustymon111
added a commit
that referenced
this pull request
Aug 4, 2026
Stacked on #5 (base: `feat/runtime-config`) — merge that first. ## Problem A 5-skill interview was finishing in ~10 minutes: roughly 2 minutes per skill. Three causes compound. ### 1. A skill could jump `initiated` → `covered` in a single turn `resolve_state` walked forward as many states as Flash proposed, applying the *same* `probe_count >= 2` check at every hop: ```ruby (current_idx + 1).upto(proposed_idx) do |i| break unless valid_transition?(from: result, to: candidate, probe_count: probe_count) result = candidate end ``` So when Flash proposed `covered` for a skill sitting at `initiated`, the loop passed `initiated→partial` (probe 2 ≥ 2 ✓) and then immediately `partial→covered` (probe 2 ≥ 2 ✓). **Two candidate turns covered a skill.** The analyzer runs once per candidate turn and caps `probe_count` at +1 per run, so 5 skills × 2 turns ≈ 10 minutes — exactly what was observed. The second-order effect is worse than the arithmetic: no skill ever *persisted* in `partial`, so the coverage map never once told the interviewer *"partial — you've probed but signal is still thin. Go deeper. Do NOT wrap up."* Every `partial` instruction in the system prompt was dead code in practice. ### 2. `covered` reused the gate meant for leaving `initiated` `coverage.min_probes_to_advance` (2) was the only depth requirement anywhere. Nothing expressed "this skill has been probed enough to be *done*". Corroborating symptom: the portfolio grades `high` confidence at `probe_count >= 3`, so with every skill finishing at 2 probes, **no skill could ever be rated high confidence.** ### 3. Coverage completion ended the session regardless of the clock `all_covered?` triggered wrap-up as soon as the agenda was complete. A generous analyzer could therefore close a 45-minute interview at minute 15 and leave 30 minutes unused. ## Fix **1. Cap advancement at one state per analyzer run** — new `coverage.max_state_advance_per_turn` (default 1). A skill must sit at `partial` for at least one turn, which is the only point at which the interviewer is told to go deeper on it. Walking forward one step (rather than rejecting a multi-step proposal outright) still prevents the "stuck state" case the original loop was written for; it just costs one extra turn. **2. Separate gate for `covered`** — new `coverage.min_probes_to_cover` (default 4), clamped to never fall below `min_probes_to_advance` whatever the config says. `CoverageAnalyzerWorker#advance_stale_partials` writes `covered` directly without going through `StateEngine`, so it now applies the same floor. **3. Hold the wrap-up while time remains** — new `session.min_elapsed_ratio_before_wrap_up` (default 0.6). While held, the coverage map carries `wrap_up_blocked: true` and `deepen_next: <thinnest skill id>`, and the system prompt instructs the interviewer to push that skill harder rather than close — looking for the ceiling of the candidate's ability, not a recap. Set to `0` to restore the old behaviour. > One trap worth flagging for review: once every skill is `covered` the analyzer **freezes** them (`next if map.state == 'covered'`), so `probe_count` stops incrementing and nothing in the coverage fingerprint can ever change again. `refresh_coverage_cache` returns early on an unchanged fingerprint, so without care the session would never re-evaluate and never notice the hold expiring. The hold flag is therefore part of `coverage_fingerprint`. **4. Tighter confidence test in the analyzer prompt** — evidence must span more than one situation (not the same story retold) and the candidate must have been pushed at least once beyond their first answer. Unsure → `partial`. **5. System prompt** — states the new depth requirement with concrete follow-up shapes, explains `wrap_up_blocked` / `deepen_next`, and adds the one exception to "never ask about a covered skill". ## Verification Turn-by-turn simulation through `StateEngine` (defaults: advance 2, cover 4, max advance 1): | Turn | State | Flash proposes | probe | Before | **After** | | --- | --- | --- | --- | --- | --- | | 1 | `not_yet` | `initiated` | 1 | `initiated` | `initiated` | | 2 | `initiated` | `covered` | 2 | **`covered`** ← the bug | **`partial`** | | 3 | `partial` | `covered` | 3 | — | `partial` | | 4 | `partial` | `covered` | 4 | — | `covered` | | — | `not_yet` | `covered` | 9 | `covered` | `initiated` | Wrap-up hold, 45-minute limit, all skills covered: 10m → held, 20m → held, 27m → releases. Payload while held carries `wrap_up_blocked: true, deepen_next: "s1"` (the skill with the lowest probe count). Fingerprint verified to change when the hold expires. Tenant override `min_probes_to_cover = 6` verified: probe 4 and 5 stay `partial`, `covered` at 6. ## Impact on interview length Roughly `skills × min_probes_to_cover` candidate turns at ~1 minute each — 5 skills ≈ 20+ minutes at the defaults, against ~10 before.⚠️ **Check `time_limit_min` on short assessments.** A 10-minute assessment with 5 skills cannot fit 20 turns, so no skill reaches `covered` and the session ends on the time ceiling instead. That is safe — no hang, transcript and portfolio still generate — but every skill will be rated low/medium confidence. For short assessments lower `coverage.min_probes_to_cover` for that tenant, or configure fewer skills. All three thresholds are `AppConfig` settings, so the depth can be tuned per tenant at runtime without a deploy — 3 vs 4 vs 5 can be compared on staging directly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Every tuning number in the interview engine was hardcoded. Changing something as basic as "minimum 2 probes before a skill can advance" meant a code change and a deploy — and some numbers were duplicated across the state engine, the analyzer prompt and the system prompt, so they could silently drift apart.
Approach
A single registry (
app/lib/app_config.rb) declares every tunable with a type, range and code default, backed by a newapp_settingstable.How to change a value
Per tenant, via API (admin role) — writes are always scoped to the caller's tenant, so one tenant's admin can never change another tenant's behaviour:
Globally, via rake (ops only — deliberately not exposed over the API):
What became configurable (29 settings)
coverage— probing depthcoverage.min_probes_to_advanceStateEngine, the analyzer prompt and the system promptcoverage.auto_advance_probe_countprobe_count >= 4inCoverageAnalyzerWorkercoverage.deep_probe_ceilingcoverage.max_discovered_skillsdiscovered.count >= 10coverage.discovery_max_exchangescoverage.analyzer_turns_contextAnalyzer::TURNS_CONTEXTpacing— minutes remaining per uncovered skillpacing.ahead_min_minutespacing.on_track_min_minutespacing.behind_min_minutesportfolio— confidence ratingportfolio.confidence_high_probe_countportfolio.confidence_medium_probe_countportfolio.max_evidence_quotessession— timing and lifecyclesession.time_warning_secondssession.wrap_up_warning_secondssession.time_ceiling_grace_secondssession.wrap_up_silence_fallback_secondssession.closing_phrase_fallback_secondssession.end_poll_max_attemptssession.browser_grace_period_secondsgemini— connection resiliencegemini.proactive_reconnect_after_secondsPROACTIVE_RECONNECT_AFTERenv var as fallback)gemini.proactive_reconnect_jitter_secondsgemini.max_reconnect_attemptsgemini.reconnect_backoff_secondsgemini.inactivity_timeout_secondsgemini.gate_open_delay_secondsgemini.silence_pump_delay_secondspreflight— candidate device check (served to the frontend)preflight.min_upload_kbpspreflight.min_download_kbpspreflight.speed_test_payload_bytespreflight.speed_test_timeout_mspreflight.block_below_minimumDeliberately NOT configurable
SILENCE_PUMP_INTERVAL/SILENCE_FRAME_SAMPLES(16kHz PCM wire-format constants — a wrong value corrupts audio) and the +1-per-run probe increment cap (a correctness invariant, not a tuning knob).When a change takes effect
coverage.*,pacing.*portfolio.*session.*,gemini.*preflight.*The probe numbers are also baked into the compiled system prompt stored on each assessment. Assessments now carry
system_prompt_config_digest; when it no longer matches the current config the prompt is recompiled automatically as the next session connects, so a settings change reaches existing assessments too.Frontend follow-up (not in this PR)
The minimum upload speed lives in the frontend repo today — the backend
/api/v1/speed_testendpoint only ever echoed the byte count and made no pass/fail judgement. This PR adds the server side of it:Both are unauthenticated (a candidate only has an invite link).
POST /api/v1/speed_testis left untouched so the current frontend keeps working. The frontend still needs a change to read its thresholds from/api/v1/preflight/configinstead of hardcoding them.Migrations
20260804000000_create_app_settings— new table, partial unique indexes on(tenant_id, key)and onkeywheretenant_id IS NULL(Postgres treats NULLs as distinct, so the global rows need their own index)20260804001add_system_prompt_config_digest_to_assessments— one nullablevarchar(64)columnBoth are additive and safe to run on a live database.
Testing
AppConfigwas exercised directly against a live Rails console: set/get/reset across all four types (integer, float, boolean, integer array), range rejection (must be <= 10), type rejection (must be a whole number), unknown-key rejection, and correctsourcereporting (tenant/global/env/default). The gate itself was verified end-to-end: withmin_probes_to_advance = 3,StateEngine.resolve_state(initiated → covered, probe_count: 2)stops atinitiated, and passes tocoveredatprobe_count: 3.db/migrate(5 migrations show asNO FILE, and theassessmentstable is absent), socreate_app_settingsran locally but the digest column could not.db/schema.rbwas edited by hand to reflect exactly the two migrations rather than a dump of that broken DB. Please run both on staging first.🤖 Generated with Claude Code