From 0a3918bc1ecd723716d0797e1cb135d46a73723e Mon Sep 17 00:00:00 2001 From: JacobWoodbury Date: Sat, 6 Jun 2026 13:05:14 -0700 Subject: [PATCH] scorecard --- backend/.env.example | 5 +- backend/services/llm/router.py | 171 +++++++++++++++++- backend/services/llm/schemas.py | 63 +++++++ backend/services/text_analysis/router.py | 4 +- frontend/app/InterviewClient.tsx | 24 ++- frontend/app/dev/flow/FlowDevClient.tsx | 35 ++-- frontend/app/scorecard/ScorecardPanel.tsx | 108 +++++++++-- .../Scorecard/Scorecard.stories.tsx | 4 - .../components/Scorecard/Scorecard.tsx | 9 +- frontend/app/scorecard/page.tsx | 2 +- .../interview-coach/aggregateReviewPayload.ts | 13 +- frontend/lib/interview-coach/mocks.ts | 14 +- .../lib/interview-coach/pipelineStages.ts | 27 +-- .../lib/interview-coach/sessionAdapter.ts | 54 ++++++ frontend/lib/interview-coach/types.ts | 31 +++- lefthook.yml | 6 +- 16 files changed, 480 insertions(+), 90 deletions(-) create mode 100644 backend/services/llm/schemas.py create mode 100644 frontend/lib/interview-coach/sessionAdapter.ts diff --git a/backend/.env.example b/backend/.env.example index 27f8323..305d08f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,10 +2,11 @@ # .env is gitignored — never commit it. # All three keys are required for the full pipeline to function. -# Required for speech-to-text (Groq Whisper API — https://console.groq.com) +# Required for speech-to-text (Groq Whisper) AND LLM feedback (Groq gpt-oss-20b). +# https://console.groq.com GROQ_API_KEY= -# Required for LLM feedback (Claude — https://console.anthropic.com) +# Optional — reserved. LLM feedback currently runs on Groq (gpt-oss-20b), not Claude. ANTHROPIC_API_KEY= # Reserved for future use (OpenAI fallback or embeddings) diff --git a/backend/services/llm/router.py b/backend/services/llm/router.py index 71f46ae..ae60e0f 100644 --- a/backend/services/llm/router.py +++ b/backend/services/llm/router.py @@ -1,6 +1,173 @@ -from fastapi import APIRouter +"""LLM feedback service — POST /feedback/generate. + +A single Groq (openai/gpt-oss-20b) call takes the recorded session context +(question + full transcript + per-segment arousal/dominance/valence) and +returns transcript scores, qualitative feedback, and a model answer. + +Transcript-quality scoring (clarity/structure/relevance/conciseness) is folded +into this one call, so the separate /analysis endpoint stays reserved. +""" + +import json +import os + +from fastapi import APIRouter, HTTPException +from groq import Groq +from pydantic import ValidationError + +from services.llm.schemas import ( + ReviewContextPayload, + SessionFeedbackResponse, +) + +FEEDBACK_MODEL = "openai/gpt-oss-20b" +_groq = Groq(api_key=os.environ.get("GROQ_API_KEY", "")) router = APIRouter() -# TODO: implement POST /generate +# JSON schema handed to Groq structured outputs. strict=false (best-effort): +# gpt-oss-20b occasionally 400s under strict constrained decoding, so we +# validate the response ourselves and retry once. +_RESPONSE_SCHEMA = { + "type": "object", + "properties": { + "transcriptScores": { + "type": "object", + "properties": { + "clarity": {"type": "number"}, + "structure": {"type": "number"}, + "relevance": {"type": "number"}, + "conciseness": {"type": "number"}, + }, + "required": ["clarity", "structure", "relevance", "conciseness"], + }, + "feedback": { + "type": "object", + "properties": { + "summary": {"type": "string"}, + "strengths": {"type": "array", "items": {"type": "string"}}, + "improvements": {"type": "array", "items": {"type": "string"}}, + "deliveryNotes": {"type": "string"}, + }, + "required": ["summary", "strengths", "improvements", "deliveryNotes"], + }, + "modelAnswer": { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + }, + }, + "required": ["transcriptScores", "feedback", "modelAnswer"], +} + +_SYSTEM_PROMPT = """You are an expert interview coach reviewing a candidate's \ +spoken answer to an interview question. + +You receive: +- The interview question. +- The full transcript of the candidate's 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: + - arousal: vocal energy / activation (low = flat/calm, high = energetic) + - dominance: assertiveness / confidence in tone + - valence: positivity / warmth of tone +These are tone-and-delivery signals only. Never treat them as clinical, \ +emotional, or diagnostic labels. + +Produce a single JSON object with: +1. transcriptScores: four numbers in 0..1 rating the ANSWER TEXT only: + - clarity: how clearly ideas are expressed + - 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. + - 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. + - 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. + +Be specific and grounded in the provided transcript and segments. Do not invent \ +facts the candidate did not say. Output only the JSON object.""" + + +def _require_groq() -> None: + if not os.environ.get("GROQ_API_KEY"): + raise HTTPException(status_code=503, detail="GROQ_API_KEY is not configured.") + + +def _build_user_prompt(payload: ReviewContextPayload) -> str: + seg_lines = [] + for i, seg in enumerate(payload.transcript.segments, start=1): + seg_lines.append( + f" Segment {i} [{seg.start:.2f}s-{seg.end:.2f}s] " + f"(arousal={seg.arousal:.2f}, dominance={seg.dominance:.2f}, " + f"valence={seg.valence:.2f}): {seg.text}" + ) + segments_block = "\n".join(seg_lines) if seg_lines else " (no segments)" + + return ( + f"QUESTION:\n{payload.question.text}\n\n" + f"FULL TRANSCRIPT:\n{payload.transcript.text}\n\n" + f"TIMESTAMPED SEGMENTS WITH DELIVERY SCORES:\n{segments_block}" + ) + + +def _call_groq(user_prompt: str) -> str: + completion = _groq.chat.completions.create( + model=FEEDBACK_MODEL, + messages=[ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + temperature=0.4, + response_format={ + "type": "json_schema", + "json_schema": { + "name": "session_feedback", + "strict": False, + "schema": _RESPONSE_SCHEMA, + }, + }, + ) + return completion.choices[0].message.content or "" + + +@router.post("/generate", response_model=SessionFeedbackResponse) +async def generate(payload: ReviewContextPayload) -> SessionFeedbackResponse: + """Generate transcript scores + qualitative feedback + a model answer for a + recorded interview answer using a single Groq call.""" + _require_groq() + + if not payload.transcript.text.strip(): + raise HTTPException(status_code=422, detail="Transcript is empty.") + + user_prompt = _build_user_prompt(payload) + + last_error: Exception | None = None + for _ in range(2): # one retry on transport or validation failure + try: + raw = _call_groq(user_prompt) + except Exception as exc: + last_error = exc + continue + + try: + data = json.loads(raw) + return SessionFeedbackResponse.model_validate(data) + except (json.JSONDecodeError, ValidationError) as exc: + last_error = exc + continue + + raise HTTPException( + status_code=502, + detail=f"Feedback generation failed: {last_error}", + ) diff --git a/backend/services/llm/schemas.py b/backend/services/llm/schemas.py new file mode 100644 index 0000000..0e54f29 --- /dev/null +++ b/backend/services/llm/schemas.py @@ -0,0 +1,63 @@ +"""Pydantic models for POST /feedback/generate. + +The frontend aggregates the recorded session (question + full transcript + +per-segment arousal/dominance/valence) and posts it here. A single Groq call +returns transcript scores, qualitative feedback, and a model answer. +""" + +from pydantic import BaseModel, Field + + +class Question(BaseModel): + id: str + text: str + + +class ScoredSegment(BaseModel): + """A timestamped transcript segment with its delivery (A/D/V) scores.""" + + start: float + end: float + text: str + arousal: float + dominance: float + valence: float + + +class Transcript(BaseModel): + text: str + segments: list[ScoredSegment] + + +class ReviewContextPayload(BaseModel): + """Request body for POST /feedback/generate.""" + + question: Question + transcript: Transcript + + +class TranscriptScores(BaseModel): + clarity: float = Field(ge=0.0, le=1.0) + structure: float = Field(ge=0.0, le=1.0) + relevance: float = Field(ge=0.0, le=1.0) + conciseness: float = Field(ge=0.0, le=1.0) + + +class QualitativeFeedback(BaseModel): + summary: str + strengths: list[str] + improvements: list[str] + deliveryNotes: str + + +class ModelAnswer(BaseModel): + text: str + + +class SessionFeedbackResponse(BaseModel): + """Response body for POST /feedback/generate — matches the frontend + SessionReviewResult feedback/scores shape.""" + + transcriptScores: TranscriptScores + feedback: QualitativeFeedback + modelAnswer: ModelAnswer diff --git a/backend/services/text_analysis/router.py b/backend/services/text_analysis/router.py index 23f4424..1c75312 100644 --- a/backend/services/text_analysis/router.py +++ b/backend/services/text_analysis/router.py @@ -3,4 +3,6 @@ router = APIRouter() -# TODO: implement endpoints +# Reserved. Transcript-quality scoring (clarity/structure/relevance/conciseness) +# is currently produced by the combined Groq call in services/llm/router.py +# (POST /feedback/generate), so no endpoints are defined here yet. diff --git a/frontend/app/InterviewClient.tsx b/frontend/app/InterviewClient.tsx index 8db9e66..02e0d44 100644 --- a/frontend/app/InterviewClient.tsx +++ b/frontend/app/InterviewClient.tsx @@ -7,6 +7,9 @@ import { type InterviewQuestion, } from "@/lib/prompts/questions"; import { INTERVIEWERS, DEFAULT_INTERVIEWER, type Interviewer } from "@/lib/prompts/interviewers"; +import { ScorecardPanel } from "@/app/scorecard/ScorecardPanel"; +import { buildReviewContext } from "@/lib/interview-coach/sessionAdapter"; +import type { ReviewContextPayload } from "@/lib/interview-coach/types"; import styles from "./InterviewClient.module.css"; const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; @@ -62,6 +65,7 @@ export default function InterviewClient() { const [showQuestion, setShowQuestion] = useState(false); const [showDonePrompt, setShowDonePrompt] = useState(false); const [segments, setSegments] = useState([]); + const [reviewContext, setReviewContext] = useState(null); const [statusText, setStatusText] = useState( "Select your interviewer and press Start Interview." ); @@ -198,6 +202,7 @@ export default function InterviewClient() { setQuestion(firstQuestion); setQuestionNumber(1); setSegments([]); + setReviewContext(null); setShowQuestion(false); setStage("playing"); setStatusText(`${interviewer.name} is introducing themselves…`); @@ -223,6 +228,7 @@ export default function InterviewClient() { setQuestion(next); setQuestionNumber((n) => n + 1); setSegments([]); + setReviewContext(null); setShowQuestion(false); setShowDonePrompt(false); setStage("playing"); @@ -240,6 +246,11 @@ export default function InterviewClient() { startRecording(); }, [interviewer, newAbort, stopRecording, startRecording]); + const hearFeedback = useCallback(() => { + if (!question || segments.length === 0) return; + setReviewContext(buildReviewContext(question, segments)); + }, [question, segments]); + const dotClass = [ styles.dot, stage === "playing" ? styles.playing : "", @@ -337,7 +348,11 @@ export default function InterviewClient() { - @@ -364,6 +379,13 @@ export default function InterviewClient() { )} + + {reviewContext && ( + <> +
+ + + )}
); diff --git a/frontend/app/dev/flow/FlowDevClient.tsx b/frontend/app/dev/flow/FlowDevClient.tsx index a8ed1b8..09af620 100644 --- a/frontend/app/dev/flow/FlowDevClient.tsx +++ b/frontend/app/dev/flow/FlowDevClient.tsx @@ -6,7 +6,6 @@ import { ScorecardPanel } from "@/app/scorecard/ScorecardPanel"; import { FlowStageCard } from "@/app/dev/flow/components/FlowStageCard/FlowStageCard"; import { aggregateReviewPayload } from "@/lib/interview-coach/aggregateReviewPayload"; import { - mockDeliveryScores, mockModelAnswer, mockQualitativeFeedback, mockQuestion, @@ -58,38 +57,36 @@ export function FlowDevClient() { stage.output = mockTranscript; break; case "audioScores": - stage.input = { audio: MOCK_RECORDING }; - stage.output = mockDeliveryScores; - break; - case "transcriptScores": stage.input = { transcript: mockTranscript }; - stage.output = mockTranscriptScores; + stage.output = mockTranscript.segments.map((s) => ({ + start: s.start, + end: s.end, + arousal: s.arousal, + dominance: s.dominance, + valence: s.valence, + })); break; case "aggregate": { const payload = aggregateReviewPayload({ question: mockQuestion, transcript: mockTranscript, - deliveryScores: mockDeliveryScores, - transcriptScores: mockTranscriptScores, }); - stage.input = { - question: mockQuestion, - transcript: mockTranscript, - deliveryScores: mockDeliveryScores, - transcriptScores: mockTranscriptScores, - }; + stage.input = { question: mockQuestion, transcript: mockTranscript }; stage.output = payload; break; } + case "transcriptScores": + stage.input = { context: "ReviewContextPayload" }; + stage.output = mockTranscriptScores; + break; case "llmFeedback": { const payload = aggregateReviewPayload({ question: mockQuestion, transcript: mockTranscript, - deliveryScores: mockDeliveryScores, - transcriptScores: mockTranscriptScores, }); stage.input = payload; stage.output = { + transcriptScores: mockTranscriptScores, feedback: mockQualitativeFeedback, modelAnswer: mockModelAnswer, }; @@ -99,11 +96,10 @@ export function FlowDevClient() { const context = aggregateReviewPayload({ question: mockQuestion, transcript: mockTranscript, - deliveryScores: mockDeliveryScores, - transcriptScores: mockTranscriptScores, }); const result: SessionReviewResult = { context, + transcriptScores: mockTranscriptScores, feedback: mockQualitativeFeedback, modelAnswer: mockModelAnswer, }; @@ -138,7 +134,6 @@ export function FlowDevClient() { const loadingFlags = useMemo(() => { const done = (id: PipelineStage["id"]) => stages.find((s) => s.id === id)?.status === "done"; return { - loadingDelivery: !done("audioScores"), loadingTranscriptScores: !done("transcriptScores"), loadingFeedback: !done("llmFeedback"), }; @@ -171,9 +166,9 @@ export function FlowDevClient() { = 0} loadingTranscriptScores={loadingFlags.loadingTranscriptScores && stepIndex >= 0} loadingFeedback={loadingFlags.loadingFeedback && stepIndex >= 0} + showShellBadge /> diff --git a/frontend/app/scorecard/ScorecardPanel.tsx b/frontend/app/scorecard/ScorecardPanel.tsx index 25ad4bf..60a29eb 100644 --- a/frontend/app/scorecard/ScorecardPanel.tsx +++ b/frontend/app/scorecard/ScorecardPanel.tsx @@ -1,38 +1,112 @@ "use client"; +import { useEffect, useState } from "react"; + import { Scorecard } from "@/app/scorecard/components/Scorecard/Scorecard"; -import type { SessionReviewResult } from "@/lib/interview-coach/types"; +import type { + ReviewContextPayload, + SessionFeedbackResponse, + SessionReviewResult, +} from "@/lib/interview-coach/types"; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; export type ScorecardPanelProps = { + /** + * Pre-resolved result (e.g. static mock or the /dev/flow simulator). + * When provided, the panel renders it directly and does not fetch. + */ result?: SessionReviewResult | null; - loadingDelivery?: boolean; + /** + * Recorded session context. When provided (and `result` is not), the panel + * fetches POST /feedback/generate itself and owns the resulting state. + */ + sessionInput?: ReviewContextPayload | null; loadingTranscriptScores?: boolean; loadingFeedback?: boolean; + showShellBadge?: boolean; }; +async function fetchFeedback( + context: ReviewContextPayload, + signal: AbortSignal +): Promise { + const res = await fetch(`${API_BASE}/feedback/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(context), + signal, + }); + if (!res.ok) throw new Error(`Feedback request failed (${res.status})`); + return (await res.json()) as SessionFeedbackResponse; +} + /** - * Container shell: will own fetch + session state later. - * For now passes through partial or full SessionReviewResult. + * Scorecard container. Two modes: + * - `result`: render a pre-resolved SessionReviewResult (mocks, dev flow). + * - `sessionInput`: fetch POST /feedback/generate and own loading/error state. */ export function ScorecardPanel({ result = null, - loadingDelivery = false, + sessionInput = null, loadingTranscriptScores = false, loadingFeedback = false, + showShellBadge = false, }: ScorecardPanelProps) { - const context = result?.context; + const [fetched, setFetched] = useState(null); + const [fetching, setFetching] = useState(false); + const [error, setError] = useState(null); + + const shouldFetch = !result && !!sessionInput; + + useEffect(() => { + if (!shouldFetch || !sessionInput) return; + const controller = new AbortController(); + + const run = async () => { + setFetching(true); + setError(null); + setFetched(null); + try { + const data = await fetchFeedback(sessionInput, controller.signal); + if (!controller.signal.aborted) setFetched(data); + } catch (err) { + if (controller.signal.aborted) return; + setError(err instanceof Error ? err.message : "Could not generate feedback."); + } finally { + if (!controller.signal.aborted) setFetching(false); + } + }; + + void run(); + return () => controller.abort(); + }, [shouldFetch, sessionInput]); + + // Resolve what to render: explicit result, or the fetched session. + const context = result?.context ?? sessionInput ?? null; + const transcriptScores = result?.transcriptScores ?? fetched?.transcriptScores ?? null; + const feedback = result?.feedback ?? fetched?.feedback ?? null; + const modelAnswer = result?.modelAnswer ?? fetched?.modelAnswer ?? null; + + const isLoadingFeedback = loadingFeedback || (shouldFetch && fetching); return ( - + <> + {error ? ( +

+ {error} +

+ ) : null} + + ); } diff --git a/frontend/app/scorecard/components/Scorecard/Scorecard.stories.tsx b/frontend/app/scorecard/components/Scorecard/Scorecard.stories.tsx index 14ee2a6..b039d1a 100644 --- a/frontend/app/scorecard/components/Scorecard/Scorecard.stories.tsx +++ b/frontend/app/scorecard/components/Scorecard/Scorecard.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { - mockDeliveryScores, mockModelAnswer, mockQualitativeFeedback, mockQuestion, @@ -28,7 +27,6 @@ export const PartialScoresOnly: Story = { args: { question: mockQuestion, transcript: mockTranscript, - deliveryScores: mockDeliveryScores, transcriptScores: mockTranscriptScores, }, }; @@ -37,7 +35,6 @@ export const Full: Story = { args: { question: mockQuestion, transcript: mockTranscript, - deliveryScores: mockDeliveryScores, transcriptScores: mockTranscriptScores, feedback: mockQualitativeFeedback, modelAnswer: mockModelAnswer, @@ -48,7 +45,6 @@ export const LoadingFeedback: Story = { args: { question: mockQuestion, transcript: mockTranscript, - deliveryScores: mockDeliveryScores, transcriptScores: mockTranscriptScores, loadingFeedback: true, }, diff --git a/frontend/app/scorecard/components/Scorecard/Scorecard.tsx b/frontend/app/scorecard/components/Scorecard/Scorecard.tsx index 5c447fc..1f9eba8 100644 --- a/frontend/app/scorecard/components/Scorecard/Scorecard.tsx +++ b/frontend/app/scorecard/components/Scorecard/Scorecard.tsx @@ -1,9 +1,7 @@ -import { DeliveryScores } from "@/app/scorecard/components/DeliveryScores/DeliveryScores"; import { ModelAnswer } from "@/app/scorecard/components/ModelAnswer/ModelAnswer"; import { QualitativeFeedback } from "@/app/scorecard/components/QualitativeFeedback/QualitativeFeedback"; import { TranscriptFeedbackScores } from "@/app/scorecard/components/TranscriptFeedbackScores/TranscriptFeedbackScores"; import type { - DeliveryScores as DeliveryScoresType, InterviewQuestion, ModelAnswer as ModelAnswerType, QualitativeFeedback as QualitativeFeedbackType, @@ -16,11 +14,9 @@ import styles from "./Scorecard.module.css"; export type ScorecardProps = { question?: InterviewQuestion | null; transcript?: Transcript | null; - deliveryScores?: DeliveryScoresType | null; transcriptScores?: TranscriptFeedbackScoresType | null; feedback?: QualitativeFeedbackType | null; modelAnswer?: ModelAnswerType | null; - loadingDelivery?: boolean; loadingTranscriptScores?: boolean; loadingFeedback?: boolean; showShellBadge?: boolean; @@ -29,11 +25,9 @@ export type ScorecardProps = { export function Scorecard({ question = null, transcript = null, - deliveryScores = null, transcriptScores = null, feedback = null, modelAnswer = null, - loadingDelivery = false, loadingTranscriptScores = false, loadingFeedback = false, showShellBadge = true, @@ -43,11 +37,10 @@ export function Scorecard({ {showShellBadge ? UI shell : null}

Question

-

{question?.prompt ?? "No question selected"}

+

{question?.text ?? "No question selected"}

{transcript ?

{transcript.text}

: null}
-
diff --git a/frontend/app/scorecard/page.tsx b/frontend/app/scorecard/page.tsx index 4f93427..31c121f 100644 --- a/frontend/app/scorecard/page.tsx +++ b/frontend/app/scorecard/page.tsx @@ -19,7 +19,7 @@ export default function ScorecardPage() { to step through the pipeline and watch payloads update.

- +
); } diff --git a/frontend/lib/interview-coach/aggregateReviewPayload.ts b/frontend/lib/interview-coach/aggregateReviewPayload.ts index 262001e..00bd775 100644 --- a/frontend/lib/interview-coach/aggregateReviewPayload.ts +++ b/frontend/lib/interview-coach/aggregateReviewPayload.ts @@ -1,26 +1,21 @@ import type { - DeliveryScores, InterviewQuestion, ReviewContextPayload, Transcript, - TranscriptFeedbackScores, } from "@/lib/interview-coach/types"; /** - * Merges audio delivery scores and transcript classifier scores into one - * payload for POST /feedback/generate. Pure function — safe to call from - * client containers or server route handlers. + * Assembles the question and timestamped (A/D/V-scored) transcript into the + * payload for POST /feedback/generate. Per-segment delivery scores live on + * transcript.segments. Pure function — safe to call from client containers or + * server route handlers. */ export function aggregateReviewPayload(input: { question: InterviewQuestion; transcript: Transcript; - deliveryScores: DeliveryScores; - transcriptScores: TranscriptFeedbackScores; }): ReviewContextPayload { return { question: input.question, transcript: input.transcript, - deliveryScores: input.deliveryScores, - transcriptScores: input.transcriptScores, }; } diff --git a/frontend/lib/interview-coach/mocks.ts b/frontend/lib/interview-coach/mocks.ts index d61fedb..2c7d6d9 100644 --- a/frontend/lib/interview-coach/mocks.ts +++ b/frontend/lib/interview-coach/mocks.ts @@ -11,7 +11,7 @@ import type { export const mockQuestion: InterviewQuestion = { id: "q-behavioral-01", - prompt: "Tell me about a time you had to influence a team without direct authority.", + text: "Tell me about a time you had to influence a team without direct authority.", }; export const mockTranscript: Transcript = { @@ -21,16 +21,25 @@ export const mockTranscript: Transcript = { start: 0, end: 4.2, text: "Last year our design and engineering leads disagreed on scope for a launch.", + arousal: 0.58, + dominance: 0.51, + valence: 0.29, }, { start: 4.2, end: 9.8, text: "I set up a short working session, mapped tradeoffs on a whiteboard,", + arousal: 0.63, + dominance: 0.57, + valence: 0.34, }, { start: 9.8, end: 15.1, text: "and proposed a phased rollout. We shipped on time and both teams felt heard.", + arousal: 0.62, + dominance: 0.57, + valence: 0.33, }, ], }; @@ -67,12 +76,11 @@ export const mockModelAnswer: ModelAnswer = { export const mockReviewContext: ReviewContextPayload = { question: mockQuestion, transcript: mockTranscript, - deliveryScores: mockDeliveryScores, - transcriptScores: mockTranscriptScores, }; export const mockSessionReview: SessionReviewResult = { context: mockReviewContext, + transcriptScores: mockTranscriptScores, feedback: mockQualitativeFeedback, modelAnswer: mockModelAnswer, }; diff --git a/frontend/lib/interview-coach/pipelineStages.ts b/frontend/lib/interview-coach/pipelineStages.ts index 71bd244..2a9dd84 100644 --- a/frontend/lib/interview-coach/pipelineStages.ts +++ b/frontend/lib/interview-coach/pipelineStages.ts @@ -9,38 +9,41 @@ export const INITIAL_PIPELINE_STAGES: PipelineStage[] = [ }, { id: "transcribe", - label: "2. Transcribe", - description: "Audio blob → Whisper API → timestamped transcript.", + label: "2. Transcribe + delivery", + description: + "Audio blob → POST /speech/transcribe → timestamped segments with per-segment arousal/dominance/valence.", status: "idle", }, { id: "audioScores", - label: "3. Audio delivery scores", - description: "Audio → POST /emotion/analyze → arousal, dominance, valence.", + label: "3. Per-segment delivery", + description: "Each segment carries its own arousal/dominance/valence from the emotion model.", status: "idle", }, { - id: "transcriptScores", - label: "4. Transcript classifier", - description: "Transcript → classifier → content/delivery scores (shell schema).", + id: "aggregate", + label: "4. Aggregate for LLM", + description: "Merge question + scored transcript → ReviewContextPayload.", status: "idle", }, { - id: "aggregate", - label: "5. Aggregate for LLM", - description: "Merge question, transcript, and both score sets → ReviewContextPayload.", + id: "transcriptScores", + label: "5. Answer-quality scores", + description: + "Produced by the combined feedback call (clarity/structure/relevance/conciseness).", status: "idle", }, { id: "llmFeedback", label: "6. LLM feedback", - description: "ReviewContextPayload → POST /feedback/generate → feedback + model answer.", + description: + "ReviewContextPayload → POST /feedback/generate → scores + feedback + model answer.", status: "idle", }, { id: "scorecard", label: "7. Scorecard UI", - description: "Frontend renders delivery metrics, qualitative feedback, model answer.", + description: "Frontend renders answer-quality scores, qualitative feedback, model answer.", status: "idle", }, ]; diff --git a/frontend/lib/interview-coach/sessionAdapter.ts b/frontend/lib/interview-coach/sessionAdapter.ts new file mode 100644 index 0000000..521b27c --- /dev/null +++ b/frontend/lib/interview-coach/sessionAdapter.ts @@ -0,0 +1,54 @@ +import type { InterviewQuestion as ScorecardQuestion } from "@/lib/prompts/questions"; +import type { ReviewContextPayload, TranscriptSegment } from "@/lib/interview-coach/types"; + +/** + * One transcribed+scored segment as returned by POST /speech/transcribe. + * Mirrors the raw shape the interview client receives. + */ +export type ScoredSegment = { + start: number; + end: number; + text: string; + arousal: number; + dominance: number; + valence: number; +}; + +/** + * Converts a live interview session (question + the raw scored segments from + * /speech/transcribe) into the ReviewContextPayload expected by + * /feedback/generate and the scorecard. + * + * Isolates the shape mismatch between the question bank + * ({ id: number, text }) and the scorecard contract ({ id: string, text }), + * and assembles the full transcript text from the segments. + */ +export function buildReviewContext( + question: ScorecardQuestion, + segments: ScoredSegment[] +): ReviewContextPayload { + const transcriptSegments: TranscriptSegment[] = segments.map((seg) => ({ + start: seg.start, + end: seg.end, + text: seg.text, + arousal: seg.arousal, + dominance: seg.dominance, + valence: seg.valence, + })); + + const fullText = segments + .map((seg) => seg.text.trim()) + .filter(Boolean) + .join(" "); + + return { + question: { + id: String(question.id), + text: question.text, + }, + transcript: { + text: fullText, + segments: transcriptSegments, + }, + }; +} diff --git a/frontend/lib/interview-coach/types.ts b/frontend/lib/interview-coach/types.ts index 0cacf48..202a3b5 100644 --- a/frontend/lib/interview-coach/types.ts +++ b/frontend/lib/interview-coach/types.ts @@ -5,21 +5,28 @@ export type DeliveryScores = { valence: number; }; +/** + * One timestamped transcript segment with its per-segment delivery (A/D/V) + * scores, as returned by POST /speech/transcribe. + */ export type TranscriptSegment = { start: number; end: number; text: string; + arousal: number; + dominance: number; + valence: number; }; -/** Timestamped transcript from POST /speech/transcribe (contract TBD) */ +/** Timestamped transcript from POST /speech/transcribe. */ export type Transcript = { text: string; segments: TranscriptSegment[]; }; /** - * Delivery / content signals from transcript classifier (contract TBD). - * Shell dimensions until backend defines the schema. + * Answer-quality scores produced by POST /feedback/generate (same Groq call + * as the qualitative feedback). All values are 0..1. */ export type TranscriptFeedbackScores = { clarity: number; @@ -30,15 +37,17 @@ export type TranscriptFeedbackScores = { export type InterviewQuestion = { id: string; - prompt: string; + text: string; }; -/** Combined context sent to POST /feedback/generate */ +/** + * Combined context sent to POST /feedback/generate. The per-segment delivery + * (A/D/V) scores live on transcript.segments, giving the LLM moment-by-moment + * context. transcriptScores are produced by the same call, not sent in. + */ export type ReviewContextPayload = { question: InterviewQuestion; transcript: Transcript; - deliveryScores: DeliveryScores; - transcriptScores: TranscriptFeedbackScores; }; /** Structured LLM feedback (contract TBD) */ @@ -56,6 +65,14 @@ export type ModelAnswer = { /** Full result rendered on the scorecard */ export type SessionReviewResult = { context: ReviewContextPayload; + transcriptScores: TranscriptFeedbackScores; + feedback: QualitativeFeedback; + modelAnswer: ModelAnswer; +}; + +/** Response body from POST /feedback/generate. */ +export type SessionFeedbackResponse = { + transcriptScores: TranscriptFeedbackScores; feedback: QualitativeFeedback; modelAnswer: ModelAnswer; }; diff --git a/lefthook.yml b/lefthook.yml index 0cfcc26..ec8abe2 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -11,7 +11,7 @@ pre-commit: backend-format: root: backend/ glob: "**/*.py" - run: ruff format {staged_files} + run: bash -c 'if [ -x "../venv/bin/python" ]; then PY="../venv/bin/python"; else PY="../venv/Scripts/python"; fi; "$PY" -m ruff format {staged_files}' stage_fixed: true pre-push: @@ -27,7 +27,7 @@ pre-push: run: pnpm typecheck backend-lint: root: backend/ - run: ruff check . + run: bash -c 'if [ -x "../venv/bin/python" ]; then PY="../venv/bin/python"; else PY="../venv/Scripts/python"; fi; "$PY" -m ruff check .' backend-format: root: backend/ - run: ruff format --check . + run: bash -c 'if [ -x "../venv/bin/python" ]; then PY="../venv/bin/python"; else PY="../venv/Scripts/python"; fi; "$PY" -m ruff format --check .'