403 lines
15 KiB
Python
403 lines
15 KiB
Python
import asyncio
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Annotated
|
|
|
|
import aiofiles
|
|
from aiocsv import AsyncDictWriter
|
|
from dotenv import load_dotenv
|
|
from pydantic import Field
|
|
|
|
from livekit.agents import (
|
|
Agent,
|
|
AgentServer,
|
|
AgentSession,
|
|
AgentTask,
|
|
JobContext,
|
|
RunContext,
|
|
cli,
|
|
inference,
|
|
llm,
|
|
room_io,
|
|
)
|
|
from livekit.agents.beta.workflows import GetEmailTask, TaskGroup
|
|
from livekit.agents.llm import function_tool
|
|
from livekit.agents.voice import UserStateChangedEvent
|
|
|
|
logger = logging.getLogger("SurveyAgent")
|
|
|
|
load_dotenv()
|
|
|
|
CommuteMethods = ["driving", "bus", "subway", "none"]
|
|
WorkStyles = ["independent", "team_player"]
|
|
|
|
CSV_COLUMNS = (
|
|
"name",
|
|
"get_name_intro_task",
|
|
"get_email_task",
|
|
"commute_task",
|
|
"experience_task",
|
|
"behavorial_task",
|
|
"summary",
|
|
"evaluation",
|
|
"disqualification_reason",
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Userdata:
|
|
filename: str
|
|
candidate_name: str
|
|
task_results: dict
|
|
|
|
|
|
@dataclass
|
|
class IntroResults:
|
|
name: str
|
|
intro: str
|
|
|
|
|
|
@dataclass
|
|
class CommuteResults:
|
|
can_commute: bool
|
|
commute_method: str
|
|
|
|
|
|
@dataclass
|
|
class ExperienceResults:
|
|
years_of_experience: int
|
|
experience_description: str
|
|
|
|
|
|
@dataclass
|
|
class BehavioralResults:
|
|
strengths: str
|
|
weaknesses: str
|
|
work_style: str
|
|
|
|
|
|
async def write_to_csv(filename: str, data: dict):
|
|
async with aiofiles.open(filename, "a", newline="") as csvfile:
|
|
is_empty = await csvfile.tell() == 0
|
|
writer = AsyncDictWriter(csvfile, fieldnames=CSV_COLUMNS, extrasaction="ignore")
|
|
if is_empty:
|
|
await writer.writeheader()
|
|
await writer.writerow(data)
|
|
|
|
|
|
async def evaluate_candidate(llm_model, summary) -> str:
|
|
"""Analyzes the full conversation to determine if the candidate is a good fit for the role and position"""
|
|
conversation_text = summary.content
|
|
chat_ctx = llm.ChatContext()
|
|
chat_ctx.add_message(
|
|
role="system",
|
|
content=(
|
|
"Evaluate whether or not this candidate is a good fit for the company and role based on the conversation summary provided.\n"
|
|
"Take into account their holistic and professional profile and the quality of their responses.\n"
|
|
"Be concise and firm in the evaluation."
|
|
),
|
|
)
|
|
chat_ctx.add_message(
|
|
role="user",
|
|
content=f"Conversation to evaluate:\n\n{conversation_text}",
|
|
)
|
|
|
|
chunks: list[str] = []
|
|
async for chunk in llm_model.chat(chat_ctx=chat_ctx):
|
|
if chunk.delta and chunk.delta.content:
|
|
chunks.append(chunk.delta.content)
|
|
evaluation = "".join(chunks).strip()
|
|
return evaluation
|
|
|
|
|
|
@function_tool()
|
|
async def disqualify(context: RunContext, disqualification_reason: str) -> None:
|
|
"""Call if the candidate refuses to cooperate, provides an unsatisfactory or inappropriate answer, or do not meet the prerequisites for the position.
|
|
This function will terminate the interview, record their disqualification, and hang up.
|
|
|
|
Args:
|
|
disqualification_reason (str): The justification for ending the interview (ex. Refuses to answer question)
|
|
"""
|
|
context.session.generate_reply(
|
|
instructions=f"The interview is ending now, inform the candidate that the reason was {disqualification_reason}. Be respectful and natural."
|
|
)
|
|
disqualification_reason = "[DISQUALIFIED] " + disqualification_reason
|
|
data = {
|
|
"name": context.session.userdata.candidate_name,
|
|
"disqualification_reason": disqualification_reason,
|
|
}
|
|
await write_to_csv(context.session.userdata.filename, data)
|
|
context.session.shutdown()
|
|
|
|
|
|
class BehavioralTask(AgentTask[BehavioralResults]):
|
|
def __init__(self) -> None:
|
|
super().__init__(
|
|
instructions="""You are an interviewer screening a candidate for a software engineering position. You have already been asking a series of questions, and this is another stage of the process.
|
|
You will now be learning more about the candidate holistically. This includes their strengths, weaknesses, and work and communication style. You are testing the candidate for a good fit in the company.
|
|
If the candidate refuses to answer, call disqualify(). Be concise and to the point.
|
|
Avoid listing out questions with bullet points or numbers, use a natural conversational tone.
|
|
""",
|
|
tools=[disqualify],
|
|
)
|
|
self._results = {}
|
|
|
|
async def on_enter(self) -> None:
|
|
await self.session.generate_reply(
|
|
instructions="Approach this task in a natural conversational manner and incrementally gather the candidate's strengths, weaknesses, and work style in no particular order."
|
|
)
|
|
|
|
@function_tool()
|
|
async def record_strengths(self, strengths_summary: str):
|
|
"""Call to record a summary of the candidate's strengths.
|
|
|
|
Args:
|
|
strengths_summary (str): A summary of the candidate's strengths
|
|
"""
|
|
self._results["strengths"] = strengths_summary
|
|
self._check_completion()
|
|
|
|
@function_tool()
|
|
async def record_weaknesses(self, weaknesses_summary: str):
|
|
"""Call to record a summary of the candidate's weaknesses.
|
|
|
|
Args:
|
|
weaknesses_summary (str): A summary of the candidate's weaknesses
|
|
"""
|
|
self._results["weaknesses"] = weaknesses_summary
|
|
self._check_completion()
|
|
|
|
@function_tool()
|
|
async def record_work_style(
|
|
self, work_style: Annotated[str, Field(json_schema_extra={"enum": WorkStyles})]
|
|
):
|
|
"""Call to record a summary of the candidate's work style.
|
|
|
|
Args:
|
|
work_style (str): The candidate's work style
|
|
"""
|
|
self._results["work_style"] = work_style
|
|
self._check_completion()
|
|
|
|
def _check_completion(self):
|
|
if self._results.keys() == {"strengths", "weaknesses", "work_style"}:
|
|
results = BehavioralResults(
|
|
strengths=self._results["strengths"],
|
|
weaknesses=self._results["weaknesses"],
|
|
work_style=self._results["work_style"],
|
|
)
|
|
self.complete(results)
|
|
else:
|
|
self.session.generate_reply(
|
|
instructions="Continue incrementally collecting the remaining answers for the behavioral stage. Maintain a conversational tone."
|
|
)
|
|
|
|
|
|
class ExperienceTask(AgentTask[ExperienceResults]):
|
|
def __init__(self) -> None:
|
|
super().__init__(
|
|
instructions="""
|
|
You are an interviewer screening a candidate for a software engineering position. You have already been asking a series of questions, and this is another stage of the process.
|
|
Record how many years of experience the candidate has and the descriptions of their previous jobs if any. There is no set required amount for this position.
|
|
Be sure to confirm details. Avoid listing out questions with bullet points or numbers, use a natural conversational tone.
|
|
""",
|
|
tools=[disqualify],
|
|
)
|
|
|
|
async def on_enter(self) -> None:
|
|
await self.session.generate_reply(
|
|
instructions="Gather the candidate's work experience including how many years of experience they have and a general overview of their career.",
|
|
)
|
|
|
|
@function_tool()
|
|
async def record_experience(
|
|
self, context: RunContext, years_of_experience: int, experience_description: str
|
|
) -> None:
|
|
"""Call to record the work experience the candidate has and a description of each role with a clear timeline.
|
|
|
|
Args:
|
|
years_of_experience (int): The years of experience the candidate has
|
|
experience_description (str): A description of each role they previously held. Take note of the corresponding companies as well.
|
|
"""
|
|
results = ExperienceResults(
|
|
years_of_experience=years_of_experience, experience_description=experience_description
|
|
)
|
|
self.complete(results)
|
|
|
|
|
|
class CommuteTask(AgentTask[CommuteResults]):
|
|
def __init__(self) -> None:
|
|
super().__init__(
|
|
instructions="""
|
|
You are an interviewer screening a candidate for a software engineering position. You have already been asking a series of questions, and this is another stage of the process.
|
|
Record if the candidate is able to commute to the office and their flexibility. Ideally, the candidate should commute to the office three days a week. Avoiding using parentheses in your response.
|
|
""",
|
|
tools=[disqualify],
|
|
)
|
|
|
|
async def on_enter(self) -> None:
|
|
await self.session.generate_reply(
|
|
instructions="Gather the candidate's commute flexibility, specfically whether or not they are able to commute to the office. If they are able to commute, collect their commute method. Be brief and to the point.",
|
|
)
|
|
|
|
@function_tool()
|
|
async def record_commute_flexibility(
|
|
self,
|
|
context: RunContext,
|
|
can_commute: bool,
|
|
commute_method: Annotated[str, Field(json_schema_extra={"enum": CommuteMethods})],
|
|
) -> None:
|
|
"""Call to record the candidate's flexibility of going into office and notes about their commute. If they are able to commute, record their method of transportation.
|
|
|
|
Args:
|
|
can_commute (bool): If the candidate can commute to the office
|
|
commute_method (str): The method of transportation the candidate will take to commute
|
|
"""
|
|
results = CommuteResults(can_commute=can_commute, commute_method=commute_method)
|
|
self.complete(results)
|
|
|
|
|
|
class IntroTask(AgentTask[IntroResults]):
|
|
def __init__(self) -> None:
|
|
super().__init__(
|
|
instructions="""
|
|
You are Alex, an interviewer screening a candidate for a software engineering position. You both have just started the call.
|
|
Welcome the candidate to the interview, remain positive and concise.
|
|
You will also be collecting their name and introduction.
|
|
""",
|
|
)
|
|
|
|
async def on_enter(self) -> None:
|
|
await self.session.generate_reply(
|
|
instructions="Welcome the candidate by introducing yourself and gather their name after their introduction.",
|
|
)
|
|
|
|
@function_tool()
|
|
async def record_intro(self, context: RunContext, name: str, intro_notes: str) -> None:
|
|
"""Call to record the candidate's name and any notes about their response
|
|
|
|
Args:
|
|
name (str): The candidate's name
|
|
intro_notes (str): The candidate's introduction and any additional notes
|
|
"""
|
|
self.session.userdata.candidate_name = name
|
|
results = IntroResults(name=name, intro=intro_notes)
|
|
self.complete(results)
|
|
|
|
|
|
class SurveyAgent(Agent):
|
|
def __init__(self) -> None:
|
|
super().__init__(
|
|
instructions="""
|
|
You are a Survey agent screening candidates for a Software Engineer position. When the interview is concluded, call end_screening to hang up.
|
|
"""
|
|
)
|
|
|
|
async def on_enter(self) -> AgentTask:
|
|
task_group = TaskGroup()
|
|
task_group.add(
|
|
lambda: IntroTask(),
|
|
id="get_name_intro_task",
|
|
description="Collects name and introduction",
|
|
)
|
|
task_group.add(
|
|
lambda: GetEmailTask(
|
|
extra_instructions="If the user refuses to provide their email, call disqualify() insted of decline_email_capture().",
|
|
tools=[disqualify],
|
|
),
|
|
id="get_email_task",
|
|
description="Collects email",
|
|
)
|
|
task_group.add(
|
|
lambda: CommuteTask(),
|
|
id="commute_task",
|
|
description="Asks about commute and corresponding method of transportation.",
|
|
)
|
|
task_group.add(
|
|
lambda: ExperienceTask(),
|
|
id="experience_task",
|
|
description="Collects years of experience and a description of their professionl work history.",
|
|
)
|
|
task_group.add(
|
|
lambda: BehavioralTask(),
|
|
id="behavorial_task",
|
|
description="Gathers a holistic view of the candidate, including their strengths, weaknesses, and work style",
|
|
)
|
|
|
|
results = await task_group
|
|
results = results.task_results
|
|
# TaskGroup returns a TaskGroupResult object. The task_results field holds a dictionary with Task IDs as the keys and the results as the values
|
|
summary = self.chat_ctx.items[-1]
|
|
evaluation = await evaluate_candidate(llm_model=self.session.llm, summary=summary)
|
|
results["name"] = self.session.userdata.candidate_name
|
|
results["summary"] = summary.content
|
|
results["evaluation"] = evaluation
|
|
self.session.userdata.task_results = results
|
|
await write_to_csv(filename=self.session.userdata.filename, data=results)
|
|
await self.session.generate_reply(
|
|
instructions="The interview is now complete, alert the user and thank them for their time. They will hear back within 3 days."
|
|
)
|
|
|
|
@function_tool()
|
|
async def end_screening(self):
|
|
"""Call when the interview/screening is concluded to hang up."""
|
|
self.session.shutdown()
|
|
|
|
|
|
server = AgentServer()
|
|
|
|
|
|
@server.rtc_session()
|
|
async def entrypoint(ctx: JobContext):
|
|
session = AgentSession[Userdata](
|
|
userdata=Userdata(filename="results.csv", candidate_name="", task_results={}),
|
|
llm=inference.LLM("google/gemma-4-31b-it"),
|
|
stt=inference.STT("deepgram/nova-3", language="multi"),
|
|
tts=inference.TTS(
|
|
"inworld/inworld-tts-2", voice="Nate", extra_kwargs={"delivery_mode": "CREATIVE"}
|
|
),
|
|
preemptive_generation=True,
|
|
# Flip user_state to "away" after 10s of mutual silence so we can
|
|
# check whether they're still there (default is 15s).
|
|
user_away_timeout=10.0,
|
|
)
|
|
|
|
async def log_usage():
|
|
logger.info(f"Usage: {session.usage}")
|
|
|
|
ctx.add_shutdown_callback(log_usage)
|
|
|
|
idle_task: asyncio.Task[None] | None = None
|
|
|
|
async def _nudge_while_idle() -> None:
|
|
# Nudge every 10s until the user speaks again — speaking flips
|
|
# user_state out of "away", which cancels this task below.
|
|
while True:
|
|
logger.info("user idle — checking if they're still there")
|
|
await session.generate_reply(
|
|
instructions="The user has been idle, see if they're still there"
|
|
)
|
|
await asyncio.sleep(10)
|
|
|
|
@session.on("user_state_changed")
|
|
def _on_user_state_changed(ev: UserStateChangedEvent) -> None:
|
|
nonlocal idle_task
|
|
if ev.new_state == "away":
|
|
if idle_task is None or idle_task.done():
|
|
idle_task = asyncio.create_task(_nudge_while_idle())
|
|
elif idle_task is not None:
|
|
idle_task.cancel()
|
|
idle_task = None
|
|
|
|
await session.start(
|
|
agent=SurveyAgent(),
|
|
room=ctx.room,
|
|
room_options=room_io.RoomOptions(delete_room_on_close=True),
|
|
)
|
|
|
|
await ctx.connect()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
cli.run_app(server)
|