Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 34 additions & 16 deletions backend/services/llm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,27 @@
"required": ["transcriptScores", "feedback", "modelAnswer"],
}

_SYSTEM_PROMPT = """You are an expert interview coach reviewing a candidate's \
spoken answer to an interview question.
_VOICE_INSTRUCTIONS = """\
VOICE AND TONE (required for every feedback string you write — summary, strengths, \
improvements, deliveryNotes, and all session-level fields):
Write as the interviewer speaking directly to the person who just answered, like live \
coaching in the moment — not a written evaluation about them.
- Use second person: "you" and "your". Address them directly.
- Never refer to them as "the candidate", "the interviewee", or in third person \
(e.g. "they", "their answer", "the candidate's response").
- Avoid impersonal report phrasing such as "your performance", "the response", \
"overall execution", or "the answer demonstrated". Prefer concrete, conversational \
coaching: "You opened strong when you…", "I'd tighten the middle by…", \
"You sounded confident when…"."""

_SYSTEM_PROMPT = f"""You are an expert interview coach. You are debriefing the person \
who just answered — speak to them directly.

You receive:
- The interview question.
- The full transcript of the candidate's spoken answer.
- The full transcript of their spoken answer.
- The transcript broken into timestamped segments. Each segment carries three \
delivery signals derived from the candidate's voice, each in the range 0..1:
delivery signals derived from their voice, each in the range 0..1:
- arousal: vocal energy / activation (low = flat/calm, high = energetic)
- dominance: assertiveness / confidence in tone
- valence: positivity / warmth of tone
Expand All @@ -83,22 +96,23 @@
- structure: logical organization (e.g. STAR for behavioral answers)
- relevance: how well it answers the question asked
- conciseness: brevity without rambling or filler
2. feedback:
- summary: 2-3 sentence cohesive overview of how the candidate did overall, \
covering both content and delivery.
2. feedback (all fields must follow VOICE AND TONE below):
- summary: 2-3 sentences on how they did overall, covering content and delivery.
- strengths: 2-4 specific things that went well. When relevant, reference \
specific moments in the answer (quote a short phrase or cite the approximate \
timestamp) and tie delivery signals to those moments.
specific moments (quote a short phrase or cite the approximate timestamp) and tie \
delivery signals to those moments.
- improvements: 2-4 specific, actionable suggestions. Reference specific \
segments where delivery (e.g. low arousal sounding flat, low valence sounding \
cold) or content could improve.
- deliveryNotes: a short paragraph on vocal delivery overall, grounded in \
the per-segment arousal/dominance/valence trends.
3. modelAnswer: a concise, strong example answer to the same question (a few \
sentences) the candidate could learn from.
sentences, first person) they could learn from.

{_VOICE_INSTRUCTIONS}

Be specific and grounded in the provided transcript and segments. Do not invent \
facts the candidate did not say. Output only the JSON object."""
facts they did not say. Output only the JSON object."""

_SESSION_RESPONSE_SCHEMA = {
"type": "object",
Expand Down Expand Up @@ -139,17 +153,17 @@
],
}

_SESSION_SYSTEM_PROMPT = """You are an expert interview coach reviewing a \
candidate's full mock interview session.
_SESSION_SYSTEM_PROMPT = f"""You are an expert interview coach. You are debriefing \
the person who just finished a mock interview — speak to them directly.

You receive multiple question-and-answer pairs. Each answer includes the \
question text, full transcript, and timestamped segments with delivery signals \
(arousal, dominance, valence — each 0..1). These are tone-and-delivery signals \
only, not clinical labels.

Produce a single JSON object with:
1. overallSummary: 3-5 sentence overview of how the candidate performed across \
the whole session (content and delivery trends).
1. overallSummary: 3-5 sentences on how they did across the whole session \
(content and delivery trends).
2. overallStrengths: 3-5 session-level strengths.
3. overallImprovements: 3-5 session-level, actionable improvements.
4. overallDeliveryNotes: one paragraph on vocal delivery patterns across answers.
Expand All @@ -158,7 +172,11 @@
- question: echo back the exact id and text from the input
- transcriptScores: clarity/structure/relevance/conciseness (0..1) for that answer
- feedback: summary, strengths, improvements, deliveryNotes for that answer
- modelAnswer: a strong example answer for that question
- modelAnswer: a strong example answer for that question (first person)

All feedback text fields must follow VOICE AND TONE below.

{_VOICE_INSTRUCTIONS}

Be specific and grounded in the transcripts. Do not invent facts. Output only JSON."""

Expand Down
111 changes: 83 additions & 28 deletions frontend/app/InterviewClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,18 @@ import { MicWaveform, type MicWaveformHandle } from "@/app/components/MicWavefor
import { ScorecardPanel } from "@/app/scorecard/ScorecardPanel";
import { SessionScorecardPanel } from "@/app/scorecard/SessionScorecardPanel";
import { buildReviewContext } from "@/lib/interview-coach/sessionAdapter";
import {
buildQualitativeFeedbackScript,
buildSessionFeedbackScript,
} from "@/lib/interview-coach/feedbackSpeech";
import type {
FeedbackMode,
FullSessionFeedbackResponse,
ReviewContextPayload,
SessionFeedbackResponse,
SessionReviewPayload,
} from "@/lib/interview-coach/types";
import { speakWithGroq } from "@/lib/speech/tts";
import styles from "./InterviewClient.module.css";

const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
Expand Down Expand Up @@ -48,30 +55,6 @@ function buildIntro(interviewer: Interviewer): string {
return `Hi there, welcome. I'm ${interviewer.name}, ${interviewer.title}. Thank you so much for coming in today — we're really glad to have you. Let's go ahead and get started.`;
}

async function speakWithGroq(text: string, voice: string, signal: AbortSignal): Promise<void> {
const res = await fetch(`${API_BASE}/speech/tts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, voice }),
signal,
});
if (!res.ok) throw new Error(`TTS ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
const audio = new Audio(url);
audio.onended = () => {
URL.revokeObjectURL(url);
resolve();
};
audio.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Audio playback failed"));
};
audio.play();
});
}

export default function InterviewClient() {
const [interviewer, setInterviewer] = useState<Interviewer>(DEFAULT_INTERVIEWER);
const [feedbackMode, setFeedbackMode] = useState<FeedbackMode>("perQuestion");
Expand All @@ -87,6 +70,7 @@ export default function InterviewClient() {
const [statusText, setStatusText] = useState(
"Select your interviewer, feedback timing, and press Start Interview."
);
const [feedbackSpeaking, setFeedbackSpeaking] = useState(false);

const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const accumulatedRef = useRef<Blob[]>([]);
Expand All @@ -101,6 +85,9 @@ export default function InterviewClient() {
const questionRef = useRef<InterviewQuestion | null>(null);
const savedAnswersRef = useRef<ReviewContextPayload[]>([]);
const waveformRef = useRef<MicWaveformHandle>(null);
const autoFeedbackShownRef = useRef<string | null>(null);
const spokenFeedbackKeyRef = useRef<string | null>(null);
const sessionFeedbackSpokenRef = useRef(false);

useEffect(() => {
questionRef.current = question;
Expand All @@ -110,13 +97,66 @@ export default function InterviewClient() {
savedAnswersRef.current = savedAnswers;
}, [savedAnswers]);

useEffect(() => {
if (stage !== "done" || feedbackMode !== "perQuestion") return;
if (!question || segments.length === 0) return;
if (autoFeedbackShownRef.current === String(question.id)) return;
autoFeedbackShownRef.current = String(question.id);
setReviewContext(buildReviewContext(question, segments));
}, [stage, feedbackMode, question, segments]);

const newAbort = useCallback((): AbortSignal => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
return ctrl.signal;
}, []);

const speakFeedbackScript = useCallback(
async (script: string, session = false) => {
const signal = newAbort();
setFeedbackSpeaking(true);
setStatusText(
session
? `${interviewer.name} is sharing your session feedback…`
: `${interviewer.name} is sharing feedback on your answer…`
);
try {
await speakWithGroq(script, interviewer.voice, signal);
setStatusText(
session
? "Interview complete. Review your session feedback below."
: "Answer received. Review your response or move to the next question."
);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
setStatusText("Could not play feedback audio. You can still read it below.");
} finally {
setFeedbackSpeaking(false);
}
},
[interviewer, newAbort]
);

const handlePerQuestionFeedbackReady = useCallback(
(response: SessionFeedbackResponse) => {
if (!question) return;
if (spokenFeedbackKeyRef.current === String(question.id)) return;
spokenFeedbackKeyRef.current = String(question.id);
void speakFeedbackScript(buildQualitativeFeedbackScript(response.feedback));
},
[question, speakFeedbackScript]
);

const handleSessionFeedbackReady = useCallback(
(response: FullSessionFeedbackResponse) => {
if (sessionFeedbackSpokenRef.current) return;
sessionFeedbackSpokenRef.current = true;
void speakFeedbackScript(buildSessionFeedbackScript(response), true);
},
[speakFeedbackScript]
);

const clearTimers = useCallback(() => {
if (silenceTimerRef.current) {
clearTimeout(silenceTimerRef.current);
Expand All @@ -137,6 +177,9 @@ export default function InterviewClient() {
clearTimers();
pendingEndRef.current = false;
usedIdsRef.current.clear();
autoFeedbackShownRef.current = null;
spokenFeedbackKeyRef.current = null;
sessionFeedbackSpokenRef.current = false;
setQuestion(null);
setQuestionNumber(0);
setSegments([]);
Expand Down Expand Up @@ -375,6 +418,7 @@ export default function InterviewClient() {
}, [question, segments]);

const dismissFeedback = useCallback(() => {
abortRef.current?.abort();
setReviewContext(null);
}, []);

Expand All @@ -384,7 +428,7 @@ export default function InterviewClient() {

const dotClass = [
styles.dot,
stage === "playing" ? styles.playing : "",
stage === "playing" || feedbackSpeaking ? styles.playing : "",
stage === "recording" ? styles.recording : "",
stage === "processing" ? styles.processing : "",
stage === "done" || stage === "finished" ? styles.done : "",
Expand Down Expand Up @@ -507,7 +551,11 @@ export default function InterviewClient() {
{stage === "done" && (
<>
{hasMoreQuestions && (
<button className={styles.btnPrimary} onClick={nextQuestion}>
<button
className={styles.btnPrimary}
onClick={nextQuestion}
disabled={feedbackSpeaking}
>
Next Question
</button>
)}
Expand Down Expand Up @@ -560,14 +608,21 @@ export default function InterviewClient() {
{reviewContext && (
<>
<div className={styles.divider} />
<ScorecardPanel sessionInput={reviewContext} showShellBadge={false} />
<ScorecardPanel
sessionInput={reviewContext}
showShellBadge={false}
onFeedbackReady={handlePerQuestionFeedbackReady}
/>
</>
)}

{sessionPayload && stage === "finished" && (
<>
<div className={styles.divider} />
<SessionScorecardPanel sessionInput={sessionPayload} />
<SessionScorecardPanel
sessionInput={sessionPayload}
onFeedbackReady={handleSessionFeedbackReady}
/>
</>
)}
</div>
Expand Down
9 changes: 9 additions & 0 deletions frontend/app/scorecard/ScorecardPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type ScorecardPanelProps = {
loadingTranscriptScores?: boolean;
loadingFeedback?: boolean;
showShellBadge?: boolean;
/** Called once when feedback has been fetched or resolved from `result`. */
onFeedbackReady?: (response: SessionFeedbackResponse) => void;
};

async function fetchFeedback(
Expand Down Expand Up @@ -52,6 +54,7 @@ export function ScorecardPanel({
loadingTranscriptScores = false,
loadingFeedback = false,
showShellBadge = false,
onFeedbackReady,
}: ScorecardPanelProps) {
const [fetched, setFetched] = useState<SessionFeedbackResponse | null>(null);
const [fetching, setFetching] = useState(false);
Expand Down Expand Up @@ -90,6 +93,12 @@ export function ScorecardPanel({

const isLoadingFeedback = loadingFeedback || (shouldFetch && fetching);

useEffect(() => {
if (isLoadingFeedback || !feedback || !onFeedbackReady) return;
if (!transcriptScores || !modelAnswer) return;
onFeedbackReady({ transcriptScores, feedback, modelAnswer });
}, [feedback, isLoadingFeedback, modelAnswer, onFeedbackReady, transcriptScores]);

return (
<>
{error ? (
Expand Down
11 changes: 10 additions & 1 deletion frontend/app/scorecard/SessionScorecardPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";

export type SessionScorecardPanelProps = {
sessionInput: SessionReviewPayload;
/** Called once when session feedback has been fetched. */
onFeedbackReady?: (response: FullSessionFeedbackResponse) => void;
};

async function fetchSessionFeedback(
Expand All @@ -30,7 +32,10 @@ async function fetchSessionFeedback(
return (await res.json()) as FullSessionFeedbackResponse;
}

export function SessionScorecardPanel({ sessionInput }: SessionScorecardPanelProps) {
export function SessionScorecardPanel({
sessionInput,
onFeedbackReady,
}: SessionScorecardPanelProps) {
const [result, setResult] = useState<FullSessionFeedbackResponse | null>(null);
const [fetching, setFetching] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand All @@ -57,6 +62,10 @@ export function SessionScorecardPanel({ sessionInput }: SessionScorecardPanelPro
return () => controller.abort();
}, [sessionInput]);

useEffect(() => {
if (result && onFeedbackReady) onFeedbackReady(result);
}, [onFeedbackReady, result]);

if (error) {
return (
<p role="alert" className={styles.error}>
Expand Down
Loading
Loading