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
156 changes: 138 additions & 18 deletions backend/services/llm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
from pydantic import ValidationError

from services.llm.schemas import (
FullSessionFeedbackResponse,
ReviewContextPayload,
SessionFeedbackResponse,
SessionReviewPayload,
)

FEEDBACK_MODEL = "openai/gpt-oss-20b"
Expand Down Expand Up @@ -98,6 +100,68 @@
Be specific and grounded in the provided transcript and segments. Do not invent \
facts the candidate did not say. Output only the JSON object."""

_SESSION_RESPONSE_SCHEMA = {
"type": "object",
"properties": {
"overallSummary": {"type": "string"},
"overallStrengths": {"type": "array", "items": {"type": "string"}},
"overallImprovements": {"type": "array", "items": {"type": "string"}},
"overallDeliveryNotes": {"type": "string"},
"questionReviews": {
"type": "array",
"items": {
"type": "object",
"properties": {
"question": {
"type": "object",
"properties": {
"id": {"type": "string"},
"text": {"type": "string"},
},
"required": ["id", "text"],
},
"transcriptScores": _RESPONSE_SCHEMA["properties"][
"transcriptScores"
],
"feedback": _RESPONSE_SCHEMA["properties"]["feedback"],
"modelAnswer": _RESPONSE_SCHEMA["properties"]["modelAnswer"],
},
"required": ["question", "transcriptScores", "feedback", "modelAnswer"],
},
},
},
"required": [
"overallSummary",
"overallStrengths",
"overallImprovements",
"overallDeliveryNotes",
"questionReviews",
],
}

_SESSION_SYSTEM_PROMPT = """You are an expert interview coach reviewing a \
candidate's full mock interview session.

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).
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.
5. questionReviews: one entry per question answered, in the same order provided. \
Each entry must include:
- 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

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


def _require_groq() -> None:
if not os.environ.get("GROQ_API_KEY"):
Expand All @@ -121,53 +185,109 @@ def _build_user_prompt(payload: ReviewContextPayload) -> str:
)


def _call_groq(user_prompt: str) -> str:
def _call_groq(user_prompt: str, *, session: bool = False) -> str:
completion = _groq.chat.completions.create(
model=FEEDBACK_MODEL,
messages=[
{"role": "system", "content": _SYSTEM_PROMPT},
{
"role": "system",
"content": _SESSION_SYSTEM_PROMPT if session else _SYSTEM_PROMPT,
},
{"role": "user", "content": user_prompt},
],
temperature=0.4,
response_format={
"type": "json_schema",
"json_schema": {
"name": "session_feedback",
"name": "session_feedback" if session else "answer_feedback",
"strict": False,
"schema": _RESPONSE_SCHEMA,
"schema": _SESSION_RESPONSE_SCHEMA if session else _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.")
def _build_session_user_prompt(payload: SessionReviewPayload) -> str:
blocks = []
for n, answer in enumerate(payload.answers, start=1):
blocks.append(f"=== ANSWER {n} ===\n{_build_user_prompt(answer)}")
return "\n\n".join(blocks)

user_prompt = _build_user_prompt(payload)

def _generate_with_retry(
user_prompt: str,
*,
session: bool = False,
) -> dict:
last_error: Exception | None = None
for _ in range(2): # one retry on transport or validation failure
for _ in range(2):
try:
raw = _call_groq(user_prompt)
raw = _call_groq(user_prompt, session=session)
except Exception as exc:
last_error = exc
continue

try:
data = json.loads(raw)
return SessionFeedbackResponse.model_validate(data)
except (json.JSONDecodeError, ValidationError) as exc:
return json.loads(raw)
except json.JSONDecodeError as exc:
last_error = exc
continue

raise HTTPException(
status_code=502,
detail=f"Feedback generation failed: {last_error}",
)


@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)

try:
data = _generate_with_retry(user_prompt)
return SessionFeedbackResponse.model_validate(data)
except HTTPException:
raise
except ValidationError as exc:
raise HTTPException(
status_code=502,
detail=f"Feedback generation failed: {exc}",
) from exc


@router.post("/generate-session", response_model=FullSessionFeedbackResponse)
async def generate_session(
payload: SessionReviewPayload,
) -> FullSessionFeedbackResponse:
"""Generate holistic session feedback plus per-question reviews in one Groq call."""
_require_groq()

if not payload.answers:
raise HTTPException(status_code=422, detail="No answers provided.")

for answer in payload.answers:
if not answer.transcript.text.strip():
raise HTTPException(
status_code=422, detail="One or more transcripts are empty."
)

user_prompt = _build_session_user_prompt(payload)

try:
data = _generate_with_retry(user_prompt, session=True)
return FullSessionFeedbackResponse.model_validate(data)
except HTTPException:
raise
except ValidationError as exc:
raise HTTPException(
status_code=502,
detail=f"Session feedback generation failed: {exc}",
) from exc
23 changes: 23 additions & 0 deletions backend/services/llm/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,26 @@ class SessionFeedbackResponse(BaseModel):
transcriptScores: TranscriptScores
feedback: QualitativeFeedback
modelAnswer: ModelAnswer


class SessionReviewPayload(BaseModel):
"""Request body for POST /feedback/generate-session — multiple Q&A pairs."""

answers: list[ReviewContextPayload]


class QuestionReview(BaseModel):
question: Question
transcriptScores: TranscriptScores
feedback: QualitativeFeedback
modelAnswer: ModelAnswer


class FullSessionFeedbackResponse(BaseModel):
"""Response body for POST /feedback/generate-session."""

overallSummary: str
overallStrengths: list[str]
overallImprovements: list[str]
overallDeliveryNotes: str
questionReviews: list[QuestionReview]
33 changes: 33 additions & 0 deletions frontend/app/InterviewClient.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,39 @@
cursor: not-allowed;
}

.feedbackModeFieldset {
border: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.55rem;
}

.feedbackModeFieldset:disabled {
opacity: 0.45;
}

.feedbackModeLegend {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.07em;
opacity: 0.5;
margin-bottom: 0.15rem;
}

.feedbackModeOption {
display: flex;
align-items: center;
gap: 0.55rem;
font-size: 0.88rem;
cursor: pointer;
}

.feedbackModeOption input {
accent-color: #fff;
}

.questionPlaceholder {
font-size: 1rem;
opacity: 0.35;
Expand Down
Loading
Loading