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, no emit_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 the emit_report self-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-naive vs interview-evolution-hardened), so the
    before/after is unmistakable, conversation by conversation, with the audio
    attached.
  • Prompt version history — the ai-interviewer-demo handle 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:

  1. Creates a Saved Evaluator (langevals/llm_score) on app.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.
  2. 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.
  3. 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, so test_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 on app.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.