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
5 changes: 3 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
171 changes: 169 additions & 2 deletions backend/services/llm/router.py
Original file line number Diff line number Diff line change
@@ -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}",
)
63 changes: 63 additions & 0 deletions backend/services/llm/schemas.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion backend/services/text_analysis/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
24 changes: 23 additions & 1 deletion frontend/app/InterviewClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -62,6 +65,7 @@ export default function InterviewClient() {
const [showQuestion, setShowQuestion] = useState(false);
const [showDonePrompt, setShowDonePrompt] = useState(false);
const [segments, setSegments] = useState<Segment[]>([]);
const [reviewContext, setReviewContext] = useState<ReviewContextPayload | null>(null);
const [statusText, setStatusText] = useState(
"Select your interviewer and press Start Interview."
);
Expand Down Expand Up @@ -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…`);
Expand All @@ -223,6 +228,7 @@ export default function InterviewClient() {
setQuestion(next);
setQuestionNumber((n) => n + 1);
setSegments([]);
setReviewContext(null);
setShowQuestion(false);
setShowDonePrompt(false);
setStage("playing");
Expand All @@ -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 : "",
Expand Down Expand Up @@ -337,7 +348,11 @@ export default function InterviewClient() {
<button className={styles.btnPrimary} onClick={nextQuestion}>
Next Question
</button>
<button className={styles.btnSecondary} disabled title="Coming soon">
<button
className={styles.btnSecondary}
onClick={hearFeedback}
disabled={segments.length === 0 || reviewContext !== null}
>
Hear Feedback
</button>
</>
Expand All @@ -364,6 +379,13 @@ export default function InterviewClient() {
</div>
</>
)}

{reviewContext && (
<>
<div className={styles.divider} />
<ScorecardPanel sessionInput={reviewContext} />
</>
)}
</div>
</div>
);
Expand Down
Loading
Loading