Spaces:
Sleeping
Sleeping
Commit ·
e39cad1
1
Parent(s): efa0074
v4.1
Browse files- backend/config.py +2 -0
- backend/main.py +10 -0
- backend/services/evaluation_service.py +65 -11
- backend/services/gemini_service.py +79 -31
- backend/services/interview_service.py +368 -28
- backend/services/queue_service.py +22 -3
- backend/utils/gemini.py +231 -66
backend/config.py
CHANGED
|
@@ -11,11 +11,13 @@ load_dotenv(os.path.join(os.path.dirname(__file__), ".env"))
|
|
| 11 |
class Settings(BaseSettings):
|
| 12 |
# App
|
| 13 |
APP_ENV: str = "production"
|
|
|
|
| 14 |
APP_PORT: int = 8000
|
| 15 |
|
| 16 |
# Gemini
|
| 17 |
GEMINI_API_KEY: str
|
| 18 |
GEMINI_MODEL: str = "gemini-2.5-flash"
|
|
|
|
| 19 |
|
| 20 |
# MongoDB Atlas
|
| 21 |
MONGO_URI: str
|
|
|
|
| 11 |
class Settings(BaseSettings):
|
| 12 |
# App
|
| 13 |
APP_ENV: str = "production"
|
| 14 |
+
APP_HOST: str = "0.0.0.0"
|
| 15 |
APP_PORT: int = 8000
|
| 16 |
|
| 17 |
# Gemini
|
| 18 |
GEMINI_API_KEY: str
|
| 19 |
GEMINI_MODEL: str = "gemini-2.5-flash"
|
| 20 |
+
GEMINI_FALLBACK_MODELS: str = ""
|
| 21 |
|
| 22 |
# MongoDB Atlas
|
| 23 |
MONGO_URI: str
|
backend/main.py
CHANGED
|
@@ -4,6 +4,7 @@ from fastapi import FastAPI
|
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
from fastapi.staticfiles import StaticFiles
|
| 6 |
import os
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
|
|
@@ -68,3 +69,12 @@ app.include_router(speech.router, prefix="/speech", tags=["Speech"])
|
|
| 68 |
@app.get("/health")
|
| 69 |
async def health_check():
|
| 70 |
return {"status": "healthy", "version": "1.0.0"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
from fastapi.staticfiles import StaticFiles
|
| 6 |
import os
|
| 7 |
+
import uvicorn
|
| 8 |
|
| 9 |
|
| 10 |
|
|
|
|
| 69 |
@app.get("/health")
|
| 70 |
async def health_check():
|
| 71 |
return {"status": "healthy", "version": "1.0.0"}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
if __name__ == "__main__":
|
| 75 |
+
uvicorn.run(
|
| 76 |
+
"main:app",
|
| 77 |
+
host=settings.APP_HOST,
|
| 78 |
+
port=settings.APP_PORT,
|
| 79 |
+
reload=settings.APP_ENV != "production",
|
| 80 |
+
)
|
backend/services/evaluation_service.py
CHANGED
|
@@ -23,6 +23,20 @@ def _safe_int(value, default: int = 0) -> int:
|
|
| 23 |
return default
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
async def generate_report(session_id: str, user_id: str) -> dict:
|
| 27 |
"""Generate final evaluation report from Redis Q&A data using Gemini."""
|
| 28 |
db = get_db()
|
|
@@ -30,7 +44,7 @@ async def generate_report(session_id: str, user_id: str) -> dict:
|
|
| 30 |
|
| 31 |
# Check if report already exists
|
| 32 |
existing = await db[RESULTS].find_one({"session_id": session_id})
|
| 33 |
-
if existing:
|
| 34 |
existing["id"] = str(existing["_id"])
|
| 35 |
del existing["_id"]
|
| 36 |
return _json_safe(existing)
|
|
@@ -51,6 +65,25 @@ async def generate_report(session_id: str, user_id: str) -> dict:
|
|
| 51 |
|
| 52 |
# Get all Q&A from Redis
|
| 53 |
qa_pairs = await get_session_qa(session_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
if not qa_pairs:
|
| 55 |
raise ValueError("No Q&A data found for this session")
|
| 56 |
|
|
@@ -80,21 +113,42 @@ async def generate_report(session_id: str, user_id: str) -> dict:
|
|
| 80 |
},
|
| 81 |
"completed_at": utc_now(),
|
| 82 |
}
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
# Store final answers in MongoDB
|
| 86 |
for qa in qa_pairs:
|
| 87 |
-
|
|
|
|
| 88 |
"session_id": session_id,
|
| 89 |
"user_id": user_id,
|
| 90 |
-
"question_id": qa["question_id"],
|
| 91 |
-
"question": qa["question"],
|
| 92 |
-
"answer": qa["answer"],
|
| 93 |
-
"difficulty": qa["difficulty"],
|
| 94 |
-
"category": qa["category"],
|
| 95 |
-
"stored_at": utc_now(),
|
| 96 |
}
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
# Clean up Redis session data
|
| 100 |
question_ids = await redis.lrange(f"session:{session_id}:questions", 0, -1)
|
|
@@ -133,5 +187,5 @@ async def generate_report(session_id: str, user_id: str) -> dict:
|
|
| 133 |
|
| 134 |
cleanup_interview_local_state(session_id)
|
| 135 |
|
| 136 |
-
result_doc["id"] =
|
| 137 |
return _json_safe(result_doc)
|
|
|
|
| 23 |
return default
|
| 24 |
|
| 25 |
|
| 26 |
+
def _is_placeholder_report(report: dict) -> bool:
|
| 27 |
+
strengths = [str(item).strip().lower() for item in (report.get("strengths") or []) if str(item).strip()]
|
| 28 |
+
weaknesses = [str(item).strip().lower() for item in (report.get("weaknesses") or []) if str(item).strip()]
|
| 29 |
+
recommendations = [str(item).strip().lower() for item in (report.get("recommendations") or []) if str(item).strip()]
|
| 30 |
+
|
| 31 |
+
if any("unable to evaluate" in item for item in strengths + weaknesses):
|
| 32 |
+
return True
|
| 33 |
+
if any("please retry the interview" in item for item in recommendations):
|
| 34 |
+
return True
|
| 35 |
+
if not (report.get("detailed_scores") or []):
|
| 36 |
+
return True
|
| 37 |
+
return False
|
| 38 |
+
|
| 39 |
+
|
| 40 |
async def generate_report(session_id: str, user_id: str) -> dict:
|
| 41 |
"""Generate final evaluation report from Redis Q&A data using Gemini."""
|
| 42 |
db = get_db()
|
|
|
|
| 44 |
|
| 45 |
# Check if report already exists
|
| 46 |
existing = await db[RESULTS].find_one({"session_id": session_id})
|
| 47 |
+
if existing and not _is_placeholder_report(existing):
|
| 48 |
existing["id"] = str(existing["_id"])
|
| 49 |
del existing["_id"]
|
| 50 |
return _json_safe(existing)
|
|
|
|
| 65 |
|
| 66 |
# Get all Q&A from Redis
|
| 67 |
qa_pairs = await get_session_qa(session_id)
|
| 68 |
+
if not qa_pairs:
|
| 69 |
+
archived_answers = await db[ANSWERS].find(
|
| 70 |
+
{"session_id": session_id, "user_id": user_id}
|
| 71 |
+
).sort("stored_at", 1).to_list(length=200)
|
| 72 |
+
for item in archived_answers:
|
| 73 |
+
question = (item.get("question") or "").strip()
|
| 74 |
+
answer = (item.get("answer") or "").strip()
|
| 75 |
+
if not question or not answer:
|
| 76 |
+
continue
|
| 77 |
+
qa_pairs.append(
|
| 78 |
+
{
|
| 79 |
+
"question_id": item.get("question_id") or "",
|
| 80 |
+
"question": question,
|
| 81 |
+
"answer": answer,
|
| 82 |
+
"difficulty": item.get("difficulty", "medium"),
|
| 83 |
+
"category": item.get("category", "general"),
|
| 84 |
+
}
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
if not qa_pairs:
|
| 88 |
raise ValueError("No Q&A data found for this session")
|
| 89 |
|
|
|
|
| 113 |
},
|
| 114 |
"completed_at": utc_now(),
|
| 115 |
}
|
| 116 |
+
if existing:
|
| 117 |
+
await db[RESULTS].update_one(
|
| 118 |
+
{"_id": existing["_id"]},
|
| 119 |
+
{"$set": result_doc},
|
| 120 |
+
)
|
| 121 |
+
result_doc_id = str(existing["_id"])
|
| 122 |
+
else:
|
| 123 |
+
inserted = await db[RESULTS].insert_one(result_doc)
|
| 124 |
+
result_doc_id = str(inserted.inserted_id)
|
| 125 |
|
| 126 |
# Store final answers in MongoDB
|
| 127 |
for qa in qa_pairs:
|
| 128 |
+
question_id = (qa.get("question_id") or "").strip()
|
| 129 |
+
upsert_filter = {
|
| 130 |
"session_id": session_id,
|
| 131 |
"user_id": user_id,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
}
|
| 133 |
+
if question_id:
|
| 134 |
+
upsert_filter["question_id"] = question_id
|
| 135 |
+
else:
|
| 136 |
+
upsert_filter["question"] = qa.get("question", "")
|
| 137 |
+
|
| 138 |
+
await db[ANSWERS].update_one(
|
| 139 |
+
upsert_filter,
|
| 140 |
+
{
|
| 141 |
+
"$set": {
|
| 142 |
+
"question_id": question_id,
|
| 143 |
+
"question": qa.get("question", ""),
|
| 144 |
+
"answer": qa.get("answer", ""),
|
| 145 |
+
"difficulty": qa.get("difficulty", "medium"),
|
| 146 |
+
"category": qa.get("category", "general"),
|
| 147 |
+
"stored_at": utc_now(),
|
| 148 |
+
}
|
| 149 |
+
},
|
| 150 |
+
upsert=True,
|
| 151 |
+
)
|
| 152 |
|
| 153 |
# Clean up Redis session data
|
| 154 |
question_ids = await redis.lrange(f"session:{session_id}:questions", 0, -1)
|
|
|
|
| 187 |
|
| 188 |
cleanup_interview_local_state(session_id)
|
| 189 |
|
| 190 |
+
result_doc["id"] = result_doc_id
|
| 191 |
return _json_safe(result_doc)
|
backend/services/gemini_service.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import json
|
| 2 |
import re
|
|
|
|
| 3 |
|
| 4 |
from utils.gemini import call_gemini
|
| 5 |
|
|
@@ -42,6 +43,30 @@ def _extract_json_array(text: str) -> str:
|
|
| 42 |
return value
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
def _fallback_score(answer: str) -> int:
|
| 46 |
text = (answer or "").strip().lower()
|
| 47 |
words = len(text.split())
|
|
@@ -91,6 +116,9 @@ Rules:
|
|
| 91 |
2) Use resume context for relevance.
|
| 92 |
3) Do not repeat or paraphrase excluded_questions.
|
| 93 |
4) Keep questions concise and practical.
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
Return ONLY valid JSON array with objects:
|
| 96 |
- question (string)
|
|
@@ -99,16 +127,12 @@ Return ONLY valid JSON array with objects:
|
|
| 99 |
"""
|
| 100 |
|
| 101 |
try:
|
| 102 |
-
result =
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
request_timeout_seconds=3.5,
|
| 107 |
-
)
|
| 108 |
)
|
| 109 |
-
data =
|
| 110 |
-
if not isinstance(data, list):
|
| 111 |
-
raise ValueError("seed output is not a list")
|
| 112 |
|
| 113 |
output = []
|
| 114 |
for item in data[:count]:
|
|
@@ -124,15 +148,19 @@ Return ONLY valid JSON array with objects:
|
|
| 124 |
return [q for q in output if q.get("question")]
|
| 125 |
except Exception:
|
| 126 |
base_skill = jd_required_skills[0] if jd_required_skills else (resume_skills[0] if resume_skills else "this role")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
fallback = []
|
| 128 |
for i in range(count):
|
|
|
|
| 129 |
fallback.append(
|
| 130 |
{
|
| 131 |
-
"question": (
|
| 132 |
-
f"Explain your hands-on experience with {base_skill} in a project relevant to {role_title}."
|
| 133 |
-
if i == 0
|
| 134 |
-
else f"What trade-offs did you consider when working with {base_skill}?"
|
| 135 |
-
),
|
| 136 |
"difficulty": "medium",
|
| 137 |
"category": "resume-seed",
|
| 138 |
}
|
|
@@ -147,6 +175,8 @@ async def evaluate_and_generate_followup(
|
|
| 147 |
current_question: str,
|
| 148 |
current_answer: str,
|
| 149 |
excluded_questions: list[str],
|
|
|
|
|
|
|
| 150 |
) -> dict:
|
| 151 |
payload = {
|
| 152 |
"role_title": role_title,
|
|
@@ -155,6 +185,8 @@ async def evaluate_and_generate_followup(
|
|
| 155 |
"current_question": current_question,
|
| 156 |
"current_answer": current_answer,
|
| 157 |
"excluded_questions": excluded_questions[-25:] if excluded_questions else [],
|
|
|
|
|
|
|
| 158 |
}
|
| 159 |
|
| 160 |
prompt = f"""You are a strict technical interviewer.
|
|
@@ -171,40 +203,60 @@ Rules:
|
|
| 171 |
2) Use recent_context for continuity.
|
| 172 |
3) Do not repeat/paraphrase excluded_questions.
|
| 173 |
4) Score should reflect conceptual correctness, not verbosity.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
Return ONLY valid JSON object:
|
| 176 |
{{
|
| 177 |
"score": 0-100,
|
| 178 |
"feedback": "short technical feedback",
|
| 179 |
"followup_question": "...",
|
|
|
|
|
|
|
| 180 |
"difficulty": "easy|medium|hard",
|
| 181 |
"category": "..."
|
| 182 |
}}
|
| 183 |
"""
|
| 184 |
|
| 185 |
try:
|
| 186 |
-
result =
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
request_timeout_seconds=2.8,
|
| 191 |
-
)
|
| 192 |
)
|
| 193 |
-
data =
|
| 194 |
followup = (data.get("followup_question") or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
return {
|
| 196 |
"score": int(data.get("score", 0)),
|
| 197 |
"feedback": (data.get("feedback") or "").strip() or "Answer reviewed.",
|
| 198 |
"followup_question": followup,
|
|
|
|
|
|
|
| 199 |
"difficulty": data.get("difficulty") if data.get("difficulty") in {"easy", "medium", "hard"} else "medium",
|
| 200 |
"category": data.get("category") or "follow-up",
|
| 201 |
}
|
| 202 |
except Exception:
|
| 203 |
fallback_skill = required_skills[0] if required_skills else "the selected role requirement"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
return {
|
| 205 |
"score": _fallback_score(current_answer),
|
| 206 |
"feedback": "Try to explain the mechanism, trade-offs, and one concrete example.",
|
| 207 |
-
"followup_question":
|
|
|
|
|
|
|
| 208 |
"difficulty": "medium",
|
| 209 |
"category": "follow-up",
|
| 210 |
}
|
|
@@ -242,16 +294,12 @@ Return ONLY valid JSON array with objects:
|
|
| 242 |
"""
|
| 243 |
|
| 244 |
try:
|
| 245 |
-
result =
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
request_timeout_seconds=3.5,
|
| 250 |
-
)
|
| 251 |
)
|
| 252 |
-
data =
|
| 253 |
-
if not isinstance(data, list):
|
| 254 |
-
raise ValueError("topic output is not a list")
|
| 255 |
|
| 256 |
out = []
|
| 257 |
for item in data[:count]:
|
|
|
|
| 1 |
import json
|
| 2 |
import re
|
| 3 |
+
import random
|
| 4 |
|
| 5 |
from utils.gemini import call_gemini
|
| 6 |
|
|
|
|
| 43 |
return value
|
| 44 |
|
| 45 |
|
| 46 |
+
def _parse_json_object_loose(text: str) -> dict:
|
| 47 |
+
value = _extract_json_object(text)
|
| 48 |
+
try:
|
| 49 |
+
parsed = json.loads(value)
|
| 50 |
+
except Exception:
|
| 51 |
+
cleaned = re.sub(r",\s*([}\]])", r"\1", value)
|
| 52 |
+
parsed = json.loads(cleaned)
|
| 53 |
+
if not isinstance(parsed, dict):
|
| 54 |
+
raise ValueError("Parsed payload is not a JSON object")
|
| 55 |
+
return parsed
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _parse_json_array_loose(text: str) -> list:
|
| 59 |
+
value = _extract_json_array(text)
|
| 60 |
+
try:
|
| 61 |
+
parsed = json.loads(value)
|
| 62 |
+
except Exception:
|
| 63 |
+
cleaned = re.sub(r",\s*([}\]])", r"\1", value)
|
| 64 |
+
parsed = json.loads(cleaned)
|
| 65 |
+
if not isinstance(parsed, list):
|
| 66 |
+
raise ValueError("Parsed payload is not a JSON array")
|
| 67 |
+
return parsed
|
| 68 |
+
|
| 69 |
+
|
| 70 |
def _fallback_score(answer: str) -> int:
|
| 71 |
text = (answer or "").strip().lower()
|
| 72 |
words = len(text.split())
|
|
|
|
| 116 |
2) Use resume context for relevance.
|
| 117 |
3) Do not repeat or paraphrase excluded_questions.
|
| 118 |
4) Keep questions concise and practical.
|
| 119 |
+
5) Make the set diverse: use different styles (scenario, debugging, trade-off, implementation, testing).
|
| 120 |
+
6) Do not prefix with numbering like "Question 1:".
|
| 121 |
+
7) Avoid generic repeats like "Explain your hands-on experience" for every question.
|
| 122 |
|
| 123 |
Return ONLY valid JSON array with objects:
|
| 124 |
- question (string)
|
|
|
|
| 127 |
"""
|
| 128 |
|
| 129 |
try:
|
| 130 |
+
result = await call_gemini(
|
| 131 |
+
prompt,
|
| 132 |
+
max_attempts=3,
|
| 133 |
+
request_timeout_seconds=20,
|
|
|
|
|
|
|
| 134 |
)
|
| 135 |
+
data = _parse_json_array_loose(result)
|
|
|
|
|
|
|
| 136 |
|
| 137 |
output = []
|
| 138 |
for item in data[:count]:
|
|
|
|
| 148 |
return [q for q in output if q.get("question")]
|
| 149 |
except Exception:
|
| 150 |
base_skill = jd_required_skills[0] if jd_required_skills else (resume_skills[0] if resume_skills else "this role")
|
| 151 |
+
fallback_templates = [
|
| 152 |
+
"In a project aligned with {role}, where did {skill} materially change your design decisions?",
|
| 153 |
+
"If your {skill} implementation regressed after deployment for {role}, how would you triage it?",
|
| 154 |
+
"What trade-offs did you make while using {skill} under real delivery constraints in {role}?",
|
| 155 |
+
"How did you test and validate a {skill}-based feature before production in {role}?",
|
| 156 |
+
"Describe one architecture decision around {skill} that improved reliability or scale for {role}.",
|
| 157 |
+
]
|
| 158 |
fallback = []
|
| 159 |
for i in range(count):
|
| 160 |
+
template = fallback_templates[i % len(fallback_templates)]
|
| 161 |
fallback.append(
|
| 162 |
{
|
| 163 |
+
"question": template.format(skill=base_skill, role=role_title),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
"difficulty": "medium",
|
| 165 |
"category": "resume-seed",
|
| 166 |
}
|
|
|
|
| 175 |
current_question: str,
|
| 176 |
current_answer: str,
|
| 177 |
excluded_questions: list[str],
|
| 178 |
+
focus_topic: str = "",
|
| 179 |
+
same_topic_streak: int = 0,
|
| 180 |
) -> dict:
|
| 181 |
payload = {
|
| 182 |
"role_title": role_title,
|
|
|
|
| 185 |
"current_question": current_question,
|
| 186 |
"current_answer": current_answer,
|
| 187 |
"excluded_questions": excluded_questions[-25:] if excluded_questions else [],
|
| 188 |
+
"focus_topic": focus_topic,
|
| 189 |
+
"same_topic_streak": int(same_topic_streak or 0),
|
| 190 |
}
|
| 191 |
|
| 192 |
prompt = f"""You are a strict technical interviewer.
|
|
|
|
| 203 |
2) Use recent_context for continuity.
|
| 204 |
3) Do not repeat/paraphrase excluded_questions.
|
| 205 |
4) Score should reflect conceptual correctness, not verbosity.
|
| 206 |
+
5) If same_topic_streak is 2 or more, avoid another same-topic follow-up unless truly critical.
|
| 207 |
+
6) Ask in realistic live-interview style (specific scenario, debugging, trade-off, design decision), not generic textbook phrasing.
|
| 208 |
+
7) Do not prefix numbering like "Question 4:".
|
| 209 |
+
8) Avoid repeating the previous follow-up wording pattern.
|
| 210 |
|
| 211 |
Return ONLY valid JSON object:
|
| 212 |
{{
|
| 213 |
"score": 0-100,
|
| 214 |
"feedback": "short technical feedback",
|
| 215 |
"followup_question": "...",
|
| 216 |
+
"followup_topic": "specific required skill/topic for the follow-up",
|
| 217 |
+
"followup_need_score": 0-100,
|
| 218 |
"difficulty": "easy|medium|hard",
|
| 219 |
"category": "..."
|
| 220 |
}}
|
| 221 |
"""
|
| 222 |
|
| 223 |
try:
|
| 224 |
+
result = await call_gemini(
|
| 225 |
+
prompt,
|
| 226 |
+
max_attempts=3,
|
| 227 |
+
request_timeout_seconds=18,
|
|
|
|
|
|
|
| 228 |
)
|
| 229 |
+
data = _parse_json_object_loose(result)
|
| 230 |
followup = (data.get("followup_question") or "").strip()
|
| 231 |
+
try:
|
| 232 |
+
followup_need_score = int(data.get("followup_need_score", 70))
|
| 233 |
+
except Exception:
|
| 234 |
+
followup_need_score = 70
|
| 235 |
+
followup_need_score = max(0, min(100, followup_need_score))
|
| 236 |
return {
|
| 237 |
"score": int(data.get("score", 0)),
|
| 238 |
"feedback": (data.get("feedback") or "").strip() or "Answer reviewed.",
|
| 239 |
"followup_question": followup,
|
| 240 |
+
"followup_topic": (data.get("followup_topic") or "").strip(),
|
| 241 |
+
"followup_need_score": followup_need_score,
|
| 242 |
"difficulty": data.get("difficulty") if data.get("difficulty") in {"easy", "medium", "hard"} else "medium",
|
| 243 |
"category": data.get("category") or "follow-up",
|
| 244 |
}
|
| 245 |
except Exception:
|
| 246 |
fallback_skill = required_skills[0] if required_skills else "the selected role requirement"
|
| 247 |
+
fallback_templates = [
|
| 248 |
+
"In a production system for {role}, describe a failure you would expect around {skill} and how you would debug it end-to-end.",
|
| 249 |
+
"Given a feature built with {skill}, what trade-offs would you make between speed, reliability, and maintainability in {role}?",
|
| 250 |
+
"How would you test and validate a {skill}-based implementation before release for {role}?",
|
| 251 |
+
"Walk through one real incident where {skill} decisions changed the final architecture for {role}.",
|
| 252 |
+
]
|
| 253 |
+
template = random.choice(fallback_templates)
|
| 254 |
return {
|
| 255 |
"score": _fallback_score(current_answer),
|
| 256 |
"feedback": "Try to explain the mechanism, trade-offs, and one concrete example.",
|
| 257 |
+
"followup_question": template.format(skill=fallback_skill, role=role_title),
|
| 258 |
+
"followup_topic": fallback_skill,
|
| 259 |
+
"followup_need_score": 70,
|
| 260 |
"difficulty": "medium",
|
| 261 |
"category": "follow-up",
|
| 262 |
}
|
|
|
|
| 294 |
"""
|
| 295 |
|
| 296 |
try:
|
| 297 |
+
result = await call_gemini(
|
| 298 |
+
prompt,
|
| 299 |
+
max_attempts=3,
|
| 300 |
+
request_timeout_seconds=20,
|
|
|
|
|
|
|
| 301 |
)
|
| 302 |
+
data = _parse_json_array_loose(result)
|
|
|
|
|
|
|
| 303 |
|
| 304 |
out = []
|
| 305 |
for item in data[:count]:
|
backend/services/interview_service.py
CHANGED
|
@@ -21,6 +21,7 @@ from services.queue_service import (
|
|
| 21 |
flush_backlog_to_queue,
|
| 22 |
get_recent_context_items,
|
| 23 |
mark_question_asked,
|
|
|
|
| 24 |
peek_next_question,
|
| 25 |
pop_next_question,
|
| 26 |
push_context_item,
|
|
@@ -45,11 +46,28 @@ TOPIC_INITIAL_ASK_COUNT = 4
|
|
| 45 |
TOPIC_AI_FOLLOWUPS = 3
|
| 46 |
TOPIC_DB_FOLLOWUPS = 2
|
| 47 |
TOPIC_TOTAL_QUESTIONS = 10
|
|
|
|
|
|
|
| 48 |
|
| 49 |
# Local process memory summary requested in workflow.
|
| 50 |
_LOCAL_SUMMARIES: dict[str, str] = {}
|
| 51 |
_PREGEN_IN_FLIGHT: set[str] = set()
|
| 52 |
_POST_SUBMIT_LOCKS: dict[str, asyncio.Lock] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
def _safe_json_list(value: str) -> list:
|
|
@@ -67,6 +85,47 @@ def _question_fingerprint(text: str) -> str:
|
|
| 67 |
return base
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
def _unique_question_items(items: list[dict], *, excluded_questions: list[str], limit: int) -> list[dict]:
|
| 71 |
excluded = {_question_fingerprint(q) for q in excluded_questions if q}
|
| 72 |
unique: list[dict] = []
|
|
@@ -104,6 +163,15 @@ def _safe_int(value, default: int = 0) -> int:
|
|
| 104 |
return default
|
| 105 |
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
def _normalize_voice_gender(value: str | None) -> str:
|
| 108 |
return "male" if (value or "").strip().lower() == "male" else "female"
|
| 109 |
|
|
@@ -164,6 +232,123 @@ def _normalize_bank_difficulty(value: str) -> str:
|
|
| 164 |
return difficulty
|
| 165 |
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
def _avg_recent_answer_words(qa_pairs: list, window: int = 3) -> int:
|
| 168 |
if not qa_pairs:
|
| 169 |
return 0
|
|
@@ -235,32 +420,68 @@ async def _get_recent_user_questions(db, user_id: str, limit: int = 40) -> list[
|
|
| 235 |
|
| 236 |
|
| 237 |
def _build_resume_intro_question(role_title: str, jd_title: str) -> str:
|
| 238 |
-
title = (jd_title or "the selected job description").strip()
|
| 239 |
role = (role_title or "this role").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
return (
|
| 241 |
-
f"Introduce yourself and explain how your background aligns with {
|
| 242 |
-
f"
|
| 243 |
)
|
| 244 |
|
| 245 |
|
| 246 |
-
def _build_resume_resilient_followup_question(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
role_title = (session.get("role_title") or "this role").strip()
|
| 248 |
-
|
| 249 |
-
focus_skills = _safe_json_list(session.get("skills", "[]"))
|
| 250 |
-
skill_pool = jd_skills or focus_skills or ["core technical concepts"]
|
| 251 |
|
| 252 |
index = max(0, question_number - 1) + max(0, variant)
|
| 253 |
-
skill = skill_pool[index % len(skill_pool)]
|
| 254 |
|
| 255 |
templates = [
|
| 256 |
-
"
|
| 257 |
-
"
|
| 258 |
-
"
|
| 259 |
-
"
|
| 260 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
]
|
| 262 |
template = templates[index % len(templates)]
|
| 263 |
-
return template.format(
|
| 264 |
|
| 265 |
|
| 266 |
def _build_topic_resilient_followup_question(session: dict, question_number: int, variant: int = 0) -> str:
|
|
@@ -268,14 +489,14 @@ def _build_topic_resilient_followup_question(session: dict, question_number: int
|
|
| 268 |
index = max(0, question_number - 1) + max(0, variant)
|
| 269 |
|
| 270 |
templates = [
|
| 271 |
-
"
|
| 272 |
-
"
|
| 273 |
-
"
|
| 274 |
-
"
|
| 275 |
-
"
|
| 276 |
]
|
| 277 |
template = templates[index % len(templates)]
|
| 278 |
-
return template.format(
|
| 279 |
|
| 280 |
|
| 281 |
async def _enqueue_resume_followup_with_fallback(
|
|
@@ -287,8 +508,10 @@ async def _enqueue_resume_followup_with_fallback(
|
|
| 287 |
suggested_text: str,
|
| 288 |
suggested_difficulty: str,
|
| 289 |
suggested_category: str,
|
|
|
|
| 290 |
) -> tuple[str | None, bool]:
|
| 291 |
candidates: list[tuple[str, str, str, bool]] = []
|
|
|
|
| 292 |
|
| 293 |
primary = (suggested_text or "").strip()
|
| 294 |
if primary:
|
|
@@ -302,12 +525,17 @@ async def _enqueue_resume_followup_with_fallback(
|
|
| 302 |
session=session,
|
| 303 |
question_number=question_number,
|
| 304 |
variant=variant,
|
|
|
|
| 305 |
)
|
| 306 |
candidates.append((fallback_text, "medium", "resume-fallback", False))
|
| 307 |
|
| 308 |
seen: set[str] = set()
|
| 309 |
for text, difficulty, category, is_primary in candidates:
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
if not key or key in seen:
|
| 312 |
continue
|
| 313 |
seen.add(key)
|
|
@@ -315,13 +543,14 @@ async def _enqueue_resume_followup_with_fallback(
|
|
| 315 |
qid = await enqueue_question(
|
| 316 |
redis=redis,
|
| 317 |
session_id=session_id,
|
| 318 |
-
question=
|
| 319 |
difficulty=difficulty,
|
| 320 |
category=category,
|
| 321 |
ttl_seconds=SESSION_TTL,
|
| 322 |
max_queue_size=MAX_QUEUE_SIZE,
|
| 323 |
)
|
| 324 |
if qid:
|
|
|
|
| 325 |
return qid, is_primary
|
| 326 |
|
| 327 |
return None, False
|
|
@@ -338,6 +567,22 @@ async def _get_session_question_texts(redis, session_id: str) -> list[str]:
|
|
| 338 |
return output
|
| 339 |
|
| 340 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
async def _sample_topic_questions(
|
| 342 |
db,
|
| 343 |
topic_id: str,
|
|
@@ -706,13 +951,16 @@ async def _generate_question_batch(
|
|
| 706 |
async def _append_batch_to_redis(redis, session_id: str, batch: list[dict]) -> list[str]:
|
| 707 |
created_ids: list[str] = []
|
| 708 |
for item in batch:
|
|
|
|
|
|
|
|
|
|
| 709 |
qid = generate_id()
|
| 710 |
created_ids.append(qid)
|
| 711 |
await redis.hset(
|
| 712 |
f"session:{session_id}:q:{qid}",
|
| 713 |
mapping={
|
| 714 |
"question_id": qid,
|
| 715 |
-
"question":
|
| 716 |
"difficulty": item.get("difficulty", "medium"),
|
| 717 |
"category": item.get("category", "general"),
|
| 718 |
},
|
|
@@ -1054,7 +1302,7 @@ async def _start_topic_interview(user_id: str, topic_id: str) -> dict:
|
|
| 1054 |
f"session:{session_id}:q:{first_id}",
|
| 1055 |
mapping={
|
| 1056 |
"question_id": first_id,
|
| 1057 |
-
"question": first_question.get("question", "Can you explain this topic?"),
|
| 1058 |
"difficulty": first_question.get("difficulty", "medium"),
|
| 1059 |
"category": first_question.get("category", topic.get("name", "topic")),
|
| 1060 |
},
|
|
@@ -1166,7 +1414,7 @@ async def _start_topic_interview(user_id: str, topic_id: str) -> dict:
|
|
| 1166 |
},
|
| 1167 |
"question": {
|
| 1168 |
"question_id": first_id,
|
| 1169 |
-
"question": first_question.get("question", "Can you explain this topic?"),
|
| 1170 |
"difficulty": first_question.get("difficulty", "medium"),
|
| 1171 |
"question_number": 1,
|
| 1172 |
"total_questions": TOPIC_TOTAL_QUESTIONS,
|
|
@@ -1316,6 +1564,7 @@ async def start_interview(
|
|
| 1316 |
skills_for_interview = build_interview_focus_skills(base_skills_for_interview) or list(jd_required_skills)
|
| 1317 |
|
| 1318 |
intro_question = _build_resume_intro_question(role_title=role_title, jd_title=selected_jd.get("title", ""))
|
|
|
|
| 1319 |
|
| 1320 |
session_id = generate_id()
|
| 1321 |
_LOCAL_SUMMARIES[session_id] = ""
|
|
@@ -1497,6 +1746,17 @@ async def _post_submit_resume_processing(
|
|
| 1497 |
if not session:
|
| 1498 |
return
|
| 1499 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1500 |
recent_context = await get_recent_context_items(
|
| 1501 |
redis=redis,
|
| 1502 |
session_id=session_id,
|
|
@@ -1510,6 +1770,8 @@ async def _post_submit_resume_processing(
|
|
| 1510 |
current_question=question_text,
|
| 1511 |
current_answer=answer,
|
| 1512 |
excluded_questions=excluded_questions,
|
|
|
|
|
|
|
| 1513 |
)
|
| 1514 |
|
| 1515 |
await redis.hset(
|
|
@@ -1529,7 +1791,16 @@ async def _post_submit_resume_processing(
|
|
| 1529 |
}
|
| 1530 |
generated_count = _safe_int(session.get("generated_count", 0))
|
| 1531 |
|
| 1532 |
-
follow_text = (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1533 |
if answered_count < max_questions and session.get("status") == "in_progress":
|
| 1534 |
qid, used_model_followup = await _enqueue_resume_followup_with_fallback(
|
| 1535 |
redis=redis,
|
|
@@ -1539,6 +1810,7 @@ async def _post_submit_resume_processing(
|
|
| 1539 |
suggested_text=follow_text,
|
| 1540 |
suggested_difficulty=evaluation.get("difficulty", "medium"),
|
| 1541 |
suggested_category=evaluation.get("category", "follow-up"),
|
|
|
|
| 1542 |
)
|
| 1543 |
if qid:
|
| 1544 |
generated_count += 1
|
|
@@ -1775,6 +2047,9 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
|
|
| 1775 |
mapping={
|
| 1776 |
"question_id": question_id,
|
| 1777 |
"answer": answer,
|
|
|
|
|
|
|
|
|
|
| 1778 |
"submitted_at": utc_now(),
|
| 1779 |
},
|
| 1780 |
)
|
|
@@ -1782,6 +2057,24 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
|
|
| 1782 |
await redis.expire(f"session:{session_id}:a:{question_id}", SESSION_TTL)
|
| 1783 |
await redis.expire(f"session:{session_id}:answers", SESSION_TTL)
|
| 1784 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1785 |
question_count = _safe_int(session.get("question_count", 1))
|
| 1786 |
answered_count = _safe_int(session.get("answered_count", 0)) + 1
|
| 1787 |
served_count = _safe_int(session.get("served_count", 1))
|
|
@@ -1853,6 +2146,17 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
|
|
| 1853 |
|
| 1854 |
# Emergency fallback for rare queue-empty cases.
|
| 1855 |
if not next_question_id and interview_type == "resume":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1856 |
recent_context = await get_recent_context_items(
|
| 1857 |
redis=redis,
|
| 1858 |
session_id=session_id,
|
|
@@ -1866,6 +2170,8 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
|
|
| 1866 |
current_question=current_question_text,
|
| 1867 |
current_answer=answer,
|
| 1868 |
excluded_questions=excluded_questions,
|
|
|
|
|
|
|
| 1869 |
)
|
| 1870 |
|
| 1871 |
await redis.hset(
|
|
@@ -1883,7 +2189,16 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
|
|
| 1883 |
"bank_shortfall": 0,
|
| 1884 |
"generation_batches": 1,
|
| 1885 |
}
|
| 1886 |
-
follow_text = (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1887 |
if answered_count < max_questions:
|
| 1888 |
qid, used_model_followup = await _enqueue_resume_followup_with_fallback(
|
| 1889 |
redis=redis,
|
|
@@ -1893,6 +2208,7 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
|
|
| 1893 |
suggested_text=follow_text,
|
| 1894 |
suggested_difficulty=fallback_evaluation.get("difficulty", "medium"),
|
| 1895 |
suggested_category=fallback_evaluation.get("category", "follow-up"),
|
|
|
|
| 1896 |
)
|
| 1897 |
if qid:
|
| 1898 |
generated_count += 1
|
|
@@ -2143,9 +2459,33 @@ async def get_session_qa(session_id: str) -> list:
|
|
| 2143 |
"""Get all Q&A pairs from Redis for a session."""
|
| 2144 |
redis = get_redis()
|
| 2145 |
|
| 2146 |
-
|
| 2147 |
qa_pairs = []
|
| 2148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2149 |
for qid in question_ids:
|
| 2150 |
q = await redis.hgetall(f"session:{session_id}:q:{qid}")
|
| 2151 |
a = await redis.hgetall(f"session:{session_id}:a:{qid}")
|
|
|
|
| 21 |
flush_backlog_to_queue,
|
| 22 |
get_recent_context_items,
|
| 23 |
mark_question_asked,
|
| 24 |
+
normalize_question_text,
|
| 25 |
peek_next_question,
|
| 26 |
pop_next_question,
|
| 27 |
push_context_item,
|
|
|
|
| 46 |
TOPIC_AI_FOLLOWUPS = 3
|
| 47 |
TOPIC_DB_FOLLOWUPS = 2
|
| 48 |
TOPIC_TOTAL_QUESTIONS = 10
|
| 49 |
+
MAX_SAME_TOPIC_FOLLOWUPS = 2
|
| 50 |
+
THIRD_FOLLOWUP_NEED_SCORE = 95
|
| 51 |
|
| 52 |
# Local process memory summary requested in workflow.
|
| 53 |
_LOCAL_SUMMARIES: dict[str, str] = {}
|
| 54 |
_PREGEN_IN_FLIGHT: set[str] = set()
|
| 55 |
_POST_SUBMIT_LOCKS: dict[str, asyncio.Lock] = {}
|
| 56 |
+
_QUESTION_STOPWORDS = {
|
| 57 |
+
"a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "how", "if", "in", "into",
|
| 58 |
+
"is", "it", "of", "on", "or", "that", "the", "this", "to", "using", "what", "when", "with", "would",
|
| 59 |
+
}
|
| 60 |
+
_GENERIC_SOFT_SKILL_KEYS = {
|
| 61 |
+
"problem solving",
|
| 62 |
+
"analytical skills",
|
| 63 |
+
"communication",
|
| 64 |
+
"communication skills",
|
| 65 |
+
"teamwork",
|
| 66 |
+
"leadership",
|
| 67 |
+
"adaptability",
|
| 68 |
+
"time management",
|
| 69 |
+
"critical thinking",
|
| 70 |
+
}
|
| 71 |
|
| 72 |
|
| 73 |
def _safe_json_list(value: str) -> list:
|
|
|
|
| 85 |
return base
|
| 86 |
|
| 87 |
|
| 88 |
+
def _question_token_set(text: str) -> set[str]:
|
| 89 |
+
key = _question_fingerprint(text)
|
| 90 |
+
tokens = [token for token in key.split() if token and token not in _QUESTION_STOPWORDS]
|
| 91 |
+
return set(tokens)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _is_question_too_similar(candidate: str, recent_questions: list[str]) -> bool:
|
| 95 |
+
candidate_key = _question_fingerprint(candidate)
|
| 96 |
+
if not candidate_key:
|
| 97 |
+
return True
|
| 98 |
+
|
| 99 |
+
candidate_tokens = _question_token_set(candidate)
|
| 100 |
+
candidate_opening = " ".join(candidate_key.split()[:6])
|
| 101 |
+
|
| 102 |
+
for text in (recent_questions or [])[-5:]:
|
| 103 |
+
other_key = _question_fingerprint(text)
|
| 104 |
+
if not other_key:
|
| 105 |
+
continue
|
| 106 |
+
if candidate_key == other_key:
|
| 107 |
+
return True
|
| 108 |
+
|
| 109 |
+
other_opening = " ".join(other_key.split()[:6])
|
| 110 |
+
if candidate_opening and candidate_opening == other_opening:
|
| 111 |
+
return True
|
| 112 |
+
|
| 113 |
+
other_tokens = _question_token_set(text)
|
| 114 |
+
if not candidate_tokens or not other_tokens:
|
| 115 |
+
continue
|
| 116 |
+
|
| 117 |
+
intersection = len(candidate_tokens & other_tokens)
|
| 118 |
+
union = len(candidate_tokens | other_tokens)
|
| 119 |
+
if union <= 0:
|
| 120 |
+
continue
|
| 121 |
+
|
| 122 |
+
jaccard = intersection / union
|
| 123 |
+
if jaccard >= 0.72:
|
| 124 |
+
return True
|
| 125 |
+
|
| 126 |
+
return False
|
| 127 |
+
|
| 128 |
+
|
| 129 |
def _unique_question_items(items: list[dict], *, excluded_questions: list[str], limit: int) -> list[dict]:
|
| 130 |
excluded = {_question_fingerprint(q) for q in excluded_questions if q}
|
| 131 |
unique: list[dict] = []
|
|
|
|
| 163 |
return default
|
| 164 |
|
| 165 |
|
| 166 |
+
def _safe_score_0_100(value, default: int = 0) -> int:
|
| 167 |
+
score = _safe_int(value, default)
|
| 168 |
+
if score < 0:
|
| 169 |
+
return 0
|
| 170 |
+
if score > 100:
|
| 171 |
+
return 100
|
| 172 |
+
return score
|
| 173 |
+
|
| 174 |
+
|
| 175 |
def _normalize_voice_gender(value: str | None) -> str:
|
| 176 |
return "male" if (value or "").strip().lower() == "male" else "female"
|
| 177 |
|
|
|
|
| 232 |
return difficulty
|
| 233 |
|
| 234 |
|
| 235 |
+
def _resume_skill_pool(session: dict) -> list[str]:
|
| 236 |
+
jd_skills = normalize_skill_list(_safe_json_list(session.get("jd_required_skills", "[]")))
|
| 237 |
+
focus_skills = normalize_skill_list(_safe_json_list(session.get("skills", "[]")))
|
| 238 |
+
|
| 239 |
+
ordered: list[str] = []
|
| 240 |
+
seen: set[str] = set()
|
| 241 |
+
for skill in jd_skills + focus_skills:
|
| 242 |
+
key = _question_fingerprint(skill)
|
| 243 |
+
if not key or key in seen:
|
| 244 |
+
continue
|
| 245 |
+
seen.add(key)
|
| 246 |
+
ordered.append(skill)
|
| 247 |
+
|
| 248 |
+
concrete = [skill for skill in ordered if _question_fingerprint(skill) not in _GENERIC_SOFT_SKILL_KEYS]
|
| 249 |
+
if len(concrete) >= 2:
|
| 250 |
+
return concrete
|
| 251 |
+
|
| 252 |
+
return ordered or ["core technical concepts"]
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _infer_focus_skill_from_question(question_text: str, skill_pool: list[str]) -> str | None:
|
| 256 |
+
normalized_question = _question_fingerprint(question_text)
|
| 257 |
+
if not normalized_question:
|
| 258 |
+
return None
|
| 259 |
+
|
| 260 |
+
best_skill = None
|
| 261 |
+
best_score = 0
|
| 262 |
+
|
| 263 |
+
for skill in skill_pool:
|
| 264 |
+
normalized_skill = _question_fingerprint(skill)
|
| 265 |
+
if not normalized_skill:
|
| 266 |
+
continue
|
| 267 |
+
|
| 268 |
+
tokens = [token for token in normalized_skill.split() if len(token) >= 3]
|
| 269 |
+
if not tokens:
|
| 270 |
+
tokens = normalized_skill.split()
|
| 271 |
+
|
| 272 |
+
score = sum(1 for token in tokens if token and token in normalized_question)
|
| 273 |
+
if normalized_skill in normalized_question:
|
| 274 |
+
score = max(score, len(tokens) + 1)
|
| 275 |
+
|
| 276 |
+
if score > best_score:
|
| 277 |
+
best_score = score
|
| 278 |
+
best_skill = skill
|
| 279 |
+
|
| 280 |
+
return best_skill if best_score > 0 else None
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _recent_focus_streak(question_texts: list[str], skill_pool: list[str]) -> tuple[str | None, int]:
|
| 284 |
+
active_skill = None
|
| 285 |
+
streak = 0
|
| 286 |
+
|
| 287 |
+
for text in reversed(question_texts):
|
| 288 |
+
skill = _infer_focus_skill_from_question(text, skill_pool)
|
| 289 |
+
if not skill:
|
| 290 |
+
break
|
| 291 |
+
|
| 292 |
+
if active_skill is None:
|
| 293 |
+
active_skill = skill
|
| 294 |
+
streak = 1
|
| 295 |
+
continue
|
| 296 |
+
|
| 297 |
+
if _question_fingerprint(skill) == _question_fingerprint(active_skill):
|
| 298 |
+
streak += 1
|
| 299 |
+
continue
|
| 300 |
+
|
| 301 |
+
break
|
| 302 |
+
|
| 303 |
+
return active_skill, streak
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def _pick_alternate_focus_skill(skill_pool: list[str], current_skill: str | None, seed: int) -> str | None:
|
| 307 |
+
if not skill_pool:
|
| 308 |
+
return None
|
| 309 |
+
|
| 310 |
+
if current_skill:
|
| 311 |
+
current_key = _question_fingerprint(current_skill)
|
| 312 |
+
alternatives = [skill for skill in skill_pool if _question_fingerprint(skill) != current_key]
|
| 313 |
+
if alternatives:
|
| 314 |
+
return alternatives[max(0, seed) % len(alternatives)]
|
| 315 |
+
|
| 316 |
+
return skill_pool[max(0, seed) % len(skill_pool)]
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def _apply_resume_followup_policy(
|
| 320 |
+
*,
|
| 321 |
+
skill_pool: list[str],
|
| 322 |
+
recent_focus_topic: str | None,
|
| 323 |
+
same_topic_streak: int,
|
| 324 |
+
suggested_question: str,
|
| 325 |
+
suggested_topic: str | None,
|
| 326 |
+
followup_need_score: int,
|
| 327 |
+
answered_count: int,
|
| 328 |
+
) -> tuple[str, str | None]:
|
| 329 |
+
follow_text = (suggested_question or "").strip()
|
| 330 |
+
topic = (suggested_topic or "").strip()
|
| 331 |
+
|
| 332 |
+
if not topic and follow_text:
|
| 333 |
+
inferred = _infer_focus_skill_from_question(follow_text, skill_pool)
|
| 334 |
+
if inferred:
|
| 335 |
+
topic = inferred
|
| 336 |
+
|
| 337 |
+
topic_key = _question_fingerprint(topic)
|
| 338 |
+
recent_key = _question_fingerprint(recent_focus_topic or "")
|
| 339 |
+
|
| 340 |
+
if (
|
| 341 |
+
same_topic_streak >= MAX_SAME_TOPIC_FOLLOWUPS
|
| 342 |
+
and topic_key
|
| 343 |
+
and recent_key
|
| 344 |
+
and topic_key == recent_key
|
| 345 |
+
and _safe_score_0_100(followup_need_score) < THIRD_FOLLOWUP_NEED_SCORE
|
| 346 |
+
):
|
| 347 |
+
return "", _pick_alternate_focus_skill(skill_pool, recent_focus_topic, answered_count)
|
| 348 |
+
|
| 349 |
+
return follow_text, None
|
| 350 |
+
|
| 351 |
+
|
| 352 |
def _avg_recent_answer_words(qa_pairs: list, window: int = 3) -> int:
|
| 353 |
if not qa_pairs:
|
| 354 |
return 0
|
|
|
|
| 420 |
|
| 421 |
|
| 422 |
def _build_resume_intro_question(role_title: str, jd_title: str) -> str:
|
|
|
|
| 423 |
role = (role_title or "this role").strip()
|
| 424 |
+
title = (jd_title or "").strip()
|
| 425 |
+
|
| 426 |
+
def _normalized_key(value: str) -> str:
|
| 427 |
+
key = re.sub(r"[^a-z0-9\s]", " ", (value or "").lower())
|
| 428 |
+
key = re.sub(r"\s+", " ", key).strip()
|
| 429 |
+
for prefix in ("the ", "an ", "a "):
|
| 430 |
+
if key.startswith(prefix):
|
| 431 |
+
key = key[len(prefix):].strip()
|
| 432 |
+
break
|
| 433 |
+
return key
|
| 434 |
+
|
| 435 |
+
role_clean = re.sub(r"\s+", " ", role).strip()
|
| 436 |
+
if role_clean.lower().startswith("the "):
|
| 437 |
+
role_clean = role_clean[4:].strip()
|
| 438 |
+
role_phrase = f"the {role_clean}" if role_clean.lower().endswith(" role") else f"the {role_clean} role"
|
| 439 |
+
|
| 440 |
+
role_key = _normalized_key(role_clean)
|
| 441 |
+
title_key = _normalized_key(title)
|
| 442 |
+
is_generic_title = title_key in {
|
| 443 |
+
"",
|
| 444 |
+
"selected job description",
|
| 445 |
+
role_key,
|
| 446 |
+
f"{role_key} role",
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
if is_generic_title:
|
| 450 |
+
return f"Introduce yourself and explain how your background aligns with {role_phrase}."
|
| 451 |
+
|
| 452 |
+
title_phrase = title if title.lower().startswith(("the ", "an ", "a ")) else f"the {title}"
|
| 453 |
return (
|
| 454 |
+
f"Introduce yourself and explain how your background aligns with {role_phrase} "
|
| 455 |
+
f"in {title_phrase} job description."
|
| 456 |
)
|
| 457 |
|
| 458 |
|
| 459 |
+
def _build_resume_resilient_followup_question(
|
| 460 |
+
session: dict,
|
| 461 |
+
question_number: int,
|
| 462 |
+
variant: int = 0,
|
| 463 |
+
focus_skill: str | None = None,
|
| 464 |
+
) -> str:
|
| 465 |
role_title = (session.get("role_title") or "this role").strip()
|
| 466 |
+
skill_pool = _resume_skill_pool(session)
|
|
|
|
|
|
|
| 467 |
|
| 468 |
index = max(0, question_number - 1) + max(0, variant)
|
| 469 |
+
skill = (focus_skill or "").strip() or skill_pool[index % len(skill_pool)]
|
| 470 |
|
| 471 |
templates = [
|
| 472 |
+
"Describe a real project where you applied {skill} for {role}. What constraints and trade-offs shaped your design?",
|
| 473 |
+
"If {skill} failed in production for a {role} workflow, how would you debug it step by step?",
|
| 474 |
+
"Explain how you would test and validate a solution using {skill} before shipping it for {role}.",
|
| 475 |
+
"Compare two approaches for {skill} in a {role} context and justify the final choice.",
|
| 476 |
+
"Design an improvement plan to make your {skill} implementation more scalable and reliable for {role}.",
|
| 477 |
+
"Your {role} service using {skill} has intermittent latency spikes. How would you investigate and stabilize it?",
|
| 478 |
+
"During code review, what risks would you look for in a {skill} implementation for {role}, and why?",
|
| 479 |
+
"How would you design rollback and observability for a feature centered on {skill} in {role}?",
|
| 480 |
+
"Assume two engineers propose different {skill} strategies for {role}. How would you evaluate and choose between them?",
|
| 481 |
+
"What failure modes around {skill} are easiest to miss in {role}, and how would you proactively test them?",
|
| 482 |
]
|
| 483 |
template = templates[index % len(templates)]
|
| 484 |
+
return template.format(skill=skill, role=role_title)
|
| 485 |
|
| 486 |
|
| 487 |
def _build_topic_resilient_followup_question(session: dict, question_number: int, variant: int = 0) -> str:
|
|
|
|
| 489 |
index = max(0, question_number - 1) + max(0, variant)
|
| 490 |
|
| 491 |
templates = [
|
| 492 |
+
"Explain {topic} with a practical example from a production-like scenario.",
|
| 493 |
+
"What are the most common failure patterns in {topic}, and how would you detect them early?",
|
| 494 |
+
"Design a step-by-step implementation plan for {topic} with measurable checkpoints.",
|
| 495 |
+
"Compare two approaches in {topic}, including trade-offs in scalability, latency, and maintainability.",
|
| 496 |
+
"If a {topic} solution regressed after deployment, how would you triage and recover safely?",
|
| 497 |
]
|
| 498 |
template = templates[index % len(templates)]
|
| 499 |
+
return template.format(topic=topic_name)
|
| 500 |
|
| 501 |
|
| 502 |
async def _enqueue_resume_followup_with_fallback(
|
|
|
|
| 508 |
suggested_text: str,
|
| 509 |
suggested_difficulty: str,
|
| 510 |
suggested_category: str,
|
| 511 |
+
focus_skill_override: str | None = None,
|
| 512 |
) -> tuple[str | None, bool]:
|
| 513 |
candidates: list[tuple[str, str, str, bool]] = []
|
| 514 |
+
existing_questions = await _get_session_question_texts(redis, session_id)
|
| 515 |
|
| 516 |
primary = (suggested_text or "").strip()
|
| 517 |
if primary:
|
|
|
|
| 525 |
session=session,
|
| 526 |
question_number=question_number,
|
| 527 |
variant=variant,
|
| 528 |
+
focus_skill=focus_skill_override,
|
| 529 |
)
|
| 530 |
candidates.append((fallback_text, "medium", "resume-fallback", False))
|
| 531 |
|
| 532 |
seen: set[str] = set()
|
| 533 |
for text, difficulty, category, is_primary in candidates:
|
| 534 |
+
normalized_text = normalize_question_text(text)
|
| 535 |
+
if _is_question_too_similar(normalized_text, existing_questions):
|
| 536 |
+
continue
|
| 537 |
+
|
| 538 |
+
key = _question_fingerprint(normalized_text)
|
| 539 |
if not key or key in seen:
|
| 540 |
continue
|
| 541 |
seen.add(key)
|
|
|
|
| 543 |
qid = await enqueue_question(
|
| 544 |
redis=redis,
|
| 545 |
session_id=session_id,
|
| 546 |
+
question=normalized_text,
|
| 547 |
difficulty=difficulty,
|
| 548 |
category=category,
|
| 549 |
ttl_seconds=SESSION_TTL,
|
| 550 |
max_queue_size=MAX_QUEUE_SIZE,
|
| 551 |
)
|
| 552 |
if qid:
|
| 553 |
+
existing_questions.append(normalized_text)
|
| 554 |
return qid, is_primary
|
| 555 |
|
| 556 |
return None, False
|
|
|
|
| 567 |
return output
|
| 568 |
|
| 569 |
|
| 570 |
+
async def _get_answered_question_texts(redis, session_id: str, limit: int = 4) -> list[str]:
|
| 571 |
+
answer_ids = await redis.lrange(f"session:{session_id}:answers", -max(1, limit), -1)
|
| 572 |
+
output: list[str] = []
|
| 573 |
+
|
| 574 |
+
for qid in answer_ids:
|
| 575 |
+
answer_data = await redis.hgetall(f"session:{session_id}:a:{qid}")
|
| 576 |
+
text = (answer_data.get("question") or "").strip()
|
| 577 |
+
if not text:
|
| 578 |
+
q = await redis.hgetall(f"session:{session_id}:q:{qid}")
|
| 579 |
+
text = (q.get("question") or "").strip()
|
| 580 |
+
if text:
|
| 581 |
+
output.append(text)
|
| 582 |
+
|
| 583 |
+
return output
|
| 584 |
+
|
| 585 |
+
|
| 586 |
async def _sample_topic_questions(
|
| 587 |
db,
|
| 588 |
topic_id: str,
|
|
|
|
| 951 |
async def _append_batch_to_redis(redis, session_id: str, batch: list[dict]) -> list[str]:
|
| 952 |
created_ids: list[str] = []
|
| 953 |
for item in batch:
|
| 954 |
+
normalized_question = normalize_question_text(item.get("question", "Can you explain your approach?"))
|
| 955 |
+
if not normalized_question:
|
| 956 |
+
continue
|
| 957 |
qid = generate_id()
|
| 958 |
created_ids.append(qid)
|
| 959 |
await redis.hset(
|
| 960 |
f"session:{session_id}:q:{qid}",
|
| 961 |
mapping={
|
| 962 |
"question_id": qid,
|
| 963 |
+
"question": normalized_question,
|
| 964 |
"difficulty": item.get("difficulty", "medium"),
|
| 965 |
"category": item.get("category", "general"),
|
| 966 |
},
|
|
|
|
| 1302 |
f"session:{session_id}:q:{first_id}",
|
| 1303 |
mapping={
|
| 1304 |
"question_id": first_id,
|
| 1305 |
+
"question": normalize_question_text(first_question.get("question", "Can you explain this topic?")),
|
| 1306 |
"difficulty": first_question.get("difficulty", "medium"),
|
| 1307 |
"category": first_question.get("category", topic.get("name", "topic")),
|
| 1308 |
},
|
|
|
|
| 1414 |
},
|
| 1415 |
"question": {
|
| 1416 |
"question_id": first_id,
|
| 1417 |
+
"question": normalize_question_text(first_question.get("question", "Can you explain this topic?")),
|
| 1418 |
"difficulty": first_question.get("difficulty", "medium"),
|
| 1419 |
"question_number": 1,
|
| 1420 |
"total_questions": TOPIC_TOTAL_QUESTIONS,
|
|
|
|
| 1564 |
skills_for_interview = build_interview_focus_skills(base_skills_for_interview) or list(jd_required_skills)
|
| 1565 |
|
| 1566 |
intro_question = _build_resume_intro_question(role_title=role_title, jd_title=selected_jd.get("title", ""))
|
| 1567 |
+
intro_question = normalize_question_text(intro_question)
|
| 1568 |
|
| 1569 |
session_id = generate_id()
|
| 1570 |
_LOCAL_SUMMARIES[session_id] = ""
|
|
|
|
| 1746 |
if not session:
|
| 1747 |
return
|
| 1748 |
|
| 1749 |
+
skill_pool = _resume_skill_pool(session)
|
| 1750 |
+
recent_answered_questions = await _get_answered_question_texts(
|
| 1751 |
+
redis=redis,
|
| 1752 |
+
session_id=session_id,
|
| 1753 |
+
limit=4,
|
| 1754 |
+
)
|
| 1755 |
+
recent_focus_topic, same_topic_streak = _recent_focus_streak(
|
| 1756 |
+
recent_answered_questions,
|
| 1757 |
+
skill_pool,
|
| 1758 |
+
)
|
| 1759 |
+
|
| 1760 |
recent_context = await get_recent_context_items(
|
| 1761 |
redis=redis,
|
| 1762 |
session_id=session_id,
|
|
|
|
| 1770 |
current_question=question_text,
|
| 1771 |
current_answer=answer,
|
| 1772 |
excluded_questions=excluded_questions,
|
| 1773 |
+
focus_topic=recent_focus_topic or "",
|
| 1774 |
+
same_topic_streak=same_topic_streak,
|
| 1775 |
)
|
| 1776 |
|
| 1777 |
await redis.hset(
|
|
|
|
| 1791 |
}
|
| 1792 |
generated_count = _safe_int(session.get("generated_count", 0))
|
| 1793 |
|
| 1794 |
+
follow_text, focus_skill_override = _apply_resume_followup_policy(
|
| 1795 |
+
skill_pool=skill_pool,
|
| 1796 |
+
recent_focus_topic=recent_focus_topic,
|
| 1797 |
+
same_topic_streak=same_topic_streak,
|
| 1798 |
+
suggested_question=(evaluation.get("followup_question") or "").strip(),
|
| 1799 |
+
suggested_topic=(evaluation.get("followup_topic") or "").strip(),
|
| 1800 |
+
followup_need_score=_safe_score_0_100(evaluation.get("followup_need_score", 0)),
|
| 1801 |
+
answered_count=answered_count,
|
| 1802 |
+
)
|
| 1803 |
+
|
| 1804 |
if answered_count < max_questions and session.get("status") == "in_progress":
|
| 1805 |
qid, used_model_followup = await _enqueue_resume_followup_with_fallback(
|
| 1806 |
redis=redis,
|
|
|
|
| 1810 |
suggested_text=follow_text,
|
| 1811 |
suggested_difficulty=evaluation.get("difficulty", "medium"),
|
| 1812 |
suggested_category=evaluation.get("category", "follow-up"),
|
| 1813 |
+
focus_skill_override=focus_skill_override,
|
| 1814 |
)
|
| 1815 |
if qid:
|
| 1816 |
generated_count += 1
|
|
|
|
| 2047 |
mapping={
|
| 2048 |
"question_id": question_id,
|
| 2049 |
"answer": answer,
|
| 2050 |
+
"question": current_question_text,
|
| 2051 |
+
"difficulty": current_q.get("difficulty", "medium"),
|
| 2052 |
+
"category": current_q.get("category", "general"),
|
| 2053 |
"submitted_at": utc_now(),
|
| 2054 |
},
|
| 2055 |
)
|
|
|
|
| 2057 |
await redis.expire(f"session:{session_id}:a:{question_id}", SESSION_TTL)
|
| 2058 |
await redis.expire(f"session:{session_id}:answers", SESSION_TTL)
|
| 2059 |
|
| 2060 |
+
await db[ANSWERS].update_one(
|
| 2061 |
+
{
|
| 2062 |
+
"session_id": session_id,
|
| 2063 |
+
"question_id": question_id,
|
| 2064 |
+
"user_id": session.get("user_id"),
|
| 2065 |
+
},
|
| 2066 |
+
{
|
| 2067 |
+
"$set": {
|
| 2068 |
+
"question": current_question_text,
|
| 2069 |
+
"answer": answer,
|
| 2070 |
+
"difficulty": current_q.get("difficulty", "medium"),
|
| 2071 |
+
"category": current_q.get("category", "general"),
|
| 2072 |
+
"stored_at": utc_now(),
|
| 2073 |
+
}
|
| 2074 |
+
},
|
| 2075 |
+
upsert=True,
|
| 2076 |
+
)
|
| 2077 |
+
|
| 2078 |
question_count = _safe_int(session.get("question_count", 1))
|
| 2079 |
answered_count = _safe_int(session.get("answered_count", 0)) + 1
|
| 2080 |
served_count = _safe_int(session.get("served_count", 1))
|
|
|
|
| 2146 |
|
| 2147 |
# Emergency fallback for rare queue-empty cases.
|
| 2148 |
if not next_question_id and interview_type == "resume":
|
| 2149 |
+
skill_pool = _resume_skill_pool(session)
|
| 2150 |
+
recent_answered_questions = await _get_answered_question_texts(
|
| 2151 |
+
redis=redis,
|
| 2152 |
+
session_id=session_id,
|
| 2153 |
+
limit=4,
|
| 2154 |
+
)
|
| 2155 |
+
recent_focus_topic, same_topic_streak = _recent_focus_streak(
|
| 2156 |
+
recent_answered_questions,
|
| 2157 |
+
skill_pool,
|
| 2158 |
+
)
|
| 2159 |
+
|
| 2160 |
recent_context = await get_recent_context_items(
|
| 2161 |
redis=redis,
|
| 2162 |
session_id=session_id,
|
|
|
|
| 2170 |
current_question=current_question_text,
|
| 2171 |
current_answer=answer,
|
| 2172 |
excluded_questions=excluded_questions,
|
| 2173 |
+
focus_topic=recent_focus_topic or "",
|
| 2174 |
+
same_topic_streak=same_topic_streak,
|
| 2175 |
)
|
| 2176 |
|
| 2177 |
await redis.hset(
|
|
|
|
| 2189 |
"bank_shortfall": 0,
|
| 2190 |
"generation_batches": 1,
|
| 2191 |
}
|
| 2192 |
+
follow_text, focus_skill_override = _apply_resume_followup_policy(
|
| 2193 |
+
skill_pool=skill_pool,
|
| 2194 |
+
recent_focus_topic=recent_focus_topic,
|
| 2195 |
+
same_topic_streak=same_topic_streak,
|
| 2196 |
+
suggested_question=(fallback_evaluation.get("followup_question") or "").strip(),
|
| 2197 |
+
suggested_topic=(fallback_evaluation.get("followup_topic") or "").strip(),
|
| 2198 |
+
followup_need_score=_safe_score_0_100(fallback_evaluation.get("followup_need_score", 0)),
|
| 2199 |
+
answered_count=answered_count,
|
| 2200 |
+
)
|
| 2201 |
+
|
| 2202 |
if answered_count < max_questions:
|
| 2203 |
qid, used_model_followup = await _enqueue_resume_followup_with_fallback(
|
| 2204 |
redis=redis,
|
|
|
|
| 2208 |
suggested_text=follow_text,
|
| 2209 |
suggested_difficulty=fallback_evaluation.get("difficulty", "medium"),
|
| 2210 |
suggested_category=fallback_evaluation.get("category", "follow-up"),
|
| 2211 |
+
focus_skill_override=focus_skill_override,
|
| 2212 |
)
|
| 2213 |
if qid:
|
| 2214 |
generated_count += 1
|
|
|
|
| 2459 |
"""Get all Q&A pairs from Redis for a session."""
|
| 2460 |
redis = get_redis()
|
| 2461 |
|
| 2462 |
+
answer_ids = await redis.lrange(f"session:{session_id}:answers", 0, -1)
|
| 2463 |
qa_pairs = []
|
| 2464 |
|
| 2465 |
+
if answer_ids:
|
| 2466 |
+
for qid in answer_ids:
|
| 2467 |
+
q = await redis.hgetall(f"session:{session_id}:q:{qid}")
|
| 2468 |
+
a = await redis.hgetall(f"session:{session_id}:a:{qid}")
|
| 2469 |
+
if not a:
|
| 2470 |
+
continue
|
| 2471 |
+
|
| 2472 |
+
question_text = (a.get("question") or q.get("question") or "").strip()
|
| 2473 |
+
answer_text = (a.get("answer") or "").strip()
|
| 2474 |
+
if not question_text or not answer_text:
|
| 2475 |
+
continue
|
| 2476 |
+
|
| 2477 |
+
qa_pairs.append({
|
| 2478 |
+
"question_id": qid,
|
| 2479 |
+
"question": question_text,
|
| 2480 |
+
"answer": answer_text,
|
| 2481 |
+
"difficulty": a.get("difficulty") or q.get("difficulty", "medium"),
|
| 2482 |
+
"category": a.get("category") or q.get("category", "general"),
|
| 2483 |
+
})
|
| 2484 |
+
|
| 2485 |
+
if qa_pairs:
|
| 2486 |
+
return qa_pairs
|
| 2487 |
+
|
| 2488 |
+
question_ids = await redis.lrange(f"session:{session_id}:questions", 0, -1)
|
| 2489 |
for qid in question_ids:
|
| 2490 |
q = await redis.hgetall(f"session:{session_id}:q:{qid}")
|
| 2491 |
a = await redis.hgetall(f"session:{session_id}:a:{qid}")
|
backend/services/queue_service.py
CHANGED
|
@@ -9,14 +9,32 @@ QUESTION_QUEUE_SUFFIX = "question_queue"
|
|
| 9 |
QUESTION_BACKLOG_SUFFIX = "question_backlog"
|
| 10 |
CONTEXT_CACHE_SUFFIX = "context_cache"
|
| 11 |
ASKED_SET_SUFFIX = "asked_questions_set"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
def _key(session_id: str, suffix: str) -> str:
|
| 15 |
return f"session:{session_id}:{suffix}"
|
| 16 |
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
def question_fingerprint(text: str) -> str:
|
| 19 |
-
value = (text
|
| 20 |
value = re.sub(r"[^a-z0-9\s]", " ", value)
|
| 21 |
value = re.sub(r"\s+", " ", value).strip()
|
| 22 |
return value
|
|
@@ -61,6 +79,7 @@ async def _append_question_object(
|
|
| 61 |
category: str,
|
| 62 |
ttl_seconds: int,
|
| 63 |
) -> str:
|
|
|
|
| 64 |
qid = generate_id()
|
| 65 |
q_key = f"session:{session_id}:q:{qid}"
|
| 66 |
|
|
@@ -68,7 +87,7 @@ async def _append_question_object(
|
|
| 68 |
q_key,
|
| 69 |
mapping={
|
| 70 |
"question_id": qid,
|
| 71 |
-
"question":
|
| 72 |
"difficulty": difficulty or "medium",
|
| 73 |
"category": category or "general",
|
| 74 |
},
|
|
@@ -90,7 +109,7 @@ async def enqueue_question(
|
|
| 90 |
ttl_seconds: int = 7200,
|
| 91 |
max_queue_size: int = 3,
|
| 92 |
) -> Optional[str]:
|
| 93 |
-
text = (question
|
| 94 |
if not text:
|
| 95 |
return None
|
| 96 |
|
|
|
|
| 9 |
QUESTION_BACKLOG_SUFFIX = "question_backlog"
|
| 10 |
CONTEXT_CACHE_SUFFIX = "context_cache"
|
| 11 |
ASKED_SET_SUFFIX = "asked_questions_set"
|
| 12 |
+
QUESTION_PREFIX_RE = re.compile(
|
| 13 |
+
r"^\s*(?:question|q)\s*#?\s*\d+(?:\s*of\s*\d+)?\s*[\:\-\)\.]\s*",
|
| 14 |
+
re.IGNORECASE,
|
| 15 |
+
)
|
| 16 |
|
| 17 |
|
| 18 |
def _key(session_id: str, suffix: str) -> str:
|
| 19 |
return f"session:{session_id}:{suffix}"
|
| 20 |
|
| 21 |
|
| 22 |
+
def normalize_question_text(text: str) -> str:
|
| 23 |
+
value = (text or "").strip()
|
| 24 |
+
if not value:
|
| 25 |
+
return ""
|
| 26 |
+
|
| 27 |
+
while True:
|
| 28 |
+
updated = QUESTION_PREFIX_RE.sub("", value).strip()
|
| 29 |
+
if updated == value:
|
| 30 |
+
break
|
| 31 |
+
value = updated
|
| 32 |
+
|
| 33 |
+
return value
|
| 34 |
+
|
| 35 |
+
|
| 36 |
def question_fingerprint(text: str) -> str:
|
| 37 |
+
value = normalize_question_text(text).lower()
|
| 38 |
value = re.sub(r"[^a-z0-9\s]", " ", value)
|
| 39 |
value = re.sub(r"\s+", " ", value).strip()
|
| 40 |
return value
|
|
|
|
| 79 |
category: str,
|
| 80 |
ttl_seconds: int,
|
| 81 |
) -> str:
|
| 82 |
+
normalized_question = normalize_question_text(question)
|
| 83 |
qid = generate_id()
|
| 84 |
q_key = f"session:{session_id}:q:{qid}"
|
| 85 |
|
|
|
|
| 87 |
q_key,
|
| 88 |
mapping={
|
| 89 |
"question_id": qid,
|
| 90 |
+
"question": normalized_question,
|
| 91 |
"difficulty": difficulty or "medium",
|
| 92 |
"category": category or "general",
|
| 93 |
},
|
|
|
|
| 109 |
ttl_seconds: int = 7200,
|
| 110 |
max_queue_size: int = 3,
|
| 111 |
) -> Optional[str]:
|
| 112 |
+
text = normalize_question_text(question)
|
| 113 |
if not text:
|
| 114 |
return None
|
| 115 |
|
backend/utils/gemini.py
CHANGED
|
@@ -14,6 +14,29 @@ settings = get_settings()
|
|
| 14 |
client = genai.Client(api_key=settings.GEMINI_API_KEY)
|
| 15 |
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
def _is_transient_gemini_error(error: Exception) -> bool:
|
| 18 |
message = str(error or "").lower()
|
| 19 |
transient_markers = [
|
|
@@ -28,6 +51,26 @@ def _is_transient_gemini_error(error: Exception) -> bool:
|
|
| 28 |
return any(marker in message for marker in transient_markers)
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
async def call_gemini(
|
| 32 |
prompt: str,
|
| 33 |
system_instruction: str = None,
|
|
@@ -43,34 +86,51 @@ async def call_gemini(
|
|
| 43 |
config["response_mime_type"] = "application/json"
|
| 44 |
|
| 45 |
last_error = None
|
|
|
|
| 46 |
|
| 47 |
attempts = max(1, int(max_attempts or 1))
|
| 48 |
for attempt in range(attempts):
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
asyncio.
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
await
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
elapsed_ms = (perf_counter() - started_at) * 1000.0
|
| 76 |
await record_latency("gemini_ms", elapsed_ms)
|
|
@@ -705,53 +765,158 @@ No markdown, no extra text."""
|
|
| 705 |
|
| 706 |
async def evaluate_interview(questions_and_answers: list, role_title: str) -> dict:
|
| 707 |
"""Batch evaluate all interview Q&A pairs using Gemini."""
|
| 708 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 709 |
for i, qa in enumerate(questions_and_answers, 1):
|
| 710 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 711 |
|
| 712 |
prompt_template = PromptTemplate.from_template(
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
Here are the interview questions and the candidate's answers:
|
| 716 |
-
{qa_text}
|
| 717 |
-
|
| 718 |
-
Scoring policy (concept-first, strict):
|
| 719 |
-
1. Score primarily on conceptual correctness, depth, and reasoning quality.
|
| 720 |
-
2. Do NOT reward answer length, confidence, or communication style when concepts are wrong.
|
| 721 |
-
3. Penalize vague, hand-wavy, or uncertain answers.
|
| 722 |
-
4. Penalize technically incorrect claims even if explanation sounds fluent.
|
| 723 |
-
5. Reward precise mechanisms, trade-offs, edge cases, and debugging logic.
|
| 724 |
-
|
| 725 |
-
Score rubric per answer:
|
| 726 |
-
- 90-100: conceptually correct, deep, and accurate with strong reasoning
|
| 727 |
-
- 70-89: mostly correct with minor conceptual gaps
|
| 728 |
-
- 50-69: partially correct but misses key mechanisms
|
| 729 |
-
- 30-49: shallow/vague with major conceptual gaps
|
| 730 |
-
- 0-29: incorrect or off-topic
|
| 731 |
-
|
| 732 |
-
Return a JSON object with:
|
| 733 |
-
- "overall_score": integer from 0-100
|
| 734 |
-
- "detailed_scores": list of objects, each with:
|
| 735 |
-
- "question": the question text
|
| 736 |
-
- "answer": the answer text
|
| 737 |
-
- "score": integer 0-100
|
| 738 |
-
- "feedback": concise concept-focused feedback for this answer
|
| 739 |
-
- "strengths": list of 3-5 strength areas
|
| 740 |
-
- "weaknesses": list of 3-5 concept gaps
|
| 741 |
-
- "recommendations": list of 3-5 actionable concept-improvement recommendations
|
| 742 |
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
|
|
|
|
| 747 |
try:
|
| 748 |
-
result = _extract_json_object(
|
| 749 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 750 |
except Exception:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
return {
|
| 752 |
-
"overall_score":
|
| 753 |
-
"detailed_scores":
|
| 754 |
-
"strengths":
|
| 755 |
-
"weaknesses":
|
| 756 |
-
"recommendations":
|
| 757 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
client = genai.Client(api_key=settings.GEMINI_API_KEY)
|
| 15 |
|
| 16 |
|
| 17 |
+
def _extract_response_text(response) -> str:
|
| 18 |
+
text = (getattr(response, "text", None) or "").strip()
|
| 19 |
+
if text:
|
| 20 |
+
return text
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
candidates = getattr(response, "candidates", None) or []
|
| 24 |
+
for candidate in candidates:
|
| 25 |
+
content = getattr(candidate, "content", None)
|
| 26 |
+
parts = getattr(content, "parts", None) or []
|
| 27 |
+
gathered = []
|
| 28 |
+
for part in parts:
|
| 29 |
+
part_text = getattr(part, "text", None)
|
| 30 |
+
if isinstance(part_text, str) and part_text.strip():
|
| 31 |
+
gathered.append(part_text.strip())
|
| 32 |
+
if gathered:
|
| 33 |
+
return "\n".join(gathered).strip()
|
| 34 |
+
except Exception:
|
| 35 |
+
return ""
|
| 36 |
+
|
| 37 |
+
return ""
|
| 38 |
+
|
| 39 |
+
|
| 40 |
def _is_transient_gemini_error(error: Exception) -> bool:
|
| 41 |
message = str(error or "").lower()
|
| 42 |
transient_markers = [
|
|
|
|
| 51 |
return any(marker in message for marker in transient_markers)
|
| 52 |
|
| 53 |
|
| 54 |
+
def _candidate_gemini_models() -> list[str]:
|
| 55 |
+
configured = [
|
| 56 |
+
item.strip()
|
| 57 |
+
for item in (getattr(settings, "GEMINI_FALLBACK_MODELS", "") or "").split(",")
|
| 58 |
+
if item and item.strip()
|
| 59 |
+
]
|
| 60 |
+
defaults = ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-flash-latest"]
|
| 61 |
+
|
| 62 |
+
ordered = [settings.GEMINI_MODEL, *configured, *defaults]
|
| 63 |
+
seen: set[str] = set()
|
| 64 |
+
unique: list[str] = []
|
| 65 |
+
for model in ordered:
|
| 66 |
+
key = (model or "").strip()
|
| 67 |
+
if not key or key in seen:
|
| 68 |
+
continue
|
| 69 |
+
seen.add(key)
|
| 70 |
+
unique.append(key)
|
| 71 |
+
return unique
|
| 72 |
+
|
| 73 |
+
|
| 74 |
async def call_gemini(
|
| 75 |
prompt: str,
|
| 76 |
system_instruction: str = None,
|
|
|
|
| 86 |
config["response_mime_type"] = "application/json"
|
| 87 |
|
| 88 |
last_error = None
|
| 89 |
+
model_candidates = _candidate_gemini_models()
|
| 90 |
|
| 91 |
attempts = max(1, int(max_attempts or 1))
|
| 92 |
for attempt in range(attempts):
|
| 93 |
+
for model_name in model_candidates:
|
| 94 |
+
try:
|
| 95 |
+
def _invoke():
|
| 96 |
+
return client.models.generate_content(
|
| 97 |
+
model=model_name,
|
| 98 |
+
contents=prompt,
|
| 99 |
+
config=config if config else None,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
if request_timeout_seconds and request_timeout_seconds > 0:
|
| 103 |
+
response = await asyncio.wait_for(
|
| 104 |
+
asyncio.to_thread(_invoke),
|
| 105 |
+
timeout=request_timeout_seconds,
|
| 106 |
+
)
|
| 107 |
+
else:
|
| 108 |
+
response = await asyncio.to_thread(_invoke)
|
| 109 |
+
|
| 110 |
+
response_text = _extract_response_text(response)
|
| 111 |
+
if not response_text:
|
| 112 |
+
raise RuntimeError("Gemini returned an empty response")
|
| 113 |
+
|
| 114 |
+
elapsed_ms = (perf_counter() - started_at) * 1000.0
|
| 115 |
+
await record_latency("gemini_ms", elapsed_ms)
|
| 116 |
+
return response_text
|
| 117 |
+
except Exception as exc:
|
| 118 |
+
last_error = exc
|
| 119 |
+
# Try next model candidate immediately on transient/unavailable errors.
|
| 120 |
+
if _is_transient_gemini_error(exc):
|
| 121 |
+
continue
|
| 122 |
+
|
| 123 |
+
# Model-not-found style errors should try the next candidate too.
|
| 124 |
+
message = str(exc or "").lower()
|
| 125 |
+
if "not found" in message or "unsupported" in message:
|
| 126 |
+
continue
|
| 127 |
+
|
| 128 |
+
break
|
| 129 |
+
|
| 130 |
+
if _is_transient_gemini_error(last_error) and attempt < attempts - 1:
|
| 131 |
+
await asyncio.sleep(0.8 * (attempt + 1))
|
| 132 |
+
continue
|
| 133 |
+
break
|
| 134 |
|
| 135 |
elapsed_ms = (perf_counter() - started_at) * 1000.0
|
| 136 |
await record_latency("gemini_ms", elapsed_ms)
|
|
|
|
| 765 |
|
| 766 |
async def evaluate_interview(questions_and_answers: list, role_title: str) -> dict:
|
| 767 |
"""Batch evaluate all interview Q&A pairs using Gemini."""
|
| 768 |
+
|
| 769 |
+
def _clamp_score(value, default: int = 50) -> int:
|
| 770 |
+
try:
|
| 771 |
+
score = int(value)
|
| 772 |
+
except Exception:
|
| 773 |
+
score = default
|
| 774 |
+
return max(0, min(100, score))
|
| 775 |
+
|
| 776 |
+
def _fallback_item_score(answer: str) -> int:
|
| 777 |
+
text = (answer or "").strip().lower()
|
| 778 |
+
words = len(text.split())
|
| 779 |
+
if words < 10:
|
| 780 |
+
return 35
|
| 781 |
+
if words < 25:
|
| 782 |
+
return 52
|
| 783 |
+
if any(marker in text for marker in ["not sure", "maybe", "i think", "dont know", "don't know"]):
|
| 784 |
+
return 50
|
| 785 |
+
if words > 90:
|
| 786 |
+
return 74
|
| 787 |
+
return 64
|
| 788 |
+
|
| 789 |
+
if not questions_and_answers:
|
| 790 |
+
return {
|
| 791 |
+
"overall_score": 50,
|
| 792 |
+
"detailed_scores": [],
|
| 793 |
+
"strengths": ["No answers were available for evaluation"],
|
| 794 |
+
"weaknesses": ["No answers were available for evaluation"],
|
| 795 |
+
"recommendations": ["Complete the interview and generate report again"],
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
compact_qa = []
|
| 799 |
for i, qa in enumerate(questions_and_answers, 1):
|
| 800 |
+
question = (qa.get("question") or "").strip()
|
| 801 |
+
answer = (qa.get("answer") or "").strip()
|
| 802 |
+
compact_qa.append(
|
| 803 |
+
{
|
| 804 |
+
"index": i,
|
| 805 |
+
"question": question[:260],
|
| 806 |
+
"answer": answer[:520],
|
| 807 |
+
}
|
| 808 |
+
)
|
| 809 |
+
|
| 810 |
+
payload = {
|
| 811 |
+
"role_title": role_title,
|
| 812 |
+
"question_count": len(compact_qa),
|
| 813 |
+
"qa": compact_qa,
|
| 814 |
+
}
|
| 815 |
|
| 816 |
prompt_template = PromptTemplate.from_template(
|
| 817 |
+
"""You are a strict technical interviewer evaluating a candidate for role: {role_title}.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 818 |
|
| 819 |
+
Input JSON:
|
| 820 |
+
{payload}
|
| 821 |
+
|
| 822 |
+
Scoring policy:
|
| 823 |
+
1) Score conceptual correctness and depth, not verbosity.
|
| 824 |
+
2) Penalize vague, uncertain, or incorrect technical claims.
|
| 825 |
+
3) Reward concrete reasoning, trade-offs, and debugging clarity.
|
| 826 |
+
|
| 827 |
+
Return ONLY valid JSON object with this exact schema:
|
| 828 |
+
{{
|
| 829 |
+
"overall_score": 0-100 integer,
|
| 830 |
+
"per_question": [
|
| 831 |
+
{{"index": 1-based integer, "score": 0-100 integer, "feedback": "short concept-focused feedback"}}
|
| 832 |
+
],
|
| 833 |
+
"strengths": ["3 to 5 concise points"],
|
| 834 |
+
"weaknesses": ["3 to 5 concise points"],
|
| 835 |
+
"recommendations": ["3 to 5 actionable points"]
|
| 836 |
+
}}
|
| 837 |
+
|
| 838 |
+
Rules:
|
| 839 |
+
- per_question must include every question index from 1..question_count exactly once.
|
| 840 |
+
- Do NOT echo full question or answer text in output.
|
| 841 |
+
- Keep each feedback under 220 characters.
|
| 842 |
+
"""
|
| 843 |
+
)
|
| 844 |
+
prompt = prompt_template.format(
|
| 845 |
+
role_title=role_title,
|
| 846 |
+
payload=json.dumps(payload, ensure_ascii=True),
|
| 847 |
+
)
|
| 848 |
|
| 849 |
+
parsed = None
|
| 850 |
try:
|
| 851 |
+
result = _extract_json_object(
|
| 852 |
+
await call_gemini(
|
| 853 |
+
prompt,
|
| 854 |
+
max_attempts=3,
|
| 855 |
+
request_timeout_seconds=45,
|
| 856 |
+
)
|
| 857 |
+
)
|
| 858 |
+
parsed = json.loads(result)
|
| 859 |
except Exception:
|
| 860 |
+
parsed = None
|
| 861 |
+
|
| 862 |
+
score_map: dict[int, tuple[int, str]] = {}
|
| 863 |
+
if isinstance(parsed, dict):
|
| 864 |
+
for item in parsed.get("per_question", []) or []:
|
| 865 |
+
if not isinstance(item, dict):
|
| 866 |
+
continue
|
| 867 |
+
idx = item.get("index")
|
| 868 |
+
try:
|
| 869 |
+
index = int(idx)
|
| 870 |
+
except Exception:
|
| 871 |
+
continue
|
| 872 |
+
if index < 1 or index > len(questions_and_answers):
|
| 873 |
+
continue
|
| 874 |
+
score = _clamp_score(item.get("score"), _fallback_item_score(questions_and_answers[index - 1].get("answer", "")))
|
| 875 |
+
feedback = (item.get("feedback") or "").strip() or "Answer reviewed with focus on conceptual correctness."
|
| 876 |
+
score_map[index] = (score, feedback)
|
| 877 |
+
|
| 878 |
+
detailed_scores = []
|
| 879 |
+
for index, qa in enumerate(questions_and_answers, 1):
|
| 880 |
+
fallback_score = _fallback_item_score(qa.get("answer", ""))
|
| 881 |
+
score, feedback = score_map.get(
|
| 882 |
+
index,
|
| 883 |
+
(fallback_score, "Could not derive detailed AI feedback for this answer; score based on response quality signals."),
|
| 884 |
+
)
|
| 885 |
+
detailed_scores.append(
|
| 886 |
+
{
|
| 887 |
+
"question": qa.get("question", ""),
|
| 888 |
+
"answer": qa.get("answer", ""),
|
| 889 |
+
"score": score,
|
| 890 |
+
"feedback": feedback,
|
| 891 |
+
}
|
| 892 |
+
)
|
| 893 |
+
|
| 894 |
+
if isinstance(parsed, dict):
|
| 895 |
+
overall_score = _clamp_score(parsed.get("overall_score"), int(round(sum(item["score"] for item in detailed_scores) / max(1, len(detailed_scores)))))
|
| 896 |
+
strengths = [str(s).strip() for s in (parsed.get("strengths") or []) if str(s).strip()][:5]
|
| 897 |
+
weaknesses = [str(w).strip() for w in (parsed.get("weaknesses") or []) if str(w).strip()][:5]
|
| 898 |
+
recommendations = [str(r).strip() for r in (parsed.get("recommendations") or []) if str(r).strip()][:5]
|
| 899 |
+
|
| 900 |
+
if not strengths:
|
| 901 |
+
strengths = ["Shows baseline understanding in parts of the discussion"]
|
| 902 |
+
if not weaknesses:
|
| 903 |
+
weaknesses = ["Needs deeper concept-level reasoning and sharper technical precision"]
|
| 904 |
+
if not recommendations:
|
| 905 |
+
recommendations = ["Practice answering with mechanisms, trade-offs, and one concrete production example per question"]
|
| 906 |
+
|
| 907 |
return {
|
| 908 |
+
"overall_score": overall_score,
|
| 909 |
+
"detailed_scores": detailed_scores,
|
| 910 |
+
"strengths": strengths,
|
| 911 |
+
"weaknesses": weaknesses,
|
| 912 |
+
"recommendations": recommendations,
|
| 913 |
}
|
| 914 |
+
|
| 915 |
+
fallback_overall = int(round(sum(item["score"] for item in detailed_scores) / max(1, len(detailed_scores))))
|
| 916 |
+
return {
|
| 917 |
+
"overall_score": _clamp_score(fallback_overall, 50),
|
| 918 |
+
"detailed_scores": detailed_scores,
|
| 919 |
+
"strengths": ["Attempted responses for all interview prompts"],
|
| 920 |
+
"weaknesses": ["Detailed AI evaluation was unavailable for this run"],
|
| 921 |
+
"recommendations": ["Retry report generation to get full AI feedback"],
|
| 922 |
+
}
|