vadirajkrishna commited on
Commit
4e8fb20
·
unverified ·
0 Parent(s):

initial commit - InterviewCopilotLocal

Browse files
.gitignore ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg-info/
8
+ .eggs/
9
+ build/
10
+ dist/
11
+
12
+ # Virtual environments
13
+ .venv/
14
+ venv/
15
+ env/
16
+ ENV/
17
+
18
+ # Local environment and secrets
19
+ .env
20
+ .env.*
21
+ !.env.example
22
+ *.pem
23
+ *.key
24
+ *.crt
25
+ *.p12
26
+ *.pfx
27
+
28
+ # App runtime data
29
+ .runtime/
30
+ interviews.db
31
+ *.db
32
+ *.sqlite
33
+ *.sqlite3
34
+ live_audi_transcript.md
35
+ *.csv
36
+
37
+ # Model and training artifacts
38
+ models/
39
+ checkpoints/
40
+ outputs/
41
+ runs/
42
+ wandb/
43
+ mlruns/
44
+ *.safetensors
45
+ *.bin
46
+ *.pt
47
+ *.pth
48
+ *.ckpt
49
+ *.gguf
50
+
51
+ # Hugging Face / ML caches
52
+ .cache/
53
+ huggingface/
54
+ hf_cache/
55
+
56
+ # Gradio
57
+ flagged/
58
+ .gradio/
59
+
60
+ # Logs
61
+ *.log
62
+ logs/
63
+
64
+ # OS and editor files
65
+ .DS_Store
66
+ Thumbs.db
67
+ .idea/
68
+ .vscode/
69
+ *.swp
70
+ *.swo
71
+
72
+ # Test and coverage artifacts
73
+ .pytest_cache/
74
+ .mypy_cache/
75
+ .ruff_cache/
76
+ .coverage
77
+ htmlcov/
AGENTS.md ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # InterviewCoach — Project Brief
2
+ > Silent real-time coaching during interviews. Runs 100% local on your Mac.
3
+
4
+ ---
5
+
6
+ ## What It Does
7
+
8
+ When user is in an interview, the app listens to the interviewer's question via mic → transcribes it → classifies the question type → displays a compact coaching card with the framework to follow. You glance at it, then answer. No cloud. No latency. No trace.
9
+
10
+ APp will add if the question is asked by interviewer or candidate and also tags if answer is answered by the candidate or interviewer. If there is a low cofidence, flags for user updates later.
11
+
12
+ As a second part, there is another agent who goes through the transcripts and rates the interview by adding critical feedbacks highlighting areas for improvements.
13
+
14
+ ---
15
+ ## Architecture
16
+ LangGraph agentic pipeline. Each node is a discrete async function. State flows through the graph.
17
+
18
+ ```
19
+ AudioNode → TranscriptNode → ClassifierAgent → FrameworkNode → UINode
20
+
21
+ EvaluationAgent (post-session)
22
+ ```
23
+
24
+ ---
25
+
26
+ ## Stack
27
+ - **Framework**: LangGraph + LangChain
28
+ - **LLM**: Qwen2.5 7B Instruct via Ollama (Part 1) / fine-tuned Qwen2.5 3B via llama.cpp (Part 2)
29
+ - **STT**: mlx-whisper (whisper-small-mlx)
30
+ - **UI**: Gradio with custom dark-mode CSS
31
+ - **Database**: SQLite (via `aiosqlite` for async)
32
+ - **Runtime**: Python 3.11+, Apple Silicon M1 Pro
33
+ ---
34
+
35
+ ## Coding Rules
36
+ - All nodes and DB calls must be `async`/`await` — no blocking I/O on the main thread
37
+ - Use `aiosqlite` for all database operations
38
+ - Use `asyncio.Queue` for audio chunk passing between nodes
39
+ - LangGraph state must be a typed `TypedDict`
40
+ - Each agent/node lives in its own file under `agents/`
41
+ ---
42
+
43
+ ## Agents
44
+
45
+ ### ClassifierAgent
46
+ - Input: transcribed question string
47
+ - Output: `{type: str, steps: list[str]}`
48
+ - Uses Qwen2.5 7B Instruct with structured output prompt
49
+ - Must return valid framework type — fallback to "General" if uncertain
50
+ ### EvaluationAgent
51
+ - Input: `{question: str, answer: str, framework: str, steps: list[str]}`
52
+ - Output: `{steps_covered: list[bool], score: int, feedback: str}`
53
+ - Runs post-session, not real-time
54
+ - One evaluation card per Q&A exchange
55
+ ---
56
+
57
+ ## Database (SQLite)
58
+ File: `interviews.db`
59
+
60
+ ```sql
61
+ sessions (id, date, company, role, duration)
62
+ exchanges (id, session_id, question, answer, framework_used, timestamp)
63
+ evaluations (id, exchange_id, steps_covered_json, score, feedback)
64
+ transcripts (id, session_id, raw_text, labelled_json)
65
+ patterns (framework, times_shown, avg_score, most_missed_step)
66
+ ```
67
+
68
+ - All DB access via `aiosqlite`
69
+ - Init schema on app startup if tables don't exist
70
+ - Never block the event loop with synchronous sqlite3 calls
71
+ ---
72
+
73
+ ## Gradio UI
74
+ - Dark mode custom CSS — no default Gradio theme
75
+ - Three tabs: **Live** (coaching cards) · **Session Log** (transcript) · **Evaluate** (post-interview report)
76
+ - Coaching cards: colour-coded by framework type, step-by-step list
77
+ - Use `gr.Blocks` not `gr.Interface`
78
+ - UI updates via async generator / `queue=True`
79
+ ---
80
+
81
+ ## File Structure
82
+ ```
83
+ interview-coach/
84
+ ├── AGENTS.md
85
+ ├── app.py # Gradio entry point
86
+ ├── graph.py # LangGraph pipeline definition
87
+ ├── state.py # TypedDict state schema
88
+ ├── agents/
89
+ │ ├── classifier.py # ClassifierAgent
90
+ │ └── evaluator.py # EvaluationAgent
91
+ ├── nodes/
92
+ │ ├── audio.py # Whisper capture node
93
+ │ ├── transcript.py # Speaker labelling node
94
+ │ └── framework.py # Framework lookup node
95
+ ├── db/
96
+ │ ├── schema.py # Table definitions + init
97
+ │ └── queries.py # Async CRUD functions
98
+ ├── frameworks.yaml # Question types + steps
99
+ ├── prompts.py # All LLM prompt templates
100
+ ├── data/
101
+ │ └── train.jsonl # Fine-tuning dataset
102
+ └── requirements.txt
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Models
108
+ - Part 1: `ollama pull qwen2.5:7b`
109
+ - Part 2: fine-tuned Qwen2.5 3B via `mlx-lm` LoRA, exported to GGUF for llama.cpp
110
+ - Swap model in one place only: `config.py` → `MODEL_PATH`
111
+ ---
112
+
README.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: InterviewCoach
3
+ colorFrom: teal
4
+ colorTo: slate
5
+ sdk: gradio
6
+ sdk_version: 6.16.0
7
+ app_file: app.py
8
+ pinned: false
9
+ ---
10
+
11
+ # Interview Coach
12
+
13
+ Interview Coach is a local-first assistant for live technical interviews. It listens to noisy interview audio, extracts the actual Data Science, ML, AI, or System Design question, and shows a compact coaching card with important pointers while the candidate is answering.
14
+
15
+ ## Why It Matters
16
+
17
+ The goal is not to handhold the candidate through the interview or generate a scripted answer. The goal is to give timely, high-signal reminders so the candidate can cover the important parts of their own answer naturally.
18
+
19
+ Interview conversations are messy: greetings, interviewer transitions, repeated words, partial transcription, candidate clarifications, and answer fragments often appear in the same transcript. Interview Coach helps by:
20
+
21
+ - Capturing the core technical question from noisy live conversation.
22
+ - Showing concise pointers that help the candidate cover the expected areas.
23
+ - Separating interviewer questions from candidate answers after the session.
24
+ - Evaluating saved Q&A exchanges with critical feedback and improvement areas.
25
+
26
+ ## Multi-Model Approach
27
+
28
+ The app uses different local models for different jobs instead of asking one model to do everything:
29
+
30
+ - Speech-to-text: Whisper via `mlx-whisper` for local audio transcription.
31
+ - Topic and pattern detection: fine-tuned `vadirajkrishna/interview-coach-3b` to identify the interview question type and coarse pattern.
32
+ - Coaching hints: `Qwen/Qwen2.5-3B-Instruct` generates short, question-specific pointers for the live coaching card.
33
+ - Transcript cleanup and Q&A extraction: the general LLM extracts structured questions and candidate answers from noisy transcripts.
34
+ - Evaluation: the evaluator uses the saved candidate answer, not the coaching hints, to provide a benchmark answer, hiring band, strengths, weaknesses, and critical gaps.
35
+
36
+ This keeps the live coaching path fast while allowing more careful reasoning for post-session extraction and evaluation.
37
+
38
+ ## Agentic Architecture
39
+
40
+ The app follows a LangGraph-style pipeline where each step has a focused responsibility:
41
+
42
+ ```text
43
+ Audio Capture -> Transcription -> Question Extraction -> Topic/Pattern Agent
44
+ -> Coaching Card UI
45
+
46
+ Saved Transcript -> Q&A Extraction -> SQLite Persistence -> Evaluation Agent
47
+ ```
48
+
49
+ At runtime, the live path prioritizes speed: it listens to system audio, updates the transcript, extracts the latest likely technical question, classifies the question type, and renders a coaching card. After the session, the slower processing path extracts all Q&A exchanges from the full transcript and stores them in SQLite for evaluation and CSV export.
50
+
51
+ ## Running Locally
52
+
53
+ Local-first interview coaching app with a Hugging Face Space demo mode.
54
+
55
+ On Hugging Face Spaces, the app uses browser microphone recording and a
56
+ Linux-compatible Transformers Whisper backend. Local-only system audio capture
57
+ via BlackHole and Ollama-based LLM calls are not available in Space mode.
agents/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
agents/evaluator.py ADDED
@@ -0,0 +1,451 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ from typing import Any
4
+
5
+ from config import EVALUATION_MODEL_PATH
6
+ from agents.hf_chat import HuggingFaceChatModel
7
+ from prompts import EVALUATOR_SYSTEM_PROMPT, EVALUATOR_USER_PROMPT
8
+
9
+
10
+ class EvaluationAgent:
11
+ def __init__(self, model: str = EVALUATION_MODEL_PATH):
12
+ self.model = model
13
+ self.llm = HuggingFaceChatModel(model)
14
+
15
+ async def evaluate(
16
+ self,
17
+ question: str,
18
+ answer: str,
19
+ framework: str,
20
+ steps: list[str],
21
+ ) -> dict[str, Any]:
22
+ result = await self._try_model(question, answer, framework, steps)
23
+ if not result or self._model_result_looks_invalid(result, answer):
24
+ result = self._heuristic(question, answer, framework, steps)
25
+
26
+ steps_covered = result.get("steps_covered", [False] * len(steps))
27
+ band = self._normalize_band(result.get("band"))
28
+ score = self._band_to_score(band)
29
+ result["band"] = band
30
+ feedback = self._format_feedback(result)
31
+ return {
32
+ "steps_covered": [bool(item) for item in steps_covered[: len(steps)]],
33
+ "score": score,
34
+ "feedback": feedback,
35
+ }
36
+
37
+ async def _try_model(
38
+ self,
39
+ question: str,
40
+ answer: str,
41
+ framework: str,
42
+ steps: list[str],
43
+ ) -> dict[str, Any] | None:
44
+ prompt = EVALUATOR_USER_PROMPT.format(
45
+ question=question,
46
+ answer=answer,
47
+ framework=framework,
48
+ steps="\n".join(f"- {step}" for step in steps),
49
+ )
50
+ response = await self.llm.generate(
51
+ EVALUATOR_SYSTEM_PROMPT,
52
+ prompt,
53
+ max_new_tokens=768,
54
+ )
55
+ if not response:
56
+ return None
57
+ try:
58
+ return json.loads(self._extract_json(response))
59
+ except Exception:
60
+ return None
61
+
62
+ def _model_result_looks_invalid(self, result: dict[str, Any], answer: str) -> bool:
63
+ answer = answer.strip()
64
+ if not answer:
65
+ return self._normalize_band(result.get("band")) != "No hire"
66
+
67
+ feedback_text = " ".join(
68
+ str(result.get(key, ""))
69
+ for key in ("strong_points", "weak_points", "critical_gaps")
70
+ ).lower()
71
+ benchmark = str(result.get("benchmark_answer", "")).lower()
72
+
73
+ generic_phrases = (
74
+ "should clarify",
75
+ "should include",
76
+ "should explain",
77
+ "strong answer should",
78
+ "benchmark answer",
79
+ "perfect answer",
80
+ )
81
+ if any(phrase in feedback_text for phrase in generic_phrases):
82
+ return True
83
+
84
+ if benchmark and feedback_text and self._text_similarity(feedback_text, benchmark) > 0.55:
85
+ return True
86
+
87
+ strong_points = result.get("strong_points", [])
88
+ if isinstance(strong_points, list) and strong_points and answer:
89
+ answer_words = {
90
+ word
91
+ for word in re.findall(r"[a-zA-Z]+", answer.lower())
92
+ if len(word) > 3
93
+ }
94
+ strong_words = {
95
+ word
96
+ for item in strong_points
97
+ for word in re.findall(r"[a-zA-Z]+", str(item).lower())
98
+ if len(word) > 3
99
+ }
100
+ if strong_words and len(answer_words.intersection(strong_words)) == 0:
101
+ return True
102
+
103
+ return False
104
+
105
+ def _text_similarity(self, left: str, right: str) -> float:
106
+ left_words = {
107
+ word
108
+ for word in re.findall(r"[a-zA-Z]+", left.lower())
109
+ if len(word) > 3
110
+ }
111
+ right_words = {
112
+ word
113
+ for word in re.findall(r"[a-zA-Z]+", right.lower())
114
+ if len(word) > 3
115
+ }
116
+ if not left_words or not right_words:
117
+ return 0.0
118
+ return len(left_words.intersection(right_words)) / len(left_words.union(right_words))
119
+
120
+ def _heuristic(self, question: str, answer: str, framework: str, steps: list[str]) -> dict[str, Any]:
121
+ benchmark = self._benchmark_answer(question, framework)
122
+ if not answer.strip():
123
+ return {
124
+ "benchmark_answer": benchmark,
125
+ "steps_covered": [False] * len(steps),
126
+ "band": "No hire",
127
+ "strong_points": ["Nil"],
128
+ "weak_points": ["Candidate did not give any answer."],
129
+ "critical_gaps": ["No candidate answer was captured, so structured thinking could not be assessed."],
130
+ }
131
+
132
+ answer_words = set(re.findall(r"[a-zA-Z]+", answer.lower()))
133
+ covered = []
134
+ for step in steps:
135
+ step_words = set(re.findall(r"[a-zA-Z]+", step.lower()))
136
+ covered.append(bool(answer_words.intersection(step_words)))
137
+
138
+ assessment = self._assess_structured_thinking(answer, covered, framework)
139
+
140
+ return {
141
+ "benchmark_answer": benchmark,
142
+ "steps_covered": covered,
143
+ "band": assessment["band"],
144
+ "strong_points": assessment["strong_points"],
145
+ "weak_points": assessment["weak_points"],
146
+ "critical_gaps": assessment["critical_gaps"],
147
+ }
148
+
149
+ def _assess_structured_thinking(
150
+ self,
151
+ answer: str,
152
+ covered: list[bool],
153
+ framework: str,
154
+ ) -> dict[str, Any]:
155
+ text = answer.lower()
156
+ structure_signals = [
157
+ bool(re.search(r"\b(first|second|third|next|then|finally|step|approach)\b", text)),
158
+ bool(re.search(r"\b(assume|requirement|constraint|clarify)\b", text)),
159
+ bool(re.search(r"\b(tradeoff|however|but|risk|limitation)\b", text)),
160
+ bool(re.search(r"\b(metric|evaluate|validate|test|monitor)\b", text)),
161
+ bool(re.search(r"\b(example|for instance|such as)\b", text)),
162
+ ]
163
+ covered_count = sum(covered)
164
+ structure_count = sum(structure_signals)
165
+ word_count = len(answer.split())
166
+
167
+ if covered_count >= 4 and structure_count >= 3 and word_count >= 80:
168
+ band = "Strong hire"
169
+ elif covered_count >= 3 or (structure_count >= 3 and word_count >= 60):
170
+ band = "Hire"
171
+ elif covered_count >= 1 or structure_count >= 1 or word_count >= 25:
172
+ band = "Borderline"
173
+ else:
174
+ band = "No hire"
175
+
176
+ strong_points = []
177
+ if covered_count >= 3:
178
+ strong_points.append("Covered most of the expected framework areas.")
179
+ elif covered_count > 0:
180
+ strong_points.append("Covered at least part of the expected framework.")
181
+ if structure_count >= 2:
182
+ strong_points.append("Showed some structured thinking instead of only giving isolated facts.")
183
+ if word_count >= 60:
184
+ strong_points.append("Provided enough detail to understand the direction of the answer.")
185
+
186
+ weak_points = []
187
+ if covered_count < 3:
188
+ weak_points.append("Did not clearly cover enough key areas for a confident pass.")
189
+ if structure_count < 2:
190
+ weak_points.append("The reasoning process was not explicit enough.")
191
+ if word_count < 40:
192
+ weak_points.append("The answer was brief, so the interviewer has limited evidence of depth.")
193
+
194
+ critical_gaps = []
195
+ if framework == "System Design" and not re.search(r"\b(requirement|constraint|scale|latency|tradeoff|failure)\b", text):
196
+ critical_gaps.append("For system design, the answer needs clearer requirements, constraints, scale, and tradeoff reasoning.")
197
+ if framework == "Technical" and not re.search(r"\b(example|metric|evaluate|tradeoff|assumption|why)\b", text):
198
+ critical_gaps.append("For a technical answer, the candidate should explain why the concept works and give an example or validation angle.")
199
+ if not critical_gaps:
200
+ critical_gaps.append("Nil")
201
+
202
+ return {
203
+ "band": band,
204
+ "strong_points": strong_points or ["Nil"],
205
+ "weak_points": weak_points or ["Nil"],
206
+ "critical_gaps": critical_gaps,
207
+ }
208
+
209
+ def _compare_to_benchmark(self, answer: str, benchmark: str) -> dict[str, Any]:
210
+ concepts = self._benchmark_concepts(benchmark)
211
+ answer_text = answer.lower()
212
+ matched = [concept for concept in concepts if self._concept_is_covered(concept, answer_text)]
213
+ missed = [concept for concept in concepts if concept not in matched]
214
+
215
+ coverage = len(matched) / max(1, len(concepts))
216
+ score = max(1, min(5, round(coverage * 5)))
217
+ if len(answer.split()) < 30 and score > 2:
218
+ score = 2
219
+
220
+ strong_points = [f"Covered {concept}." for concept in matched[:4]] or ["Nil"]
221
+ weak_points = [f"Did not clearly explain {concept}." for concept in missed[:4]] or ["Nil"]
222
+ critical_gaps = [f"Missing benchmark concept: {concept}." for concept in missed[:5]] or ["Nil"]
223
+
224
+ if len(answer.split()) < 30:
225
+ weak_points.append("The answer is too brief to demonstrate the reasoning expected by the benchmark.")
226
+ return {
227
+ "score": score,
228
+ "strong_points": strong_points,
229
+ "weak_points": weak_points,
230
+ "critical_gaps": critical_gaps,
231
+ }
232
+
233
+ def _benchmark_concepts(self, benchmark: str) -> list[str]:
234
+ sentences = re.split(r"(?<=[.!?])\s+", benchmark)
235
+ concepts = []
236
+ for sentence in sentences:
237
+ clean = sentence.strip()
238
+ if not clean:
239
+ continue
240
+ if ":" in clean and len(clean.split(":")[0].split()) <= 5:
241
+ label, detail = clean.split(":", 1)
242
+ clean = f"{label.strip()} ({detail.strip()})"
243
+ concepts.append(clean.rstrip("."))
244
+ return concepts[:10]
245
+
246
+ def _concept_is_covered(self, concept: str, answer_text: str) -> bool:
247
+ concept_words = {
248
+ word
249
+ for word in re.findall(r"[a-zA-Z]+", concept.lower())
250
+ if len(word) > 3 and word not in self._stopwords()
251
+ }
252
+ if not concept_words:
253
+ return False
254
+ answer_words = set(re.findall(r"[a-zA-Z]+", answer_text))
255
+ overlap = concept_words.intersection(answer_words)
256
+ required = 1 if len(concept_words) <= 3 else max(2, min(4, len(concept_words) // 3))
257
+ return len(overlap) >= required
258
+
259
+ def _stopwords(self) -> set[str]:
260
+ return {
261
+ "correct",
262
+ "answer",
263
+ "should",
264
+ "include",
265
+ "explain",
266
+ "mention",
267
+ "strong",
268
+ "typical",
269
+ "common",
270
+ "uses",
271
+ "such",
272
+ "that",
273
+ "with",
274
+ "from",
275
+ "into",
276
+ "where",
277
+ "when",
278
+ "while",
279
+ "also",
280
+ "mainly",
281
+ "useful",
282
+ }
283
+
284
+ def _benchmark_answer(self, question: str, framework: str) -> str:
285
+ text = question.lower()
286
+ if "supervised" in text and "unsupervised" in text:
287
+ return (
288
+ "A correct answer should explain that supervised learning uses labeled training data, where each example has input features "
289
+ "and a known target label or value. The model learns a mapping from inputs to outputs and is evaluated against ground truth. "
290
+ "Typical supervised tasks include classification, such as spam detection or churn prediction, and regression, such as predicting "
291
+ "house prices. Common algorithms include linear regression, logistic regression, decision trees, random forests, gradient boosting, "
292
+ "support vector machines, and neural networks. Unsupervised learning uses unlabeled data and tries to discover structure, patterns, "
293
+ "or representations without a target label. Typical tasks include clustering, dimensionality reduction, anomaly detection, and "
294
+ "association discovery. Common algorithms include k-means, hierarchical clustering, DBSCAN, PCA, t-SNE or UMAP for visualization, "
295
+ "autoencoders, and Gaussian mixture models. A strong answer should also mention evaluation differences: supervised models can use "
296
+ "metrics like accuracy, precision, recall, F1, RMSE, or MAE, while unsupervised models are harder to evaluate and may use silhouette "
297
+ "score, reconstruction error, downstream task performance, or human/business validation."
298
+ )
299
+
300
+ if "linear regression" in text and "assumption" in text:
301
+ return (
302
+ "A correct answer should state the main assumptions of linear regression: "
303
+ "1. Linearity: the expected target is a linear combination of the predictors. "
304
+ "2. Independence: observations and residual errors are independent, with no autocorrelation. "
305
+ "3. Homoscedasticity: residuals have constant variance across predicted values. "
306
+ "4. No perfect multicollinearity: predictors are not exact linear combinations of each other. "
307
+ "5. Exogeneity: errors have mean zero and are not correlated with the predictors. "
308
+ "6. Normality of residuals is mainly needed for small-sample hypothesis tests and confidence intervals, "
309
+ "not for unbiased coefficient estimates. A strong answer should also mention checking residual plots, "
310
+ "variance inflation factor for multicollinearity, and transformations or robust standard errors when assumptions fail."
311
+ )
312
+
313
+ if "logistic regression" in text and "assumption" in text:
314
+ return (
315
+ "A correct answer should explain that logistic regression assumes independent observations, "
316
+ "a linear relationship between predictors and the log-odds of the target, no severe multicollinearity, "
317
+ "adequate sample size, correctly specified features and interactions, and limited influence from extreme outliers. "
318
+ "It should also mention that the target is binary or modeled as binomial, and that calibration, ROC-AUC, precision, recall, "
319
+ "and confusion-matrix tradeoffs are useful evaluation checks."
320
+ )
321
+
322
+ if "recommendation" in text or "recommender" in text:
323
+ return (
324
+ "A strong benchmark answer should clarify users, items, goals, constraints, and success metrics such as CTR, conversion, "
325
+ "retention, NDCG, recall@K, and diversity. It should propose candidate generation using collaborative filtering, "
326
+ "content-based retrieval, or embeddings; ranking using a learned model with user, item, and context features; "
327
+ "and feedback loops from clicks, ratings, purchases, skips, and dwell time. It should cover cold start, popularity bias, "
328
+ "exploration versus exploitation, freshness, latency, offline and online evaluation, A/B testing, monitoring drift, "
329
+ "and abuse or privacy concerns."
330
+ )
331
+
332
+ if "overfitting" in text or "underfitting" in text:
333
+ return (
334
+ "A correct answer should define overfitting as low training error but poor generalization, and underfitting as poor performance "
335
+ "on both train and validation data. It should mention causes such as excessive model complexity, noisy features, data leakage, "
336
+ "or insufficient regularization for overfitting, and overly simple models or insufficient features for underfitting. "
337
+ "It should include fixes such as cross-validation, regularization, more data, feature selection, early stopping, pruning, "
338
+ "simpler or richer models as appropriate, and monitoring train-validation learning curves."
339
+ )
340
+
341
+ if "bias" in text and "variance" in text:
342
+ return (
343
+ "A correct answer should explain bias as error from overly restrictive assumptions and variance as sensitivity to training data. "
344
+ "High bias causes underfitting; high variance causes overfitting. The answer should discuss the tradeoff, how model complexity "
345
+ "affects each side, and practical diagnosis using train and validation errors. It should mention remedies such as adding features "
346
+ "or model capacity for high bias, and regularization, more data, ensembling, or simpler models for high variance."
347
+ )
348
+
349
+ if ("rate limit" in text or "rate limiting" in text) and (
350
+ "token" in text or "consumption" in text or "aggregate" in text or "aggregates" in text
351
+ ):
352
+ return (
353
+ "A strong benchmark answer should design a low-latency token usage metering and rate-limiting service. "
354
+ "First clarify requirements: limit by user, API key, organization, model, endpoint, or time window; support per-minute, "
355
+ "daily, and monthly quotas; handle burst limits; provide accurate enough enforcement with very low request-path latency; "
356
+ "and expose usage dashboards and audit logs. The request path should call a Rate Limit service before or during inference. "
357
+ "That service should use Redis or another fast distributed counter store for hot-window counters, commonly with token bucket, "
358
+ "leaky bucket, or sliding-window counters keyed by tenant and model. Estimated input tokens can be checked before admission, "
359
+ "then final actual input plus output tokens should be committed after completion. For streaming responses, token usage can be "
360
+ "reserved up front, incrementally updated, or reconciled at stream end. The system should write durable usage events to Kafka, "
361
+ "Kinesis, or a log table, then aggregate asynchronously into OLAP/storage such as ClickHouse, BigQuery, or partitioned Postgres "
362
+ "tables for reporting. The design should cover idempotency with request IDs, atomic counter updates, TTLs for window counters, "
363
+ "clock/window boundary handling, refunds for failed requests, backpressure behavior, multi-region consistency tradeoffs, "
364
+ "eventual reconciliation between Redis and durable aggregates, and observability metrics such as allowed/blocked requests, "
365
+ "counter latency, aggregation lag, dropped events, and quota accuracy."
366
+ )
367
+
368
+ if framework == "System Design":
369
+ return (
370
+ "A strong answer should clarify functional and non-functional requirements, define APIs and data entities, propose a high-level "
371
+ "architecture, explain storage and serving choices, discuss scaling, caching, reliability, observability, latency, throughput, "
372
+ "and failure modes, then close with tradeoffs and validation metrics."
373
+ )
374
+
375
+ if framework == "Technical":
376
+ return (
377
+ "A strong technical answer should define the concept accurately, state assumptions, explain the mechanism or formula where relevant, "
378
+ "give practical examples, discuss edge cases and tradeoffs, and mention how to validate the approach in production or experiments."
379
+ )
380
+
381
+ return (
382
+ "A strong answer should directly answer the question, define key terms, provide a structured explanation, include concrete examples, "
383
+ "discuss tradeoffs or limitations, and close with how the answer would be validated or applied in practice."
384
+ )
385
+
386
+ def _normalize_score(self, value: Any) -> int:
387
+ try:
388
+ score = int(float(value))
389
+ except (TypeError, ValueError):
390
+ score = 3
391
+ return max(0, min(5, score))
392
+
393
+ def _normalize_band(self, value: Any) -> str:
394
+ text = str(value or "").strip().lower()
395
+ labels = {
396
+ "strong hire": "Strong hire",
397
+ "hire": "Hire",
398
+ "borderline": "Borderline",
399
+ "no hire": "No hire",
400
+ }
401
+ if text in labels:
402
+ return labels[text]
403
+
404
+ score = self._normalize_score(value)
405
+ if score >= 5:
406
+ return "Strong hire"
407
+ if score >= 4:
408
+ return "Hire"
409
+ if score >= 2:
410
+ return "Borderline"
411
+ return "No hire"
412
+
413
+ def _band_to_score(self, band: str) -> int:
414
+ return {
415
+ "Strong hire": 5,
416
+ "Hire": 4,
417
+ "Borderline": 2,
418
+ "No hire": 0,
419
+ }.get(band, 2)
420
+
421
+ def _format_feedback(self, result: dict[str, Any]) -> str:
422
+ benchmark = str(
423
+ result.get("benchmark_answer")
424
+ or result.get("baseline_answer")
425
+ or "No benchmark answer was generated."
426
+ ).strip()
427
+ band = self._normalize_band(result.get("band") or result.get("score", 2))
428
+ strong_points = self._format_list(result.get("strong_points") or result.get("strengths") or ["Nil"])
429
+ weak_points = self._format_list(result.get("weak_points") or ["Nil"])
430
+ critical_gaps = self._format_list(
431
+ result.get("critical_gaps")
432
+ or result.get("gaps")
433
+ or result.get("improvements")
434
+ or ["Nil"]
435
+ )
436
+ return (
437
+ f"Agent benchmark answer:\n{benchmark}\n\n"
438
+ f"Evaluation band: {band}\n\n"
439
+ f"Strong points:\n{strong_points}\n\n"
440
+ f"Weak points:\n{weak_points}\n\n"
441
+ f"Critical gaps:\n{critical_gaps}"
442
+ )
443
+
444
+ def _format_list(self, value: Any) -> str:
445
+ if not isinstance(value, list):
446
+ return str(value).strip()
447
+ return "\n".join(f"- {item}" for item in value if str(item).strip())
448
+
449
+ def _extract_json(self, text: str) -> str:
450
+ match = re.search(r"\{.*\}", text, flags=re.DOTALL)
451
+ return match.group(0) if match else text
agents/hf_chat.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import threading
3
+ import traceback
4
+ from typing import Any
5
+
6
+ from config import GENERAL_LLM_MODEL
7
+
8
+
9
+ class HuggingFaceChatModel:
10
+ """Lazy local Hugging Face chat model for non-topic reasoning tasks."""
11
+
12
+ def __init__(self, model_name: str = GENERAL_LLM_MODEL):
13
+ self.model_name = model_name
14
+ self._model: Any | None = None
15
+ self._tokenizer: Any | None = None
16
+ self._load_lock = threading.Lock()
17
+ self.last_error = ""
18
+
19
+ async def generate(
20
+ self,
21
+ system_prompt: str,
22
+ user_prompt: str,
23
+ max_new_tokens: int = 512,
24
+ ) -> str:
25
+ try:
26
+ return await asyncio.to_thread(
27
+ self._generate_sync,
28
+ system_prompt,
29
+ user_prompt,
30
+ max_new_tokens,
31
+ )
32
+ except Exception as exc:
33
+ self.last_error = str(exc)
34
+ return ""
35
+
36
+ def _generate_sync(
37
+ self,
38
+ system_prompt: str,
39
+ user_prompt: str,
40
+ max_new_tokens: int,
41
+ ) -> str:
42
+ self.last_error = ""
43
+ self._ensure_model_loaded_sync()
44
+
45
+ messages = [
46
+ {"role": "system", "content": system_prompt},
47
+ {"role": "user", "content": user_prompt},
48
+ ]
49
+ try:
50
+ encoded = self._tokenizer.apply_chat_template(
51
+ messages,
52
+ add_generation_prompt=True,
53
+ return_tensors="pt",
54
+ return_dict=True,
55
+ )
56
+ encoded = {key: value.to(self._model.device) for key, value in encoded.items()}
57
+ except Exception as exc:
58
+ raise RuntimeError(f"chat template failed for {self.model_name}: {exc}") from exc
59
+
60
+ import torch
61
+
62
+ try:
63
+ with torch.no_grad():
64
+ outputs = self._model.generate(
65
+ **encoded,
66
+ max_new_tokens=max_new_tokens,
67
+ do_sample=False,
68
+ pad_token_id=self._tokenizer.eos_token_id,
69
+ )
70
+ except Exception as exc:
71
+ detail = "".join(traceback.format_exception_only(type(exc), exc)).strip()
72
+ raise RuntimeError(
73
+ f"generation failed for {self.model_name}: {detail}. "
74
+ "This can happen if the model exceeds available memory or the Transformers input format changed."
75
+ ) from exc
76
+
77
+ input_length = encoded["input_ids"].shape[-1]
78
+ generated = outputs[0][input_length:]
79
+ return self._tokenizer.decode(generated, skip_special_tokens=True).strip()
80
+
81
+ def _ensure_model_loaded_sync(self) -> None:
82
+ if self._model is not None and self._tokenizer is not None:
83
+ return
84
+
85
+ with self._load_lock:
86
+ if self._model is not None and self._tokenizer is not None:
87
+ return
88
+
89
+ import torch
90
+ from transformers import AutoModelForCausalLM, AutoTokenizer
91
+
92
+ try:
93
+ tokenizer = AutoTokenizer.from_pretrained(self.model_name, trust_remote_code=False)
94
+ except Exception as exc:
95
+ detail = "".join(traceback.format_exception_only(type(exc), exc)).strip()
96
+ raise RuntimeError(f"tokenizer load failed for {self.model_name}: {detail}") from exc
97
+ if tokenizer.pad_token_id is None:
98
+ tokenizer.pad_token = tokenizer.eos_token
99
+
100
+ model_kwargs = {"trust_remote_code": False, "low_cpu_mem_usage": True}
101
+ if torch.backends.mps.is_available():
102
+ model_kwargs["torch_dtype"] = torch.float16
103
+
104
+ try:
105
+ model = AutoModelForCausalLM.from_pretrained(self.model_name, **model_kwargs)
106
+ except Exception as exc:
107
+ detail = "".join(traceback.format_exception_only(type(exc), exc)).strip()
108
+ raise RuntimeError(f"model load failed for {self.model_name}: {detail}") from exc
109
+ if torch.backends.mps.is_available():
110
+ model = model.to("mps")
111
+ model.eval()
112
+
113
+ self._tokenizer = tokenizer
114
+ self._model = model
agents/topic_pattern.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import re
3
+ import threading
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+ from config import TOPIC_PATTERN_BASE_MODEL, TOPIC_PATTERN_MODEL, USE_TOPIC_PATTERN_MODEL
9
+
10
+
11
+ class TopicPatternAgent:
12
+ """Fine-tuned topic/pattern + coaching-step classifier."""
13
+
14
+ def __init__(
15
+ self,
16
+ frameworks_path: str = "frameworks.yaml",
17
+ model_name: str = TOPIC_PATTERN_MODEL,
18
+ base_model_name: str = TOPIC_PATTERN_BASE_MODEL,
19
+ ):
20
+ self.model_name = model_name
21
+ self.base_model_name = base_model_name
22
+ self.enabled = USE_TOPIC_PATTERN_MODEL
23
+ self._model: Any | None = None
24
+ self._tokenizer: Any | None = None
25
+ self._device: str = "cpu"
26
+ self._load_error: str = ""
27
+ self.last_error = ""
28
+ self._load_lock = threading.Lock()
29
+ with open(frameworks_path, "r", encoding="utf-8") as file:
30
+ self.frameworks: dict[str, dict[str, Any]] = yaml.safe_load(file)
31
+
32
+ async def analyze(self, question: str) -> dict[str, Any]:
33
+ if not self.enabled or not question.strip():
34
+ self.last_error = "Topic/pattern model is disabled or question is empty."
35
+ return {}
36
+
37
+ try:
38
+ output = await asyncio.to_thread(self._generate, question.strip())
39
+ except Exception as exc:
40
+ self._load_error = str(exc)
41
+ self.last_error = str(exc)
42
+ return {}
43
+
44
+ parsed = self.parse_output(output)
45
+ framework = self._valid_framework(parsed.get("type", ""))
46
+ steps = parsed.get("steps") or []
47
+ if not framework or not steps:
48
+ self.last_error = f"Could not parse topic model output: {output[:300]}"
49
+ return {}
50
+
51
+ self.last_error = ""
52
+ return {
53
+ "type": framework,
54
+ "pattern": parsed.get("type", ""),
55
+ "steps": steps,
56
+ "confidence": 0.85,
57
+ "model": self.model_name,
58
+ }
59
+
60
+ def parse_output(self, text: str) -> dict[str, Any]:
61
+ clean = self._extract_assistant_text(text)
62
+ type_match = re.search(r"Type\s*:\s*(.+?)(?:\n|$)", clean, flags=re.IGNORECASE)
63
+ steps_match = re.search(r"Steps\s*:\s*(.+)", clean, flags=re.IGNORECASE | re.DOTALL)
64
+
65
+ raw_type = type_match.group(1).strip() if type_match else ""
66
+ raw_steps = steps_match.group(1).strip() if steps_match else ""
67
+ steps = self._split_steps(raw_steps)
68
+
69
+ return {"type": raw_type, "steps": steps}
70
+
71
+ def _generate(self, question: str) -> str:
72
+ self._ensure_model_loaded_sync()
73
+ prompt = f"<|im_start|>user\n{question}<|im_end|>\n<|im_start|>assistant\n"
74
+ inputs = self._tokenizer(prompt, return_tensors="pt")
75
+ inputs = {key: value.to(self._model.device) for key, value in inputs.items()}
76
+
77
+ import torch
78
+
79
+ with torch.no_grad():
80
+ outputs = self._model.generate(
81
+ **inputs,
82
+ max_new_tokens=80,
83
+ temperature=0.1,
84
+ do_sample=False,
85
+ pad_token_id=self._tokenizer.eos_token_id,
86
+ )
87
+
88
+ return self._tokenizer.decode(outputs[0], skip_special_tokens=False)
89
+
90
+ def _ensure_model_loaded_sync(self) -> None:
91
+ if self._model is not None and self._tokenizer is not None:
92
+ return
93
+
94
+ with self._load_lock:
95
+ if self._model is not None and self._tokenizer is not None:
96
+ return
97
+
98
+ import torch
99
+ from huggingface_hub import snapshot_download
100
+ from transformers import AutoModelForCausalLM, AutoTokenizer
101
+
102
+ adapter_path = snapshot_download(self.model_name, local_files_only=True)
103
+ tokenizer = AutoTokenizer.from_pretrained(self.base_model_name, trust_remote_code=False)
104
+ if tokenizer.pad_token_id is None:
105
+ tokenizer.pad_token = tokenizer.eos_token
106
+
107
+ model_kwargs = {"trust_remote_code": False, "low_cpu_mem_usage": True}
108
+ if torch.backends.mps.is_available():
109
+ model_kwargs["torch_dtype"] = torch.float16
110
+
111
+ try:
112
+ from peft import PeftModel
113
+
114
+ base_model = AutoModelForCausalLM.from_pretrained(self.base_model_name, **model_kwargs)
115
+ model = PeftModel.from_pretrained(base_model, adapter_path)
116
+ except Exception as exc:
117
+ self.last_error = f"PEFT load failed: {exc}"
118
+ raise
119
+
120
+ if torch.backends.mps.is_available():
121
+ model = model.to("mps")
122
+ model.eval()
123
+
124
+ self._tokenizer = tokenizer
125
+ self._model = model
126
+
127
+ def _extract_assistant_text(self, text: str) -> str:
128
+ if "<|im_start|>assistant" in text:
129
+ text = text.split("<|im_start|>assistant", 1)[-1]
130
+ if "<|im_end|>" in text:
131
+ text = text.split("<|im_end|>", 1)[0]
132
+ return text.strip()
133
+
134
+ def _split_steps(self, text: str) -> list[str]:
135
+ if not text:
136
+ return []
137
+
138
+ parts = re.split(r"\s*(?:→|->|,|\n|;)\s*", text)
139
+ steps = []
140
+ for part in parts:
141
+ clean = re.sub(r"^\s*(?:[-*]|\d+[.)])\s*", "", part).strip()
142
+ if clean:
143
+ steps.append(clean)
144
+ return steps[:6]
145
+
146
+ def _valid_framework(self, value: str) -> str:
147
+ aliases = {
148
+ "Behavioural": "Behavioral",
149
+ "Product Design": "Product Sense",
150
+ "Data Science": "Technical",
151
+ "AI Engineering": "Technical",
152
+ "Estimation": "Case",
153
+ }
154
+ normalized = aliases.get(value.strip(), value.strip())
155
+ return normalized if normalized in self.frameworks else ""
app.py ADDED
@@ -0,0 +1,1721 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import csv
3
+ import html
4
+ import json
5
+ import os
6
+ import re
7
+ from typing import Any
8
+
9
+ import gradio as gr
10
+ import numpy as np
11
+
12
+ from agents.evaluator import EvaluationAgent
13
+ from agents.hf_chat import HuggingFaceChatModel
14
+ from agents.topic_pattern import TopicPatternAgent
15
+ from config import APP_HOST, APP_PORT, BASE_DIR, GENERAL_LLM_MODEL, HF_SPACE_MODE, STREAMING_WHISPER_MODEL
16
+ from db.queries import (
17
+ add_evaluation,
18
+ append_exchange_answer,
19
+ clear_all_tables,
20
+ create_session,
21
+ list_all_evaluations,
22
+ list_evaluations,
23
+ list_exchanges,
24
+ )
25
+ from db.schema import init_db
26
+ from graph import coach_graph
27
+ from nodes.audio import LiveAudioTranscriber, transcribe_audio_array, transcribe_audio_file
28
+ from prompts import (
29
+ CLARIFICATION_CHECK_SYSTEM_PROMPT,
30
+ CLARIFICATION_CHECK_USER_PROMPT,
31
+ COACHING_GUIDANCE_SYSTEM_PROMPT,
32
+ COACHING_GUIDANCE_USER_PROMPT,
33
+ MULTI_EXCHANGE_EXTRACTOR_SYSTEM_PROMPT,
34
+ MULTI_EXCHANGE_EXTRACTOR_USER_PROMPT,
35
+ TRANSCRIPT_NORMALIZER_REPAIR_SYSTEM_PROMPT,
36
+ TRANSCRIPT_NORMALIZER_REPAIR_USER_PROMPT,
37
+ TRANSCRIPT_NORMALIZER_SYSTEM_PROMPT,
38
+ TRANSCRIPT_NORMALIZER_USER_PROMPT,
39
+ )
40
+
41
+
42
+ CSS = """
43
+ :root {
44
+ --bg: #0b0f14;
45
+ --panel: #111827;
46
+ --panel-soft: #151c28;
47
+ --panel-muted: #0f1622;
48
+ --border: #263241;
49
+ --border-strong: #344154;
50
+ --text: #e7ebf0;
51
+ --muted: #9aa5b1;
52
+ --accent: #14b8a6;
53
+ --accent-strong: #2dd4bf;
54
+ --danger: #ef4444;
55
+ }
56
+ body,
57
+ .gradio-container {
58
+ background: var(--bg) !important;
59
+ color: var(--text) !important;
60
+ }
61
+ .gradio-container {
62
+ max-width: 1280px !important;
63
+ margin: 0 auto !important;
64
+ padding: 12px 18px !important;
65
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
66
+ }
67
+ #app-shell {
68
+ display: flex;
69
+ flex-direction: column;
70
+ gap: 10px;
71
+ }
72
+ #app-header {
73
+ padding: 0;
74
+ }
75
+ #app-header h1 {
76
+ margin: 0;
77
+ font-size: 22px;
78
+ line-height: 1.15;
79
+ font-weight: 700;
80
+ }
81
+ #app-header p {
82
+ margin: 3px 0 0;
83
+ color: var(--muted);
84
+ font-size: 12px;
85
+ }
86
+ .form {
87
+ border: 1px solid var(--border) !important;
88
+ border-radius: 8px !important;
89
+ background: var(--panel-muted) !important;
90
+ padding: 8px 10px !important;
91
+ }
92
+ .action-row {
93
+ align-items: end;
94
+ }
95
+ .action-row button,
96
+ button {
97
+ border-radius: 6px !important;
98
+ font-weight: 650 !important;
99
+ min-height: 34px !important;
100
+ }
101
+ button.primary {
102
+ background: var(--accent) !important;
103
+ border-color: var(--accent) !important;
104
+ color: #041412 !important;
105
+ }
106
+ button.secondary {
107
+ background: #1f2937 !important;
108
+ border-color: var(--border-strong) !important;
109
+ color: var(--text) !important;
110
+ }
111
+ button.stop {
112
+ background: #3b1417 !important;
113
+ border-color: #7f1d1d !important;
114
+ color: #fecaca !important;
115
+ }
116
+ .tabs {
117
+ border-radius: 8px !important;
118
+ }
119
+ .tab-nav {
120
+ border-bottom: 1px solid var(--border) !important;
121
+ }
122
+ .tab-nav button {
123
+ border-radius: 6px 6px 0 0 !important;
124
+ color: var(--muted) !important;
125
+ font-weight: 650 !important;
126
+ }
127
+ .tab-nav button.selected {
128
+ color: var(--text) !important;
129
+ border-color: var(--accent) !important;
130
+ }
131
+ .block,
132
+ .panel,
133
+ .form,
134
+ textarea,
135
+ input {
136
+ border-color: var(--border) !important;
137
+ }
138
+ textarea,
139
+ input {
140
+ background: #0d131d !important;
141
+ color: var(--text) !important;
142
+ border-radius: 6px !important;
143
+ }
144
+ label,
145
+ .label-wrap span {
146
+ color: var(--muted) !important;
147
+ font-weight: 650 !important;
148
+ }
149
+ #status_box textarea,
150
+ #stream_status_box textarea {
151
+ color: var(--accent-strong) !important;
152
+ font-size: 13px !important;
153
+ }
154
+ .section-title {
155
+ margin: 0 0 6px;
156
+ color: var(--muted);
157
+ font-size: 11px;
158
+ font-weight: 750;
159
+ letter-spacing: 0.04em;
160
+ text-transform: uppercase;
161
+ }
162
+ .compact-panel {
163
+ border: 1px solid var(--border) !important;
164
+ border-radius: 8px !important;
165
+ background: var(--panel-muted) !important;
166
+ padding: 10px !important;
167
+ min-width: 0 !important;
168
+ }
169
+ #live_grid {
170
+ flex-wrap: nowrap !important;
171
+ gap: 10px !important;
172
+ }
173
+ #live_grid > div {
174
+ min-width: 0 !important;
175
+ }
176
+ #live_transcript_box textarea {
177
+ min-height: 330px !important;
178
+ }
179
+ #log_box textarea,
180
+ #report_box textarea {
181
+ min-height: 500px !important;
182
+ }
183
+ #status_box textarea {
184
+ min-height: 34px !important;
185
+ }
186
+ #stream_status_box textarea {
187
+ min-height: 72px !important;
188
+ font-size: 12px !important;
189
+ }
190
+ .coach-card {
191
+ border: 1px solid var(--border);
192
+ border-left: 5px solid var(--framework-color);
193
+ border-radius: 8px;
194
+ background: var(--panel);
195
+ padding: 14px;
196
+ min-height: 425px;
197
+ max-height: 425px;
198
+ overflow: auto;
199
+ }
200
+ .floating-card {
201
+ resize: both;
202
+ min-width: 220px;
203
+ min-height: 145px;
204
+ max-width: 100%;
205
+ }
206
+ .coach-card summary {
207
+ cursor: pointer;
208
+ list-style: none;
209
+ }
210
+ .coach-card summary::-webkit-details-marker {
211
+ display: none;
212
+ }
213
+ .card-question {
214
+ margin: 0 0 8px;
215
+ color: var(--text);
216
+ font-size: 14px;
217
+ line-height: 1.45;
218
+ font-weight: 650;
219
+ }
220
+ .card-type {
221
+ display: inline-block;
222
+ margin: 4px 0 8px;
223
+ color: var(--accent-strong);
224
+ font-size: 13px;
225
+ font-weight: 700;
226
+ }
227
+ .coach-card h3 {
228
+ margin: 0 0 6px;
229
+ font-size: 17px;
230
+ line-height: 1.25;
231
+ }
232
+ .coach-card p {
233
+ margin: 10px 0;
234
+ color: var(--text);
235
+ line-height: 1.5;
236
+ }
237
+ .coach-card ol {
238
+ margin: 10px 0 0 22px;
239
+ padding: 0;
240
+ color: #d5dbe3;
241
+ }
242
+ .coach-card li {
243
+ margin-bottom: 6px;
244
+ }
245
+ .coach-card-stack {
246
+ display: grid;
247
+ grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
248
+ gap: 10px;
249
+ max-height: 425px;
250
+ overflow: auto;
251
+ padding-right: 4px;
252
+ }
253
+ .coach-card-stack .coach-card {
254
+ min-height: 0;
255
+ max-height: none;
256
+ box-shadow: 0 14px 32px rgba(0, 0, 0, 0.24);
257
+ }
258
+ .meta {
259
+ color: var(--muted);
260
+ font-size: 13px;
261
+ }
262
+ .review {
263
+ color: #fbbf24;
264
+ }
265
+ @keyframes card-pulse {
266
+ 0% { box-shadow: 0 0 0 0 rgba(20, 184, 166, 0.35); }
267
+ 55% { box-shadow: 0 0 0 10px rgba(20, 184, 166, 0); }
268
+ 100% { box-shadow: 0 0 0 0 rgba(20, 184, 166, 0); }
269
+ }
270
+ .flash-card {
271
+ animation: card-pulse 1.1s ease-out infinite;
272
+ }
273
+ """
274
+
275
+ FRAMEWORK_COLORS = {
276
+ "General": "#64748b",
277
+ "Behavioral": "#0ea5e9",
278
+ "Behavioural": "#0ea5e9",
279
+ "Technical": "#22c55e",
280
+ "Data Science": "#22c55e",
281
+ "AI Engineering": "#14b8a6",
282
+ "System Design": "#f59e0b",
283
+ "Product Sense": "#ec4899",
284
+ "Product Design": "#ec4899",
285
+ "Case": "#a855f7",
286
+ "Estimation": "#a855f7",
287
+ }
288
+
289
+ GENERIC_EXTRACTION_MAX_WINDOW_CHARS = 1800
290
+ GENERIC_EXTRACTION_MIN_LLM_DELTA_CHARS = 80
291
+ LIVE_CARD_ANALYSIS_MIN_DELTA_CHARS = 30
292
+ LIVE_FAST_QUESTION_WINDOW_CHARS = 420
293
+ LIVE_FAST_MIN_DELTA_CHARS = 90
294
+ LIVE_FAST_DUPLICATE_SIMILARITY = 0.78
295
+ LIVE_TARGET_FRAMEWORKS = {"Technical", "System Design"}
296
+ LIVE_QUESTION_MARKERS = (
297
+ "first,",
298
+ "first.",
299
+ "first ",
300
+ "first question",
301
+ "first one",
302
+ "second,",
303
+ "second.",
304
+ "second ",
305
+ "second question",
306
+ "third,",
307
+ "third.",
308
+ "third ",
309
+ "third question",
310
+ "next question",
311
+ "another question",
312
+ "you have",
313
+ "let me ask",
314
+ "can you",
315
+ "could you",
316
+ "tell me",
317
+ "explain",
318
+ "how would",
319
+ "what would",
320
+ )
321
+ LIVE_ANSWER_STARTS = (
322
+ " i would ",
323
+ " i will ",
324
+ " i can ",
325
+ " i think ",
326
+ " i would first ",
327
+ " first i ",
328
+ " my approach ",
329
+ " so i ",
330
+ " sure ",
331
+ " yeah ",
332
+ " yes ",
333
+ )
334
+
335
+ evaluator = EvaluationAgent()
336
+ general_llm = HuggingFaceChatModel(GENERAL_LLM_MODEL)
337
+ topic_pattern_agent = TopicPatternAgent()
338
+ live_audio = LiveAudioTranscriber()
339
+ topic_model_warmup_task: asyncio.Task | None = None
340
+
341
+
342
+ def ensure_topic_model_warmup() -> None:
343
+ global topic_model_warmup_task
344
+ if topic_model_warmup_task and not topic_model_warmup_task.done():
345
+ return
346
+ topic_model_warmup_task = asyncio.create_task(
347
+ topic_pattern_agent.analyze("How would you handle class imbalance in a fraud detection model?")
348
+ )
349
+
350
+
351
+ async def start_session(company: str, role: str) -> tuple[int, str]:
352
+ ensure_topic_model_warmup()
353
+ await init_db()
354
+ session_id = await create_session(company=company, role=role)
355
+ return session_id, f"Session {session_id} started"
356
+
357
+
358
+ async def coach_question(session_id: int | None, question: str, answer: str) -> tuple[str, str, dict[str, Any]]:
359
+ if not session_id:
360
+ await init_db()
361
+ session_id = await create_session()
362
+
363
+ classification = await classify_topic_and_steps(question)
364
+ if classification.get("model_unavailable"):
365
+ state = {
366
+ "session_id": session_id,
367
+ "question": question,
368
+ "answer": answer,
369
+ "message": classification["message"],
370
+ }
371
+ return render_answer_card(state), classification["message"], state
372
+
373
+ clarification_state = await handle_clarification_if_needed(session_id, question, answer)
374
+ if clarification_state:
375
+ return render_card(clarification_state), await render_log(session_id), clarification_state
376
+
377
+ state = await coach_graph.ainvoke(
378
+ {
379
+ "session_id": session_id,
380
+ "raw_text": question,
381
+ "question": question,
382
+ "answer": answer.strip(),
383
+ "framework": classification["type"],
384
+ "pattern": classification.get("pattern", classification["type"]),
385
+ "steps": classification["steps"],
386
+ "confidence": classification["confidence"],
387
+ "topic_model_used": classification.get("topic_model_used", ""),
388
+ }
389
+ )
390
+ framework_steps = state.get("steps", [])
391
+ state["steps"] = await generate_coaching_cues(
392
+ question,
393
+ classification["type"],
394
+ classification.get("pattern", classification["type"]),
395
+ framework_steps,
396
+ )
397
+ state["framework_steps"] = framework_steps
398
+ return render_card(state), await render_log(session_id), state
399
+
400
+
401
+ async def classify_topic_and_steps(question: str) -> dict[str, Any]:
402
+ topic_result = await topic_pattern_agent.analyze(question)
403
+ if not topic_result:
404
+ details = topic_pattern_agent.last_error
405
+ suffix = f" Details: {details}" if details else ""
406
+ return {
407
+ "type": "General",
408
+ "steps": [],
409
+ "confidence": 0.0,
410
+ "model_unavailable": True,
411
+ "message": (
412
+ "Topic/steps model unavailable. Expected Hugging Face model "
413
+ f"vadirajkrishna/interview-coach-3b.{suffix}"
414
+ ),
415
+ }
416
+
417
+ return {
418
+ "type": topic_result["type"],
419
+ "steps": topic_result["steps"],
420
+ "confidence": topic_result.get("confidence", 0.0),
421
+ "pattern": topic_result.get("pattern", topic_result["type"]),
422
+ "topic_model_used": topic_result.get("model", ""),
423
+ }
424
+
425
+
426
+ async def normalize_interview_exchange_with_llm(
427
+ transcript: str,
428
+ question: str = "",
429
+ answer: str = "",
430
+ prefer_latest: bool = False,
431
+ ) -> dict[str, Any] | None:
432
+ mode_instruction = (
433
+ "Extract the latest complete target interviewer question in the transcript window. "
434
+ "Ignore earlier target questions that already have candidate answers. "
435
+ "If the transcript says 'second question', 'next question', or similar, prefer the target question after that marker. "
436
+ "In a technical/ML interview, production checks, data drift, monitoring, and training-serving data issues are target ML/MLOps questions. "
437
+ "If first-person answer text follows the latest question, such as 'I will...' or 'I would...', keep that text in answer, not question."
438
+ if prefer_latest
439
+ else "Extract the clearest complete target interviewer question and its candidate answer, if present."
440
+ )
441
+ prompt = TRANSCRIPT_NORMALIZER_USER_PROMPT.format(
442
+ transcript=transcript,
443
+ mode_instruction=mode_instruction,
444
+ question=question,
445
+ answer=answer,
446
+ )
447
+ response = await general_llm.generate(
448
+ TRANSCRIPT_NORMALIZER_SYSTEM_PROMPT,
449
+ prompt,
450
+ max_new_tokens=512,
451
+ )
452
+ if not response:
453
+ if not general_llm.last_error:
454
+ general_llm.last_error = "Model returned an empty response."
455
+ return None
456
+ try:
457
+ payload = json.loads(extract_json_object(response))
458
+ except Exception as exc:
459
+ preview = response.replace("\n", " ")[:300]
460
+ general_llm.last_error = f"Model returned non-JSON output: {exc}. Output preview: {preview}"
461
+ return None
462
+
463
+ normalized = normalize_normalizer_payload(payload)
464
+ if normalized["is_target"] and normalized["complete"] and not normalized["question"]:
465
+ repaired = await repair_normalizer_payload_with_llm(transcript, payload)
466
+ if repaired:
467
+ normalized = normalize_normalizer_payload(repaired)
468
+ if (
469
+ normalized["question"]
470
+ and normalized["answer"]
471
+ and normalized["answer"].lower() in normalized["question"].lower()
472
+ ):
473
+ repaired = await repair_normalizer_payload_with_llm(transcript, normalized)
474
+ if repaired:
475
+ repaired_normalized = normalize_normalizer_payload(repaired)
476
+ if repaired_normalized["is_target"] and repaired_normalized["complete"]:
477
+ normalized = repaired_normalized
478
+ if normalized["is_target"] and normalized["complete"] and not normalized["question"]:
479
+ general_llm.last_error = "Normalizer marked target complete but returned an empty question."
480
+ normalized["is_target"] = False
481
+ normalized["complete"] = False
482
+ normalized["reason"] = "Target question was empty."
483
+ return normalized
484
+
485
+
486
+ def normalize_normalizer_payload(payload: dict[str, Any]) -> dict[str, Any]:
487
+ clean_question = normalize_llm_text(str(payload.get("question", "")))
488
+ clean_answer = normalize_llm_text(str(payload.get("answer", "")))
489
+ if clean_question and not clean_question.endswith("?"):
490
+ clean_question = f"{clean_question.rstrip('.')}?"
491
+ return {
492
+ "question": clean_question,
493
+ "answer": clean_answer,
494
+ "is_target": bool(payload.get("is_target", False)),
495
+ "complete": bool(payload.get("complete", False)),
496
+ "reason": str(payload.get("reason", "")).strip(),
497
+ }
498
+
499
+
500
+ async def repair_normalizer_payload_with_llm(transcript: str, payload: dict[str, Any]) -> dict[str, Any] | None:
501
+ prompt = TRANSCRIPT_NORMALIZER_REPAIR_USER_PROMPT.format(
502
+ transcript=transcript,
503
+ payload=json.dumps(payload),
504
+ )
505
+ response = await general_llm.generate(
506
+ TRANSCRIPT_NORMALIZER_REPAIR_SYSTEM_PROMPT,
507
+ prompt,
508
+ max_new_tokens=384,
509
+ )
510
+ if not response:
511
+ return None
512
+ try:
513
+ return json.loads(extract_json_object(response))
514
+ except Exception as exc:
515
+ preview = response.replace("\n", " ")[:300]
516
+ general_llm.last_error = f"Normalizer repair returned non-JSON output: {exc}. Output preview: {preview}"
517
+ return None
518
+
519
+
520
+ async def extract_all_interview_exchanges_with_llm(transcript: str) -> list[dict[str, Any]]:
521
+ prompt = MULTI_EXCHANGE_EXTRACTOR_USER_PROMPT.format(transcript=transcript)
522
+ response = await general_llm.generate(
523
+ MULTI_EXCHANGE_EXTRACTOR_SYSTEM_PROMPT,
524
+ prompt,
525
+ max_new_tokens=1400,
526
+ )
527
+ if not response:
528
+ return []
529
+ try:
530
+ payload = json.loads(extract_json_object(response))
531
+ except Exception as exc:
532
+ preview = response.replace("\n", " ")[:300]
533
+ general_llm.last_error = f"Multi-exchange extractor returned non-JSON output: {exc}. Output preview: {preview}"
534
+ return []
535
+
536
+ exchanges = payload.get("exchanges", [])
537
+ if not isinstance(exchanges, list):
538
+ return []
539
+
540
+ cleaned = []
541
+ for item in exchanges:
542
+ if not isinstance(item, dict):
543
+ continue
544
+ normalized = normalize_normalizer_payload(item)
545
+ if not normalized["is_target"] or not normalized["complete"] or not normalized["question"]:
546
+ continue
547
+ cleaned.append(normalized)
548
+ return cleaned
549
+
550
+
551
+ def normalize_llm_text(text: str) -> str:
552
+ return re.sub(r"\s+", " ", text).strip(" -:")
553
+
554
+
555
+ def general_llm_unavailable_message() -> str:
556
+ details = f" Details: {general_llm.last_error}" if general_llm.last_error else ""
557
+ return f"General LLM unavailable. Expected Hugging Face model {GENERAL_LLM_MODEL}.{details}"
558
+
559
+
560
+ async def handle_clarification_if_needed(
561
+ session_id: int,
562
+ question: str,
563
+ answer: str,
564
+ ) -> dict[str, Any] | None:
565
+ exchanges = await list_exchanges(session_id)
566
+ if not exchanges:
567
+ return None
568
+
569
+ previous = exchanges[-1]
570
+ if not await is_clarification_question(previous["question"], question, answer):
571
+ return None
572
+
573
+ addition = f"Clarification: {question}"
574
+ if answer.strip():
575
+ addition = f"{addition}\nCandidate follow-up: {answer.strip()}"
576
+ await append_exchange_answer(previous["id"], addition)
577
+
578
+ result = await classify_topic_and_steps(previous["question"])
579
+ cues = await generate_coaching_cues(
580
+ previous["question"],
581
+ previous["framework_used"],
582
+ result.get("pattern", previous["framework_used"]),
583
+ result["steps"],
584
+ )
585
+ return {
586
+ "session_id": session_id,
587
+ "exchange_id": previous["id"],
588
+ "question": previous["question"],
589
+ "answer": addition,
590
+ "framework": previous["framework_used"],
591
+ "pattern": result.get("pattern", previous["framework_used"]),
592
+ "steps": cues,
593
+ "framework_steps": result["steps"],
594
+ "confidence": result["confidence"],
595
+ "needs_review": False,
596
+ }
597
+
598
+
599
+ async def is_clarification_question(previous_question: str, new_question: str, answer: str) -> bool:
600
+ heuristic = is_clarification_question_heuristic(previous_question, new_question)
601
+ llm_result = await is_clarification_question_with_llm(previous_question, new_question, answer)
602
+ return llm_result if llm_result is not None else heuristic
603
+
604
+
605
+ async def is_clarification_question_with_llm(
606
+ previous_question: str,
607
+ new_question: str,
608
+ answer: str,
609
+ ) -> bool | None:
610
+ prompt = CLARIFICATION_CHECK_USER_PROMPT.format(
611
+ previous_question=previous_question,
612
+ new_question=new_question,
613
+ answer=answer,
614
+ )
615
+ response = await general_llm.generate(
616
+ CLARIFICATION_CHECK_SYSTEM_PROMPT,
617
+ prompt,
618
+ max_new_tokens=256,
619
+ )
620
+ if not response:
621
+ return None
622
+ try:
623
+ payload = json.loads(extract_json_object(response))
624
+ return bool(payload.get("is_clarification", False))
625
+ except Exception:
626
+ return None
627
+
628
+
629
+ def is_clarification_question_heuristic(previous_question: str, new_question: str) -> bool:
630
+ previous = previous_question.lower()
631
+ current = new_question.lower().strip()
632
+ if len(current.split()) < 3:
633
+ return False
634
+
635
+ clarification_starts = (
636
+ "do you mean",
637
+ "did you mean",
638
+ "when you say",
639
+ "should i assume",
640
+ "can i assume",
641
+ "are we assuming",
642
+ "are we considering",
643
+ "should we consider",
644
+ "is it okay if",
645
+ "can i clarify",
646
+ "could you clarify",
647
+ "what do you mean by",
648
+ "for this question",
649
+ "in this case",
650
+ )
651
+ if current.startswith(clarification_starts):
652
+ return True
653
+
654
+ clarification_terms = (
655
+ "assume",
656
+ "clarify",
657
+ "constraint",
658
+ "scope",
659
+ "mean by",
660
+ "considering",
661
+ "requirement",
662
+ "latency",
663
+ "scale",
664
+ "users",
665
+ "time window",
666
+ )
667
+ overlap = set(re.findall(r"[a-zA-Z]+", previous)).intersection(
668
+ set(re.findall(r"[a-zA-Z]+", current))
669
+ )
670
+ return any(term in current for term in clarification_terms) and bool(overlap)
671
+
672
+
673
+ async def transcribe_and_coach(
674
+ session_id: int | None,
675
+ audio_input: Any,
676
+ typed_transcript: str,
677
+ ) -> tuple[str, str, str, dict[str, Any]]:
678
+ if audio_input is None:
679
+ return "Record audio first, then press Transcribe & Coach.", render_answer_card(), "", {}
680
+
681
+ transcript = await transcribe_audio_input(audio_input)
682
+ if transcript.startswith("[transcription unavailable:"):
683
+ return transcript, render_answer_card(), "", {}
684
+
685
+ normalized = await normalize_interview_exchange_with_llm(transcript)
686
+ if (
687
+ normalized
688
+ and normalized.get("is_target")
689
+ and normalized.get("complete")
690
+ and normalized.get("question", "").strip()
691
+ ):
692
+ card, log, state = await coach_question(
693
+ session_id,
694
+ normalized.get("question", ""),
695
+ normalized.get("answer", ""),
696
+ )
697
+ return transcript, card, log, state
698
+
699
+ state = {
700
+ "message": (
701
+ "General LLM could not extract a complete DS/ML/AI/System Design question."
702
+ if normalized
703
+ else general_llm_unavailable_message()
704
+ )
705
+ }
706
+ return transcript, render_answer_card(state), state["message"], state
707
+
708
+
709
+ async def stream_live_transcript(
710
+ audio_input: Any,
711
+ current_transcript: str,
712
+ stream_state: dict[str, Any] | None,
713
+ ) -> tuple[str, str, str, dict[str, Any], dict[str, Any]]:
714
+ if audio_input is None:
715
+ state = stream_state or fresh_stream_state()
716
+ return (
717
+ current_transcript or "",
718
+ state.get("card_html") or render_answer_card(),
719
+ format_stream_status(state, "waiting for microphone"),
720
+ state,
721
+ state.get("card_state") or {},
722
+ )
723
+
724
+ state = update_stream_state(audio_input, stream_state)
725
+ audio_buffer = state.get("audio_buffer")
726
+ sample_rate = int(state.get("sample_rate") or 16000)
727
+ processed_until = int(state.get("processed_until") or 0)
728
+ available = 0 if audio_buffer is None else len(audio_buffer) - processed_until
729
+ if available < sample_rate * 3:
730
+ return (
731
+ current_transcript or "",
732
+ state.get("card_html") or render_answer_card(),
733
+ format_stream_status(state, "buffering audio"),
734
+ state,
735
+ state.get("card_state") or {},
736
+ )
737
+
738
+ overlap = int(sample_rate * 0.5)
739
+ start = max(0, processed_until - overlap)
740
+ end = len(audio_buffer)
741
+ audio_window = audio_buffer[start:end].copy()
742
+ state["processed_until"] = end
743
+ rms = audio_rms(audio_window)
744
+ state["last_rms"] = rms
745
+ if rms < 0.003:
746
+ return (
747
+ current_transcript or "",
748
+ state.get("card_html") or render_answer_card(),
749
+ format_stream_status(state, "quiet audio skipped"),
750
+ state,
751
+ state.get("card_state") or {},
752
+ )
753
+
754
+ chunk_text = await transcribe_audio_array(
755
+ sample_rate,
756
+ audio_window,
757
+ model=STREAMING_WHISPER_MODEL,
758
+ temperature=0.0,
759
+ condition_on_previous_text=False,
760
+ compression_ratio_threshold=1.8,
761
+ logprob_threshold=-0.6,
762
+ no_speech_threshold=0.35,
763
+ )
764
+ if not chunk_text or chunk_text.startswith("[transcription unavailable:"):
765
+ return (
766
+ current_transcript or "",
767
+ state.get("card_html") or render_answer_card(),
768
+ format_stream_status(state, chunk_text or "no speech detected yet"),
769
+ state,
770
+ state.get("card_state") or {},
771
+ )
772
+ if is_repetitive_hallucination(chunk_text):
773
+ state["rejected"] = int(state.get("rejected") or 0) + 1
774
+ state["last_text"] = f"rejected: {chunk_text[:60]}"
775
+ return (
776
+ current_transcript or "",
777
+ state.get("card_html") or render_answer_card(),
778
+ format_stream_status(state, "repeated-word output skipped"),
779
+ state,
780
+ state.get("card_state") or {},
781
+ )
782
+
783
+ state["transcriptions"] = int(state.get("transcriptions") or 0) + 1
784
+ state["last_text"] = chunk_text
785
+ updated_transcript = merge_transcript_text(current_transcript or "", chunk_text)
786
+ card_html, card_state = await monitor_answer_card(updated_transcript, state, force=True)
787
+ state["card_html"] = card_html
788
+ state["card_state"] = card_state
789
+ return (
790
+ updated_transcript,
791
+ card_html,
792
+ format_stream_status(state, "transcribing"),
793
+ state,
794
+ card_state,
795
+ )
796
+
797
+
798
+ async def start_backend_live_transcript():
799
+ ensure_topic_model_warmup()
800
+ await live_audio.start()
801
+ monitor_state = fresh_card_monitor_state()
802
+ async for transcript in live_audio.transcript_stream():
803
+ card_html, card_state = await monitor_answer_card(
804
+ transcript,
805
+ monitor_state,
806
+ force=False,
807
+ fast=True,
808
+ )
809
+ yield (
810
+ transcript,
811
+ card_html,
812
+ "Capturing system audio via BlackHole/default input",
813
+ card_state,
814
+ monitor_state,
815
+ )
816
+
817
+
818
+ async def stop_backend_live_transcript() -> str:
819
+ await live_audio.stop()
820
+ return "Stopped live audio capture"
821
+
822
+
823
+ async def transcribe_audio_input(audio_input: Any) -> str:
824
+ if isinstance(audio_input, str):
825
+ return await transcribe_audio_file(audio_input)
826
+ if isinstance(audio_input, tuple) and len(audio_input) == 2:
827
+ sample_rate, audio = audio_input
828
+ return await transcribe_audio_array(int(sample_rate), np.asarray(audio))
829
+ return "[transcription unavailable: unsupported audio input]"
830
+
831
+
832
+ async def process_typed_transcript(
833
+ session_id: int | None,
834
+ transcript: str,
835
+ ) -> tuple[str, str, str, dict[str, Any]]:
836
+ exchanges = await extract_all_interview_exchanges_with_llm(transcript)
837
+ if exchanges:
838
+ cards = []
839
+ last_state: dict[str, Any] = {}
840
+ log = ""
841
+ for exchange in exchanges:
842
+ card, log, last_state = await coach_question(
843
+ session_id,
844
+ exchange["question"],
845
+ exchange.get("answer", ""),
846
+ )
847
+ cards.append(card)
848
+ session_id = last_state.get("session_id", session_id)
849
+ last_state["processed_exchanges"] = exchanges
850
+ return transcript, render_cards(cards), log, last_state
851
+
852
+ normalized = await normalize_interview_exchange_with_llm(transcript)
853
+ if (
854
+ normalized
855
+ and normalized.get("is_target")
856
+ and normalized.get("complete")
857
+ and normalized.get("question", "").strip()
858
+ ):
859
+ card, log, state = await coach_question(
860
+ session_id,
861
+ normalized.get("question", ""),
862
+ normalized.get("answer", ""),
863
+ )
864
+ return transcript, card, log, state
865
+
866
+ state = {
867
+ "message": (
868
+ "General LLM could not extract a complete DS/ML/AI/System Design question."
869
+ if normalized
870
+ else general_llm_unavailable_message()
871
+ )
872
+ }
873
+ return transcript, render_answer_card(state), state["message"], state
874
+
875
+
876
+ def clear_live_state() -> tuple[str, str, dict[str, Any], str, dict[str, Any]]:
877
+ state = fresh_stream_state()
878
+ return "", render_answer_card(), state, format_stream_status(state, "cleared"), fresh_card_monitor_state()
879
+
880
+
881
+ async def clear_database() -> tuple[None, dict[str, Any], dict[str, Any], str, str, str, str, str, dict[str, Any]]:
882
+ await init_db()
883
+ await clear_all_tables()
884
+ return (
885
+ None,
886
+ {},
887
+ fresh_stream_state(),
888
+ "All SQLite tables cleared. Start a new session to continue.",
889
+ "",
890
+ render_answer_card(),
891
+ "",
892
+ "",
893
+ fresh_card_monitor_state(),
894
+ )
895
+
896
+
897
+ async def monitor_answer_card(
898
+ transcript: str,
899
+ monitor_state: dict[str, Any] | None,
900
+ force: bool = False,
901
+ fast: bool = True,
902
+ ) -> tuple[str, dict[str, Any]]:
903
+ monitor_state = monitor_state or fresh_card_monitor_state()
904
+ if fast:
905
+ question = extract_fast_live_question_candidate(transcript, monitor_state, force=force)
906
+ else:
907
+ question = await extract_target_question_from_transcript(transcript, monitor_state, force=force)
908
+ if not question:
909
+ message = monitor_state.get("last_extraction_message", "")
910
+ card_html = monitor_state.get("card_html") or render_answer_card({"message": message})
911
+ return card_html, monitor_state.get("card_state") or {}
912
+
913
+ question_key = question_dedupe_key(question)
914
+ if is_duplicate_live_question(question_key, monitor_state):
915
+ return monitor_state.get("card_html") or render_answer_card(), monitor_state.get("card_state") or {}
916
+ if question_key == monitor_state.get("last_non_target_question_key"):
917
+ return monitor_state.get("card_html") or render_answer_card(), monitor_state.get("card_state") or {}
918
+
919
+ result = await classify_topic_and_steps(question)
920
+ if result.get("model_unavailable"):
921
+ monitor_state["last_extraction_message"] = result["message"]
922
+ return monitor_state.get("card_html") or render_answer_card({"message": result["message"]}), {}
923
+ if fast and result.get("type") not in LIVE_TARGET_FRAMEWORKS:
924
+ monitor_state["last_non_target_question"] = question
925
+ monitor_state["last_non_target_question_key"] = question_key
926
+ monitor_state["last_extraction_message"] = "Listening for a DS/ML/AI/System Design question."
927
+ return monitor_state.get("card_html") or render_answer_card({"message": monitor_state["last_extraction_message"]}), {}
928
+
929
+ cues = await generate_coaching_cues(
930
+ question,
931
+ result["type"],
932
+ result.get("pattern", result["type"]),
933
+ result["steps"],
934
+ )
935
+ card_state = {
936
+ "question": question,
937
+ "question_key": question_key,
938
+ "framework": result["type"],
939
+ "pattern": result.get("pattern", result["type"]),
940
+ "steps": cues,
941
+ "framework_steps": result["steps"],
942
+ "confidence": result["confidence"],
943
+ "needs_review": result["confidence"] < 0.6,
944
+ }
945
+ cards = monitor_state.setdefault("cards", [])
946
+ if not any(questions_are_similar(question_key, str(card.get("question_key", ""))) for card in cards):
947
+ cards.append(card_state)
948
+ card_html = render_cards([render_card(card, flash=card.get("question") == question) for card in cards])
949
+ monitor_state["last_question"] = question
950
+ monitor_state["last_question_key"] = question_key
951
+ monitor_state["card_html"] = card_html
952
+ monitor_state["card_state"] = card_state
953
+ return card_html, card_state
954
+
955
+
956
+ async def update_live_card_from_transcript(
957
+ transcript: str,
958
+ monitor_state: dict[str, Any] | None,
959
+ ) -> tuple[str, dict[str, Any], dict[str, Any]]:
960
+ return await refresh_live_card_from_transcript(transcript, monitor_state, force=False)
961
+
962
+
963
+ async def call_coaching_from_transcript(
964
+ transcript: str,
965
+ monitor_state: dict[str, Any] | None,
966
+ ) -> tuple[str, dict[str, Any], dict[str, Any]]:
967
+ ensure_topic_model_warmup()
968
+ return await refresh_live_card_from_transcript(transcript, monitor_state, force=True)
969
+
970
+
971
+ async def refresh_live_card_from_transcript(
972
+ transcript: str,
973
+ monitor_state: dict[str, Any] | None,
974
+ force: bool,
975
+ ) -> tuple[str, dict[str, Any], dict[str, Any]]:
976
+ monitor_state = monitor_state or fresh_card_monitor_state()
977
+ if not transcript.strip():
978
+ monitor_state = fresh_card_monitor_state()
979
+ return render_answer_card(), {}, monitor_state
980
+
981
+ card_html, card_state = await monitor_answer_card(
982
+ transcript,
983
+ monitor_state,
984
+ force=force,
985
+ fast=True,
986
+ )
987
+ return card_html, card_state, monitor_state
988
+
989
+
990
+ async def generate_coaching_cues(
991
+ question: str,
992
+ framework: str,
993
+ pattern: str,
994
+ fallback_steps: list[str],
995
+ ) -> list[str]:
996
+ cues = await generate_coaching_cues_with_llm(question, framework, pattern, fallback_steps)
997
+ if cues:
998
+ return cues
999
+ return fallback_steps
1000
+
1001
+
1002
+ async def generate_coaching_cues_with_llm(
1003
+ question: str,
1004
+ framework: str,
1005
+ pattern: str,
1006
+ fallback_steps: list[str],
1007
+ ) -> list[str]:
1008
+ prompt = COACHING_GUIDANCE_USER_PROMPT.format(
1009
+ question=question,
1010
+ framework=framework,
1011
+ pattern=pattern,
1012
+ steps="\n".join(f"- {step}" for step in fallback_steps),
1013
+ )
1014
+ response = await general_llm.generate(
1015
+ COACHING_GUIDANCE_SYSTEM_PROMPT,
1016
+ prompt,
1017
+ max_new_tokens=384,
1018
+ )
1019
+ if not response:
1020
+ return []
1021
+ try:
1022
+ payload = json.loads(extract_json_object(response))
1023
+ cues = payload.get("cues", [])
1024
+ if isinstance(cues, list):
1025
+ return [str(cue).strip() for cue in cues if str(cue).strip()][:6]
1026
+ except Exception:
1027
+ return []
1028
+ return []
1029
+
1030
+
1031
+ def fresh_card_monitor_state() -> dict[str, Any]:
1032
+ return {
1033
+ "last_question": "",
1034
+ "last_question_key": "",
1035
+ "card_html": render_answer_card(),
1036
+ "card_state": {},
1037
+ "cards": [],
1038
+ "last_non_target_question": "",
1039
+ "last_non_target_question_key": "",
1040
+ "generic_last_llm_until": 0,
1041
+ "generic_last_extracted_question": "",
1042
+ "fast_last_checked_until": 0,
1043
+ }
1044
+
1045
+
1046
+ def extract_fast_live_question_candidate(
1047
+ transcript: str,
1048
+ state: dict[str, Any],
1049
+ force: bool = False,
1050
+ ) -> str | None:
1051
+ clean = normalize_llm_text(transcript)
1052
+ if len(clean.split()) < 5:
1053
+ state["last_extraction_message"] = "Listening for a complete DS/ML/AI/System Design question."
1054
+ return None
1055
+
1056
+ last_checked_until = int(state.get("fast_last_checked_until") or 0)
1057
+ if not force and len(clean) - last_checked_until < LIVE_FAST_MIN_DELTA_CHARS:
1058
+ return None
1059
+
1060
+ state["fast_last_checked_until"] = len(clean)
1061
+ candidate = clean_fast_live_question(clean[-LIVE_FAST_QUESTION_WINDOW_CHARS:])
1062
+ if len(candidate.split()) < 5:
1063
+ state["last_extraction_message"] = "Listening for a complete DS/ML/AI/System Design question."
1064
+ return None
1065
+
1066
+ state["last_extraction_message"] = ""
1067
+ return candidate
1068
+
1069
+
1070
+ def clean_fast_live_question(window: str) -> str:
1071
+ candidate = f" {normalize_llm_text(window)} "
1072
+ lowered = candidate.lower()
1073
+
1074
+ marker_positions = [lowered.rfind(marker) for marker in LIVE_QUESTION_MARKERS]
1075
+ marker_positions = [position for position in marker_positions if position >= 0]
1076
+ if marker_positions:
1077
+ candidate = candidate[max(marker_positions) :].strip()
1078
+
1079
+ lowered = f" {candidate.lower()} "
1080
+ answer_positions = [lowered.find(marker) for marker in LIVE_ANSWER_STARTS]
1081
+ answer_positions = [position for position in answer_positions if position > 6]
1082
+ if answer_positions:
1083
+ candidate = candidate[: min(answer_positions)].strip()
1084
+
1085
+ candidate = re.sub(
1086
+ r"^(?:first|second|third|next|another)(?:\s+(?:question|one))?\s*[:,.-]?\s*",
1087
+ "",
1088
+ candidate,
1089
+ flags=re.IGNORECASE,
1090
+ )
1091
+ candidate = re.sub(r"^(you have)\.\s+\1\b", r"\1", candidate, flags=re.IGNORECASE)
1092
+
1093
+ question_mark = candidate.rfind("?")
1094
+ if question_mark >= 0:
1095
+ candidate = candidate[: question_mark + 1]
1096
+
1097
+ candidate = remove_fast_transcript_repeats(candidate)
1098
+ candidate = candidate.strip(" .,-:")
1099
+ if candidate and not candidate.endswith("?"):
1100
+ candidate = f"{candidate}?"
1101
+ return candidate
1102
+
1103
+
1104
+ def remove_fast_transcript_repeats(text: str) -> str:
1105
+ words = text.split()
1106
+ cleaned: list[str] = []
1107
+ for word in words:
1108
+ normalized = word.lower().strip(".,?!:;")
1109
+ if cleaned and normalized == cleaned[-1].lower().strip(".,?!:;"):
1110
+ continue
1111
+ cleaned.append(word)
1112
+ return " ".join(cleaned)
1113
+
1114
+
1115
+ def question_dedupe_key(question: str) -> str:
1116
+ words = re.findall(r"[a-z0-9]+", question.lower())
1117
+ stop_words = {
1118
+ "a",
1119
+ "an",
1120
+ "and",
1121
+ "are",
1122
+ "as",
1123
+ "be",
1124
+ "briefly",
1125
+ "can",
1126
+ "could",
1127
+ "do",
1128
+ "does",
1129
+ "explain",
1130
+ "for",
1131
+ "how",
1132
+ "i",
1133
+ "in",
1134
+ "is",
1135
+ "it",
1136
+ "me",
1137
+ "of",
1138
+ "please",
1139
+ "question",
1140
+ "tell",
1141
+ "the",
1142
+ "to",
1143
+ "we",
1144
+ "what",
1145
+ "would",
1146
+ "you",
1147
+ "your",
1148
+ }
1149
+ useful = [word for word in words if word not in stop_words and len(word) > 1]
1150
+ return " ".join(useful[:40])
1151
+
1152
+
1153
+ def is_duplicate_live_question(question_key: str, state: dict[str, Any]) -> bool:
1154
+ if not question_key:
1155
+ return False
1156
+ if questions_are_similar(question_key, str(state.get("last_question_key", ""))):
1157
+ return True
1158
+ return any(
1159
+ questions_are_similar(question_key, str(card.get("question_key", "")))
1160
+ for card in state.get("cards", [])
1161
+ if isinstance(card, dict)
1162
+ )
1163
+
1164
+
1165
+ def questions_are_similar(left_key: str, right_key: str) -> bool:
1166
+ left = set(left_key.split())
1167
+ right = set(right_key.split())
1168
+ if not left or not right:
1169
+ return False
1170
+ overlap = len(left & right)
1171
+ containment = overlap / min(len(left), len(right))
1172
+ union = len(left | right)
1173
+ jaccard = overlap / union if union else 0.0
1174
+ return containment >= LIVE_FAST_DUPLICATE_SIMILARITY or jaccard >= LIVE_FAST_DUPLICATE_SIMILARITY
1175
+
1176
+
1177
+ async def extract_target_question_from_transcript(
1178
+ transcript: str,
1179
+ state: dict[str, Any],
1180
+ force: bool = False,
1181
+ ) -> str | None:
1182
+ clean = re.sub(r"\s+", " ", transcript).strip()
1183
+ if len(clean.split()) < 4:
1184
+ return None
1185
+
1186
+ last_llm_until = int(state.get("generic_last_llm_until") or 0)
1187
+ if (
1188
+ not force
1189
+ and len(clean) - last_llm_until < GENERIC_EXTRACTION_MIN_LLM_DELTA_CHARS
1190
+ and "?" not in clean[last_llm_until:]
1191
+ ):
1192
+ return None
1193
+
1194
+ window = clean[-GENERIC_EXTRACTION_MAX_WINDOW_CHARS:]
1195
+ state["generic_last_llm_until"] = len(clean)
1196
+ result = await normalize_interview_exchange_with_llm(window, prefer_latest=True)
1197
+ if not result:
1198
+ details = f" Details: {general_llm.last_error}" if general_llm.last_error else ""
1199
+ state["last_extraction_message"] = (
1200
+ f"General LLM unavailable. Expected Hugging Face model {GENERAL_LLM_MODEL}.{details}"
1201
+ )
1202
+ return None
1203
+ if not result.get("is_target") or not result.get("complete"):
1204
+ state["last_extraction_message"] = "Listening for a complete DS/ML/AI/System Design question."
1205
+ return None
1206
+
1207
+ question = str(result.get("question", "")).strip()
1208
+ if not question or question == state.get("generic_last_extracted_question"):
1209
+ return None
1210
+
1211
+ state["generic_last_extracted_question"] = question
1212
+ state["last_extraction_message"] = ""
1213
+ return question
1214
+
1215
+
1216
+ def extract_json_object(text: str) -> str:
1217
+ match = re.search(r"\{.*\}", text, flags=re.DOTALL)
1218
+ return match.group(0) if match else text
1219
+
1220
+
1221
+ def merge_transcript_text(existing: str, incoming: str) -> str:
1222
+ existing = re.sub(r"\s+", " ", existing).strip()
1223
+ incoming = re.sub(r"\s+", " ", incoming).strip()
1224
+ if not existing:
1225
+ return incoming
1226
+ if not incoming:
1227
+ return existing
1228
+ if incoming.lower().startswith(existing.lower()):
1229
+ return incoming
1230
+ if existing.lower().endswith(incoming.lower()):
1231
+ return existing
1232
+ overlap = find_text_overlap(existing, incoming)
1233
+ if overlap:
1234
+ return f"{existing}{incoming[overlap:]}".strip()
1235
+ return f"{existing} {incoming}".strip()
1236
+
1237
+
1238
+ def find_text_overlap(existing: str, incoming: str) -> int:
1239
+ existing_lower = existing.lower()
1240
+ incoming_lower = incoming.lower()
1241
+ max_overlap = min(len(existing), len(incoming), 120)
1242
+ for size in range(max_overlap, 4, -1):
1243
+ if existing_lower.endswith(incoming_lower[:size]):
1244
+ return size
1245
+ return 0
1246
+
1247
+
1248
+ def fresh_stream_state() -> dict[str, Any]:
1249
+ return {
1250
+ "sample_rate": None,
1251
+ "audio_buffer": np.array([], dtype=np.float32),
1252
+ "last_seen_samples": 0,
1253
+ "processed_until": 0,
1254
+ "chunks": 0,
1255
+ "transcriptions": 0,
1256
+ "rejected": 0,
1257
+ "last_rms": 0.0,
1258
+ "last_text": "",
1259
+ }
1260
+
1261
+
1262
+ def update_stream_state(audio_input: Any, stream_state: dict[str, Any] | None) -> dict[str, Any]:
1263
+ state = stream_state or fresh_stream_state()
1264
+ if not isinstance(audio_input, tuple) or len(audio_input) != 2:
1265
+ return state
1266
+
1267
+ sample_rate, audio = audio_input
1268
+ audio = np.asarray(audio)
1269
+ if audio.ndim > 1:
1270
+ audio = audio.mean(axis=1)
1271
+ if np.issubdtype(audio.dtype, np.integer):
1272
+ audio = audio.astype(np.float32) / np.iinfo(audio.dtype).max
1273
+ else:
1274
+ audio = audio.astype(np.float32, copy=False)
1275
+
1276
+ last_seen = int(state.get("last_seen_samples") or 0)
1277
+ if len(audio) > last_seen:
1278
+ new_audio = audio[last_seen:]
1279
+ state["last_seen_samples"] = len(audio)
1280
+ else:
1281
+ new_audio = audio
1282
+ state["last_seen_samples"] = len(audio)
1283
+
1284
+ audio_buffer = state.get("audio_buffer")
1285
+ if audio_buffer is None:
1286
+ audio_buffer = np.array([], dtype=np.float32)
1287
+
1288
+ state["sample_rate"] = int(sample_rate)
1289
+ state["chunks"] = int(state.get("chunks") or 0) + 1
1290
+ combined = np.concatenate([audio_buffer, new_audio])
1291
+ max_samples = int(sample_rate) * 45
1292
+ dropped = max(0, len(combined) - max_samples)
1293
+ state["audio_buffer"] = combined[-max_samples:]
1294
+ state["processed_until"] = max(0, int(state.get("processed_until") or 0) - dropped)
1295
+ return state
1296
+
1297
+
1298
+ def format_stream_status(state: dict[str, Any], message: str) -> str:
1299
+ sample_rate = int(state.get("sample_rate") or 16000)
1300
+ audio_buffer = state.get("audio_buffer")
1301
+ buffered_seconds = 0.0
1302
+ if audio_buffer is not None:
1303
+ buffered_seconds = len(audio_buffer) / sample_rate
1304
+ processed_seconds = int(state.get("processed_until") or 0) / sample_rate
1305
+
1306
+ chunks = int(state.get("chunks") or 0)
1307
+ transcriptions = int(state.get("transcriptions") or 0)
1308
+ rejected = int(state.get("rejected") or 0)
1309
+ rms = float(state.get("last_rms") or 0.0)
1310
+ last_text = str(state.get("last_text") or "").strip()
1311
+ if last_text:
1312
+ last_text = f" | last: {last_text[:80]}"
1313
+ return (
1314
+ f"{message} | chunks: {chunks} | buffered: {buffered_seconds:.1f}s "
1315
+ f"| processed: {processed_seconds:.1f}s | runs: {transcriptions} "
1316
+ f"| rejected: {rejected} | rms: {rms:.4f}{last_text}"
1317
+ )
1318
+
1319
+
1320
+ def audio_rms(audio: np.ndarray) -> float:
1321
+ if audio.size == 0:
1322
+ return 0.0
1323
+ return float(np.sqrt(np.mean(np.square(audio.astype(np.float32)))))
1324
+
1325
+
1326
+ def is_repetitive_hallucination(text: str) -> bool:
1327
+ words = re.findall(r"[a-zA-Z']+", text.lower())
1328
+ if len(words) < 8:
1329
+ return False
1330
+
1331
+ unique_words = set(words)
1332
+ if len(unique_words) <= 2:
1333
+ return True
1334
+
1335
+ most_common = max(words.count(word) for word in unique_words)
1336
+ if most_common / len(words) >= 0.65:
1337
+ return True
1338
+
1339
+ repeated_run = 1
1340
+ for previous, current in zip(words, words[1:]):
1341
+ repeated_run = repeated_run + 1 if previous == current else 1
1342
+ if repeated_run >= 5:
1343
+ return True
1344
+ return False
1345
+
1346
+
1347
+ async def evaluate_session(session_id: int | None, last_state: dict[str, Any] | None) -> str:
1348
+ if not session_id:
1349
+ return "Start a session first."
1350
+
1351
+ exchanges = await list_exchanges(session_id)
1352
+ if not exchanges:
1353
+ return "No exchanges to evaluate yet."
1354
+
1355
+ existing_evaluations = await list_evaluations(session_id)
1356
+ evaluated_exchange_ids = {item["exchange_id"] for item in existing_evaluations}
1357
+ pending_exchanges = [exchange for exchange in exchanges if exchange["id"] not in evaluated_exchange_ids]
1358
+ if not pending_exchanges:
1359
+ return await render_evaluations(session_id)
1360
+
1361
+ latest_state_exchange_id = (last_state or {}).get("exchange_id")
1362
+ for exchange in pending_exchanges:
1363
+ if exchange["id"] == latest_state_exchange_id:
1364
+ steps = (last_state or {}).get("framework_steps") or (last_state or {}).get("steps") or []
1365
+ else:
1366
+ classification = await classify_topic_and_steps(exchange["question"])
1367
+ steps = classification.get("steps", [])
1368
+ if not steps:
1369
+ steps = ["Clarify", "Answer", "Example", "Impact"]
1370
+
1371
+ result = await evaluator.evaluate(
1372
+ question=exchange["question"],
1373
+ answer=exchange["answer"],
1374
+ framework=exchange["framework_used"],
1375
+ steps=steps,
1376
+ )
1377
+ await add_evaluation(
1378
+ exchange_id=exchange["id"],
1379
+ steps_covered=result["steps_covered"],
1380
+ score=result["score"],
1381
+ feedback=result["feedback"],
1382
+ )
1383
+ return await render_evaluations(session_id)
1384
+
1385
+
1386
+ async def render_log(session_id: int) -> str:
1387
+ exchanges = await list_exchanges(session_id)
1388
+ if not exchanges:
1389
+ return "No exchanges yet."
1390
+ lines = []
1391
+ for item in exchanges:
1392
+ lines.append(
1393
+ f"Q{item['id']} [{item['framework_used']}]: {item['question']}\n"
1394
+ f"A: {item['answer'] or '(not captured yet)'}"
1395
+ )
1396
+ return "\n\n".join(lines)
1397
+
1398
+
1399
+ async def render_evaluations(session_id: int) -> str:
1400
+ evaluations = await list_evaluations(session_id)
1401
+ if not evaluations:
1402
+ return "No evaluations yet."
1403
+ blocks = []
1404
+ for item in evaluations:
1405
+ blocks.append(
1406
+ f"Exchange {item['exchange_id']}\n"
1407
+ f"Framework: {item['framework_used']}\n"
1408
+ f"{item['feedback']}"
1409
+ )
1410
+ return "\n\n".join(blocks)
1411
+
1412
+
1413
+ async def render_sqlite_evaluation_summary(session_id: int | None) -> tuple[str, list[list[str]]]:
1414
+ evaluations = await list_evaluations(session_id) if session_id else await list_all_evaluations()
1415
+ if not evaluations:
1416
+ return "No evaluated exchanges found in SQLite. Run Evaluate Session first.", []
1417
+
1418
+ blocks = []
1419
+ rows = []
1420
+ for item in evaluations:
1421
+ feedback_parts = split_evaluation_feedback(item["feedback"])
1422
+ session_name = format_session_name(item)
1423
+ session_date = format_session_date(item.get("date", ""))
1424
+ session_label = ""
1425
+ if item.get("session_id"):
1426
+ session_label = f"{session_name} - {session_date}\n"
1427
+ blocks.append(
1428
+ f"{session_label}"
1429
+ f"Exchange {item['exchange_id']} [{item['framework_used']}]\n\n"
1430
+ f"Question:\n{item['question']}\n\n"
1431
+ f"My answer:\n{item['answer'] or '(not captured yet)'}\n\n"
1432
+ f"Benchmark answer:\n{feedback_parts['benchmark']}\n\n"
1433
+ f"Evaluation band:\n{feedback_parts['band']}\n\n"
1434
+ f"Feedback:\n{feedback_parts['feedback']}"
1435
+ )
1436
+ rows.append(
1437
+ [
1438
+ session_name,
1439
+ session_date,
1440
+ item["question"],
1441
+ item["answer"] or "(not captured yet)",
1442
+ feedback_parts["benchmark"],
1443
+ feedback_parts["band"],
1444
+ feedback_parts["feedback"],
1445
+ ]
1446
+ )
1447
+ return "\n\n---\n\n".join(blocks), rows
1448
+
1449
+
1450
+ async def export_sqlite_evaluation_summary_csv(session_id: int | None) -> str | None:
1451
+ _, rows = await render_sqlite_evaluation_summary(session_id)
1452
+ if not rows:
1453
+ return None
1454
+
1455
+ export_dir = BASE_DIR / ".runtime" / "exports"
1456
+ export_dir.mkdir(parents=True, exist_ok=True)
1457
+ export_path = export_dir / "evaluation_summary.csv"
1458
+ with export_path.open("w", newline="", encoding="utf-8") as file:
1459
+ writer = csv.writer(file)
1460
+ writer.writerow(EVALUATION_TABLE_HEADERS)
1461
+ writer.writerows(rows)
1462
+ return str(export_path)
1463
+
1464
+
1465
+ EVALUATION_TABLE_HEADERS = [
1466
+ "Session",
1467
+ "Date",
1468
+ "Question",
1469
+ "Candidate Answer",
1470
+ "Model Benchmark Answer",
1471
+ "Evaluation Band",
1472
+ "Feedback",
1473
+ ]
1474
+
1475
+
1476
+ def format_session_name(item: dict[str, Any]) -> str:
1477
+ session_id = item.get("session_id", "")
1478
+ company = item.get("company") or "Unknown company"
1479
+ role = item.get("role") or "Unknown role"
1480
+ return f"Session {session_id} - {company} / {role}"
1481
+
1482
+
1483
+ def format_session_date(value: str) -> str:
1484
+ if not value:
1485
+ return ""
1486
+ return value.replace("T", " ").split(".")[0]
1487
+
1488
+
1489
+ def split_evaluation_feedback(feedback: str) -> dict[str, str]:
1490
+ benchmark = extract_feedback_section(feedback, "Agent benchmark answer:", "Evaluation band:")
1491
+ if not benchmark:
1492
+ benchmark = extract_feedback_section(feedback, "Agent benchmark answer:", "Score:")
1493
+
1494
+ band = extract_feedback_section(feedback, "Evaluation band:", "Strong points:")
1495
+ score_and_feedback = extract_feedback_section(feedback, "Evaluation band:", "")
1496
+ if band:
1497
+ score_and_feedback = extract_feedback_section(feedback, "Strong points:", "")
1498
+ score_and_feedback = f"Strong points:\n{score_and_feedback}".strip()
1499
+ else:
1500
+ score_and_feedback = extract_feedback_section(feedback, "Score:", "")
1501
+ if score_and_feedback:
1502
+ score_text = re.match(r"^\s*(\d+\s*/\s*5)", score_and_feedback)
1503
+ band = score_text.group(1) if score_text else "Unknown"
1504
+ score_and_feedback = re.sub(r"^\s*\d+\s*/\s*5\s*", "", score_and_feedback).strip()
1505
+ return {
1506
+ "benchmark": benchmark or "(benchmark not available)",
1507
+ "band": band or "Unknown",
1508
+ "feedback": score_and_feedback or feedback.strip() or "(feedback not available)",
1509
+ }
1510
+
1511
+
1512
+ def extract_feedback_section(text: str, start_label: str, end_label: str) -> str:
1513
+ start = text.find(start_label)
1514
+ if start == -1:
1515
+ return ""
1516
+ start += len(start_label)
1517
+ end = text.find(end_label, start) if end_label else -1
1518
+ if end == -1:
1519
+ return text[start:].strip()
1520
+ return text[start:end].strip()
1521
+
1522
+
1523
+ def render_card(state: dict[str, Any], flash: bool = False) -> str:
1524
+ framework = html.escape(state.get("framework", "General"))
1525
+ pattern = html.escape(state.get("pattern", ""))
1526
+ display_type = pattern or framework
1527
+ question = html.escape(state.get("question", "Question unavailable"))
1528
+ color = FRAMEWORK_COLORS.get(display_type, FRAMEWORK_COLORS.get(framework, FRAMEWORK_COLORS["General"]))
1529
+ steps = "".join(f"<li>{html.escape(step)}</li>" for step in state.get("steps", []))
1530
+ flash_class = " flash-card" if flash else ""
1531
+ return f"""
1532
+ <details class="coach-card floating-card{flash_class}" style="--framework-color: {color}" open>
1533
+ <summary>
1534
+ <div class="card-question">{question}</div>
1535
+ <span class="card-type">Type: {display_type}</span>
1536
+ </summary>
1537
+ <div class="meta">Steps</div>
1538
+ <ol>{steps}</ol>
1539
+ </details>
1540
+ """
1541
+
1542
+
1543
+ def render_cards(cards: list[str]) -> str:
1544
+ if not cards:
1545
+ return render_answer_card()
1546
+ return "<div class='coach-card-stack'>" + "\n".join(cards) + "</div>"
1547
+
1548
+
1549
+ def render_answer_card(state: dict[str, Any] | None = None) -> str:
1550
+ state = state or {}
1551
+ status = state.get("message") or "Waiting for answer card content."
1552
+ if state.get("answer"):
1553
+ status = "Answer captured. Card content to be defined."
1554
+ return f"""
1555
+ <div class="coach-card" style="--framework-color: #22c55e">
1556
+ <h3>Answer Card</h3>
1557
+ <div class="meta">{html.escape(status)}</div>
1558
+ </div>
1559
+ """
1560
+
1561
+
1562
+ with gr.Blocks(elem_id="app-shell") as demo:
1563
+ session_id = gr.State(None)
1564
+ last_state = gr.State({})
1565
+ stream_state = gr.State(fresh_stream_state())
1566
+ card_monitor_state = gr.State(fresh_card_monitor_state())
1567
+
1568
+ runtime_label = "Hugging Face Space browser-mic demo" if HF_SPACE_MODE else "Local real-time system-audio coaching"
1569
+ gr.Markdown(
1570
+ f"# InterviewCoach\n{runtime_label} with live transcript, framework cards, and post-session evaluation.",
1571
+ elem_id="app-header",
1572
+ )
1573
+ with gr.Row(elem_classes=["form", "action-row"]):
1574
+ company = gr.Textbox(label="Company", placeholder="Anthropic", scale=2)
1575
+ role = gr.Textbox(label="Role", placeholder="ML Engineer", scale=2)
1576
+ start = gr.Button("Create Session", variant="primary", scale=1)
1577
+ status = gr.Textbox(label="Session Status", interactive=False, elem_id="status_box")
1578
+
1579
+ with gr.Tabs():
1580
+ with gr.Tab("Live"):
1581
+ with gr.Row(equal_height=True, elem_id="live_grid"):
1582
+ with gr.Column(scale=2, min_width=180, elem_classes=["compact-panel"]):
1583
+ if HF_SPACE_MODE:
1584
+ gr.Markdown("Browser microphone", elem_classes=["section-title"])
1585
+ mic = gr.Audio(
1586
+ sources=["microphone"],
1587
+ type="numpy",
1588
+ label="Record from browser",
1589
+ elem_id="mic_box",
1590
+ )
1591
+ with gr.Row(elem_classes=["action-row"]):
1592
+ transcribe_coach = gr.Button("Transcribe & Process", variant="primary")
1593
+ else:
1594
+ gr.Markdown("System audio", elem_classes=["section-title"])
1595
+ with gr.Row(elem_classes=["action-row"]):
1596
+ start_live = gr.Button("Start System Audio", variant="secondary")
1597
+ stop_live = gr.Button("Stop System Audio", variant="stop")
1598
+ with gr.Row(elem_classes=["action-row"]):
1599
+ clear = gr.Button("Clear Screen", variant="secondary")
1600
+ stream_status = gr.Textbox(
1601
+ label="Audio Status",
1602
+ interactive=False,
1603
+ lines=2,
1604
+ elem_id="stream_status_box",
1605
+ )
1606
+
1607
+ with gr.Column(scale=3, min_width=280, elem_classes=["compact-panel"]):
1608
+ gr.Markdown("Transcript workspace", elem_classes=["section-title"])
1609
+ live_transcript = gr.Textbox(
1610
+ label="Live Transcript",
1611
+ lines=11,
1612
+ placeholder="Recorded or typed transcript appears here.",
1613
+ elem_id="live_transcript_box",
1614
+ )
1615
+ with gr.Row(elem_classes=["action-row"]):
1616
+ call_coaching = gr.Button("Call Coaching", variant="primary")
1617
+ coach = gr.Button("Process Text", variant="secondary")
1618
+
1619
+ with gr.Column(scale=2, min_width=220, elem_classes=["compact-panel"]):
1620
+ gr.Markdown("Coaching card", elem_classes=["section-title"])
1621
+ answer_card = gr.HTML(render_answer_card())
1622
+
1623
+ with gr.Tab("Session Log"):
1624
+ log = gr.Textbox(label="Saved Exchanges", lines=16, interactive=False, elem_id="log_box")
1625
+ clear_db = gr.Button("Clear SQLite Tables", variant="stop")
1626
+
1627
+ with gr.Tab("Evaluate"):
1628
+ with gr.Row(elem_classes=["action-row"]):
1629
+ evaluate = gr.Button("Evaluate Session", variant="primary")
1630
+ load_eval_summary = gr.Button("Load SQLite Summary", variant="secondary")
1631
+ export_eval_csv = gr.Button("Export CSV", variant="secondary")
1632
+ evaluation_table = gr.Dataframe(
1633
+ headers=EVALUATION_TABLE_HEADERS,
1634
+ datatype=["str", "str", "str", "str", "str", "str", "str"],
1635
+ label="SQLite Evaluation History",
1636
+ interactive=False,
1637
+ wrap=True,
1638
+ )
1639
+ export_file = gr.File(label="CSV Download", interactive=False)
1640
+ report = gr.Textbox(label="Evaluation Report", lines=16, interactive=False, elem_id="report_box")
1641
+
1642
+ start.click(start_session, inputs=[company, role], outputs=[session_id, status])
1643
+ if HF_SPACE_MODE:
1644
+ transcribe_coach.click(
1645
+ transcribe_and_coach,
1646
+ inputs=[session_id, mic, live_transcript],
1647
+ outputs=[live_transcript, answer_card, log, last_state],
1648
+ queue=True,
1649
+ )
1650
+ else:
1651
+ start_live.click(
1652
+ start_backend_live_transcript,
1653
+ outputs=[live_transcript, answer_card, stream_status, last_state, card_monitor_state],
1654
+ queue=True,
1655
+ )
1656
+ stop_live.click(
1657
+ stop_backend_live_transcript,
1658
+ outputs=[stream_status],
1659
+ queue=False,
1660
+ )
1661
+ coach.click(
1662
+ process_typed_transcript,
1663
+ inputs=[session_id, live_transcript],
1664
+ outputs=[live_transcript, answer_card, log, last_state],
1665
+ queue=True,
1666
+ )
1667
+ call_coaching.click(
1668
+ call_coaching_from_transcript,
1669
+ inputs=[live_transcript, card_monitor_state],
1670
+ outputs=[answer_card, last_state, card_monitor_state],
1671
+ queue=True,
1672
+ )
1673
+ live_transcript.change(
1674
+ update_live_card_from_transcript,
1675
+ inputs=[live_transcript, card_monitor_state],
1676
+ outputs=[answer_card, last_state, card_monitor_state],
1677
+ queue=True,
1678
+ )
1679
+ clear.click(
1680
+ clear_live_state,
1681
+ outputs=[live_transcript, answer_card, stream_state, stream_status, card_monitor_state],
1682
+ queue=False,
1683
+ )
1684
+ clear_db.click(
1685
+ clear_database,
1686
+ outputs=[
1687
+ session_id,
1688
+ last_state,
1689
+ stream_state,
1690
+ status,
1691
+ live_transcript,
1692
+ answer_card,
1693
+ log,
1694
+ report,
1695
+ card_monitor_state,
1696
+ ],
1697
+ queue=True,
1698
+ )
1699
+ evaluate.click(evaluate_session, inputs=[session_id, last_state], outputs=[report], queue=True)
1700
+ load_eval_summary.click(
1701
+ render_sqlite_evaluation_summary,
1702
+ inputs=[session_id],
1703
+ outputs=[report, evaluation_table],
1704
+ queue=True,
1705
+ )
1706
+ export_eval_csv.click(
1707
+ export_sqlite_evaluation_summary_csv,
1708
+ inputs=[session_id],
1709
+ outputs=[export_file],
1710
+ queue=True,
1711
+ )
1712
+
1713
+
1714
+ if __name__ == "__main__":
1715
+ port = int(os.environ.get("INTERVIEW_COACH_PORT", APP_PORT))
1716
+ demo.queue().launch(
1717
+ css=CSS,
1718
+ theme=gr.themes.Base(),
1719
+ server_name=APP_HOST,
1720
+ server_port=port,
1721
+ )
article.md ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Interview Coach: Real-Time, Local Interview Assistance Without Taking Over the Answer
2
+
3
+ ## Introduction
4
+
5
+ Technical interviews are noisy, fast, and cognitively expensive. A candidate has to listen carefully, identify the actual question, choose the right structure, remember the important concepts, and answer naturally, all in a few seconds.
6
+
7
+ Interview Coach is built for that exact moment. It listens to the interview audio, extracts the interviewer’s Data Science, Machine Learning, AI, or System Design question, and shows a compact coaching card with important pointers. The goal is not to generate an answer for the candidate or handhold them through the interview. The goal is to provide timely reminders so the candidate can cover the important parts of their own answer.
8
+
9
+ The app also helps after the interview. It extracts question-answer pairs from noisy transcripts, stores them in SQLite, and evaluates the candidate’s answers with structured feedback. That makes it useful both as a live coaching tool and as a practice-review system.
10
+
11
+ The full setup is designed to run locally on a Mac. This keeps latency low, avoids sending interview audio to a cloud service, and makes the experience feel private and responsive.
12
+
13
+ ## Architecture
14
+
15
+ At a high level, Interview Coach has two paths: a fast live coaching path and a slower post-session evaluation path.
16
+
17
+ ```text
18
+ Live audio
19
+ -> Speech-to-text
20
+ -> Fast question boundary detection
21
+ -> Fine-tuned topic/pattern classifier
22
+ -> Coaching hint generator
23
+ -> Floating coaching cards
24
+
25
+ Full transcript
26
+ -> Q&A extraction
27
+ -> SQLite persistence
28
+ -> Evaluation agent
29
+ -> Feedback table / CSV export
30
+ ```
31
+
32
+ The live path is optimized for speed. It tries to show the coaching card immediately after the interviewer asks the question, before the candidate has already started answering. The post-session path is optimized for accuracy and reflection, so it can spend more time cleaning up the transcript and evaluating the answer.
33
+
34
+ ## Models Used
35
+
36
+ The project uses a multi-model approach. Instead of forcing one model to handle every task, each model has a focused responsibility.
37
+
38
+ | Model | Approx Size | Purpose |
39
+ | --- | ---: | --- |
40
+ | `mlx-community/whisper-tiny` | ~39M parameters | Fast streaming transcription for live audio |
41
+ | `mlx-community/whisper-small-mlx` | ~244M parameters | Higher-quality local transcription when needed |
42
+ | `vadirajkrishna/interview-coach-3b` | 3B base model plus LoRA adapter | Fine-tuned topic and pattern detection |
43
+ | `Qwen/Qwen2.5-3B-Instruct` | ~3B parameters | General reasoning, coaching hint generation, transcript cleanup, and evaluation |
44
+ | SQLite | Local database | Session, exchange, and evaluation storage |
45
+
46
+ This split is important. The fine-tuned model is used for what it is good at: quickly identifying the type and pattern of the question. The general instruction model is used when more flexible reasoning is needed, such as generating hints or extracting structured Q&A from a noisy transcript.
47
+
48
+ ## Fine-Tuning Approach
49
+
50
+ The fine-tuned model was trained to map interview questions to a type and a set of coarse answering steps. For example:
51
+
52
+ ```json
53
+ {
54
+ "prompt": "How would you handle class imbalance in a fraud detection model?",
55
+ "completion": "Type: Data Science\nSteps: Check base rate -> Choose right metric -> Handle imbalance -> Tune threshold -> Evaluate"
56
+ }
57
+ ```
58
+
59
+ This helped in two ways.
60
+
61
+ First, the coaching system became more reliable at deciding whether a transcript segment was relevant. Greetings, logistics, and generic discussion should not trigger a coaching card. The fine-tuned model helps classify only meaningful DS, ML, AI, or System Design questions.
62
+
63
+ Second, it gave the live system a fast topic/pattern signal. The app does not need to ask a larger model to deeply reason about the question before showing anything. It can quickly classify the question and then use that classification as context for generating better hints.
64
+
65
+ The final design uses the fine-tuned 3B model for type and pattern detection, not for the final coaching bullets. The displayed hints are generated separately so they can be more specific and useful.
66
+
67
+ ## Challenge 1: Making the Coaching Card Appear Fast Enough
68
+
69
+ The most critical product challenge was timing.
70
+
71
+ If the coaching card appears after the candidate has already started answering, it is too late. The card has to appear right after the interviewer asks the question and before the candidate has committed to an answer structure.
72
+
73
+ The first version used a general LLM to extract the clean question before showing the card. That was accurate, but too slow. The fix was to split the live path from the post-processing path.
74
+
75
+ For the live path, the app now uses:
76
+
77
+ - A recent transcript window instead of the full transcript.
78
+ - Fast question boundary cleanup.
79
+ - A warm-loaded fine-tuned topic/pattern model.
80
+ - A separate coaching-hints prompt only after the question is detected.
81
+ - Persistent floating cards so earlier coaching cards do not disappear.
82
+
83
+ The live extractor intentionally does not try to produce a perfect transcript. It aims to identify the current question quickly enough to help the candidate. Accuracy cleanup happens later in `Process Text`.
84
+
85
+ This tradeoff made the coaching card feel much faster and more useful during an actual interview.
86
+
87
+ ## Challenge 2: Extracting Questions and Answers From Noisy Conversations
88
+
89
+ Live transcripts are messy. A single transcript may contain:
90
+
91
+ - Interviewer greetings.
92
+ - Candidate acknowledgements.
93
+ - Repeated STT fragments.
94
+ - Half-finished questions.
95
+ - Candidate answers starting before punctuation is clear.
96
+ - Transitions like “Good, next question”.
97
+ - Multiple questions and answers in one block.
98
+
99
+ One recurring issue was that the candidate’s answer was being included inside the question. Another issue was that the extracted answer was sometimes shortened too aggressively.
100
+
101
+ The solution was to separate extraction responsibilities:
102
+
103
+ - The live coaching path extracts a fast question candidate only for the card.
104
+ - The post-session path uses a structured LLM prompt to extract all Q&A exchanges chronologically.
105
+ - The extraction prompt explicitly says to preserve the candidate’s full answer, including definitions, reasoning, examples, caveats, and explanatory setup.
106
+ - The answer ends only when the next interviewer question or transition begins.
107
+
108
+ This makes the session log more useful. It does not just store the shortest direct answer; it stores what the candidate actually said, lightly cleaned for transcription noise.
109
+
110
+ ## Challenge 3: Avoiding Duplicate or Noisy Coaching Cards
111
+
112
+ Another challenge was duplicate cards. Live transcription can produce slightly different versions of the same question:
113
+
114
+ ```text
115
+ How does linear regression work?
116
+ Can you briefly explain how linear regression works?
117
+ Can you explain how linear regression works?
118
+ ```
119
+
120
+ Exact string matching was not enough. The app now creates a dedupe key from the important words in the question and compares similarity between cards. That prevents repeated STT variants from creating multiple coaching cards for the same question.
121
+
122
+ The app also filters out non-target topics. If the detected type is too generic or not relevant to DS, ML, AI, or System Design, the live card keeps listening instead of showing noise.
123
+
124
+ ## Challenge 4: Making the Evaluation Fair
125
+
126
+ Evaluation had a subtle failure mode. The evaluator generated a benchmark answer so the candidate could learn, but the feedback sometimes looked like it was evaluating the benchmark rather than the candidate’s actual answer.
127
+
128
+ The fix was to make the evaluator contract explicit:
129
+
130
+ - The benchmark answer is only for learning.
131
+ - The hiring band and feedback must be based only on the candidate answer.
132
+ - Empty candidate answers should not receive credit.
133
+ - Generic benchmark-style feedback is rejected and falls back to a local candidate-answer heuristic.
134
+
135
+ This made the evaluation more faithful to what the candidate actually said.
136
+
137
+ ## Local-First Runtime
138
+
139
+ The entire system can run locally on a Mac. That is a major part of the design.
140
+
141
+ Local execution gives three practical benefits:
142
+
143
+ - Privacy: interview audio and transcripts do not need to leave the machine.
144
+ - Latency: the coaching card can appear quickly enough to be useful.
145
+ - Control: models, prompts, and fine-tuned adapters can be swapped without changing the whole app.
146
+
147
+ The local setup uses Python, Gradio, SQLite, Hugging Face Transformers, PEFT, and MLX Whisper. For system audio capture on macOS, the app can use a local audio routing setup such as BlackHole.
148
+
149
+ ## What Makes the Design Agentic
150
+
151
+ The app is agentic because it is not a single prompt wrapped in a UI. It is a pipeline of specialized steps, each with a clear role and state handoff.
152
+
153
+ The main agents and nodes are:
154
+
155
+ - Audio/transcription node: converts live audio into text.
156
+ - Question extraction logic: identifies the likely interviewer question boundary.
157
+ - Topic/pattern agent: classifies the question using the fine-tuned model.
158
+ - Coaching hint generator: creates short, useful pointers for the candidate.
159
+ - Persistence layer: stores sessions, exchanges, and evaluations in SQLite.
160
+ - Evaluation agent: reviews saved Q&A exchanges and produces structured feedback.
161
+
162
+ This modular design made it easier to improve one part without breaking the rest. For example, the live coaching path could be optimized for speed while the post-session extractor stayed more careful and LLM-driven.
163
+
164
+ ## Conclusion
165
+
166
+ Interview Coach is designed around a simple idea: candidates do not need a model to answer for them, but they can benefit from timely reminders that help them structure their own thinking.
167
+
168
+ The project combines local speech-to-text, a fine-tuned 3B model, a general instruction model, SQLite persistence, and a Gradio interface into a practical interview practice system. The hardest part was not simply building a chatbot. It was making the coaching card appear at the right moment, extracting useful Q&A from noisy speech, and evaluating the candidate���s actual answer fairly.
169
+
170
+ The result is a local-first tool that helps during the interview and becomes a feedback system after the interview.
config.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import os
3
+
4
+
5
+ BASE_DIR = Path(__file__).resolve().parent
6
+ DB_PATH = BASE_DIR / "interviews.db"
7
+ GENERAL_LLM_MODEL = os.environ.get("INTERVIEW_COACH_GENERAL_LLM_MODEL", "Qwen/Qwen2.5-3B-Instruct")
8
+ MODEL_PATH = GENERAL_LLM_MODEL
9
+ EVALUATION_MODEL_PATH = GENERAL_LLM_MODEL
10
+ WHISPER_MODEL = "mlx-community/whisper-small-mlx"
11
+ STREAMING_WHISPER_MODEL = "mlx-community/whisper-tiny"
12
+ HF_SPACE_MODE = bool(os.environ.get("SPACE_ID")) or os.environ.get("INTERVIEW_COACH_RUNTIME") == "space"
13
+ STT_BACKEND = os.environ.get("INTERVIEW_COACH_STT_BACKEND", "transformers" if HF_SPACE_MODE else "mlx")
14
+ HF_WHISPER_MODEL = os.environ.get("INTERVIEW_COACH_HF_WHISPER_MODEL", "openai/whisper-tiny")
15
+ APP_HOST = os.environ.get("INTERVIEW_COACH_HOST", "0.0.0.0" if HF_SPACE_MODE else "127.0.0.1")
16
+ APP_PORT = 7860
17
+ TOPIC_PATTERN_MODEL = os.environ.get("INTERVIEW_COACH_TOPIC_PATTERN_MODEL", "vadirajkrishna/interview-coach-3b")
18
+ TOPIC_PATTERN_BASE_MODEL = os.environ.get("INTERVIEW_COACH_TOPIC_PATTERN_BASE_MODEL", GENERAL_LLM_MODEL)
19
+ USE_TOPIC_PATTERN_MODEL = os.environ.get("INTERVIEW_COACH_USE_TOPIC_PATTERN_MODEL", "1").strip().lower() not in {
20
+ "0",
21
+ "false",
22
+ "no",
23
+ }
data/train.jsonl ADDED
@@ -0,0 +1 @@
 
 
1
+
db/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
db/queries.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from datetime import datetime, timezone
3
+ from typing import Any
4
+
5
+ import aiosqlite
6
+
7
+ from config import DB_PATH
8
+
9
+
10
+ def utc_now() -> str:
11
+ return datetime.now(timezone.utc).isoformat()
12
+
13
+
14
+ async def create_session(
15
+ company: str = "",
16
+ role: str = "",
17
+ duration: int = 0,
18
+ db_path=DB_PATH,
19
+ ) -> int:
20
+ async with aiosqlite.connect(db_path) as db:
21
+ cursor = await db.execute(
22
+ "INSERT INTO sessions (date, company, role, duration) VALUES (?, ?, ?, ?)",
23
+ (utc_now(), company, role, duration),
24
+ )
25
+ await db.commit()
26
+ return int(cursor.lastrowid)
27
+
28
+
29
+ async def add_exchange(
30
+ session_id: int,
31
+ question: str,
32
+ answer: str,
33
+ framework_used: str,
34
+ db_path=DB_PATH,
35
+ ) -> int:
36
+ async with aiosqlite.connect(db_path) as db:
37
+ cursor = await db.execute(
38
+ """
39
+ INSERT INTO exchanges (session_id, question, answer, framework_used, timestamp)
40
+ VALUES (?, ?, ?, ?, ?)
41
+ """,
42
+ (session_id, question, answer, framework_used, utc_now()),
43
+ )
44
+ await db.execute(
45
+ """
46
+ INSERT INTO patterns (framework, times_shown)
47
+ VALUES (?, 1)
48
+ ON CONFLICT(framework) DO UPDATE SET times_shown = times_shown + 1
49
+ """,
50
+ (framework_used,),
51
+ )
52
+ await db.commit()
53
+ return int(cursor.lastrowid)
54
+
55
+
56
+ async def add_transcript(
57
+ session_id: int,
58
+ raw_text: str,
59
+ labelled: dict[str, Any],
60
+ db_path=DB_PATH,
61
+ ) -> int:
62
+ async with aiosqlite.connect(db_path) as db:
63
+ cursor = await db.execute(
64
+ "INSERT INTO transcripts (session_id, raw_text, labelled_json) VALUES (?, ?, ?)",
65
+ (session_id, raw_text, json.dumps(labelled)),
66
+ )
67
+ await db.commit()
68
+ return int(cursor.lastrowid)
69
+
70
+
71
+ async def add_evaluation(
72
+ exchange_id: int,
73
+ steps_covered: list[bool],
74
+ score: int,
75
+ feedback: str,
76
+ db_path=DB_PATH,
77
+ ) -> int:
78
+ async with aiosqlite.connect(db_path) as db:
79
+ cursor = await db.execute(
80
+ """
81
+ INSERT INTO evaluations (exchange_id, steps_covered_json, score, feedback)
82
+ VALUES (?, ?, ?, ?)
83
+ """,
84
+ (exchange_id, json.dumps(steps_covered), score, feedback),
85
+ )
86
+ await db.commit()
87
+ return int(cursor.lastrowid)
88
+
89
+
90
+ async def update_exchange_answer(exchange_id: int, answer: str, db_path=DB_PATH) -> None:
91
+ async with aiosqlite.connect(db_path) as db:
92
+ await db.execute(
93
+ "UPDATE exchanges SET answer = ? WHERE id = ?",
94
+ (answer, exchange_id),
95
+ )
96
+ await db.commit()
97
+
98
+
99
+ async def append_exchange_answer(exchange_id: int, addition: str, db_path=DB_PATH) -> None:
100
+ async with aiosqlite.connect(db_path) as db:
101
+ cursor = await db.execute(
102
+ "SELECT answer FROM exchanges WHERE id = ?",
103
+ (exchange_id,),
104
+ )
105
+ row = await cursor.fetchone()
106
+ existing = row[0] if row else ""
107
+ updated = f"{existing.strip()}\n{addition.strip()}".strip()
108
+ await db.execute(
109
+ "UPDATE exchanges SET answer = ? WHERE id = ?",
110
+ (updated, exchange_id),
111
+ )
112
+ await db.commit()
113
+
114
+
115
+ async def clear_all_tables(db_path=DB_PATH) -> None:
116
+ async with aiosqlite.connect(db_path) as db:
117
+ await db.executescript(
118
+ """
119
+ DELETE FROM evaluations;
120
+ DELETE FROM exchanges;
121
+ DELETE FROM transcripts;
122
+ DELETE FROM patterns;
123
+ DELETE FROM sessions;
124
+ DELETE FROM sqlite_sequence
125
+ WHERE name IN ('evaluations', 'exchanges', 'transcripts', 'sessions');
126
+ """
127
+ )
128
+ await db.commit()
129
+
130
+
131
+ async def list_exchanges(session_id: int, db_path=DB_PATH) -> list[dict[str, Any]]:
132
+ async with aiosqlite.connect(db_path) as db:
133
+ db.row_factory = aiosqlite.Row
134
+ cursor = await db.execute(
135
+ """
136
+ SELECT id, session_id, question, answer, framework_used, timestamp
137
+ FROM exchanges
138
+ WHERE session_id = ?
139
+ ORDER BY id
140
+ """,
141
+ (session_id,),
142
+ )
143
+ rows = await cursor.fetchall()
144
+ return [dict(row) for row in rows]
145
+
146
+
147
+ async def list_evaluations(session_id: int, db_path=DB_PATH) -> list[dict[str, Any]]:
148
+ async with aiosqlite.connect(db_path) as db:
149
+ db.row_factory = aiosqlite.Row
150
+ cursor = await db.execute(
151
+ """
152
+ SELECT
153
+ evaluations.id,
154
+ evaluations.exchange_id,
155
+ exchanges.session_id,
156
+ sessions.date,
157
+ sessions.company,
158
+ sessions.role,
159
+ exchanges.question,
160
+ exchanges.answer,
161
+ exchanges.framework_used,
162
+ evaluations.steps_covered_json,
163
+ evaluations.score,
164
+ evaluations.feedback
165
+ FROM evaluations
166
+ JOIN exchanges ON evaluations.exchange_id = exchanges.id
167
+ JOIN sessions ON exchanges.session_id = sessions.id
168
+ WHERE exchanges.session_id = ?
169
+ ORDER BY evaluations.id
170
+ """,
171
+ (session_id,),
172
+ )
173
+ rows = await cursor.fetchall()
174
+ results = []
175
+ for row in rows:
176
+ item = dict(row)
177
+ item["steps_covered"] = json.loads(item.pop("steps_covered_json"))
178
+ results.append(item)
179
+ return results
180
+
181
+
182
+ async def list_all_evaluations(db_path=DB_PATH) -> list[dict[str, Any]]:
183
+ async with aiosqlite.connect(db_path) as db:
184
+ db.row_factory = aiosqlite.Row
185
+ cursor = await db.execute(
186
+ """
187
+ SELECT
188
+ evaluations.id,
189
+ evaluations.exchange_id,
190
+ exchanges.session_id,
191
+ sessions.date,
192
+ sessions.company,
193
+ sessions.role,
194
+ exchanges.question,
195
+ exchanges.answer,
196
+ exchanges.framework_used,
197
+ evaluations.steps_covered_json,
198
+ evaluations.score,
199
+ evaluations.feedback
200
+ FROM evaluations
201
+ JOIN exchanges ON evaluations.exchange_id = exchanges.id
202
+ JOIN sessions ON exchanges.session_id = sessions.id
203
+ ORDER BY sessions.id DESC, evaluations.id DESC
204
+ """
205
+ )
206
+ rows = await cursor.fetchall()
207
+ results = []
208
+ for row in rows:
209
+ item = dict(row)
210
+ item["steps_covered"] = json.loads(item.pop("steps_covered_json"))
211
+ results.append(item)
212
+ return results
db/schema.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import aiosqlite
2
+
3
+ from config import DB_PATH
4
+
5
+
6
+ SCHEMA = """
7
+ CREATE TABLE IF NOT EXISTS sessions (
8
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9
+ date TEXT NOT NULL,
10
+ company TEXT,
11
+ role TEXT,
12
+ duration INTEGER DEFAULT 0
13
+ );
14
+
15
+ CREATE TABLE IF NOT EXISTS exchanges (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ session_id INTEGER NOT NULL,
18
+ question TEXT NOT NULL,
19
+ answer TEXT DEFAULT '',
20
+ framework_used TEXT NOT NULL,
21
+ timestamp TEXT NOT NULL,
22
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
23
+ );
24
+
25
+ CREATE TABLE IF NOT EXISTS evaluations (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ exchange_id INTEGER NOT NULL,
28
+ steps_covered_json TEXT NOT NULL,
29
+ score INTEGER NOT NULL,
30
+ feedback TEXT NOT NULL,
31
+ FOREIGN KEY (exchange_id) REFERENCES exchanges(id)
32
+ );
33
+
34
+ CREATE TABLE IF NOT EXISTS transcripts (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ session_id INTEGER NOT NULL,
37
+ raw_text TEXT NOT NULL,
38
+ labelled_json TEXT NOT NULL,
39
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
40
+ );
41
+
42
+ CREATE TABLE IF NOT EXISTS patterns (
43
+ framework TEXT PRIMARY KEY,
44
+ times_shown INTEGER NOT NULL DEFAULT 0,
45
+ avg_score REAL NOT NULL DEFAULT 0,
46
+ most_missed_step TEXT DEFAULT ''
47
+ );
48
+ """
49
+
50
+
51
+ async def init_db(db_path=DB_PATH) -> None:
52
+ async with aiosqlite.connect(db_path) as db:
53
+ await db.executescript(SCHEMA)
54
+ await db.commit()
55
+
fine-tune/data/train.json ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "prompt": "How would you design a URL shortener like bit.ly?",
4
+ "completion": "Type: System Design\nSteps: Clarify scale → High-level components → Deep dive → Bottlenecks → Tradeoffs"
5
+ },
6
+ {
7
+ "prompt": "Design a rate limiter for a public API.",
8
+ "completion": "Type: System Design\nSteps: Clarify scale → High-level components → Deep dive → Bottlenecks → Tradeoffs"
9
+ },
10
+ {
11
+ "prompt": "How would you architect a notification system for a banking app?",
12
+ "completion": "Type: System Design\nSteps: Clarify scale → High-level components → Deep dive → Bottlenecks → Tradeoffs"
13
+ },
14
+ {
15
+ "prompt": "Design a distributed cache like Redis.",
16
+ "completion": "Type: System Design\nSteps: Clarify scale → High-level components → Deep dive → Bottlenecks → Tradeoffs"
17
+ },
18
+ {
19
+ "prompt": "How would you build a real-time leaderboard for a gaming platform?",
20
+ "completion": "Type: System Design\nSteps: Clarify scale → High-level components → Deep dive → Bottlenecks → Tradeoffs"
21
+ },
22
+ {
23
+ "prompt": "Design a search autocomplete system.",
24
+ "completion": "Type: System Design\nSteps: Clarify scale → High-level components → Deep dive → Bottlenecks → Tradeoffs"
25
+ },
26
+ {
27
+ "prompt": "How would you design a ride-sharing service like Uber?",
28
+ "completion": "Type: System Design\nSteps: Clarify scale → High-level components → Deep dive → Bottlenecks → Tradeoffs"
29
+ },
30
+ {
31
+ "prompt": "How would you design Spotify for rural areas with poor connectivity?",
32
+ "completion": "Type: Product Design\nSteps: Clarify scope → Identify users → Map pain points → Propose solution → Define metrics"
33
+ },
34
+ {
35
+ "prompt": "How would you improve the onboarding experience for a fintech app?",
36
+ "completion": "Type: Product Design\nSteps: Clarify scope → Identify users → Map pain points → Propose solution → Define metrics"
37
+ },
38
+ {
39
+ "prompt": "Design a feature to help elderly users navigate a mobile banking app.",
40
+ "completion": "Type: Product Design\nSteps: Clarify scope → Identify users → Map pain points → Propose solution → Define metrics"
41
+ },
42
+ {
43
+ "prompt": "How would you design YouTube for blind users?",
44
+ "completion": "Type: Product Design\nSteps: Clarify scope → Identify users → Map pain points → Propose solution → Define metrics"
45
+ },
46
+ {
47
+ "prompt": "How would you redesign the checkout flow for an e-commerce app?",
48
+ "completion": "Type: Product Design\nSteps: Clarify scope → Identify users → Map pain points → Propose solution → Define metrics"
49
+ },
50
+ {
51
+ "prompt": "Design a feature to reduce churn in a subscription app.",
52
+ "completion": "Type: Product Design\nSteps: Clarify scope → Identify users → Map pain points → Propose solution → Define metrics"
53
+ },
54
+ {
55
+ "prompt": "How would you design Airbnb for pets?",
56
+ "completion": "Type: Product Design\nSteps: Clarify scope → Identify users → Map pain points → Propose solution → Define metrics"
57
+ },
58
+ {
59
+ "prompt": "How would you handle class imbalance in a fraud detection model?",
60
+ "completion": "Type: Data Science\nSteps: Check base rate → Choose right metric → Handle imbalance → Tune threshold → Evaluate"
61
+ },
62
+ {
63
+ "prompt": "How would you detect anomalies in a time series dataset?",
64
+ "completion": "Type: Data Science\nSteps: Define anomaly → Explore data → Choose method → Evaluate → Production concerns"
65
+ },
66
+ {
67
+ "prompt": "Walk me through building a churn prediction model.",
68
+ "completion": "Type: Data Science\nSteps: Define churn → Feature engineering → Baseline model → Iterate → Deploy"
69
+ },
70
+ {
71
+ "prompt": "How would you evaluate a recommender system?",
72
+ "completion": "Type: Data Science\nSteps: Define metric → Offline eval → A/B test → Business impact → Monitor"
73
+ },
74
+ {
75
+ "prompt": "How would you approach a dataset with 40% missing values?",
76
+ "completion": "Type: Data Science\nSteps: Understand missingness → Explore patterns → Imputation strategy → Validate → Document"
77
+ },
78
+ {
79
+ "prompt": "How would you build a model to predict customer lifetime value?",
80
+ "completion": "Type: Data Science\nSteps: Define CLV → Feature engineering → Baseline model → Iterate → Deploy"
81
+ },
82
+ {
83
+ "prompt": "How would you select features for a high-dimensional dataset?",
84
+ "completion": "Type: Data Science\nSteps: Understand data → Correlation analysis → Feature importance → Dimensionality reduction → Validate"
85
+ },
86
+ {
87
+ "prompt": "How would you build a RAG pipeline for a customer support chatbot?",
88
+ "completion": "Type: AI Engineering\nSteps: Define retrieval scope → Chunking strategy → Embedding model → Retrieval + rerank → Evaluate"
89
+ },
90
+ {
91
+ "prompt": "How would you reduce hallucinations in an LLM-based application?",
92
+ "completion": "Type: AI Engineering\nSteps: Identify sources → Grounding strategy → Prompt engineering → Evaluation → Monitoring"
93
+ },
94
+ {
95
+ "prompt": "How would you fine-tune an LLM for a specific domain?",
96
+ "completion": "Type: AI Engineering\nSteps: Define task → Curate data → Choose method → Train → Evaluate → Deploy"
97
+ },
98
+ {
99
+ "prompt": "How would you evaluate the quality of a RAG system?",
100
+ "completion": "Type: AI Engineering\nSteps: Define metrics → Retrieval eval → Generation eval → End-to-end eval → Monitor drift"
101
+ },
102
+ {
103
+ "prompt": "How would you deploy an LLM with sub-100ms latency requirements?",
104
+ "completion": "Type: AI Engineering\nSteps: Clarify latency budget → Quantisation → Runtime choice → Caching → Load test"
105
+ },
106
+ {
107
+ "prompt": "How would you implement guardrails for an LLM in production?",
108
+ "completion": "Type: AI Engineering\nSteps: Define risks → Input filtering → Output validation → Monitoring → Escalation"
109
+ },
110
+ {
111
+ "prompt": "How would you handle prompt injection attacks in an LLM app?",
112
+ "completion": "Type: AI Engineering\nSteps: Define threat model → Input sanitisation → Prompt hardening → Detection → Response"
113
+ },
114
+ {
115
+ "prompt": "Tell me about a time you disagreed with your manager.",
116
+ "completion": "Type: Behavioural\nSteps: Situation → Task → Action → Result → Reflection"
117
+ },
118
+ {
119
+ "prompt": "Give me an example of when you had to deliver under a tight deadline.",
120
+ "completion": "Type: Behavioural\nSteps: Situation → Task → Action → Result → Reflection"
121
+ },
122
+ {
123
+ "prompt": "Describe a time you failed and what you learned from it.",
124
+ "completion": "Type: Behavioural\nSteps: Situation → Task → Action → Result → Reflection"
125
+ },
126
+ {
127
+ "prompt": "Tell me about a time you had to influence without authority.",
128
+ "completion": "Type: Behavioural\nSteps: Situation → Task → Action → Result → Reflection"
129
+ },
130
+ {
131
+ "prompt": "Give an example of a time you handled ambiguity well.",
132
+ "completion": "Type: Behavioural\nSteps: Situation → Task → Action → Result → Reflection"
133
+ },
134
+ {
135
+ "prompt": "Tell me about a time you took ownership of a failing project.",
136
+ "completion": "Type: Behavioural\nSteps: Situation → Task → Action → Result → Reflection"
137
+ },
138
+ {
139
+ "prompt": "Describe a situation where you had to learn something quickly.",
140
+ "completion": "Type: Behavioural\nSteps: Situation → Task → Action → Result → Reflection"
141
+ },
142
+ {
143
+ "prompt": "How many piano tuners are there in London?",
144
+ "completion": "Type: Estimation\nSteps: Clarify → Decompose → Anchor number → Calculate → Sanity check"
145
+ },
146
+ {
147
+ "prompt": "How many WhatsApp messages are sent per day globally?",
148
+ "completion": "Type: Estimation\nSteps: Clarify → Decompose → Anchor number → Calculate → Sanity check"
149
+ },
150
+ {
151
+ "prompt": "Estimate the storage required for all photos on Instagram.",
152
+ "completion": "Type: Estimation\nSteps: Clarify → Decompose → Anchor number → Calculate → Sanity check"
153
+ },
154
+ {
155
+ "prompt": "How many Ubers are active in London at 9am on a Monday?",
156
+ "completion": "Type: Estimation\nSteps: Clarify → Decompose → Anchor number → Calculate → Sanity check"
157
+ },
158
+ {
159
+ "prompt": "How much revenue does a McDonald's in central London make per year?",
160
+ "completion": "Type: Estimation\nSteps: Clarify → Decompose → Anchor number → Calculate → Sanity check"
161
+ },
162
+ {
163
+ "prompt": "How many golf balls fit in a Boeing 747?",
164
+ "completion": "Type: Estimation\nSteps: Clarify → Decompose → Anchor number → Calculate → Sanity check"
165
+ },
166
+ {
167
+ "prompt": "Estimate the number of software developers in the world.",
168
+ "completion": "Type: Estimation\nSteps: Clarify → Decompose → Anchor number → Calculate → Sanity check"
169
+ }
170
+ ]
fine-tune/data/valid.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "prompt": "Design a Twitter trending topics feature.",
4
+ "completion": "Type: System Design\nSteps: Clarify scale → High-level components → Deep dive → Bottlenecks → Tradeoffs"
5
+ },
6
+ {
7
+ "prompt": "How would you design a food delivery app for hospitals?",
8
+ "completion": "Type: Product Design\nSteps: Clarify scope → Identify users → Map pain points → Propose solution → Define metrics"
9
+ },
10
+ {
11
+ "prompt": "How would you build a model to detect fake reviews?",
12
+ "completion": "Type: Data Science\nSteps: Check base rate → Choose right metric → Handle imbalance → Tune threshold → Evaluate"
13
+ },
14
+ {
15
+ "prompt": "How would you version and monitor ML models in production?",
16
+ "completion": "Type: AI Engineering\nSteps: Define metrics → Retrieval eval → Generation eval → End-to-end eval → Monitor drift"
17
+ },
18
+ {
19
+ "prompt": "Tell me about a time you had to change your approach mid-project.",
20
+ "completion": "Type: Behavioural\nSteps: Situation → Task → Action → Result → Reflection"
21
+ },
22
+ {
23
+ "prompt": "How many coffee cups are sold in London every day?",
24
+ "completion": "Type: Estimation\nSteps: Clarify → Decompose → Anchor number → Calculate → Sanity check"
25
+ }
26
+ ]
fine-tune/lora-finetune.py ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ InterviewCoach — Fine-tuning with Unsloth on Modal
3
+ Trains Qwen2.5-3B-Instruct for interview question classification using LoRA.
4
+
5
+ ## Objective
6
+ The base Qwen2.5-3B model is capable but general-purpose. This fine-tune
7
+ teaches it one narrow task: given a raw interview question, output the correct
8
+ framework type and ordered coaching steps — nothing else.
9
+
10
+ Without fine-tuning, a 3B model needs a long system prompt with many few-shot
11
+ examples to produce consistent output, and still occasionally adds waffle or
12
+ gets the format wrong. After fine-tuning on ~80 domain-specific examples, the
13
+ model learns to:
14
+ 1. Classify questions reliably into 6 types (System Design, Product Design,
15
+ Data Science, AI Engineering, Behavioural, Estimation)
16
+ 2. Return a clean, consistently formatted coaching card every time
17
+ 3. Do this faster and with less memory than the 7B model it replaces
18
+
19
+ The result is a lightweight 3B model that matches 7B quality on this specific
20
+ task — suitable for real-time inference on a local Mac during a live interview.
21
+
22
+ ## Usage
23
+ modal run finetune_unsloth_modal.py
24
+
25
+ ## Optional CLI flags
26
+ modal run finetune_unsloth_modal.py --max-steps 300
27
+ modal run finetune_unsloth_modal.py --lora-r 16
28
+ """
29
+
30
+ import json
31
+ import sys
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+
35
+ import modal
36
+
37
+ # ===========================================================================
38
+ # Configurations
39
+ # ===========================================================================
40
+
41
+ SCRIPT_DIR = Path(__file__).resolve().parent
42
+
43
+ # Your Hugging Face username (https://huggingface.co/settings/profile)
44
+ HF_USERNAME = "vadirajkrishna"
45
+
46
+ # Name for the fine-tuned model repo on Hugging Face
47
+ HF_MODEL_NAME = "interview-coach-3b"
48
+
49
+ # Base model to fine-tune (from Unsloth's HF collection)
50
+ BASE_MODEL = "unsloth/Qwen2.5-3B-Instruct"
51
+
52
+ # Path to training data files (relative to this script)
53
+ TRAIN_DATA_FILE = "data/train.json"
54
+ VALID_DATA_FILE = "data/valid.json"
55
+
56
+ # Modal secret name containing HF_TOKEN
57
+ # Create at: https://modal.com/secrets key=HF_TOKEN
58
+ MODAL_HF_SECRET = "huggingface-secret"
59
+
60
+ # Modal GPU — A10G is cost-effective for 3B models (~$1.10/hr, job ~10 min)
61
+ # Options: "A10G", "A100", "L40S"
62
+ MODAL_GPU = "A10G"
63
+
64
+ # ===========================================================================
65
+ # LoRA hyperparameters — safe defaults for a small classification dataset
66
+ # ===========================================================================
67
+
68
+ LORA_R = 8 # LoRA rank — increase to 16 for harder tasks
69
+ LORA_ALPHA = 16 # scaling factor, usually 2x rank
70
+ LORA_DROPOUT = 0.05
71
+ MAX_SEQ_LENGTH = 256 # interview questions are short
72
+ MAX_STEPS = 200 # small dataset — 200 is enough, increase if loss plateaus
73
+ BATCH_SIZE = 4
74
+ GRAD_ACCUM_STEPS = 2
75
+ LEARNING_RATE = 2e-4
76
+ WARMUP_RATIO = 0.1
77
+ SEED = 42
78
+
79
+ # LoRA target modules — all projection layers in Qwen2.5
80
+ LORA_TARGET_MODULES = [
81
+ "q_proj", "k_proj", "v_proj", "o_proj",
82
+ "gate_proj", "up_proj", "down_proj",
83
+ ]
84
+
85
+ # ===========================================================================
86
+ # Validation — fail loudly before Modal even starts
87
+ # ===========================================================================
88
+
89
+ def _validate_config():
90
+ errors = []
91
+ if not HF_USERNAME:
92
+ errors.append(" • HF_USERNAME is empty — set your Hugging Face username")
93
+ if not HF_MODEL_NAME:
94
+ errors.append(" • HF_MODEL_NAME is empty — set a repo name")
95
+ if not (SCRIPT_DIR / TRAIN_DATA_FILE).exists():
96
+ errors.append(f" • TRAIN_DATA_FILE not found: {TRAIN_DATA_FILE}")
97
+ if not (SCRIPT_DIR / VALID_DATA_FILE).exists():
98
+ errors.append(f" • VALID_DATA_FILE not found: {VALID_DATA_FILE}")
99
+ if errors:
100
+ print("\n❌ Config errors — fix before running:\n")
101
+ for e in errors:
102
+ print(e)
103
+ sys.exit(1)
104
+
105
+ # ===========================================================================
106
+ # Modal infrastructure
107
+ # ===========================================================================
108
+
109
+ app = modal.App("interview-coach-finetune")
110
+
111
+ image = (
112
+ modal.Image.debian_slim(python_version="3.11")
113
+ .uv_pip_install(
114
+ "accelerate==1.9.0",
115
+ "datasets==3.6.0",
116
+ "hf-transfer==0.1.9",
117
+ "huggingface_hub==0.34.2",
118
+ "peft==0.16.0",
119
+ "transformers==4.54.0",
120
+ "trl==0.19.1",
121
+ "unsloth[cu128-torch270]==2025.7.8",
122
+ "unsloth_zoo==2025.7.10",
123
+ )
124
+ .env({"HF_HOME": "/model_cache"})
125
+ )
126
+
127
+ with image.imports():
128
+ import unsloth # noqa: F401 — must be first
129
+ import torch
130
+ from datasets import Dataset
131
+ from transformers import TrainingArguments
132
+ from trl import SFTTrainer
133
+ from unsloth import FastLanguageModel
134
+
135
+ model_cache_vol = modal.Volume.from_name("ic-model-cache", create_if_missing=True)
136
+ checkpoint_vol = modal.Volume.from_name("ic-checkpoints", create_if_missing=True)
137
+
138
+ # ===========================================================================
139
+ # Training config dataclass — populated from constants above
140
+ # ===========================================================================
141
+
142
+ @dataclass
143
+ class TrainingConfig:
144
+ hf_repo: str
145
+ model_name: str
146
+ max_seq_length: int
147
+ lora_r: int
148
+ lora_alpha: int
149
+ lora_dropout: float
150
+ learning_rate: float
151
+ batch_size: int
152
+ gradient_accumulation_steps: int
153
+ max_steps: int
154
+ warmup_ratio: float
155
+ seed: int
156
+ train_data: list
157
+ valid_data: list
158
+
159
+
160
+ def _build_config(
161
+ max_steps: int = MAX_STEPS,
162
+ lora_r: int = LORA_R,
163
+ ) -> TrainingConfig:
164
+ """Load data files and assemble config. Call only after _validate_config()."""
165
+ train_data = json.loads((SCRIPT_DIR / TRAIN_DATA_FILE).read_text())
166
+ valid_data = json.loads((SCRIPT_DIR / VALID_DATA_FILE).read_text())
167
+ return TrainingConfig(
168
+ hf_repo = f"{HF_USERNAME}/{HF_MODEL_NAME}",
169
+ model_name = BASE_MODEL,
170
+ max_seq_length = MAX_SEQ_LENGTH,
171
+ lora_r = lora_r,
172
+ lora_alpha = LORA_ALPHA,
173
+ lora_dropout = LORA_DROPOUT,
174
+ learning_rate = LEARNING_RATE,
175
+ batch_size = BATCH_SIZE,
176
+ gradient_accumulation_steps= GRAD_ACCUM_STEPS,
177
+ max_steps = max_steps,
178
+ warmup_ratio = WARMUP_RATIO,
179
+ seed = SEED,
180
+ train_data = train_data,
181
+ valid_data = valid_data,
182
+ )
183
+
184
+ # ===========================================================================
185
+ # Fine-tuning function — runs on Modal GPU
186
+ # ===========================================================================
187
+
188
+ @app.function(
189
+ image=image,
190
+ gpu=MODAL_GPU,
191
+ volumes={
192
+ "/model_cache": model_cache_vol,
193
+ "/checkpoints": checkpoint_vol,
194
+ },
195
+ secrets=[modal.Secret.from_name(MODAL_HF_SECRET)],
196
+ timeout=3600,
197
+ )
198
+ def finetune(config: TrainingConfig):
199
+ import os
200
+ from huggingface_hub import login
201
+
202
+ login(token=os.environ["HF_TOKEN"])
203
+
204
+ print(f"Model: {config.model_name}")
205
+ print(f"HF repo: {config.hf_repo}")
206
+ print(f"Steps: {config.max_steps}")
207
+ print(f"LoRA rank: {config.lora_r}")
208
+ print(f"Train: {len(config.train_data)} examples")
209
+ print(f"Valid: {len(config.valid_data)} examples")
210
+
211
+ model, tokenizer = FastLanguageModel.from_pretrained(
212
+ model_name = config.model_name,
213
+ max_seq_length = config.max_seq_length,
214
+ dtype = None,
215
+ load_in_4bit = True,
216
+ )
217
+
218
+ model = FastLanguageModel.get_peft_model(
219
+ model,
220
+ r = config.lora_r,
221
+ target_modules = LORA_TARGET_MODULES,
222
+ lora_alpha = config.lora_alpha,
223
+ lora_dropout = config.lora_dropout,
224
+ bias = "none",
225
+ use_gradient_checkpointing = "unsloth",
226
+ random_state = config.seed,
227
+ use_rslora = False,
228
+ )
229
+ model.print_trainable_parameters()
230
+
231
+ def format_example(example):
232
+ return {
233
+ "text": (
234
+ f"<|im_start|>user\n{example['prompt']}<|im_end|>\n"
235
+ f"<|im_start|>assistant\n{example['completion']}<|im_end|>"
236
+ )
237
+ }
238
+
239
+ train_dataset = Dataset.from_list(config.train_data).map(format_example)
240
+ valid_dataset = Dataset.from_list(config.valid_data).map(format_example)
241
+
242
+ training_args = TrainingArguments(
243
+ output_dir = "/checkpoints/interview-coach",
244
+ per_device_train_batch_size = config.batch_size,
245
+ gradient_accumulation_steps = config.gradient_accumulation_steps,
246
+ learning_rate = config.learning_rate,
247
+ max_steps = config.max_steps,
248
+ warmup_ratio = config.warmup_ratio,
249
+ fp16 = not torch.cuda.is_bf16_supported(),
250
+ bf16 = torch.cuda.is_bf16_supported(),
251
+ optim = "adamw_8bit",
252
+ lr_scheduler_type = "cosine",
253
+ weight_decay = 0.01,
254
+ logging_steps = 10,
255
+ eval_strategy = "steps",
256
+ eval_steps = 50,
257
+ save_strategy = "steps",
258
+ save_steps = 100,
259
+ load_best_model_at_end = True,
260
+ report_to = "none",
261
+ seed = config.seed,
262
+ )
263
+
264
+ trainer = SFTTrainer(
265
+ model = model,
266
+ tokenizer = tokenizer,
267
+ train_dataset = train_dataset,
268
+ eval_dataset = valid_dataset,
269
+ dataset_text_field = "text",
270
+ max_seq_length = config.max_seq_length,
271
+ args = training_args,
272
+ )
273
+
274
+ print("Training...")
275
+ trainer.train()
276
+ print("Training complete.")
277
+
278
+ print(f"Pushing to {config.hf_repo}...")
279
+ model.push_to_hub(config.hf_repo)
280
+ tokenizer.push_to_hub(config.hf_repo)
281
+ print(f"Done → https://huggingface.co/{config.hf_repo}")
282
+
283
+ return config.hf_repo
284
+
285
+
286
+ # ===========================================================================
287
+ # Test function — run after training to verify model output
288
+ # ===========================================================================
289
+
290
+ @app.function(
291
+ image=image,
292
+ gpu=MODAL_GPU,
293
+ volumes={"/model_cache": model_cache_vol},
294
+ secrets=[modal.Secret.from_name(MODAL_HF_SECRET)],
295
+ timeout=600,
296
+ )
297
+ def test(
298
+ question: str = "How would you design a payment system for a marketplace?",
299
+ ):
300
+ import os
301
+ from huggingface_hub import login
302
+
303
+ login(token=os.environ["HF_TOKEN"])
304
+
305
+ hf_repo = f"{HF_USERNAME}/{HF_MODEL_NAME}"
306
+ print(f"Loading {hf_repo}...")
307
+
308
+ model, tokenizer = FastLanguageModel.from_pretrained(
309
+ model_name = hf_repo,
310
+ max_seq_length = MAX_SEQ_LENGTH,
311
+ dtype = None,
312
+ load_in_4bit = True,
313
+ )
314
+ FastLanguageModel.for_inference(model)
315
+
316
+ prompt = f"<|im_start|>user\n{question}<|im_end|>\n<|im_start|>assistant\n"
317
+ inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
318
+
319
+ with torch.no_grad():
320
+ outputs = model.generate(
321
+ **inputs,
322
+ max_new_tokens = 80,
323
+ temperature = 0.1,
324
+ do_sample = False,
325
+ )
326
+
327
+ full = tokenizer.decode(outputs[0], skip_special_tokens=True)
328
+ reply = full.split("<|im_start|>assistant\n")[-1].strip()
329
+ print(f"\nQ: {question}\n\n{reply}")
330
+
331
+
332
+ # ===========================================================================
333
+ # Entry point
334
+ # ===========================================================================
335
+
336
+ @app.local_entrypoint()
337
+ def main(
338
+ max_steps: int = MAX_STEPS,
339
+ lora_r: int = LORA_R,
340
+ ):
341
+ _validate_config()
342
+ config = _build_config(max_steps=max_steps, lora_r=lora_r)
343
+
344
+ print("\n🚀 InterviewCoach fine-tuning")
345
+ print(f" Model: {config.model_name}")
346
+ print(f" HF repo: {config.hf_repo}")
347
+ print(f" Steps: {config.max_steps}")
348
+ print(f" LoRA rank: {config.lora_r}")
349
+ print(f" Train: {len(config.train_data)} examples")
350
+ print(f" Valid: {len(config.valid_data)} examples\n")
351
+
352
+ repo = finetune.remote(config)
353
+ print(f"\n✅ Done → https://huggingface.co/{repo}")
frameworks.yaml ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ General:
2
+ color: "#64748b"
3
+ steps:
4
+ - Clarify the question if needed
5
+ - State a concise answer
6
+ - Add one concrete example
7
+ - Close with impact or tradeoff
8
+ Behavioral:
9
+ color: "#0ea5e9"
10
+ steps:
11
+ - Situation
12
+ - Task
13
+ - Action
14
+ - Result
15
+ Technical:
16
+ color: "#22c55e"
17
+ steps:
18
+ - Restate constraints
19
+ - Outline the approach
20
+ - Discuss complexity or tradeoffs
21
+ - Validate with edge cases
22
+ System Design:
23
+ color: "#f59e0b"
24
+ steps:
25
+ - Requirements
26
+ - APIs and data model
27
+ - Architecture
28
+ - Bottlenecks and tradeoffs
29
+ Product Sense:
30
+ color: "#ec4899"
31
+ steps:
32
+ - User and goal
33
+ - Pain points
34
+ - Prioritized solution
35
+ - Success metrics
36
+ Case:
37
+ color: "#a855f7"
38
+ steps:
39
+ - Clarify objective
40
+ - Structure the problem
41
+ - Estimate or analyze
42
+ - Recommend next step
43
+
graph.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langgraph.graph import END, StateGraph
2
+
3
+ from agents.topic_pattern import TopicPatternAgent
4
+ from db.queries import add_exchange, add_transcript
5
+ from nodes.audio import audio_node
6
+ from nodes.framework import framework_node
7
+ from nodes.transcript import transcript_node
8
+ from state import CoachState
9
+
10
+
11
+ topic_pattern_agent = TopicPatternAgent()
12
+
13
+
14
+ async def topic_pattern_node(state: CoachState) -> CoachState:
15
+ if state.get("framework") and state.get("steps"):
16
+ return state
17
+
18
+ question = state.get("question", "")
19
+ result = await topic_pattern_agent.analyze(question)
20
+ if not result:
21
+ return state
22
+
23
+ state["framework"] = result["type"]
24
+ state["pattern"] = result.get("pattern", result["type"])
25
+ state["steps"] = result["steps"]
26
+ state["confidence"] = max(float(state.get("confidence", 0.0)), float(result["confidence"]))
27
+ state["topic_model_used"] = result.get("model", "")
28
+ state["needs_review"] = state.get("needs_review", False) and result["confidence"] < 0.6
29
+ return state
30
+
31
+
32
+ async def persistence_node(state: CoachState) -> CoachState:
33
+ session_id = state.get("session_id")
34
+ if not session_id:
35
+ return state
36
+
37
+ await add_transcript(
38
+ session_id=session_id,
39
+ raw_text=state.get("raw_text", ""),
40
+ labelled=state.get("labelled_json", {}),
41
+ )
42
+ if state.get("question"):
43
+ exchange_id = await add_exchange(
44
+ session_id=session_id,
45
+ question=state["question"],
46
+ answer=state.get("answer", ""),
47
+ framework_used=state.get("framework", "General"),
48
+ )
49
+ state["exchange_id"] = exchange_id
50
+ return state
51
+
52
+
53
+ def should_classify(state: CoachState) -> str:
54
+ return "topic_pattern" if state.get("question") else "persist"
55
+
56
+
57
+ def build_graph():
58
+ builder = StateGraph(CoachState)
59
+ builder.add_node("audio", audio_node)
60
+ builder.add_node("transcript", transcript_node)
61
+ builder.add_node("topic_pattern", topic_pattern_node)
62
+ builder.add_node("framework", framework_node)
63
+ builder.add_node("persist", persistence_node)
64
+
65
+ builder.set_entry_point("audio")
66
+ builder.add_edge("audio", "transcript")
67
+ builder.add_conditional_edges(
68
+ "transcript",
69
+ should_classify,
70
+ {"topic_pattern": "topic_pattern", "persist": "persist"},
71
+ )
72
+ builder.add_edge("topic_pattern", "framework")
73
+ builder.add_edge("framework", "persist")
74
+ builder.add_edge("persist", END)
75
+ return builder.compile()
76
+
77
+
78
+ coach_graph = build_graph()
nodes/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
nodes/audio.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import shutil
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ from config import BASE_DIR, HF_WHISPER_MODEL, STT_BACKEND, WHISPER_MODEL
10
+ from state import CoachState
11
+
12
+ SAMPLE_RATE = 16000
13
+ CHUNK_SECONDS = 3
14
+ OVERLAP_SECONDS = 0.5
15
+ CHUNK_SAMPLES = int(CHUNK_SECONDS * SAMPLE_RATE)
16
+ OVERLAP_SAMPLES = int(OVERLAP_SECONDS * SAMPLE_RATE)
17
+ QUEUE_MAX_SIZE = 10
18
+ SILENCE_RMS_THRESHOLD = 0.003
19
+
20
+ executor = ThreadPoolExecutor(max_workers=2)
21
+ _asr_pipeline = None
22
+
23
+
24
+ async def audio_node(state: CoachState) -> CoachState:
25
+ if "audio_queue" not in state:
26
+ state["audio_queue"] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
27
+ return state
28
+
29
+
30
+ async def enqueue_audio(audio_queue: asyncio.Queue, chunk: Any) -> None:
31
+ await audio_queue.put(chunk)
32
+
33
+
34
+ def get_input_device() -> int | None:
35
+ try:
36
+ import sounddevice as sd
37
+
38
+ devices = sd.query_devices()
39
+ except Exception:
40
+ return None
41
+
42
+ for index, device in enumerate(devices):
43
+ name = str(device.get("name", ""))
44
+ max_inputs = int(device.get("max_input_channels", 0))
45
+ if "BlackHole" in name and max_inputs > 0:
46
+ return index
47
+ return None
48
+
49
+
50
+ class LiveAudioTranscriber:
51
+ def __init__(self, model: str = WHISPER_MODEL):
52
+ self.model = model
53
+ self.audio_queue: asyncio.Queue[np.ndarray] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
54
+ self.stop_event = asyncio.Event()
55
+ self.capture_task: asyncio.Task | None = None
56
+
57
+ async def start(self) -> None:
58
+ if self.capture_task and not self.capture_task.done():
59
+ await self.stop()
60
+ self.stop_event = asyncio.Event()
61
+ self.audio_queue = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
62
+ device_index = get_input_device()
63
+ self.capture_task = asyncio.create_task(
64
+ capture_audio(
65
+ audio_queue=self.audio_queue,
66
+ stop_event=self.stop_event,
67
+ device_index=device_index,
68
+ )
69
+ )
70
+
71
+ async def stop(self) -> None:
72
+ self.stop_event.set()
73
+ if self.capture_task:
74
+ await self.capture_task
75
+ self.capture_task = None
76
+
77
+ async def transcript_stream(self):
78
+ transcript = ""
79
+ while not self.stop_event.is_set():
80
+ try:
81
+ chunk = await asyncio.wait_for(self.audio_queue.get(), timeout=1.0)
82
+ except asyncio.TimeoutError:
83
+ continue
84
+
85
+ try:
86
+ text = await transcribe_chunk(chunk, model=self.model)
87
+ except Exception as exc:
88
+ text = f"[live transcription error: {exc}]"
89
+ if text:
90
+ transcript = merge_chunk_text(transcript, text)
91
+ yield transcript
92
+
93
+
94
+ async def capture_audio(
95
+ audio_queue: asyncio.Queue[np.ndarray],
96
+ stop_event: asyncio.Event,
97
+ device_index: int | None = None,
98
+ ) -> None:
99
+ try:
100
+ import sounddevice as sd
101
+ except Exception as exc:
102
+ raise RuntimeError(
103
+ "sounddevice is required for live audio capture. Install dependencies with "
104
+ "`python3 -m pip install -r requirements.txt`."
105
+ ) from exc
106
+
107
+ loop = asyncio.get_running_loop()
108
+ buffer: list[float] = []
109
+
110
+ def callback(indata, frames, time, status):
111
+ if status:
112
+ return
113
+
114
+ buffer.extend(indata[:, 0].tolist())
115
+ while len(buffer) >= CHUNK_SAMPLES:
116
+ chunk = np.array(buffer[:CHUNK_SAMPLES], dtype=np.float32)
117
+ buffer[:] = buffer[CHUNK_SAMPLES - OVERLAP_SAMPLES :]
118
+ if is_silent(chunk):
119
+ continue
120
+ loop.call_soon_threadsafe(enqueue_chunk_nowait, audio_queue, chunk)
121
+
122
+ with sd.InputStream(
123
+ samplerate=SAMPLE_RATE,
124
+ channels=1,
125
+ dtype="float32",
126
+ device=device_index,
127
+ callback=callback,
128
+ blocksize=1024,
129
+ ):
130
+ while not stop_event.is_set():
131
+ await asyncio.sleep(0.1)
132
+
133
+
134
+ def enqueue_chunk_nowait(audio_queue: asyncio.Queue[np.ndarray], chunk: np.ndarray) -> None:
135
+ if audio_queue.full():
136
+ try:
137
+ audio_queue.get_nowait()
138
+ except asyncio.QueueEmpty:
139
+ pass
140
+ audio_queue.put_nowait(chunk)
141
+
142
+
143
+ async def transcribe_chunk(audio: np.ndarray, model: str = WHISPER_MODEL) -> str:
144
+ if audio.size == 0 or is_silent(audio):
145
+ return ""
146
+
147
+ loop = asyncio.get_running_loop()
148
+ result = await loop.run_in_executor(
149
+ executor,
150
+ lambda: _transcribe_audio_array_sync(
151
+ audio,
152
+ model,
153
+ {
154
+ "language": "en",
155
+ "temperature": 0.0,
156
+ "condition_on_previous_text": False,
157
+ "compression_ratio_threshold": 1.8,
158
+ "logprob_threshold": -0.6,
159
+ "no_speech_threshold": 0.35,
160
+ },
161
+ ),
162
+ )
163
+ return "" if is_repetitive_hallucination(result) else result
164
+
165
+
166
+ def is_silent(audio: np.ndarray, threshold: float = SILENCE_RMS_THRESHOLD) -> bool:
167
+ if audio.size == 0:
168
+ return True
169
+ rms = float(np.sqrt(np.mean(np.square(audio.astype(np.float32)))))
170
+ return rms < threshold
171
+
172
+
173
+ def merge_chunk_text(existing: str, incoming: str) -> str:
174
+ existing = " ".join(existing.split()).strip()
175
+ incoming = " ".join(incoming.split()).strip()
176
+ if not existing:
177
+ return incoming
178
+ if not incoming:
179
+ return existing
180
+ if incoming.lower().startswith(existing.lower()):
181
+ return incoming
182
+ if existing.lower().endswith(incoming.lower()):
183
+ return existing
184
+
185
+ existing_lower = existing.lower()
186
+ incoming_lower = incoming.lower()
187
+ max_overlap = min(len(existing), len(incoming), 120)
188
+ for size in range(max_overlap, 4, -1):
189
+ if existing_lower.endswith(incoming_lower[:size]):
190
+ return f"{existing}{incoming[size:]}".strip()
191
+ return f"{existing} {incoming}".strip()
192
+
193
+
194
+ def is_repetitive_hallucination(text: str) -> bool:
195
+ import re
196
+
197
+ words = re.findall(r"[a-zA-Z']+", text.lower())
198
+ if len(words) < 8:
199
+ return False
200
+ unique_words = set(words)
201
+ if len(unique_words) <= 2:
202
+ return True
203
+ most_common = max(words.count(word) for word in unique_words)
204
+ return most_common / len(words) >= 0.65
205
+
206
+
207
+ async def transcribe_audio_file(audio_path: str, model: str = WHISPER_MODEL) -> str:
208
+ if not audio_path:
209
+ return ""
210
+
211
+ return await asyncio.to_thread(_transcribe_audio_file_sync, audio_path, model)
212
+
213
+
214
+ async def transcribe_audio_array(
215
+ sample_rate: int,
216
+ audio: np.ndarray,
217
+ model: str = WHISPER_MODEL,
218
+ **decode_options: Any,
219
+ ) -> str:
220
+ if audio.size == 0:
221
+ return ""
222
+
223
+ waveform = prepare_audio_array(sample_rate, audio)
224
+ return await asyncio.to_thread(_transcribe_audio_array_sync, waveform, model, decode_options)
225
+
226
+
227
+ def _transcribe_audio_file_sync(audio_path: str, model: str) -> str:
228
+ if STT_BACKEND == "transformers":
229
+ return _transcribe_with_transformers(audio_path, HF_WHISPER_MODEL)
230
+
231
+ try:
232
+ ensure_ffmpeg_on_path()
233
+ import mlx_whisper
234
+
235
+ result = mlx_whisper.transcribe(audio_path, path_or_hf_repo=model)
236
+ if isinstance(result, dict):
237
+ return str(result.get("text", "")).strip()
238
+ return str(result).strip()
239
+ except Exception as exc:
240
+ return f"[transcription unavailable: {exc}]"
241
+
242
+
243
+ def _transcribe_audio_array_sync(
244
+ waveform: np.ndarray,
245
+ model: str,
246
+ decode_options: dict[str, Any] | None = None,
247
+ ) -> str:
248
+ if STT_BACKEND == "transformers":
249
+ return _transcribe_with_transformers(
250
+ {"array": waveform.astype(np.float32), "sampling_rate": 16000},
251
+ HF_WHISPER_MODEL,
252
+ )
253
+
254
+ try:
255
+ import mlx_whisper
256
+
257
+ result = mlx_whisper.transcribe(
258
+ waveform,
259
+ path_or_hf_repo=model,
260
+ verbose=False,
261
+ **(decode_options or {}),
262
+ )
263
+ if isinstance(result, dict):
264
+ return str(result.get("text", "")).strip()
265
+ return str(result).strip()
266
+ except Exception as exc:
267
+ return f"[transcription unavailable: {exc}]"
268
+
269
+
270
+ def _transcribe_with_transformers(audio_input: Any, model: str) -> str:
271
+ try:
272
+ pipeline = get_asr_pipeline(model)
273
+ result = pipeline(audio_input, generate_kwargs={"language": "english", "task": "transcribe"})
274
+ if isinstance(result, dict):
275
+ return str(result.get("text", "")).strip()
276
+ return str(result).strip()
277
+ except Exception as exc:
278
+ return f"[transcription unavailable: {exc}]"
279
+
280
+
281
+ def get_asr_pipeline(model: str):
282
+ global _asr_pipeline
283
+ if _asr_pipeline is None:
284
+ from transformers import pipeline
285
+
286
+ _asr_pipeline = pipeline(
287
+ "automatic-speech-recognition",
288
+ model=model,
289
+ )
290
+ return _asr_pipeline
291
+
292
+
293
+ def prepare_audio_array(sample_rate: int, audio: np.ndarray) -> np.ndarray:
294
+ if audio.ndim > 1:
295
+ audio = audio.mean(axis=1)
296
+
297
+ if np.issubdtype(audio.dtype, np.integer):
298
+ audio = audio.astype(np.float32) / np.iinfo(audio.dtype).max
299
+ else:
300
+ audio = audio.astype(np.float32)
301
+
302
+ if sample_rate != 16000:
303
+ from scipy.signal import resample_poly
304
+
305
+ gcd = np.gcd(sample_rate, 16000)
306
+ audio = resample_poly(audio, 16000 // gcd, sample_rate // gcd).astype(np.float32)
307
+
308
+ return audio
309
+
310
+
311
+ def ensure_ffmpeg_on_path() -> None:
312
+ if shutil.which("ffmpeg"):
313
+ return
314
+
315
+ try:
316
+ import imageio_ffmpeg
317
+
318
+ ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
319
+ bin_dir = BASE_DIR / ".runtime" / "bin"
320
+ bin_dir.mkdir(parents=True, exist_ok=True)
321
+ shim = bin_dir / "ffmpeg"
322
+ if not shim.exists():
323
+ shim.symlink_to(ffmpeg)
324
+ os.environ["PATH"] = f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}"
325
+ except Exception as exc:
326
+ raise RuntimeError(
327
+ "ffmpeg is required for audio decoding. Install it with `brew install ffmpeg` "
328
+ "or `python3 -m pip install imageio-ffmpeg`."
329
+ ) from exc
nodes/framework.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import yaml
2
+
3
+ from state import CoachState
4
+
5
+
6
+ async def framework_node(state: CoachState) -> CoachState:
7
+ framework = state.get("framework", "General")
8
+ with open("frameworks.yaml", "r", encoding="utf-8") as file:
9
+ frameworks = yaml.safe_load(file)
10
+
11
+ if framework not in frameworks:
12
+ framework = "General"
13
+
14
+ state["framework"] = framework
15
+ if not state.get("steps"):
16
+ state["steps"] = frameworks[framework]["steps"]
17
+ return state
nodes/transcript.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from state import CoachState
2
+
3
+
4
+ QUESTION_HINTS = ("?", "tell me", "how would", "can you", "describe", "design")
5
+
6
+
7
+ async def transcript_node(state: CoachState) -> CoachState:
8
+ raw_text = state.get("raw_text", "").strip()
9
+ if state.get("question"):
10
+ speaker = "interviewer"
11
+ elif state.get("answer"):
12
+ speaker = "candidate"
13
+ else:
14
+ speaker = infer_speaker(raw_text)
15
+ labelled = {
16
+ "speaker": speaker,
17
+ "text": raw_text,
18
+ "confidence": 0.65 if raw_text else 0.0,
19
+ }
20
+
21
+ state["speaker"] = speaker
22
+ state["labelled_json"] = labelled
23
+ if speaker == "interviewer" and not state.get("question"):
24
+ state["question"] = raw_text
25
+ elif speaker == "candidate" and not state.get("answer"):
26
+ state["answer"] = raw_text
27
+ state["needs_review"] = labelled["confidence"] < 0.6
28
+ return state
29
+
30
+
31
+ def infer_speaker(text: str) -> str:
32
+ lowered = text.lower()
33
+ if text.endswith("?") or any(hint in lowered for hint in QUESTION_HINTS):
34
+ return "interviewer"
35
+ return "candidate"
prompts.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CLASSIFIER_SYSTEM_PROMPT = """You classify interview questions into one coaching framework.
2
+ Return only valid JSON with keys "type" and "confidence".
3
+ Use "General" when uncertain."""
4
+
5
+ CLASSIFIER_USER_PROMPT = """Question:
6
+ {question}
7
+
8
+ Valid frameworks:
9
+ {frameworks}"""
10
+
11
+ COACHING_GUIDANCE_SYSTEM_PROMPT = """You generate silent interview coaching cues.
12
+ The candidate will glance at these while answering, so keep them short, natural, and specific to the question.
13
+ Do not give a full scripted answer. Do not answer for the candidate. Do not sound robotic.
14
+ Return only valid JSON with key "cues", a list of 4 to 6 concise strings.
15
+ Each cue should be a hint that helps the candidate cover important aspects naturally: definition, structure, mechanism, metrics, tradeoffs, examples, risks, or closing checks.
16
+ Make cues specific to the question, not generic framework labels."""
17
+
18
+ COACHING_GUIDANCE_USER_PROMPT = """Interview question:
19
+ {question}
20
+
21
+ Detected framework:
22
+ {framework}
23
+
24
+ Fine-tuned topic/pattern:
25
+ {pattern}
26
+
27
+ Fine-tuned coarse steps, for context only:
28
+ {steps}
29
+
30
+ Generate question-specific coaching hints the candidate can glance at while answering."""
31
+
32
+ EVALUATOR_SYSTEM_PROMPT = """You are a senior ML interview evaluator.
33
+ You are NOT generating a perfect answer.
34
+ You are assessing whether the candidate demonstrated structured thinking.
35
+ A real interviewer would pass a candidate who covered 3 of 5 areas clearly and thought out loud well, even if they missed some detail.
36
+ Be encouraging about what was done well before noting gaps.
37
+ Never give "No hire" unless the candidate showed no structure at all or gave no answer.
38
+ Generate a benchmark answer only so the candidate can learn and improve. Do not grade by comparing the candidate against a perfect benchmark answer.
39
+
40
+ Critical evaluation rules:
41
+ - First generate "benchmark_answer" as a learning reference only.
42
+ - Then ignore the benchmark when assigning the band and feedback.
43
+ - The band, steps_covered, strong_points, weak_points, and critical_gaps must be based only on the text under "Candidate answer".
44
+ - Never evaluate your own benchmark_answer.
45
+ - Never say the answer covered an item unless that idea appears in the candidate answer.
46
+ - If the candidate answer is empty, say the candidate did not give an answer.
47
+ - Strong/weak points must cite what the candidate did or did not say, not what a model answer should say.
48
+
49
+ Use these evaluation bands:
50
+ Strong hire = Covered all key areas, showed depth
51
+ Hire = Covered most areas, good structure
52
+ Borderline = Some structure, missed important areas
53
+ No hire = Jumped to solution, no process, or no answer
54
+
55
+ Return only valid JSON with these keys:
56
+ "benchmark_answer": string,
57
+ "steps_covered": list of booleans,
58
+ "band": one of ["Strong hire", "Hire", "Borderline", "No hire"],
59
+ "strong_points": list of strings,
60
+ "weak_points": list of strings,
61
+ "critical_gaps": list of strings.
62
+ Use ["Nil"] for any list that has no relevant items.
63
+ Do not include generic encouragement or extra prose outside the JSON."""
64
+
65
+ EVALUATOR_USER_PROMPT = """Interview question:
66
+ {question}
67
+
68
+ Candidate answer to evaluate. Use only this text for score and feedback:
69
+ {answer}
70
+
71
+ Framework: {framework}
72
+ Framework steps:
73
+ {steps}"""
74
+
75
+ CLARIFICATION_CHECK_SYSTEM_PROMPT = """You decide whether a newly extracted question is actually a candidate clarification about the previous interview question.
76
+ Return only valid JSON with keys "is_clarification" and "reason".
77
+ A clarification asks about constraints, assumptions, scope, definitions, examples, or what the interviewer means.
78
+ Do not mark it as clarification if it is a new interview question from the interviewer."""
79
+
80
+ CLARIFICATION_CHECK_USER_PROMPT = """Previous interview question:
81
+ {previous_question}
82
+
83
+ New extracted question:
84
+ {new_question}
85
+
86
+ Candidate answer captured with new question:
87
+ {answer}
88
+
89
+ Is the new extracted question a candidate clarification of the previous interview question?"""
90
+
91
+ TRANSCRIPT_NORMALIZER_SYSTEM_PROMPT = """You clean noisy live interview transcripts for InterviewCoach.
92
+ Return only valid JSON with keys:
93
+ "question": string,
94
+ "answer": string,
95
+ "is_target": boolean,
96
+ "complete": boolean,
97
+ "reason": string.
98
+
99
+ Target questions are only Data Science, Machine Learning, AI Engineering, or System Design interview questions.
100
+ Reject greetings, logistics, small talk, background discussion, interviewer transitions, and generic interview setup.
101
+
102
+ Rules:
103
+ - Extract the interviewer's actual target question, not a candidate clarification.
104
+ - Put only the candidate's response in "answer".
105
+ - Preserve the candidate's full response in "answer" until the next interviewer question or transition.
106
+ - Do not compress the answer to only the most direct sentence. Keep supporting explanation, examples, caveats, and reasoning.
107
+ - If there is no candidate response yet, "answer" must be "".
108
+ - A candidate answer is not required for "complete": a complete interviewer question with no answer should still return "is_target": true and "complete": true.
109
+ - Never put interviewer question text in "answer".
110
+ - Never create a question by summarizing or rephrasing the candidate's answer.
111
+ - The question must come from interviewer wording before the candidate starts answering.
112
+ - Candidate answer cues include "not necessarily", "I would", "I will", "I think", "accuracy is misleading", "look at", "since", and first-person explanation.
113
+ - If the answer begins with the question, move that text into "question" and remove it from "answer".
114
+ - Fix obvious transcription artifacts generically: repeated words, broken phrases, missing punctuation, and minor speech-to-text errors.
115
+ - Do not hard-code domain-specific rewrites. Preserve the interviewer intent.
116
+ - Live STT can be very noisy. If the transcript contains a clearly inferable ML/AI/System Design question, reconstruct the intended grammatical question instead of rejecting it.
117
+ - Treat phrases like "supervised machines", "machine learning on..." or broken algorithm/model wording as noisy ML interview speech when the intent is clear.
118
+ - Join fragmented interviewer question pieces when STT inserts punctuation too early. For example, "Can you briefly explain? how does linear regression work?" is one complete question: "Can you briefly explain how linear regression works?"
119
+ - If the interviewer question is corrupted but the intent is inferable, extract the shortest faithful question. Do not add details that appear only in the candidate answer.
120
+ - If there are multiple questions, follow the extraction mode in the user prompt.
121
+ - A noisy but answerable reconstructed question is complete.
122
+ - If "is_target" is true and "complete" is true, "question" must be non-empty.
123
+ - Interview prompts phrased as imperatives are questions. "Tell me...", "Explain...", "Describe...", and "Walk me through..." are complete questions when they target ML/AI/System Design.
124
+ - Interview prompts phrased as knowledge checks are questions. "Do you know anything about...", "Are you familiar with...", and "Can you explain..." are complete questions when they target ML/AI/System Design.
125
+ - Ignore setup questions such as "Can I ask one question?" when a real target question follows.
126
+ - If no complete target question is present, return {"question": "", "answer": "", "is_target": false, "complete": false, "reason": "..."}.
127
+ - If a target question is present but no candidate answer is present, return the question and an empty answer.
128
+ - Keep "reason" under 20 words.
129
+ - Examples below are illustrative only. Never copy an example question unless the same question appears in the transcript.
130
+
131
+ Examples:
132
+ Noisy transcript: "hello let me start asking questions can you tell me how many supervised machines otherwise machine learning algorithms you have worked with"
133
+ JSON: {"question": "Can you tell me which supervised machine learning algorithms you have worked with?", "answer": "", "is_target": true, "complete": true, "reason": "Noisy ML question reconstructed."}
134
+
135
+ Noisy transcript: "Tell me the difference between supervised and unsupervised. and unsupervised machine learning models."
136
+ JSON: {"question": "Tell me the difference between supervised and unsupervised machine learning models.", "answer": "", "is_target": true, "complete": true, "reason": "Extracted ML comparison prompt."}
137
+
138
+ Noisy transcript: "Hope you are good. Can I ask one question? Do you know anything about supervised? supervised machine learning algorithms."
139
+ JSON: {"question": "Do you know anything about supervised machine learning algorithms?", "answer": "", "is_target": true, "complete": true, "reason": "Extracted ML knowledge question."}
140
+
141
+ Noisy transcript: "Okay that's also good. Can you briefly explain? how does linear regression work? linear regression we have set up of predictors and then there is a target."
142
+ JSON: {"question": "Can you briefly explain how linear regression works?", "answer": "Linear regression uses predictors to estimate a target.", "is_target": true, "complete": true, "reason": "Joined fragmented ML question."}
143
+
144
+ Noisy transcript: "First one. You have a data set where 95% and only 5% fraud. a model and get 95% accuracy. Please try good model. not necessarily a model that predicts every transaction as legitimate. So accuracy is misleading here."
145
+ JSON: {"question": "You have a dataset with 95% legitimate transactions and 5% fraud, and a model gets 95% accuracy. Is it a good model?", "answer": "Not necessarily. A model that predicts every transaction as legitimate would get 95% accuracy, so accuracy is misleading here.", "is_target": true, "complete": true, "reason": "Separated question from answer."}
146
+
147
+ Noisy transcript: "Good. Second question. in production, what is the first thing we check? I will check for data."
148
+ JSON: {"question": "In production, what is the first thing we check?", "answer": "I will check for data.", "is_target": true, "complete": true, "reason": "Extracted latest production question."}
149
+
150
+ Noisy transcript: "What is and what is x and y variables. Yes. So the linear regression is F form of machine learning algorithm predict an output based on certain features given input into the model. The x variables are called the predictors and the y variable is the target variable. For example, if I want to predict on the number of years experience that's a classic linear regression."
151
+ JSON: {"question": "What are x and y variables in linear regression?", "answer": "Linear regression is a form of machine learning algorithm that predicts an output based on certain features given as input into the model. The x variables are called the predictors and the y variable is the target variable. For example, if I want to predict based on the number of years of experience, that's a classic linear regression example.", "is_target": true, "complete": true, "reason": "Preserved full candidate answer."}
152
+
153
+ """
154
+
155
+ TRANSCRIPT_NORMALIZER_USER_PROMPT = """Raw transcript or transcript window:
156
+ {transcript}
157
+
158
+ Extraction mode:
159
+ {mode_instruction}
160
+
161
+ Current extracted question, if any:
162
+ {question}
163
+
164
+ Current extracted answer, if any:
165
+ {answer}
166
+
167
+ Normalize this into one clean target interview Q&A boundary."""
168
+
169
+ TRANSCRIPT_NORMALIZER_REPAIR_SYSTEM_PROMPT = """You repair invalid JSON produced by an interview transcript normalizer.
170
+ Return only valid JSON with keys "question", "answer", "is_target", "complete", and "reason".
171
+ The "question" field must contain the interviewer's DS/ML/AI/System Design question.
172
+ The "answer" field must contain only the candidate response.
173
+ If the previous output put interviewer question text in "answer", move it to "question" and set "answer" to "" unless a real candidate answer exists.
174
+ If the previous output put candidate answer text inside "question", remove it from "question" and keep it only in "answer".
175
+ After repairing fields, re-evaluate "is_target" and "complete" from the repaired question.
176
+ If the repaired question is about supervised learning, machine learning algorithms, AI, data science, or system design, set "is_target": true.
177
+ If the repaired question can be answered as a standalone interview question, set "complete": true.
178
+ A noisy but answerable ML question is complete after repair.
179
+
180
+ Example invalid JSON:
181
+ {"question": "In production, what is the first thing we check? I will check for data.", "answer": "I will check for data.", "is_target": true, "complete": true, "reason": "duplicate answer"}
182
+ Repaired JSON:
183
+ {"question": "In production, what is the first thing we check?", "answer": "I will check for data.", "is_target": true, "complete": true, "reason": "Removed answer from question."}"""
184
+
185
+ TRANSCRIPT_NORMALIZER_REPAIR_USER_PROMPT = """Raw transcript:
186
+ {transcript}
187
+
188
+ Previous invalid JSON:
189
+ {payload}
190
+
191
+ Repair the JSON."""
192
+
193
+ MULTI_EXCHANGE_EXTRACTOR_SYSTEM_PROMPT = """You extract all target interview Q&A exchanges from a noisy transcript.
194
+ Return only valid JSON with key "exchanges", a list of objects with keys:
195
+ "question": string,
196
+ "answer": string,
197
+ "is_target": boolean,
198
+ "complete": boolean,
199
+ "reason": string.
200
+
201
+ Target questions are only Data Science, Machine Learning, AI Engineering, MLOps, or System Design interview questions.
202
+
203
+ Rules:
204
+ - Extract every target interviewer question in chronological order.
205
+ - Do not only extract the latest question.
206
+ - Each candidate answer belongs to the target question immediately before it.
207
+ - Preserve candidate answers fully. Do not summarize, rewrite conceptually, improve, or add missing ideas.
208
+ - Do not shorten the candidate answer to only the final/direct sentence. Keep definitions, reasoning, examples, caveats, and explanatory setup.
209
+ - The answer ends only when the next interviewer question, interviewer transition, or transcript end begins.
210
+ - Only clean obvious STT noise: repeated words, broken punctuation, filler fragments, and spelling/word recognition mistakes when meaning is clear.
211
+ - Correct obvious ML/STT word errors when context is clear, such as "Frot" -> "fraud" and "data trips/drips" -> "data drift".
212
+ - Never use candidate answer content to invent or expand the interviewer question.
213
+ - If the interviewer question is noisy but inferable, reconstruct the shortest faithful question.
214
+ - If a target question has no answer, return an empty string for "answer".
215
+ - Exclude greetings, logistics, transitions, interviewer praise, and non-target small talk.
216
+ - Keep "reason" under 20 words.
217
+
218
+ Example:
219
+ Transcript: "First one you have a data set where 95% are legitimate and 5% fraud. You train a model and get 95% accuracy. Is this a good model? not necessarily a model that predicts every transaction as legitimate. accuracy is misleading. Good. Second question your model performs well in testing poorly in production. What's the first thing you would do? I would check for data drift."
220
+ JSON: {"exchanges":[{"question":"You have a dataset where 95% of transactions are legitimate and 5% are fraud. You train a model and get 95% accuracy. Is this a good model?","answer":"Not necessarily. A model that predicts every transaction as legitimate would get 95% accuracy. Accuracy is misleading.","is_target":true,"complete":true,"reason":"Extracted fraud metric question."},{"question":"Your model performs well in testing but poorly in production. What is the first thing you would do?","answer":"I would check for data drift.","is_target":true,"complete":true,"reason":"Extracted production performance question."}]}
221
+
222
+ Example:
223
+ Transcript: "What is and what is x and y variables. Yes. So the linear regression is F form of machine learning algorithm predict an output based on certain features given input into the model. The x variables are called the predictors and the y variable is the target variable. For example, if I want to predict on the number of years experience that's a classic linear regression."
224
+ JSON: {"exchanges":[{"question":"What are x and y variables in linear regression?","answer":"Linear regression is a form of machine learning algorithm that predicts an output based on certain features given as input into the model. The x variables are called the predictors and the y variable is the target variable. For example, if I want to predict based on the number of years of experience, that's a classic linear regression example.","is_target":true,"complete":true,"reason":"Preserved full candidate answer."}]}"""
225
+
226
+ MULTI_EXCHANGE_EXTRACTOR_USER_PROMPT = """Transcript:
227
+ {transcript}
228
+
229
+ Extract all target Q&A exchanges."""
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ aiosqlite>=0.20.0
2
+ accelerate>=1.9.0
3
+ gradio>=4.44.0
4
+ imageio-ffmpeg>=0.5.1
5
+ langgraph>=0.2.0
6
+ mlx-whisper>=0.4.0; platform_system == "Darwin"
7
+ modal>=1.1.0
8
+ peft>=0.16.0
9
+ pyyaml>=6.0.2
10
+ scipy>=1.11.0
11
+ sounddevice>=0.5.1; platform_system == "Darwin"
12
+ torch>=2.3.0
13
+ transformers>=4.54.0
state.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, TypedDict
2
+
3
+
4
+ class CoachState(TypedDict, total=False):
5
+ session_id: int
6
+ audio_queue: Any
7
+ raw_text: str
8
+ labelled_json: dict[str, Any]
9
+ speaker: str
10
+ question: str
11
+ answer: str
12
+ framework: str
13
+ pattern: str
14
+ steps: list[str]
15
+ confidence: float
16
+ needs_review: bool
17
+ topic_model_used: str
18
+ exchange_id: int
19
+ evaluation: dict[str, Any]