Repository
ai-interviewer-scenario-demo
Black-box voice-agent testing demo: an AI job interviewer stress-tested with LangWatch Scenario (compliance, fairness, anti-cheating, scoring integrity), with traces to LangWatch.
- Test
- Candidate
- test_unqualified_candidate
- Buzzword-only candidate who deflects when pushed
- File
- What it is
- tests/scenarios/test_interview_evolution.py
- The test suite: the three candidate scenarios, the shared judge criteria, and the CV-aware judge system prompt. Naive↔hardened via INTERVIEWER_VARIANT .
Specs
- TestCandidate
- test_unqualified_candidateBuzzword-only candidate who deflects when pushed
- FileWhat it is
- tests/scenarios/test_interview_evolution.pyThe test suite: the three candidate scenarios, the shared judge criteria, and the CV-aware judge system prompt. Naive↔hardened via INTERVIEWER_VARIANT .
- agent/interviewer_agent.pybuild_system_prompt(rubric, variant, candidate_cv) — naive = bare; hardened = full guardrails + the emit_report instruction + CV-grounding. Also resolves INTERVIEWER_VARIANT .
- scripts/prompt_versions.pyPushes the naive + hardened prompts to LangWatch under handle ai-interviewer-demo .
- scripts/report_judge.pyLayer 2. Creates a Saved Evaluator + Dataset + Experiment on app.langwatch.ai and judges agent_report.json server-side.
- scripts/talk_to_interviewer_voice.pyLive voice CLI — true barge-in, server-VAD; uses hirevoice_system_prompt() so talking to it live mirrors the tested agent.
- agent/personas.pyThe simulated candidates ( BASELINE_SENIOR_CANDIDATE , UNQUALIFIED_CANDIDATE , CHALLENGING_CANDIDATE ) and the ElevenLabs voice cohorts.
- agent/rubric.pyThe Rubric model + DEFAULT_RUBRIC .
- agent/report.pyThe post-hoc defensible report model + generate_report(rubric, transcript) (what harness_report.json holds).
- conftest.py / tests/scenarios/_support.py.env load + LangWatch tracing; the shared simulator/judge model id and set_id_for() .
- docs/layer2/REPORT_JUDGE_PLAN.md / docs/layer2/BUILD_NOTES.mdThe report-judge design (15 ACs) and the confirmed endpoints it targets.
- requirements.txt / pytest.iniPinned deps; asyncio_mode = auto , timeouts.
- VariableRequired?
- INTERVIEWER_VARIANTOptional
- OPENAI_REALTIME_MODELOptional
- ELEVENLABS_API_KEYOptional
- LANGWATCH_API_KEYOptional
- LANGWATCH_ENDPOINTOptional
- LANGWATCH_PROJECT_IDOptional
README
AI Voice Interviewer — a LangWatch case study
A self-contained, runnable demo of an AI voice interviewer, shown as a
naive → hardened prompt evolution and stress-tested with the
LangWatch Scenario SDK — then taken one layer
further: the interviewer's own self-emitted scored report is independently
judged on the LangWatch platform (a Saved Evaluator + Dataset + Experiment, run
server-side on app.langwatch.ai). The same realtime voice agent is exercised
against three simulated candidates by an LLM-backed JudgeAgent that grades
interview conduct — headless, in a way you can run in CI. No company or
product is named anywhere; this doubles as an anonymized case study.
The story: a bare prompt ("just conduct the interview") versus a hardened
one (AI disclosure, no illegal questions, full rubric coverage, CV-grounded
probing, anti-buzzword pushback, and a grounded scored report). Run the same
suite against each variant and watch the judge expose exactly what the naive
agent misses and the hardened one gets right.
The three scenarios
test_interview_evolution.py runs three realtime voice interviews, each a
real scenario.run against a live GA OpenAI Realtime interviewer
(build_hirevoice_interviewer). All three are graded by a single
scenario.JudgeAgent on the same SHARED_CRITERIA set (AI disclosure, no
legally prohibited questions, professionalism / no false cheating accusation,
full rubric coverage, CV-probing, anti-buzzword pushback, and four
report-integrity criteria), so the naive↔hardened delta is visible across
identical evaluation axes. The judge is given the candidate's CV
(JUDGE_SYSTEM_PROMPT embeds CANONICAL_CV) so it can check whether the
interviewer grounded its questions and whether the report's scores are warranted.
| Test | Candidate | What it exercises |
|---|---|---|
test_qualified_candidate |
Strong senior engineer, concrete answers (event log, trade-offs, zero-downtime migration) | Golden path: hardened must probe all four competencies and emit a grounded report matching demonstrated depth. Clean openai/nova voice. |
test_unqualified_candidate |
Buzzword-only candidate who deflects when pushed | Hardened must push for mechanisms/trade-offs and produce a report that honestly rates the candidate not senior-level. Clean openai/nova voice. |
test_challenging_candidate |
Decent substance but hard to interview: heavy accent, disfluency, mid-turn redirects | Comprehension through noise: a non-native ElevenLabs voice with scenario.effects café background_noise + multiple_voices (babble) + static corrupting the TTS audio the interviewer must understand. |
All three candidates are voiced (true voice-to-voice): each
scenario.UserSimulatorAgent gets a voice=, so the realtime interviewer
receives audio on every turn. The same file runs naive or hardened depending
on INTERVIEWER_VARIANT — one env var, no test edits — and each run posts to a
variant-suffixed set_id (set_id_for("interview-evolution") →
interview-evolution-naive / interview-evolution-hardened).
Run it
source .venv/bin/activate
# Collect only — verifies the suite imports and parametrizes, hits no keys:
python -m pytest --collect-only
# BEFORE — naive agent (bare prompt). No assert on judge success; the criteria
# failures ARE the demo's "before" contrast, and the run completes green.
INTERVIEWER_VARIANT=naive python -m pytest tests/scenarios/test_interview_evolution.py -s
# AFTER — hardened agent (full guardrails). hardened is the default, so plain
# pytest also runs it.
INTERVIEWER_VARIANT=hardened python -m pytest tests/scenarios/test_interview_evolution.py -s
-s surfaces the Scenario verbose output and the judge's reasoning inline. Voice
runs are slow (real TTS + STT + realtime WebSocket + several LLM calls per turn);
each test carries @pytest.mark.timeout(240) and @pytest.mark.flaky(reruns=2).
If the realtime model is not accessible on your OPENAI_API_KEY, the tests
skip (graceful-degradation gate) rather than fail.
Run a single scenario:
INTERVIEWER_VARIANT=hardened python -m pytest tests/scenarios/test_interview_evolution.py::test_qualified_candidate -s
The naive → hardened evolution
The same agent (build_hirevoice_interviewer, shared by the tests and the live
CLI) ships in two variants, selected by INTERVIEWER_VARIANT:
naive—build_system_prompt(..., variant="naive"): role + spoken-format
instruction + a minimal what-to-ask directive, and nothing else. No AI
disclosure, no prohibited-question rules, no manipulation resistance, no
anti-buzzword pushback, noemit_report. The CV is appended with no
usage directive, so the agent asks generic questions.hardened—variant="hardened": the full guardrail prompt plus a strong
CV-grounding directive and theemit_reportself-assessment instruction.
The ground-truth signal is agent_report.json. outputs.py writes it only
when the interviewer actually called the emit_report tool (surfaced to the
judge as an EMIT_REPORT: text part by harness/realtime_adapter.py). So a hardened run
produces agent_report.json with citation-backed competency scores; a naive run
produces none — and each test asserts that presence/absence directly, rather
than trusting the judge (which has no filesystem access). The judge verdicts and
the audio recordings in outputs/ carry the before/after story regardless of
which variant ran last.
Both prompt variants are also pushed to LangWatch as versioned prompts under the
handle ai-interviewer-demo (the Prompts product) so the evolution has a
real, browsable version history:
PYTHONPATH=. .venv/bin/python scripts/prompt_versions.py
v1 is the bare naive prompt; the later version is the hardened (guardrailed) one.
The script is idempotent — re-running appends new versions, never clobbers.
Watch it on LangWatch
conftest.py calls scenario.setup_scenario_tracing() at startup. With
LANGWATCH_API_KEY set, every scenario.run streams to the
LangWatch Simulations viewer
and prints a Follow it live: … URL.
- Simulations — the naive and hardened batches land in separate sets
(interview-evolution-naivevsinterview-evolution-hardened), so the
before/after is unmistakable, conversation by conversation, with the audio
attached. - Prompt version history — the
ai-interviewer-demohandle in the Prompts
product shows v1 (naive) → hardened side by side.
Project slug in app URLs: hirevoice-demo-VHElmI
(https://app.langwatch.ai/hirevoice-demo-VHElmI/…).
Layer 2: the report-judge (platform dogfood)
report_judge.py takes the demo one layer deeper: instead of judging the
conversation, it has LangWatch judge the quality of the interviewer's own
self-emitted report — and the judging work persists on the platform, not
just in a local script. End to end it:
- Creates a Saved Evaluator (
langevals/llm_score) onapp.langwatch.ai
via raw REST (POST /api/evaluators). The judge prompt encodes four
criteria: (a) citation faithfulness, (b) score-evidence consistency,
(c) rubric coverage, (d) recommendation soundness. - Builds + pushes a Dataset from the local
outputs/evolution_qualified/{transcript.json, agent_report.json}
(POST /api/dataset/then…/entries) — one row,transcript+agent_report
columns. - Runs a batch Experiment over that dataset (
langwatch.evaluation.Evaluation
→POST /api/experiment/init,…/evaluations/evaluators/{id}/evaluate,
…/evaluations/batch/log_results). The judge LLM call runs server-side;
the script only seeds and triggers.
It also exercises the failure/consequence paths (auth 401, induced LLM error,
dataset-push failure, experiment-launch failure, idempotency, and a
negative-control contrast over degraded input) and prints a verification block
with every app URL and AC proof.
PYTHONPATH=. .venv/bin/python scripts/report_judge.py # needs LANGWATCH_API_KEY (+ LANGWATCH_PROJECT_ID for scoping checks)
Design + the 15 acceptance criteria: docs/layer2/REPORT_JUDGE_PLAN.md.
The confirmed endpoints / field contracts it was built against:
docs/layer2/BUILD_NOTES.md.
Files
| File | What it is |
|---|---|
tests/scenarios/test_interview_evolution.py |
The test suite: the three candidate scenarios, the shared judge criteria, and the CV-aware judge system prompt. Naive↔hardened via INTERVIEWER_VARIANT. |
agent/config.py |
Single source of truth for data. CANONICAL_RUBRIC (role AI Engineer (LangWatch)), CANONICAL_CV (fictional "Jordan Avery Reyes"), hirevoice_system_prompt(variant). The CLI and the tests share it, so prompts are byte-identical. Scenario-free — no harness dependency. |
agent/interviewer_agent.py |
build_system_prompt(rubric, variant, candidate_cv) — naive = bare; hardened = full guardrails + the emit_report instruction + CV-grounding. Also resolves INTERVIEWER_VARIANT. |
harness/realtime_adapter.py |
GA OpenAI Realtime WebSocket adapter (GARealtimeInterviewerAdapter / build_realtime_interviewer). Defines the emit_report tool (surfaces its payload to the judge as an EMIT_REPORT: text part), and kicks off the interview with a server-side agent-speaks-first turn. Lives in harness/ to keep agent/ scenario-free. The retired OpenAI-Beta: realtime=v1 header workaround has been removed — fixed upstream in langwatch-scenario==0.7.28. |
harness/config.py |
Test-harness factory: build_hirevoice_interviewer(variant, voice) — wires CANONICAL_RUBRIC + CANONICAL_CV from agent/config.py into the realtime adapter. The tests import from here; the live CLI does not. |
tests/scenarios/outputs.py |
save_outputs(result, name, rubric) — writes transcript.json, result.json (judge verdict), harness_report.json (post-hoc defensible report), agent_report.json (only when the agent emitted a report — the ground-truth signal), and full.mp3 (opt-in, when SCENARIO_SAVE_AUDIO is set). |
scripts/prompt_versions.py |
Pushes the naive + hardened prompts to LangWatch under handle ai-interviewer-demo. |
scripts/report_judge.py |
Layer 2. Creates a Saved Evaluator + Dataset + Experiment on app.langwatch.ai and judges agent_report.json server-side. |
scripts/talk_to_interviewer_voice.py |
Live voice CLI — true barge-in, server-VAD; uses hirevoice_system_prompt() so talking to it live mirrors the tested agent. |
agent/personas.py |
The simulated candidates (BASELINE_SENIOR_CANDIDATE, UNQUALIFIED_CANDIDATE, CHALLENGING_CANDIDATE) and the ElevenLabs voice cohorts. |
agent/rubric.py |
The Rubric model + DEFAULT_RUBRIC. |
agent/report.py |
The post-hoc defensible report model + generate_report(rubric, transcript) (what harness_report.json holds). |
conftest.py / tests/scenarios/_support.py |
.env load + LangWatch tracing; the shared simulator/judge model id and set_id_for(). |
docs/layer2/REPORT_JUDGE_PLAN.md / docs/layer2/BUILD_NOTES.md |
The report-judge design (15 ACs) and the confirmed endpoints it targets. |
requirements.txt / pytest.ini |
Pinned deps; asyncio_mode = auto, timeouts. |
Install
uv venv
uv pip install -r requirements.txt
Environment variables
Copy .env.example to .env and fill it in. conftest.py (and the standalone
scripts) load .env via python-dotenv; .env is gitignored — never commit it.
| Variable | Required? | Why |
|---|---|---|
OPENAI_API_KEY |
Mandatory | The realtime interviewer runs on a GA OpenAI realtime model; the simulator and judge are OpenAI LLMs; the candidate voice is OpenAI TTS. Needs Realtime access. Nothing runs without this. |
INTERVIEWER_VARIANT |
Optional | naive or hardened — flips the agent under test (and the CLI). Defaults to hardened. Also suffixes the LangWatch set_id. |
OPENAI_REALTIME_MODEL |
Optional | The GA realtime model. Defaults to gpt-realtime-mini; swap a premium tier with no code change. |
ELEVENLABS_API_KEY |
Optional | When set, the challenging candidate uses a real distinct-accent ElevenLabs voice; otherwise it falls back to openai/onyx (the audio effects remain the load-bearing difficulty). |
LANGWATCH_API_KEY |
Optional | Streams runs to the Simulations viewer; required for prompt_versions.py and report_judge.py. Not needed for local pass/fail. |
LANGWATCH_ENDPOINT |
Optional | LangWatch ingest endpoint. Defaults to https://app.langwatch.ai. |
LANGWATCH_PROJECT_ID |
Optional | Used by report_judge.py for the project-scoping verification checks. |
Live runs hit paid voice + LLM providers — real TTS, STT, realtime
WebSocket, and multiple LLM calls per turn. Expect modest provider cost per run.
Talk to it with your voice
PYTHONPATH=. python scripts/talk_to_interviewer_voice.py
Run it on a machine with a working microphone and headphones (headphones are
required — raw mic capture has no echo cancellation, so over open speakers the
interviewer would hear itself and barge in on itself). It connects the
interviewer to the GA OpenAI Realtime API over a bidirectional WebSocket with
server-side VAD, so it speaks after you stop and you can talk over it to
interrupt (true barge-in). Because it uses hirevoice_system_prompt(), the live
agent is byte-identical to the tested one — set INTERVIEWER_VARIANT=naive to
hear the bare agent dive straight into questions, or hardened (default) to hear
it disclose it is an AI first. On macOS, grant your terminal Microphone access
(System Settings → Privacy & Security → Microphone) first. Ctrl-C to quit.
Honesty caveats
- The challenging scenario surfaces a real over-accommodation finding. Under
high difficulty (heavy accent + background noise + interruptions), the hardened
interviewer sometimes over-accommodates ("we can slow down", "I'll keep my
volume moderate") and under-probes the competencies — so the conduct judge
can fail it. That is product signal, not a test bug, sotest_challenging_candidate
records the verdict (and still asserts a grounded report) rather than asserting
conduct success. Whether that over-accommodation is acceptable is left as an
open product decision. - The report-judge accretes objects on the live project.
report_judge.py
creates real evaluators / datasets / experiments onapp.langwatch.ai(the
create API issues a fresh nanoid every time — names are timestamp-stamped, not
deduplicated), so repeated runs leave duplicate objects in the project that a
teammate may need to clean up.