Tuned prompts with persona

This commit is contained in:
ldy
2026-06-17 21:10:36 -04:00
parent f358a33b88
commit 38386fe9e7
6 changed files with 466 additions and 15 deletions

View File

@@ -0,0 +1,285 @@
# AI Mock Interview Agent Walkthrough
## Purpose
This project turns the LiveKit Agents Python starter into a realtime voice AI mock interviewer. The agent plays the interviewer, named Jordan, and guides the candidate through two foundational interview stages:
1. Self-introduction
2. Past experience
The implementation is centered in `src/agent.py`. It uses LiveKit's multi-agent workflow model so each interview stage has its own focused agent, prompt, entry behavior, and transition rules.
## Architecture at a Glance
The runtime is one LiveKit `AgentSession[InterviewData]` with shared speech, voice, turn-taking, and LLM configuration. Stage-specific behavior lives in two `Agent` subclasses:
- `SelfIntroAgent`: opens the interview and asks for the candidate's self-introduction.
- `PastExperienceAgent`: asks targeted questions about the candidate's prior work or projects.
The shared session state is intentionally small:
```python
@dataclass
class InterviewData:
"""Session-level state for the mock interview."""
intro_was_given: bool = field(default=False)
```
The production session uses LiveKit Inference for models, prewarmed Silero VAD for speech detection, multilingual turn detection, adaptive interruption handling, preemptive generation, and AI-coustics noise cancellation:
```python
session = AgentSession[InterviewData](
llm=inference.LLM(model="openai/gpt-4.1-mini"),
stt=inference.STT(model="deepgram/nova-3", language="en"),
tts=inference.TTS(
model="cartesia/sonic-3",
voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc",
),
vad=ctx.proc.userdata["vad"],
turn_handling=TurnHandlingOptions(
turn_detection=MultilingualModel(),
endpointing={"mode": "dynamic", "min_delay": 0.5, "max_delay": 3.0},
interruption={
"mode": "adaptive",
"min_words": 2,
"resume_false_interruption": True,
},
preemptive_generation={"enabled": True, "preemptive_tts": True},
),
userdata=InterviewData(),
)
```
## Goal One: Two Interview Stages with Clear Logic
The project avoids one large, general-purpose interviewer prompt. Instead, each stage has a dedicated agent with only the instructions needed for that phase.
`SelfIntroAgent` is responsible for exactly one job: greet the candidate and collect a self-introduction. Its instructions explicitly tell the model to call `intro_complete` as soon as the candidate gives any substantive introduction:
```python
class SelfIntroAgent(Agent):
def __init__(self, *, silence_budget: float = 20.0, stage_budget: float = 60.0) -> None:
super().__init__(
instructions=textwrap.dedent(
"""\
You are Jordan, a senior hiring manager...
You are conducting the opening stage of a mock job interview, and your
sole goal at this stage is to hear the candidate's self-introduction.
...Once they have shared a description of themselves
and their experience — even briefly — call `intro_complete` immediately
to advance the interview. Do not ask follow-up questions...
"""
)
+ "\n"
+ _VOICE_RULES,
)
```
When the self-introduction stage begins, the agent generates a short greeting and invitation, then starts both fallback timers:
```python
async def on_enter(self) -> None:
await self.session.generate_reply(
instructions="Greet the candidate warmly and invite them to introduce themselves."
)
self._silence_task = asyncio.create_task(self._silence_watchdog())
self._stage_task = asyncio.create_task(self._stage_watchdog())
```
`PastExperienceAgent` owns the second stage. It assumes the introduction has already happened and focuses on one meaningful experience at a time:
```python
class PastExperienceAgent(Agent):
def __init__(self, chat_ctx: ChatContext | None = None) -> None:
super().__init__(
instructions=textwrap.dedent(
"""\
You are Jordan, the same senior hiring manager from the opening stage...
You are now conducting the past-experience stage of a mock job interview.
Pick the most relevant experience the candidate mentioned and ask them
to walk you through it: what the challenge was, their specific
contribution, the outcome, and what they learned.
"""
)
+ "\n"
+ _VOICE_RULES,
chat_ctx=chat_ctx,
)
```
This split keeps each prompt short, reduces unnecessary context, and makes the conversation easier to test.
## Goal Two: Smooth Stage Transition Without Repetition
The normal transition is handled by the `intro_complete` function tool. It is called by `SelfIntroAgent` after the candidate gives a self-introduction.
```python
@function_tool
async def intro_complete(self, context: RunContext[InterviewData]):
"""Call this tool as soon as the candidate has described themselves and
their work experience."""
context.userdata.intro_was_given = True
return (
PastExperienceAgent(chat_ctx=self.chat_ctx.copy(exclude_instructions=True)),
"Thanks for that introduction. Now I'd like to explore your past experience.",
)
```
There are three important details here:
- `context.userdata.intro_was_given = True` records that the normal introduction happened.
- `PastExperienceAgent(chat_ctx=self.chat_ctx.copy(exclude_instructions=True))` preserves conversation history without carrying old system instructions into the next agent.
- The tool returns only the next agent and one transition line, avoiding competing replies during the handoff.
When `PastExperienceAgent` enters, it checks the shared session state. If the self-introduction happened normally, it immediately asks about the most relevant experience without reintroducing Jordan or restating the transition:
```python
async def on_enter(self) -> None:
if self.session.userdata.intro_was_given:
instructions = (
"Ask the candidate to walk you through their most relevant past experience "
"or project. Do not re-introduce yourself or re-explain the transition — "
"go straight to the question."
)
else:
instructions = (
"Start directly: ask the candidate to walk you through their most recent "
"role or a project they've worked on. Do not reference any prior "
"introduction — go straight to the question."
)
await self.session.generate_reply(instructions=instructions)
```
That fallback branch matters because forced transitions can happen even if the candidate never completed a normal introduction.
## Goal Three: Time-Based Fallback for Continuous Progress
The normal path depends on the LLM calling `intro_complete`. To guarantee the interview still moves forward, `SelfIntroAgent` starts two independent watchdogs after its opening prompt:
- Silence watchdog: moves on after continuous candidate silence.
- Stage watchdog: moves on after the total stage time budget is reached.
The default budgets are:
```python
def __init__(self, *, silence_budget: float = 20.0, stage_budget: float = 60.0) -> None:
```
The silence watchdog resets while LiveKit reports that the user is speaking:
```python
while True:
if self.session.user_state == "speaking":
last_speech_time = time.monotonic()
silence_elapsed = time.monotonic() - last_speech_time
if silence_elapsed >= self._silence_budget:
break
await asyncio.sleep(min(self._silence_budget - silence_elapsed, poll))
```
When a fallback fires, it uses a deterministic spoken transition and then updates the active agent:
```python
if not isinstance(self.session.current_agent, SelfIntroAgent):
return
if self._transitioning:
return
self._transitioning = True
await self.session.say(
"Let's go ahead and move on to your background and experience."
)
self.session.update_agent(
PastExperienceAgent(chat_ctx=self.chat_ctx.copy(exclude_instructions=True))
)
```
The fallback also protects against double transitions. Each watchdog checks whether `SelfIntroAgent` is still active, waits for any in-flight speech to finish, checks again, and then uses `_transitioning` as a final guard.
On normal handoff or fallback handoff, `on_exit` cancels both watchdog tasks:
```python
async def on_exit(self) -> None:
for task in (self._silence_task, self._stage_task):
if task is not None:
task.cancel()
self._silence_task = None
self._stage_task = None
```
This prevents the old stage from firing a second transition after control has already moved to `PastExperienceAgent`.
## Options Considered but Not Used
| Option | Why We Did Not Use It |
| --- | --- |
| One monolithic interviewer agent | It would mix stage instructions, increase prompt scope, and make transitions less reliable. |
| LLM-only transition logic | If the model fails to call the handoff tool, the interview can stall. The watchdog fallback guarantees progress. |
| `generate_reply` for forced transitions | Fallback movement needs deterministic wording. `session.say(...)` avoids wandering or repeated explanations. |
| Dropping chat history on handoff | The past-experience stage needs the candidate's introduction. Without copied `chat_ctx`, the candidate may have to repeat themselves. |
| Direct provider plugins for every model | LiveKit Inference keeps model routing under LiveKit Cloud credentials and avoids separate provider-key setup for the demo. |
| Extra interview stages now | The current roadmap limits scope to the foundational two-stage demo. More stages can be added later with the same handoff pattern. |
## Verification
The test suite in `tests/test_agent.py` covers the core behaviors:
- `test_self_intro_greeting`: verifies the opening agent greets and asks for an introduction.
- `test_intro_complete_handoff`: verifies a complete introduction calls `intro_complete` and transfers to `PastExperienceAgent`.
- `test_no_premature_handoff`: verifies a bare greeting does not trigger the handoff.
- `test_watchdog_cancelled_on_normal_handoff`: verifies fallback timers are cancelled after normal transition.
- `test_fallback_handoff`: verifies the silence fallback moves to `PastExperienceAgent`.
- `test_silence_reset_on_speech`: verifies the silence timer resets while the candidate is speaking.
Latest local verification:
```console
$ uv run pytest -q
...... [100%]
6 passed in 14.60s
```
## Three-to-Five Minute Video Transcript
**Opening**
This project is an AI mock interview agent built on LiveKit's multi-agent framework. The goal is to create a realtime voice interviewer that can guide a candidate through a self-introduction and then move naturally into past-experience questions.
**Architecture**
The core implementation lives in `src/agent.py`. We use one LiveKit `AgentSession` to hold the shared LLM, speech-to-text, text-to-speech, voice activity detection, turn detection, and session state. The session state is a small `InterviewData` dataclass that records whether the candidate gave a self-introduction.
Instead of writing one large interviewer prompt, we split the workflow into two agents. `SelfIntroAgent` handles only the opening stage. `PastExperienceAgent` handles only the experience stage. This keeps each agent focused and helps reduce latency and prompt confusion in a live voice conversation.
**Stage One**
When `SelfIntroAgent` enters, it greets the candidate and asks them to introduce themselves. Its prompt tells the model that one real introduction is enough. As soon as the candidate describes who they are and their background or experience, the agent should call the `intro_complete` tool.
**Normal Transition**
The `intro_complete` tool is the normal handoff mechanism. It marks `intro_was_given` as true, creates a `PastExperienceAgent`, and passes a copy of the chat context into the next agent. That context transfer is important because the second stage can use the candidate's introduction without asking them to repeat it.
The tool returns only two things: the next agent and one transition sentence. That keeps the handoff clean and avoids multiple assistant responses competing in the same turn.
**Stage Two**
When `PastExperienceAgent` starts, it checks whether the introduction happened normally. If it did, it goes straight into a past-experience question without reintroducing itself. It asks the candidate to walk through a relevant project or role, focusing on the challenge, their contribution, the outcome, and what they learned.
**Fallback Mechanism**
The project also includes a time-based fallback so the interview keeps moving even if the LLM does not trigger the normal handoff. After the opening greeting, `SelfIntroAgent` starts two watchdog tasks. One watches for continuous silence. The other enforces a total stage budget.
If either watchdog fires, the agent waits for any current speech to finish, says a deterministic transition line, and then calls `session.update_agent(...)` to move to `PastExperienceAgent`. The transition passes the same chat context copy, so the second stage still has whatever context is available.
The watchdogs also include guard checks and a `_transitioning` flag. These prevent duplicate transitions if the normal tool call and fallback timer happen close together. When the self-introduction agent exits, it cancels both watchdog tasks so the old stage cannot interfere with the new one.
**Verification**
We verify this behavior with six tests. They cover the greeting, the normal self-introduction handoff, preventing premature handoff, cancelling watchdogs after normal transition, fallback handoff, and resetting the silence timer during speech. The latest test run passed with six out of six tests.
**Closing**
In short, the agent achieves the demo goals by combining LiveKit's realtime voice pipeline with a focused multi-agent interview workflow. The self-introduction and past-experience stages are separated cleanly, the normal handoff is explicit and context-preserving, and the fallback timers guarantee that the interview continues even when the LLM-driven transition does not fire.