Spaces:
Runtime error
Runtime error
Commit ·
4e8fb20
unverified ·
0
Parent(s):
initial commit - InterviewCopilotLocal
Browse files- .gitignore +77 -0
- AGENTS.md +112 -0
- README.md +57 -0
- agents/__init__.py +1 -0
- agents/evaluator.py +451 -0
- agents/hf_chat.py +114 -0
- agents/topic_pattern.py +155 -0
- app.py +1721 -0
- article.md +170 -0
- config.py +23 -0
- data/train.jsonl +1 -0
- db/__init__.py +1 -0
- db/queries.py +212 -0
- db/schema.py +55 -0
- fine-tune/data/train.json +170 -0
- fine-tune/data/valid.json +26 -0
- fine-tune/lora-finetune.py +353 -0
- frameworks.yaml +43 -0
- graph.py +78 -0
- nodes/__init__.py +1 -0
- nodes/audio.py +329 -0
- nodes/framework.py +17 -0
- nodes/transcript.py +35 -0
- prompts.py +229 -0
- requirements.txt +13 -0
- state.py +19 -0
.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]
|