Spaces:
Sleeping
Sleeping
adeshboudh16 commited on
Commit ·
c2de869
1
Parent(s): 08dfc64
bug fix for dynamic routing logic
Browse files- backend/db/connection.py +3 -2
- backend/db/queries.py +7 -1
- backend/main.py +10 -1
- backend/prompts.py +11 -5
- backend/routers/interview.py +67 -0
- backend/routers/sessions.py +1 -0
- docs/question_bank/linear_regression.csv +15 -98
- frontend/src/api/interview.ts +11 -0
- frontend/src/components/student/AttemptRow.tsx +26 -4
- frontend/src/pages/Report.tsx +104 -49
- frontend/src/pages/StudentDashboard.tsx +6 -3
- frontend/src/types/index.ts +4 -1
backend/db/connection.py
CHANGED
|
@@ -21,11 +21,12 @@ async def init_db_pool() -> None:
|
|
| 21 |
log.info("Connecting to DB: %s...", db_url[:40])
|
| 22 |
_pool = await asyncpg.create_pool(
|
| 23 |
db_url,
|
| 24 |
-
min_size=
|
| 25 |
max_size=10,
|
|
|
|
| 26 |
init=_init_connection,
|
| 27 |
)
|
| 28 |
-
log.info("asyncpg pool created (min=
|
| 29 |
|
| 30 |
|
| 31 |
def get_pool() -> asyncpg.Pool:
|
|
|
|
| 21 |
log.info("Connecting to DB: %s...", db_url[:40])
|
| 22 |
_pool = await asyncpg.create_pool(
|
| 23 |
db_url,
|
| 24 |
+
min_size=0,
|
| 25 |
max_size=10,
|
| 26 |
+
max_inactive_connection_lifetime=60, # discard idle connections after 60s (NeonDB suspends at ~300s)
|
| 27 |
init=_init_connection,
|
| 28 |
)
|
| 29 |
+
log.info("asyncpg pool created (min=0, max=10, idle_lifetime=60s)")
|
| 30 |
|
| 31 |
|
| 32 |
def get_pool() -> asyncpg.Pool:
|
backend/db/queries.py
CHANGED
|
@@ -251,7 +251,13 @@ async def get_sessions_by_student_id(student_id: str) -> list[asyncpg.Record]:
|
|
| 251 |
async def get_session_by_id(session_id: str) -> Optional[asyncpg.Record]:
|
| 252 |
async with get_pool().acquire() as conn:
|
| 253 |
return await conn.fetchrow(
|
| 254 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
)
|
| 256 |
|
| 257 |
|
|
|
|
| 251 |
async def get_session_by_id(session_id: str) -> Optional[asyncpg.Record]:
|
| 252 |
async with get_pool().acquire() as conn:
|
| 253 |
return await conn.fetchrow(
|
| 254 |
+
"""
|
| 255 |
+
SELECT s.*, t.name AS topic_name
|
| 256 |
+
FROM interview_sessions s
|
| 257 |
+
JOIN topics t ON t.id = s.topic_id
|
| 258 |
+
WHERE s.id = $1
|
| 259 |
+
""",
|
| 260 |
+
session_id,
|
| 261 |
)
|
| 262 |
|
| 263 |
|
backend/main.py
CHANGED
|
@@ -49,10 +49,19 @@ app.include_router(interview.router, prefix="/interview")
|
|
| 49 |
# React static build — MUST be last (serves the SPA for all non-API routes)
|
| 50 |
from pathlib import Path
|
| 51 |
from fastapi.staticfiles import StaticFiles
|
|
|
|
| 52 |
|
| 53 |
_dist = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
| 54 |
if _dist.is_dir():
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
log.info("Static files mounted from %s", _dist)
|
| 57 |
else:
|
| 58 |
log.warning("frontend/dist not found at %s — SPA will not be served", _dist)
|
|
|
|
| 49 |
# React static build — MUST be last (serves the SPA for all non-API routes)
|
| 50 |
from pathlib import Path
|
| 51 |
from fastapi.staticfiles import StaticFiles
|
| 52 |
+
from fastapi.responses import FileResponse
|
| 53 |
|
| 54 |
_dist = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
| 55 |
if _dist.is_dir():
|
| 56 |
+
# Catch-all for React Router — serve index.html for any path that isn't a real file
|
| 57 |
+
@app.get("/{full_path:path}", include_in_schema=False)
|
| 58 |
+
async def spa_fallback(full_path: str):
|
| 59 |
+
file = _dist / full_path
|
| 60 |
+
if file.is_file():
|
| 61 |
+
return FileResponse(str(file))
|
| 62 |
+
return FileResponse(str(_dist / "index.html"))
|
| 63 |
+
|
| 64 |
+
app.mount("/", StaticFiles(directory=str(_dist)), name="static")
|
| 65 |
log.info("Static files mounted from %s", _dist)
|
| 66 |
else:
|
| 67 |
log.warning("frontend/dist not found at %s — SPA will not be served", _dist)
|
backend/prompts.py
CHANGED
|
@@ -100,18 +100,24 @@ def build_report_prompt(
|
|
| 100 |
|
| 101 |
system = (
|
| 102 |
"You are an expert technical interview judge. "
|
| 103 |
-
"Review the full interview transcript and generate a detailed performance report.
|
| 104 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
'{"score": 0-100, "summary": "2-3 sentence overall assessment", '
|
| 106 |
'"concept_score": 0-100, "depth_score": 0-100, '
|
| 107 |
-
'"mistakes": ["specific mistake 1", ...], "tips": ["actionable
|
| 108 |
)
|
| 109 |
user = (
|
| 110 |
f"Topic: {topic}\n"
|
| 111 |
f"{past_ctx}\n"
|
| 112 |
f"Questions covered: {', '.join(asked) if asked else 'none'}\n"
|
| 113 |
-
f"Identified weak areas: {', '.join(weak_areas) if weak_areas else 'none'}\n\n"
|
| 114 |
f"Full interview transcript:\n{transcript or summary or 'No transcript available.'}\n\n"
|
| 115 |
-
"Judge the student's performance and generate the report JSON."
|
| 116 |
)
|
| 117 |
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
|
|
|
| 100 |
|
| 101 |
system = (
|
| 102 |
"You are an expert technical interview judge. "
|
| 103 |
+
"Review the full interview transcript and generate a detailed performance report.\n\n"
|
| 104 |
+
"Scoring criteria:\n"
|
| 105 |
+
"- score: overall performance (0-100)\n"
|
| 106 |
+
"- concept_score: did the student correctly understand the underlying concepts and theory? (0-100)\n"
|
| 107 |
+
"- depth_score: did the student give detailed, thorough answers with examples — or just surface-level responses? (0-100)\n"
|
| 108 |
+
"- mistakes: specific factual errors or wrong statements made (list, be concrete)\n"
|
| 109 |
+
"- tips: actionable things the student should study or practise to improve (list)\n\n"
|
| 110 |
+
"Respond with ONLY valid JSON, no markdown:\n"
|
| 111 |
'{"score": 0-100, "summary": "2-3 sentence overall assessment", '
|
| 112 |
'"concept_score": 0-100, "depth_score": 0-100, '
|
| 113 |
+
'"mistakes": ["specific mistake 1", ...], "tips": ["actionable tip 1", ...]}'
|
| 114 |
)
|
| 115 |
user = (
|
| 116 |
f"Topic: {topic}\n"
|
| 117 |
f"{past_ctx}\n"
|
| 118 |
f"Questions covered: {', '.join(asked) if asked else 'none'}\n"
|
| 119 |
+
f"Identified weak areas during interview: {', '.join(weak_areas) if weak_areas else 'none'}\n\n"
|
| 120 |
f"Full interview transcript:\n{transcript or summary or 'No transcript available.'}\n\n"
|
| 121 |
+
"Judge the student's performance strictly and generate the report JSON."
|
| 122 |
)
|
| 123 |
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
backend/routers/interview.py
CHANGED
|
@@ -158,6 +158,73 @@ async def interview_turn(
|
|
| 158 |
return response
|
| 159 |
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
@router.get("/state/{session_id}")
|
| 162 |
async def get_interview_state(
|
| 163 |
session_id: str,
|
|
|
|
| 158 |
return response
|
| 159 |
|
| 160 |
|
| 161 |
+
@router.post("/finish/{session_id}")
|
| 162 |
+
async def finish_session(
|
| 163 |
+
session_id: str,
|
| 164 |
+
request: Request,
|
| 165 |
+
user: dict = Depends(require_student),
|
| 166 |
+
):
|
| 167 |
+
"""Force-finish an in-progress session and generate a report from whatever answers were given."""
|
| 168 |
+
log.info("POST /finish/%s — user=%s", session_id, user["user_id"])
|
| 169 |
+
session = await queries.get_session_by_id(session_id)
|
| 170 |
+
if not session:
|
| 171 |
+
raise HTTPException(404, "Session not found")
|
| 172 |
+
if str(session["student_id"]) != user["user_id"]:
|
| 173 |
+
raise HTTPException(403, "Access denied")
|
| 174 |
+
if session["status"] == "completed":
|
| 175 |
+
raise HTTPException(400, "Session is already complete")
|
| 176 |
+
|
| 177 |
+
graph = request.app.state.graph
|
| 178 |
+
config = {"configurable": {"thread_id": session_id}}
|
| 179 |
+
checkpoint = await graph.checkpointer.aget(config)
|
| 180 |
+
|
| 181 |
+
if not checkpoint:
|
| 182 |
+
# No checkpoint at all — mark as abandoned with score 0
|
| 183 |
+
abandoned_feedback = {
|
| 184 |
+
"score": 0,
|
| 185 |
+
"summary": "Interview was abandoned before any questions were answered.",
|
| 186 |
+
"concept_score": 0,
|
| 187 |
+
"depth_score": 0,
|
| 188 |
+
"mistakes": [],
|
| 189 |
+
"tips": ["Complete at least one question to receive a score."],
|
| 190 |
+
}
|
| 191 |
+
await queries.update_session_complete(session_id, 0, abandoned_feedback)
|
| 192 |
+
return {"score": 0, "feedback": abandoned_feedback}
|
| 193 |
+
|
| 194 |
+
ch = checkpoint.get("channel_values", {})
|
| 195 |
+
from backend.prompts import build_report_prompt
|
| 196 |
+
from backend.llm import call_llm
|
| 197 |
+
import json as _json
|
| 198 |
+
|
| 199 |
+
prompt = build_report_prompt(
|
| 200 |
+
ch.get("topic_name", ""),
|
| 201 |
+
ch.get("questions_asked", []),
|
| 202 |
+
ch.get("student_weak_areas", []),
|
| 203 |
+
ch.get("conversation_summary", ""),
|
| 204 |
+
ch.get("past_best_score"),
|
| 205 |
+
messages=ch.get("messages", []),
|
| 206 |
+
)
|
| 207 |
+
raw = await call_llm(prompt, max_tokens=400)
|
| 208 |
+
|
| 209 |
+
try:
|
| 210 |
+
feedback = _json.loads(raw)
|
| 211 |
+
score = int(feedback.get("score", 0))
|
| 212 |
+
except (ValueError, _json.JSONDecodeError):
|
| 213 |
+
feedback = {
|
| 214 |
+
"score": 0,
|
| 215 |
+
"summary": raw,
|
| 216 |
+
"concept_score": 0,
|
| 217 |
+
"depth_score": 0,
|
| 218 |
+
"mistakes": [],
|
| 219 |
+
"tips": [],
|
| 220 |
+
}
|
| 221 |
+
score = 0
|
| 222 |
+
|
| 223 |
+
await queries.update_session_complete(session_id, score, feedback)
|
| 224 |
+
log.info("Force-finished session=%s, score=%d", session_id, score)
|
| 225 |
+
return {"score": score, "feedback": feedback}
|
| 226 |
+
|
| 227 |
+
|
| 228 |
@router.get("/state/{session_id}")
|
| 229 |
async def get_interview_state(
|
| 230 |
session_id: str,
|
backend/routers/sessions.py
CHANGED
|
@@ -17,6 +17,7 @@ async def get_session(session_id: str, user: dict = Depends(get_current_user)):
|
|
| 17 |
return {
|
| 18 |
"id": str(session["id"]),
|
| 19 |
"topic_id": str(session["topic_id"]),
|
|
|
|
| 20 |
"student_id": str(session["student_id"]),
|
| 21 |
"status": session["status"],
|
| 22 |
"score": session["score"],
|
|
|
|
| 17 |
return {
|
| 18 |
"id": str(session["id"]),
|
| 19 |
"topic_id": str(session["topic_id"]),
|
| 20 |
+
"topic_name": session["topic_name"],
|
| 21 |
"student_id": str(session["student_id"]),
|
| 22 |
"status": session["status"],
|
| 23 |
"score": session["score"],
|
docs/question_bank/linear_regression.csv
CHANGED
|
@@ -1,101 +1,18 @@
|
|
| 1 |
question_text,difficulty
|
| 2 |
What is linear regression?,easy
|
| 3 |
-
What is the primary goal of a linear regression model?,easy
|
| 4 |
What is the difference between simple and multiple linear regression?,easy
|
| 5 |
-
|
| 6 |
-
What
|
| 7 |
-
What
|
| 8 |
-
What
|
| 9 |
-
|
| 10 |
-
What is
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
What
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
What does an R-squared of 1.0 indicate?,easy
|
| 21 |
-
What does an R-squared of 0.0 indicate?,easy
|
| 22 |
-
What are the four primary assumptions of linear regression?,medium
|
| 23 |
-
What does the assumption of linearity mean?,medium
|
| 24 |
-
How can you check if the linearity assumption holds true?,medium
|
| 25 |
-
What is homoscedasticity?,medium
|
| 26 |
-
What is heteroscedasticity and why is it a problem?,medium
|
| 27 |
-
How do you test for homoscedasticity?,medium
|
| 28 |
-
What does the assumption of independence of errors mean?,medium
|
| 29 |
-
What is autocorrelation?,medium
|
| 30 |
-
How can you test for autocorrelation in your residuals?,medium
|
| 31 |
-
What does the normality of residuals assumption mean?,medium
|
| 32 |
-
Is it necessary for the independent variables to be normally distributed?,medium
|
| 33 |
-
How can you verify that your residuals are normally distributed?,medium
|
| 34 |
-
What is multicollinearity?,medium
|
| 35 |
-
Why is multicollinearity an issue for linear regression?,medium
|
| 36 |
-
How do you detect multicollinearity?,medium
|
| 37 |
-
What is the Variance Inflation Factor?,medium
|
| 38 |
-
What VIF score indicates severe multicollinearity?,medium
|
| 39 |
-
How do you resolve high multicollinearity in your dataset?,medium
|
| 40 |
-
What is the difference between R-squared and Adjusted R-squared?,medium
|
| 41 |
-
Why is Adjusted R-squared preferred when using multiple features?,medium
|
| 42 |
-
What is Mean Absolute Error?,medium
|
| 43 |
-
What is Mean Squared Error?,medium
|
| 44 |
-
What is Root Mean Squared Error?,medium
|
| 45 |
-
When would you prefer MAE over RMSE?,medium
|
| 46 |
-
When would you prefer RMSE over MAE?,medium
|
| 47 |
-
How do outliers affect a linear regression model?,medium
|
| 48 |
-
How can you identify outliers in your dataset?,medium
|
| 49 |
-
What are leverage points?,medium
|
| 50 |
-
What is Cook's Distance used for?,medium
|
| 51 |
-
How do you handle categorical variables in linear regression?,medium
|
| 52 |
-
What is one-hot encoding?,medium
|
| 53 |
-
What is the dummy variable trap?,medium
|
| 54 |
-
How do you avoid the dummy variable trap?,medium
|
| 55 |
-
Can you use ordinal encoding for linear regression features?,medium
|
| 56 |
-
How does feature scaling affect linear regression?,medium
|
| 57 |
-
Is feature scaling strictly required for Ordinary Least Squares?,medium
|
| 58 |
-
When is feature scaling absolutely necessary for linear regression?,medium
|
| 59 |
-
What is a baseline model in regression?,medium
|
| 60 |
-
How do you interpret a negative coefficient in your model?,medium
|
| 61 |
-
What does a coefficient close to zero imply?,medium
|
| 62 |
-
How do you derive the normal equation?,hard
|
| 63 |
-
What is the mathematical formula for the normal equation?,hard
|
| 64 |
-
What is the computational complexity of the normal equation?,hard
|
| 65 |
-
Why might the normal equation fail?,hard
|
| 66 |
-
What is a singular matrix and how does it relate to OLS?,hard
|
| 67 |
-
How does gradient descent optimize a linear regression model?,hard
|
| 68 |
-
What is the learning rate in gradient descent?,hard
|
| 69 |
-
What happens if your learning rate is too high?,hard
|
| 70 |
-
What happens if your learning rate is too low?,hard
|
| 71 |
-
What is the difference between batch and stochastic gradient descent?,hard
|
| 72 |
-
What is polynomial regression?,hard
|
| 73 |
-
Is polynomial regression a linear or non-linear model?,hard
|
| 74 |
-
How do interaction terms work in a regression model?,hard
|
| 75 |
-
What is the bias-variance tradeoff?,hard
|
| 76 |
-
How does adding more features affect bias and variance?,hard
|
| 77 |
-
What is overfitting in the context of linear regression?,hard
|
| 78 |
-
How do you detect overfitting?,hard
|
| 79 |
-
What is underfitting?,hard
|
| 80 |
-
What is regularization?,hard
|
| 81 |
-
How does Ridge regression differ from standard linear regression?,hard
|
| 82 |
-
What penalty term does Ridge regression use?,hard
|
| 83 |
-
How does Lasso regression differ from Ridge regression?,hard
|
| 84 |
-
What penalty term does Lasso regression use?,hard
|
| 85 |
-
Why does Lasso regression tend to produce sparse models?,hard
|
| 86 |
-
What is Elastic Net regularization?,hard
|
| 87 |
-
When would you choose Elastic Net over Lasso or Ridge?,hard
|
| 88 |
-
How do you tune the regularization hyperparameter?,hard
|
| 89 |
-
What is cross-validation and why is it used?,hard
|
| 90 |
-
What happens to Ridge coefficients as the penalty term approaches infinity?,hard
|
| 91 |
-
What happens to Lasso coefficients as the penalty term approaches infinity?,hard
|
| 92 |
-
Can linear regression handle non-linear relationships without polynomial features?,hard
|
| 93 |
-
How would you implement gradient descent for linear regression from scratch in Python?,hard
|
| 94 |
-
What is the difference between a confidence interval and a prediction interval?,hard
|
| 95 |
-
How do you handle missing data before training a linear model?,hard
|
| 96 |
-
What is the curse of dimensionality?,hard
|
| 97 |
-
How does Principal Component Analysis relate to linear regression?,hard
|
| 98 |
-
What is Principal Component Regression?,hard
|
| 99 |
-
What are generalized linear models?,hard
|
| 100 |
-
How do you interpret the intercept when all features are mean-centered?,hard
|
| 101 |
-
What would you do if you have more features than observations?,hard
|
|
|
|
| 1 |
question_text,difficulty
|
| 2 |
What is linear regression?,easy
|
|
|
|
| 3 |
What is the difference between simple and multiple linear regression?,easy
|
| 4 |
+
What is the primary cost function used in linear regression?,easy
|
| 5 |
+
What do the slope and intercept represent in a simple linear regression model?,easy
|
| 6 |
+
What is the R-squared metric and what does it tell you?,easy
|
| 7 |
+
What are the four core assumptions of linear regression?,medium
|
| 8 |
+
How do you handle categorical variables when building a linear model?,medium
|
| 9 |
+
What is multicollinearity and how can you detect it?,medium
|
| 10 |
+
Explain the difference between R-squared and Adjusted R-squared.,medium
|
| 11 |
+
How does gradient descent work in the context of linear regression?,medium
|
| 12 |
+
What are residuals and why is residual analysis important?,medium
|
| 13 |
+
How do you derive the normal equation for ordinary least squares?,hard
|
| 14 |
+
What are the specific consequences of violating the homoscedasticity assumption?,hard
|
| 15 |
+
Explain the bias-variance tradeoff specifically for linear regression models.,hard
|
| 16 |
+
How do Ridge and Lasso regularization modify standard linear regression?,hard
|
| 17 |
+
How would you implement linear regression from scratch without using external libraries?,hard
|
| 18 |
+
What happens to your regression model when the number of features exceeds the number of observations?,hard
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/api/interview.ts
CHANGED
|
@@ -29,6 +29,17 @@ export async function sendTurn(
|
|
| 29 |
return res.json()
|
| 30 |
}
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
export async function getInterviewState(
|
| 33 |
sessionId: string,
|
| 34 |
): Promise<{ status: string; turn_count: number; last_message: string | null }> {
|
|
|
|
| 29 |
return res.json()
|
| 30 |
}
|
| 31 |
|
| 32 |
+
export async function finishSession(
|
| 33 |
+
sessionId: string,
|
| 34 |
+
): Promise<{ score: number; feedback: Record<string, unknown> }> {
|
| 35 |
+
const res = await apiFetch(`/interview/finish/${sessionId}`, { method: 'POST' })
|
| 36 |
+
if (!res.ok) {
|
| 37 |
+
const err = await res.json()
|
| 38 |
+
throw new Error(err.detail ?? 'Failed to finish session')
|
| 39 |
+
}
|
| 40 |
+
return res.json()
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
export async function getInterviewState(
|
| 44 |
sessionId: string,
|
| 45 |
): Promise<{ status: string; turn_count: number; last_message: string | null }> {
|
frontend/src/components/student/AttemptRow.tsx
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
|
|
| 1 |
import { useNavigate } from 'react-router-dom'
|
| 2 |
import type { StudentSession } from '../../types'
|
|
|
|
| 3 |
|
| 4 |
interface Props {
|
| 5 |
session: StudentSession
|
|
|
|
| 6 |
}
|
| 7 |
|
| 8 |
function formatDate(iso: string | null): string {
|
|
@@ -10,8 +13,10 @@ function formatDate(iso: string | null): string {
|
|
| 10 |
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
| 11 |
}
|
| 12 |
|
| 13 |
-
export default function AttemptRow({ session }: Props) {
|
| 14 |
const navigate = useNavigate()
|
|
|
|
|
|
|
| 15 |
const isCompleted = session.status === 'completed'
|
| 16 |
|
| 17 |
const scoreColor =
|
|
@@ -23,11 +28,24 @@ export default function AttemptRow({ session }: Props) {
|
|
| 23 |
? 'text-yellow-400'
|
| 24 |
: 'text-red-400'
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
return (
|
| 27 |
<div className="flex items-center justify-between bg-gray-900 border border-gray-800 rounded-xl px-5 py-4">
|
| 28 |
<div>
|
| 29 |
<p className="text-white font-medium">{session.topic_name}</p>
|
| 30 |
<p className="text-gray-500 text-xs mt-0.5">{formatDate(session.started_at)}</p>
|
|
|
|
| 31 |
</div>
|
| 32 |
<div className="flex items-center gap-4">
|
| 33 |
<span className={`text-lg font-semibold ${scoreColor}`}>
|
|
@@ -41,9 +59,13 @@ export default function AttemptRow({ session }: Props) {
|
|
| 41 |
Report
|
| 42 |
</button>
|
| 43 |
) : (
|
| 44 |
-
<
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
)}
|
| 48 |
</div>
|
| 49 |
</div>
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
| 2 |
import { useNavigate } from 'react-router-dom'
|
| 3 |
import type { StudentSession } from '../../types'
|
| 4 |
+
import { finishSession } from '../../api/interview'
|
| 5 |
|
| 6 |
interface Props {
|
| 7 |
session: StudentSession
|
| 8 |
+
onFinished?: () => void
|
| 9 |
}
|
| 10 |
|
| 11 |
function formatDate(iso: string | null): string {
|
|
|
|
| 13 |
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
| 14 |
}
|
| 15 |
|
| 16 |
+
export default function AttemptRow({ session, onFinished }: Props) {
|
| 17 |
const navigate = useNavigate()
|
| 18 |
+
const [loading, setLoading] = useState(false)
|
| 19 |
+
const [error, setError] = useState('')
|
| 20 |
const isCompleted = session.status === 'completed'
|
| 21 |
|
| 22 |
const scoreColor =
|
|
|
|
| 28 |
? 'text-yellow-400'
|
| 29 |
: 'text-red-400'
|
| 30 |
|
| 31 |
+
async function handleGetScore() {
|
| 32 |
+
setLoading(true)
|
| 33 |
+
setError('')
|
| 34 |
+
try {
|
| 35 |
+
await finishSession(session.id)
|
| 36 |
+
onFinished?.()
|
| 37 |
+
} catch (e: unknown) {
|
| 38 |
+
setError(e instanceof Error ? e.message : 'Failed to generate score')
|
| 39 |
+
setLoading(false)
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
return (
|
| 44 |
<div className="flex items-center justify-between bg-gray-900 border border-gray-800 rounded-xl px-5 py-4">
|
| 45 |
<div>
|
| 46 |
<p className="text-white font-medium">{session.topic_name}</p>
|
| 47 |
<p className="text-gray-500 text-xs mt-0.5">{formatDate(session.started_at)}</p>
|
| 48 |
+
{error && <p className="text-red-400 text-xs mt-1">{error}</p>}
|
| 49 |
</div>
|
| 50 |
<div className="flex items-center gap-4">
|
| 51 |
<span className={`text-lg font-semibold ${scoreColor}`}>
|
|
|
|
| 59 |
Report
|
| 60 |
</button>
|
| 61 |
) : (
|
| 62 |
+
<button
|
| 63 |
+
onClick={handleGetScore}
|
| 64 |
+
disabled={loading}
|
| 65 |
+
className="text-xs bg-yellow-600 hover:bg-yellow-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-3 py-1.5 rounded-lg transition-colors"
|
| 66 |
+
>
|
| 67 |
+
{loading ? 'Scoring…' : 'Get Score'}
|
| 68 |
+
</button>
|
| 69 |
)}
|
| 70 |
</div>
|
| 71 |
</div>
|
frontend/src/pages/Report.tsx
CHANGED
|
@@ -3,10 +3,35 @@ import { useParams, useNavigate } from 'react-router-dom'
|
|
| 3 |
import { getSession } from '../api/sessions'
|
| 4 |
import type { SessionReport } from '../types'
|
| 5 |
import ScoreRing from '../components/report/ScoreRing'
|
| 6 |
-
import FeedbackCard from '../components/report/FeedbackCard'
|
| 7 |
-
import SummaryBlock from '../components/report/SummaryBlock'
|
| 8 |
import Navbar from '../components/shared/Navbar'
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
export default function Report() {
|
| 11 |
const { sessionId } = useParams<{ sessionId: string }>()
|
| 12 |
const navigate = useNavigate()
|
|
@@ -27,7 +52,7 @@ export default function Report() {
|
|
| 27 |
<div className="min-h-screen bg-gray-950">
|
| 28 |
<Navbar />
|
| 29 |
|
| 30 |
-
<div className="max-w-2xl mx-auto px-4 py-8 space-y-
|
| 31 |
{loading && (
|
| 32 |
<div className="space-y-4">
|
| 33 |
{[...Array(4)].map((_, i) => (
|
|
@@ -37,61 +62,91 @@ export default function Report() {
|
|
| 37 |
)}
|
| 38 |
|
| 39 |
{error && (
|
| 40 |
-
<div className="bg-gray-900 border border-
|
| 41 |
{error}
|
| 42 |
</div>
|
| 43 |
)}
|
| 44 |
|
| 45 |
-
{report &&
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
</div>
|
| 53 |
-
<ScoreRing score={report.score} />
|
| 54 |
-
</div>
|
| 55 |
|
| 56 |
-
|
| 57 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
<FeedbackCard
|
| 67 |
-
title="Areas to Improve"
|
| 68 |
-
items={report.feedback.mistakes}
|
| 69 |
-
variant="negative"
|
| 70 |
-
/>
|
| 71 |
-
</div>
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
<
|
| 77 |
-
<
|
| 78 |
-
</div>
|
| 79 |
-
<div className="w-px bg-gray-800" />
|
| 80 |
-
<div className="flex-1 text-center">
|
| 81 |
-
<p className="text-2xl font-bold text-white">{report.feedback.depth_score}</p>
|
| 82 |
-
<p className="text-xs text-gray-500 mt-1">Depth Score</p>
|
| 83 |
</div>
|
| 84 |
-
</div>
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
</div>
|
| 96 |
</div>
|
| 97 |
)
|
|
|
|
| 3 |
import { getSession } from '../api/sessions'
|
| 4 |
import type { SessionReport } from '../types'
|
| 5 |
import ScoreRing from '../components/report/ScoreRing'
|
|
|
|
|
|
|
| 6 |
import Navbar from '../components/shared/Navbar'
|
| 7 |
|
| 8 |
+
function formatDate(iso: string | null): string {
|
| 9 |
+
if (!iso) return '—'
|
| 10 |
+
return new Date(iso).toLocaleDateString(undefined, {
|
| 11 |
+
month: 'long',
|
| 12 |
+
day: 'numeric',
|
| 13 |
+
year: 'numeric',
|
| 14 |
+
})
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function ScoreBar({ label, value }: { label: string; value: number }) {
|
| 18 |
+
const color = value >= 70 ? 'bg-green-500' : value >= 40 ? 'bg-yellow-500' : 'bg-red-500'
|
| 19 |
+
return (
|
| 20 |
+
<div>
|
| 21 |
+
<div className="flex justify-between mb-1.5">
|
| 22 |
+
<span className="text-xs text-gray-400">{label}</span>
|
| 23 |
+
<span className="text-xs font-semibold text-white">{value}/100</span>
|
| 24 |
+
</div>
|
| 25 |
+
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
| 26 |
+
<div
|
| 27 |
+
className={`h-full rounded-full transition-all duration-700 ${color}`}
|
| 28 |
+
style={{ width: `${value}%` }}
|
| 29 |
+
/>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
export default function Report() {
|
| 36 |
const { sessionId } = useParams<{ sessionId: string }>()
|
| 37 |
const navigate = useNavigate()
|
|
|
|
| 52 |
<div className="min-h-screen bg-gray-950">
|
| 53 |
<Navbar />
|
| 54 |
|
| 55 |
+
<div className="max-w-2xl mx-auto px-4 py-8 space-y-5">
|
| 56 |
{loading && (
|
| 57 |
<div className="space-y-4">
|
| 58 |
{[...Array(4)].map((_, i) => (
|
|
|
|
| 62 |
)}
|
| 63 |
|
| 64 |
{error && (
|
| 65 |
+
<div className="bg-gray-900 border border-red-800 rounded-xl p-6 text-center text-red-400 text-sm">
|
| 66 |
{error}
|
| 67 |
</div>
|
| 68 |
)}
|
| 69 |
|
| 70 |
+
{report && (() => {
|
| 71 |
+
const fb = report.feedback ?? {}
|
| 72 |
+
const mistakes: string[] = Array.isArray(fb.mistakes) ? fb.mistakes : []
|
| 73 |
+
const tips: string[] = Array.isArray(fb.tips) ? fb.tips : []
|
| 74 |
+
const conceptScore: number = Number(fb.concept_score ?? 0)
|
| 75 |
+
const depthScore: number = Number(fb.depth_score ?? 0)
|
| 76 |
+
const summary: string = fb.summary ?? ''
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
+
return (
|
| 79 |
+
<>
|
| 80 |
+
{/* Header card */}
|
| 81 |
+
<div className="bg-gray-900 border border-gray-800 rounded-xl p-6">
|
| 82 |
+
<div className="flex items-start justify-between gap-4">
|
| 83 |
+
<div className="flex-1 min-w-0">
|
| 84 |
+
<p className="text-xs text-gray-500 uppercase tracking-widest mb-1">Interview Report</p>
|
| 85 |
+
<h1 className="text-2xl font-bold text-white truncate">{report.topic_name}</h1>
|
| 86 |
+
<p className="text-sm text-gray-500 mt-1">{formatDate(report.completed_at ?? report.started_at)}</p>
|
| 87 |
+
<span className="inline-block mt-3 text-xs font-medium bg-green-900/40 text-green-400 border border-green-800 px-2.5 py-1 rounded-full">
|
| 88 |
+
Completed
|
| 89 |
+
</span>
|
| 90 |
+
</div>
|
| 91 |
+
<ScoreRing score={report.score ?? 0} />
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
|
| 95 |
+
{/* Summary */}
|
| 96 |
+
{summary && (
|
| 97 |
+
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
| 98 |
+
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Overall Assessment</h2>
|
| 99 |
+
<p className="text-sm text-gray-200 leading-relaxed">{summary}</p>
|
| 100 |
+
</div>
|
| 101 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
+
{/* Sub-scores */}
|
| 104 |
+
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5 space-y-4">
|
| 105 |
+
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-widest">Score Breakdown</h2>
|
| 106 |
+
<ScoreBar label="Concept Understanding" value={conceptScore} />
|
| 107 |
+
<ScoreBar label="Answer Depth" value={depthScore} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
</div>
|
|
|
|
| 109 |
|
| 110 |
+
{/* Mistakes */}
|
| 111 |
+
{mistakes.length > 0 && (
|
| 112 |
+
<div className="bg-gray-900 border border-gray-800 border-l-4 border-l-red-500 rounded-xl p-5">
|
| 113 |
+
<h2 className="text-xs font-semibold text-red-400 uppercase tracking-widest mb-3">Mistakes Made</h2>
|
| 114 |
+
<ul className="space-y-2">
|
| 115 |
+
{mistakes.map((item, i) => (
|
| 116 |
+
<li key={i} className="flex gap-2.5 text-sm text-gray-300">
|
| 117 |
+
<span className="text-red-500 mt-0.5 shrink-0">✗</span>
|
| 118 |
+
{item}
|
| 119 |
+
</li>
|
| 120 |
+
))}
|
| 121 |
+
</ul>
|
| 122 |
+
</div>
|
| 123 |
+
)}
|
| 124 |
+
|
| 125 |
+
{/* Tips */}
|
| 126 |
+
{tips.length > 0 && (
|
| 127 |
+
<div className="bg-gray-900 border border-gray-800 border-l-4 border-l-indigo-500 rounded-xl p-5">
|
| 128 |
+
<h2 className="text-xs font-semibold text-indigo-400 uppercase tracking-widest mb-3">Improvement Tips</h2>
|
| 129 |
+
<ul className="space-y-2">
|
| 130 |
+
{tips.map((item, i) => (
|
| 131 |
+
<li key={i} className="flex gap-2.5 text-sm text-gray-300">
|
| 132 |
+
<span className="text-indigo-400 mt-0.5 shrink-0">→</span>
|
| 133 |
+
{item}
|
| 134 |
+
</li>
|
| 135 |
+
))}
|
| 136 |
+
</ul>
|
| 137 |
+
</div>
|
| 138 |
+
)}
|
| 139 |
+
|
| 140 |
+
{/* Back */}
|
| 141 |
+
<button
|
| 142 |
+
onClick={() => navigate('/student/dashboard')}
|
| 143 |
+
className="w-full bg-gray-800 hover:bg-gray-700 text-white rounded-xl px-4 py-3 text-sm font-medium transition-colors"
|
| 144 |
+
>
|
| 145 |
+
Back to Dashboard
|
| 146 |
+
</button>
|
| 147 |
+
</>
|
| 148 |
+
)
|
| 149 |
+
})()}
|
| 150 |
</div>
|
| 151 |
</div>
|
| 152 |
)
|
frontend/src/pages/StudentDashboard.tsx
CHANGED
|
@@ -16,7 +16,8 @@ export default function StudentDashboard() {
|
|
| 16 |
const [loading, setLoading] = useState(true)
|
| 17 |
const [error, setError] = useState('')
|
| 18 |
|
| 19 |
-
|
|
|
|
| 20 |
Promise.all([getStudentTopics(), getStudentSessions()])
|
| 21 |
.then(([t, s]) => {
|
| 22 |
setTopics(t)
|
|
@@ -24,7 +25,9 @@ export default function StudentDashboard() {
|
|
| 24 |
})
|
| 25 |
.catch(() => setError('Failed to load dashboard.'))
|
| 26 |
.finally(() => setLoading(false))
|
| 27 |
-
}
|
|
|
|
|
|
|
| 28 |
|
| 29 |
function handleStartTopic(topic: StudentTopic) {
|
| 30 |
resetInterview()
|
|
@@ -95,7 +98,7 @@ export default function StudentDashboard() {
|
|
| 95 |
</h2>
|
| 96 |
<div className="space-y-3">
|
| 97 |
{sessions.map((s) => (
|
| 98 |
-
<AttemptRow key={s.id} session={s} />
|
| 99 |
))}
|
| 100 |
</div>
|
| 101 |
</section>
|
|
|
|
| 16 |
const [loading, setLoading] = useState(true)
|
| 17 |
const [error, setError] = useState('')
|
| 18 |
|
| 19 |
+
function loadDashboard() {
|
| 20 |
+
setLoading(true)
|
| 21 |
Promise.all([getStudentTopics(), getStudentSessions()])
|
| 22 |
.then(([t, s]) => {
|
| 23 |
setTopics(t)
|
|
|
|
| 25 |
})
|
| 26 |
.catch(() => setError('Failed to load dashboard.'))
|
| 27 |
.finally(() => setLoading(false))
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
useEffect(() => { loadDashboard() }, [])
|
| 31 |
|
| 32 |
function handleStartTopic(topic: StudentTopic) {
|
| 33 |
resetInterview()
|
|
|
|
| 98 |
</h2>
|
| 99 |
<div className="space-y-3">
|
| 100 |
{sessions.map((s) => (
|
| 101 |
+
<AttemptRow key={s.id} session={s} onFinished={loadDashboard} />
|
| 102 |
))}
|
| 103 |
</div>
|
| 104 |
</section>
|
frontend/src/types/index.ts
CHANGED
|
@@ -33,10 +33,13 @@ export interface InterviewSession {
|
|
| 33 |
export interface SessionReport {
|
| 34 |
id: string
|
| 35 |
topic_id: string
|
|
|
|
|
|
|
| 36 |
status: string
|
| 37 |
score: number
|
| 38 |
feedback: Feedback
|
| 39 |
-
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
// Student dashboard types
|
|
|
|
| 33 |
export interface SessionReport {
|
| 34 |
id: string
|
| 35 |
topic_id: string
|
| 36 |
+
topic_name: string
|
| 37 |
+
student_id: string
|
| 38 |
status: string
|
| 39 |
score: number
|
| 40 |
feedback: Feedback
|
| 41 |
+
started_at: string | null
|
| 42 |
+
completed_at: string | null
|
| 43 |
}
|
| 44 |
|
| 45 |
// Student dashboard types
|