Spaces:
Runtime error
Runtime error
final changes
Browse filesCo-authored-by: Codex <codex@openai.com>
- README.md +5 -0
- agents/hf_chat.py +7 -0
- agents/topic_pattern.py +10 -0
- app.py +1114 -274
- article.md +34 -6
- config.py +2 -6
- nodes/audio.py +104 -13
- prompts.py +102 -3
README.md
CHANGED
|
@@ -27,6 +27,11 @@ Interview Coach is a local-first assistant for live technical interviews. It lis
|
|
| 27 |
|
| 28 |
Development note: the live transcription flow, coaching-card timing, and transcript extraction loop were iterated with Codex assistance.
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
## Why It Matters
|
| 31 |
|
| 32 |
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.
|
|
|
|
| 27 |
|
| 28 |
Development note: the live transcription flow, coaching-card timing, and transcript extraction loop were iterated with Codex assistance.
|
| 29 |
|
| 30 |
+
## Links
|
| 31 |
+
|
| 32 |
+
- Hugging Face Space README: https://huggingface.co/spaces/build-small-hackathon/interview-copilot-local/blob/main/README.md
|
| 33 |
+
- Demo video: https://www.loom.com/share/d44244e43927423b9be237fbb207a65b
|
| 34 |
+
|
| 35 |
## Why It Matters
|
| 36 |
|
| 37 |
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.
|
agents/hf_chat.py
CHANGED
|
@@ -16,6 +16,13 @@ class HuggingFaceChatModel:
|
|
| 16 |
self._load_lock = threading.Lock()
|
| 17 |
self.last_error = ""
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
async def generate(
|
| 20 |
self,
|
| 21 |
system_prompt: str,
|
|
|
|
| 16 |
self._load_lock = threading.Lock()
|
| 17 |
self.last_error = ""
|
| 18 |
|
| 19 |
+
@property
|
| 20 |
+
def is_loaded(self) -> bool:
|
| 21 |
+
return self._model is not None and self._tokenizer is not None
|
| 22 |
+
|
| 23 |
+
async def warmup(self) -> None:
|
| 24 |
+
await asyncio.to_thread(self._ensure_model_loaded_sync)
|
| 25 |
+
|
| 26 |
async def generate(
|
| 27 |
self,
|
| 28 |
system_prompt: str,
|
agents/topic_pattern.py
CHANGED
|
@@ -29,6 +29,16 @@ class TopicPatternAgent:
|
|
| 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."
|
|
|
|
| 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 |
+
@property
|
| 33 |
+
def is_loaded(self) -> bool:
|
| 34 |
+
return self._model is not None and self._tokenizer is not None
|
| 35 |
+
|
| 36 |
+
async def warmup(self) -> None:
|
| 37 |
+
if not self.enabled:
|
| 38 |
+
self.last_error = "Topic/pattern model is disabled."
|
| 39 |
+
return
|
| 40 |
+
await asyncio.to_thread(self._ensure_model_loaded_sync)
|
| 41 |
+
|
| 42 |
async def analyze(self, question: str) -> dict[str, Any]:
|
| 43 |
if not self.enabled or not question.strip():
|
| 44 |
self.last_error = "Topic/pattern model is disabled or question is empty."
|
app.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import asyncio
|
| 2 |
import csv
|
|
|
|
| 3 |
import html
|
| 4 |
import json
|
| 5 |
import os
|
|
@@ -15,7 +16,6 @@ import numpy as np
|
|
| 15 |
from agents.evaluator import EvaluationAgent
|
| 16 |
from agents.hf_chat import HuggingFaceChatModel
|
| 17 |
from agents.topic_pattern import TopicPatternAgent
|
| 18 |
-
<<<<<<< HEAD
|
| 19 |
from config import (
|
| 20 |
APP_HOST,
|
| 21 |
APP_PORT,
|
|
@@ -25,21 +25,21 @@ from config import (
|
|
| 25 |
STREAMING_WHISPER_MODEL,
|
| 26 |
TOPIC_PATTERN_MODEL,
|
| 27 |
)
|
| 28 |
-
=======
|
| 29 |
-
from config import APP_HOST, APP_PORT, BASE_DIR, GENERAL_LLM_MODEL, HF_SPACE_MODE, STREAMING_WHISPER_MODEL
|
| 30 |
-
>>>>>>> c0f39ad (initial commit - InterviewCopilotLocal)
|
| 31 |
from db.queries import (
|
|
|
|
| 32 |
add_evaluation,
|
|
|
|
| 33 |
append_exchange_answer,
|
| 34 |
clear_all_tables,
|
| 35 |
create_session,
|
| 36 |
list_all_evaluations,
|
| 37 |
list_evaluations,
|
| 38 |
list_exchanges,
|
|
|
|
| 39 |
)
|
| 40 |
from db.schema import init_db
|
| 41 |
from graph import coach_graph
|
| 42 |
-
from nodes.audio import LiveAudioTranscriber, transcribe_audio_array, transcribe_audio_file
|
| 43 |
from prompts import (
|
| 44 |
CLARIFICATION_CHECK_SYSTEM_PROMPT,
|
| 45 |
CLARIFICATION_CHECK_USER_PROMPT,
|
|
@@ -47,6 +47,10 @@ from prompts import (
|
|
| 47 |
COACHING_GUIDANCE_USER_PROMPT,
|
| 48 |
MULTI_EXCHANGE_EXTRACTOR_SYSTEM_PROMPT,
|
| 49 |
MULTI_EXCHANGE_EXTRACTOR_USER_PROMPT,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
TRANSCRIPT_NORMALIZER_REPAIR_SYSTEM_PROMPT,
|
| 51 |
TRANSCRIPT_NORMALIZER_REPAIR_USER_PROMPT,
|
| 52 |
TRANSCRIPT_NORMALIZER_SYSTEM_PROMPT,
|
|
@@ -162,6 +166,7 @@ label,
|
|
| 162 |
font-weight: 650 !important;
|
| 163 |
}
|
| 164 |
#status_box textarea,
|
|
|
|
| 165 |
#stream_status_box textarea {
|
| 166 |
color: var(--accent-strong) !important;
|
| 167 |
font-size: 13px !important;
|
|
@@ -190,6 +195,9 @@ label,
|
|
| 190 |
}
|
| 191 |
#live_transcript_box textarea {
|
| 192 |
min-height: 330px !important;
|
|
|
|
|
|
|
|
|
|
| 193 |
}
|
| 194 |
#log_box textarea,
|
| 195 |
#report_box textarea {
|
|
@@ -198,6 +206,9 @@ label,
|
|
| 198 |
#status_box textarea {
|
| 199 |
min-height: 34px !important;
|
| 200 |
}
|
|
|
|
|
|
|
|
|
|
| 201 |
#stream_status_box textarea {
|
| 202 |
min-height: 72px !important;
|
| 203 |
font-size: 12px !important;
|
|
@@ -301,69 +312,24 @@ FRAMEWORK_COLORS = {
|
|
| 301 |
"Estimation": "#a855f7",
|
| 302 |
}
|
| 303 |
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
LIVE_FAST_MIN_DELTA_CHARS = 180
|
| 309 |
-
LIVE_FAST_DUPLICATE_SIMILARITY = 0.78
|
| 310 |
BROWSER_STREAM_STEP_SECONDS = 2.0
|
| 311 |
BROWSER_STREAM_CONTEXT_SECONDS = 12.0
|
| 312 |
-
LIVE_TARGET_FRAMEWORKS = {"Technical", "System Design"}
|
| 313 |
-
LIVE_QUESTION_MARKERS = (
|
| 314 |
-
"first,",
|
| 315 |
-
"first.",
|
| 316 |
-
"first ",
|
| 317 |
-
"first question",
|
| 318 |
-
"first one",
|
| 319 |
-
"second,",
|
| 320 |
-
"second.",
|
| 321 |
-
"second ",
|
| 322 |
-
"second question",
|
| 323 |
-
"third,",
|
| 324 |
-
"third.",
|
| 325 |
-
"third ",
|
| 326 |
-
"third question",
|
| 327 |
-
"next question",
|
| 328 |
-
"another question",
|
| 329 |
-
"you have",
|
| 330 |
-
"let me ask",
|
| 331 |
-
"can you",
|
| 332 |
-
"could you",
|
| 333 |
-
"tell me",
|
| 334 |
-
"explain",
|
| 335 |
-
"how would",
|
| 336 |
-
"what would",
|
| 337 |
-
)
|
| 338 |
-
LIVE_ANSWER_STARTS = (
|
| 339 |
-
" i would ",
|
| 340 |
-
" i will ",
|
| 341 |
-
" i can ",
|
| 342 |
-
" i think ",
|
| 343 |
-
" i would first ",
|
| 344 |
-
" first i ",
|
| 345 |
-
" my approach ",
|
| 346 |
-
" so i ",
|
| 347 |
-
" sure ",
|
| 348 |
-
" yeah ",
|
| 349 |
-
" yes ",
|
| 350 |
-
)
|
| 351 |
-
LIVE_COMPLETION_PATTERNS = (
|
| 352 |
-
r"\b(is|are)\s+(this|it|that)\s+(an?\s+)?(adequate|good|bad|acceptable|enough|right|wrong)\b",
|
| 353 |
-
r"\b(is|are)\s+(this|it|that).*\bmodel\b",
|
| 354 |
-
r"\bwhat\s+(metric|metrics|would|should|do|is|are)\b",
|
| 355 |
-
r"\bhow\s+(would|should|do|can)\b",
|
| 356 |
-
r"\bwhy\s+(is|are|would|should|could|might)\b",
|
| 357 |
-
r"\bshould\s+(we|you|i|the)\b",
|
| 358 |
-
r"\bevaluate\b",
|
| 359 |
-
r"\baccuracy\b.*\b(adequate|enough|misleading|metric|problem)\b",
|
| 360 |
-
)
|
| 361 |
|
| 362 |
evaluator = EvaluationAgent()
|
| 363 |
general_llm = HuggingFaceChatModel(GENERAL_LLM_MODEL)
|
| 364 |
topic_pattern_agent = TopicPatternAgent()
|
| 365 |
live_audio = LiveAudioTranscriber()
|
| 366 |
topic_model_warmup_task: asyncio.Task | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
|
| 368 |
|
| 369 |
def ensure_topic_model_warmup() -> None:
|
|
@@ -375,8 +341,91 @@ def ensure_topic_model_warmup() -> None:
|
|
| 375 |
)
|
| 376 |
|
| 377 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
async def start_session(company: str, role: str) -> tuple[int, str]:
|
| 379 |
-
|
| 380 |
await init_db()
|
| 381 |
session_id = await create_session(company=company, role=role)
|
| 382 |
return session_id, f"Session {session_id} started"
|
|
@@ -437,11 +486,7 @@ async def classify_topic_and_steps(question: str) -> dict[str, Any]:
|
|
| 437 |
"model_unavailable": True,
|
| 438 |
"message": (
|
| 439 |
"Topic/steps model unavailable. Expected Hugging Face model "
|
| 440 |
-
<<<<<<< HEAD
|
| 441 |
f"{TOPIC_PATTERN_MODEL}.{suffix}"
|
| 442 |
-
=======
|
| 443 |
-
f"vadirajkrishna/interview-coach-3b.{suffix}"
|
| 444 |
-
>>>>>>> c0f39ad (initial commit - InterviewCopilotLocal)
|
| 445 |
),
|
| 446 |
}
|
| 447 |
|
|
@@ -575,10 +620,103 @@ async def extract_all_interview_exchanges_with_llm(transcript: str) -> list[dict
|
|
| 575 |
normalized = normalize_normalizer_payload(item)
|
| 576 |
if not normalized["is_target"] or not normalized["complete"] or not normalized["question"]:
|
| 577 |
continue
|
|
|
|
|
|
|
| 578 |
cleaned.append(normalized)
|
| 579 |
return cleaned
|
| 580 |
|
| 581 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 582 |
def normalize_llm_text(text: str) -> str:
|
| 583 |
return re.sub(r"\s+", " ", text).strip(" -:")
|
| 584 |
|
|
@@ -628,6 +766,11 @@ async def handle_clarification_if_needed(
|
|
| 628 |
|
| 629 |
|
| 630 |
async def is_clarification_question(previous_question: str, new_question: str, answer: str) -> bool:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
heuristic = is_clarification_question_heuristic(previous_question, new_question)
|
| 632 |
llm_result = await is_clarification_question_with_llm(previous_question, new_question, answer)
|
| 633 |
return llm_result if llm_result is not None else heuristic
|
|
@@ -709,7 +852,7 @@ async def transcribe_and_coach(
|
|
| 709 |
if audio_input is None:
|
| 710 |
return "Record audio first, then press Transcribe & Coach.", render_answer_card(), "", {}
|
| 711 |
|
| 712 |
-
transcript = await transcribe_audio_input(audio_input, use_space_stt=
|
| 713 |
if transcript.startswith("[transcription unavailable:"):
|
| 714 |
return transcript, render_answer_card(), "", {}
|
| 715 |
|
|
@@ -747,12 +890,16 @@ async def transcribe_browser_recording(
|
|
| 747 |
transcript = latest_stream_transcript(state, current_transcript)
|
| 748 |
return transcript, format_stream_status(state, "waiting for browser recording"), state
|
| 749 |
|
| 750 |
-
transcript = await transcribe_audio_input(audio_input, use_space_stt=
|
| 751 |
if transcript.startswith("[transcription unavailable:"):
|
| 752 |
state["last_text"] = transcript
|
| 753 |
return latest_stream_transcript(state, current_transcript), format_stream_status(state, transcript), state
|
| 754 |
|
| 755 |
-
updated_transcript =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 756 |
state["transcript"] = updated_transcript
|
| 757 |
state["last_text"] = transcript
|
| 758 |
state["transcriptions"] = int(state.get("transcriptions") or 0) + 1
|
|
@@ -760,10 +907,11 @@ async def transcribe_browser_recording(
|
|
| 760 |
|
| 761 |
|
| 762 |
async def stream_live_transcript(
|
|
|
|
| 763 |
audio_input: Any,
|
| 764 |
current_transcript: str,
|
| 765 |
stream_state: dict[str, Any] | None,
|
| 766 |
-
) -> tuple[str, str, str, dict[str, Any], dict[str, Any]]:
|
| 767 |
if audio_input is None:
|
| 768 |
state = stream_state or fresh_stream_state()
|
| 769 |
return (
|
|
@@ -772,6 +920,7 @@ async def stream_live_transcript(
|
|
| 772 |
format_stream_status(state, "waiting for microphone"),
|
| 773 |
state,
|
| 774 |
state.get("card_state") or {},
|
|
|
|
| 775 |
)
|
| 776 |
|
| 777 |
state = update_stream_state(audio_input, stream_state)
|
|
@@ -780,6 +929,19 @@ async def stream_live_transcript(
|
|
| 780 |
processed_until = int(state.get("processed_until") or 0)
|
| 781 |
available = 0 if audio_buffer is None else len(audio_buffer) - processed_until
|
| 782 |
transcript_so_far = latest_stream_transcript(state, current_transcript)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 783 |
if available < int(sample_rate * BROWSER_STREAM_STEP_SECONDS):
|
| 784 |
return (
|
| 785 |
transcript_so_far,
|
|
@@ -787,6 +949,7 @@ async def stream_live_transcript(
|
|
| 787 |
format_stream_status(state, "buffering audio"),
|
| 788 |
state,
|
| 789 |
state.get("card_state") or {},
|
|
|
|
| 790 |
)
|
| 791 |
|
| 792 |
context_samples = int(sample_rate * BROWSER_STREAM_CONTEXT_SECONDS)
|
|
@@ -799,19 +962,46 @@ async def stream_live_transcript(
|
|
| 799 |
rms = audio_rms(new_audio)
|
| 800 |
state["last_rms"] = rms
|
| 801 |
if rms < 0.003:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 802 |
return (
|
| 803 |
transcript_so_far,
|
| 804 |
-
|
| 805 |
format_stream_status(state, "quiet audio skipped"),
|
| 806 |
state,
|
| 807 |
-
|
|
|
|
| 808 |
)
|
|
|
|
| 809 |
|
| 810 |
chunk_text = await transcribe_audio_array(
|
| 811 |
sample_rate,
|
| 812 |
audio_window,
|
| 813 |
model=STREAMING_WHISPER_MODEL,
|
| 814 |
-
backend="transformers",
|
| 815 |
temperature=0.0,
|
| 816 |
condition_on_previous_text=False,
|
| 817 |
compression_ratio_threshold=1.8,
|
|
@@ -825,6 +1015,7 @@ async def stream_live_transcript(
|
|
| 825 |
format_stream_status(state, chunk_text or "no speech detected yet"),
|
| 826 |
state,
|
| 827 |
state.get("card_state") or {},
|
|
|
|
| 828 |
)
|
| 829 |
if is_repetitive_hallucination(chunk_text):
|
| 830 |
state["rejected"] = int(state.get("rejected") or 0) + 1
|
|
@@ -835,42 +1026,79 @@ async def stream_live_transcript(
|
|
| 835 |
format_stream_status(state, "repeated-word output skipped"),
|
| 836 |
state,
|
| 837 |
state.get("card_state") or {},
|
|
|
|
| 838 |
)
|
| 839 |
|
| 840 |
state["transcriptions"] = int(state.get("transcriptions") or 0) + 1
|
| 841 |
state["last_text"] = chunk_text
|
| 842 |
updated_transcript = merge_transcript_text(transcript_so_far, chunk_text)
|
| 843 |
state["transcript"] = updated_transcript
|
| 844 |
-
card_html, card_state = await monitor_answer_card(updated_transcript, state, force=True)
|
| 845 |
-
state["card_html"] = card_html
|
| 846 |
-
state["card_state"] = card_state
|
| 847 |
return (
|
| 848 |
updated_transcript,
|
| 849 |
-
card_html,
|
| 850 |
format_stream_status(state, "transcribing"),
|
| 851 |
state,
|
| 852 |
-
card_state,
|
|
|
|
| 853 |
)
|
| 854 |
|
| 855 |
|
| 856 |
-
async def start_backend_live_transcript():
|
| 857 |
-
|
| 858 |
await live_audio.start()
|
| 859 |
monitor_state = fresh_card_monitor_state()
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 874 |
|
| 875 |
|
| 876 |
async def stop_backend_live_transcript() -> str:
|
|
@@ -891,22 +1119,73 @@ async def transcribe_audio_input(audio_input: Any, use_space_stt: bool = False)
|
|
| 891 |
async def process_typed_transcript(
|
| 892 |
session_id: int | None,
|
| 893 |
transcript: str,
|
| 894 |
-
) -> tuple[str, str, str, dict[str, Any]]:
|
| 895 |
-
exchanges = await extract_all_interview_exchanges_with_llm(transcript)
|
| 896 |
if exchanges:
|
| 897 |
cards = []
|
|
|
|
| 898 |
last_state: dict[str, Any] = {}
|
| 899 |
log = ""
|
| 900 |
for exchange in exchanges:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 901 |
card, log, last_state = await coach_question(
|
| 902 |
session_id,
|
| 903 |
exchange["question"],
|
| 904 |
exchange.get("answer", ""),
|
| 905 |
)
|
| 906 |
cards.append(card)
|
|
|
|
| 907 |
session_id = last_state.get("session_id", session_id)
|
| 908 |
last_state["processed_exchanges"] = exchanges
|
| 909 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 910 |
|
| 911 |
normalized = await normalize_interview_exchange_with_llm(transcript)
|
| 912 |
if (
|
|
@@ -920,7 +1199,13 @@ async def process_typed_transcript(
|
|
| 920 |
normalized.get("question", ""),
|
| 921 |
normalized.get("answer", ""),
|
| 922 |
)
|
| 923 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 924 |
|
| 925 |
state = {
|
| 926 |
"message": (
|
|
@@ -929,7 +1214,7 @@ async def process_typed_transcript(
|
|
| 929 |
else general_llm_unavailable_message()
|
| 930 |
)
|
| 931 |
}
|
| 932 |
-
return transcript, render_answer_card(state), state["message"], state
|
| 933 |
|
| 934 |
|
| 935 |
def clear_live_state() -> tuple[str, str, dict[str, Any], str, dict[str, Any]]:
|
|
@@ -956,34 +1241,45 @@ async def clear_database() -> tuple[None, dict[str, Any], dict[str, Any], str, s
|
|
| 956 |
async def monitor_answer_card(
|
| 957 |
transcript: str,
|
| 958 |
monitor_state: dict[str, Any] | None,
|
|
|
|
| 959 |
force: bool = False,
|
| 960 |
fast: bool = True,
|
|
|
|
|
|
|
| 961 |
) -> tuple[str, dict[str, Any]]:
|
| 962 |
monitor_state = monitor_state or fresh_card_monitor_state()
|
| 963 |
-
if fast:
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 967 |
if not question:
|
| 968 |
message = monitor_state.get("last_extraction_message", "")
|
| 969 |
-
card_html =
|
|
|
|
| 970 |
return card_html, monitor_state.get("card_state") or {}
|
| 971 |
|
| 972 |
-
|
| 973 |
-
if
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 977 |
|
| 978 |
result = await classify_topic_and_steps(question)
|
| 979 |
if result.get("model_unavailable"):
|
| 980 |
monitor_state["last_extraction_message"] = result["message"]
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
monitor_state["last_non_target_question_key"] = question_key
|
| 985 |
-
monitor_state["last_extraction_message"] = "Listening for a DS/ML/AI/System Design question."
|
| 986 |
-
return monitor_state.get("card_html") or render_answer_card({"message": monitor_state["last_extraction_message"]}), {}
|
| 987 |
|
| 988 |
cues = await generate_coaching_cues(
|
| 989 |
question,
|
|
@@ -993,7 +1289,7 @@ async def monitor_answer_card(
|
|
| 993 |
)
|
| 994 |
card_state = {
|
| 995 |
"question": question,
|
| 996 |
-
"
|
| 997 |
"framework": result["type"],
|
| 998 |
"pattern": result.get("pattern", result["type"]),
|
| 999 |
"steps": cues,
|
|
@@ -1001,17 +1297,111 @@ async def monitor_answer_card(
|
|
| 1001 |
"confidence": result["confidence"],
|
| 1002 |
"needs_review": result["confidence"] < 0.6,
|
| 1003 |
}
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1008 |
monitor_state["last_question"] = question
|
| 1009 |
-
monitor_state["
|
| 1010 |
monitor_state["card_html"] = card_html
|
| 1011 |
monitor_state["card_state"] = card_state
|
| 1012 |
return card_html, card_state
|
| 1013 |
|
| 1014 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1015 |
async def update_live_card_from_transcript(
|
| 1016 |
transcript: str,
|
| 1017 |
monitor_state: dict[str, Any] | None,
|
|
@@ -1020,11 +1410,24 @@ async def update_live_card_from_transcript(
|
|
| 1020 |
|
| 1021 |
|
| 1022 |
async def call_coaching_from_transcript(
|
|
|
|
| 1023 |
transcript: str,
|
| 1024 |
monitor_state: dict[str, Any] | None,
|
| 1025 |
-
) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
| 1026 |
-
|
| 1027 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1028 |
|
| 1029 |
|
| 1030 |
async def refresh_live_card_from_transcript(
|
|
@@ -1033,6 +1436,8 @@ async def refresh_live_card_from_transcript(
|
|
| 1033 |
force: bool,
|
| 1034 |
) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
| 1035 |
monitor_state = monitor_state or fresh_card_monitor_state()
|
|
|
|
|
|
|
| 1036 |
if not transcript.strip():
|
| 1037 |
monitor_state = fresh_card_monitor_state()
|
| 1038 |
return render_answer_card(), {}, monitor_state
|
|
@@ -1090,199 +1495,620 @@ async def generate_coaching_cues_with_llm(
|
|
| 1090 |
def fresh_card_monitor_state() -> dict[str, Any]:
|
| 1091 |
return {
|
| 1092 |
"last_question": "",
|
| 1093 |
-
"
|
| 1094 |
"card_html": render_answer_card(),
|
| 1095 |
"card_state": {},
|
| 1096 |
"cards": [],
|
| 1097 |
-
"last_non_target_question": "",
|
| 1098 |
-
"last_non_target_question_key": "",
|
| 1099 |
-
"generic_last_llm_until": 0,
|
| 1100 |
-
"generic_last_extracted_question": "",
|
| 1101 |
-
"fast_last_checked_until": 0,
|
| 1102 |
}
|
| 1103 |
|
| 1104 |
|
| 1105 |
-
def
|
| 1106 |
transcript: str,
|
| 1107 |
state: dict[str, Any],
|
| 1108 |
-
|
| 1109 |
) -> str | None:
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1113 |
return None
|
| 1114 |
|
| 1115 |
-
|
| 1116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1117 |
return None
|
| 1118 |
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1123 |
return None
|
| 1124 |
-
if not
|
| 1125 |
-
state["last_extraction_message"] = "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1126 |
return None
|
| 1127 |
|
| 1128 |
state["last_extraction_message"] = ""
|
| 1129 |
-
return
|
| 1130 |
|
| 1131 |
|
| 1132 |
-
def
|
| 1133 |
-
|
| 1134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1135 |
|
| 1136 |
-
marker_positions = [lowered.rfind(marker) for marker in LIVE_QUESTION_MARKERS]
|
| 1137 |
-
marker_positions = [position for position in marker_positions if position >= 0]
|
| 1138 |
-
if marker_positions:
|
| 1139 |
-
candidate = candidate[max(marker_positions) :].strip()
|
| 1140 |
|
| 1141 |
-
|
| 1142 |
-
|
| 1143 |
-
|
| 1144 |
-
|
| 1145 |
-
candidate = candidate[: min(answer_positions)].strip()
|
| 1146 |
|
| 1147 |
-
|
| 1148 |
-
|
| 1149 |
-
|
| 1150 |
-
|
| 1151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1152 |
)
|
| 1153 |
-
|
| 1154 |
|
| 1155 |
-
question_mark = candidate.rfind("?")
|
| 1156 |
-
if question_mark >= 0:
|
| 1157 |
-
candidate = candidate[: question_mark + 1]
|
| 1158 |
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1164 |
|
| 1165 |
|
| 1166 |
-
def
|
| 1167 |
-
words = text.split()
|
| 1168 |
cleaned: list[str] = []
|
| 1169 |
for word in words:
|
| 1170 |
-
|
| 1171 |
-
if cleaned and normalized == cleaned[-1].lower().strip(".,?!:;"):
|
| 1172 |
continue
|
| 1173 |
cleaned.append(word)
|
| 1174 |
-
return
|
| 1175 |
|
| 1176 |
|
| 1177 |
-
def
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
-
if len(clean.split()) >= 22 and any(re.search(pattern, lowered) for pattern in LIVE_COMPLETION_PATTERNS):
|
| 1181 |
return True
|
| 1182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1183 |
return True
|
| 1184 |
-
return False
|
| 1185 |
|
| 1186 |
-
|
| 1187 |
-
|
| 1188 |
-
|
| 1189 |
-
|
| 1190 |
-
"
|
| 1191 |
-
"
|
| 1192 |
-
"
|
| 1193 |
-
"
|
| 1194 |
-
"
|
| 1195 |
-
"
|
| 1196 |
-
"
|
| 1197 |
-
|
| 1198 |
-
|
| 1199 |
-
"
|
| 1200 |
-
"
|
| 1201 |
-
"
|
| 1202 |
-
"
|
| 1203 |
-
"
|
| 1204 |
-
"
|
| 1205 |
-
"
|
| 1206 |
-
"is",
|
| 1207 |
-
"
|
| 1208 |
-
"
|
| 1209 |
-
"
|
| 1210 |
-
"
|
| 1211 |
-
"
|
| 1212 |
-
"
|
| 1213 |
-
"
|
| 1214 |
-
"
|
| 1215 |
-
"
|
| 1216 |
-
"
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
"your",
|
| 1220 |
-
}
|
| 1221 |
-
useful = [word for word in words if word not in stop_words and len(word) > 1]
|
| 1222 |
-
return " ".join(useful[:40])
|
| 1223 |
|
| 1224 |
|
| 1225 |
-
def
|
| 1226 |
-
|
|
|
|
|
|
|
| 1227 |
return False
|
| 1228 |
-
if questions_are_similar(question_key, str(state.get("last_question_key", ""))):
|
| 1229 |
-
return True
|
| 1230 |
-
return any(
|
| 1231 |
-
questions_are_similar(question_key, str(card.get("question_key", "")))
|
| 1232 |
-
for card in state.get("cards", [])
|
| 1233 |
-
if isinstance(card, dict)
|
| 1234 |
-
)
|
| 1235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1236 |
|
| 1237 |
-
|
| 1238 |
-
left = set(left_key.split())
|
| 1239 |
-
right = set(right_key.split())
|
| 1240 |
-
if not left or not right:
|
| 1241 |
return False
|
| 1242 |
-
overlap = len(left & right)
|
| 1243 |
-
containment = overlap / min(len(left), len(right))
|
| 1244 |
-
union = len(left | right)
|
| 1245 |
-
jaccard = overlap / union if union else 0.0
|
| 1246 |
-
return containment >= LIVE_FAST_DUPLICATE_SIMILARITY or jaccard >= LIVE_FAST_DUPLICATE_SIMILARITY
|
| 1247 |
|
|
|
|
|
|
|
| 1248 |
|
| 1249 |
-
async def extract_target_question_from_transcript(
|
| 1250 |
-
transcript: str,
|
| 1251 |
-
state: dict[str, Any],
|
| 1252 |
-
force: bool = False,
|
| 1253 |
-
) -> str | None:
|
| 1254 |
-
clean = re.sub(r"\s+", " ", transcript).strip()
|
| 1255 |
-
if len(clean.split()) < 4:
|
| 1256 |
-
return None
|
| 1257 |
|
| 1258 |
-
|
| 1259 |
-
|
| 1260 |
-
|
| 1261 |
-
|
| 1262 |
-
|
| 1263 |
-
|
| 1264 |
-
|
| 1265 |
|
| 1266 |
-
window = clean[-GENERIC_EXTRACTION_MAX_WINDOW_CHARS:]
|
| 1267 |
-
state["generic_last_llm_until"] = len(clean)
|
| 1268 |
-
result = await normalize_interview_exchange_with_llm(window, prefer_latest=True)
|
| 1269 |
-
if not result:
|
| 1270 |
-
details = f" Details: {general_llm.last_error}" if general_llm.last_error else ""
|
| 1271 |
-
state["last_extraction_message"] = (
|
| 1272 |
-
f"General LLM unavailable. Expected Hugging Face model {GENERAL_LLM_MODEL}.{details}"
|
| 1273 |
-
)
|
| 1274 |
-
return None
|
| 1275 |
-
if not result.get("is_target") or not result.get("complete"):
|
| 1276 |
-
state["last_extraction_message"] = "Listening for a complete DS/ML/AI/System Design question."
|
| 1277 |
-
return None
|
| 1278 |
|
| 1279 |
-
|
| 1280 |
-
|
| 1281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1282 |
|
| 1283 |
-
|
| 1284 |
-
|
| 1285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1286 |
|
| 1287 |
|
| 1288 |
def extract_json_object(text: str) -> str:
|
|
@@ -1330,6 +2156,7 @@ def fresh_stream_state() -> dict[str, Any]:
|
|
| 1330 |
"rejected": 0,
|
| 1331 |
"last_rms": 0.0,
|
| 1332 |
"last_window_seconds": 0.0,
|
|
|
|
| 1333 |
"last_text": "",
|
| 1334 |
}
|
| 1335 |
|
|
@@ -1674,6 +2501,13 @@ with gr.Blocks(elem_id="app-shell") as demo:
|
|
| 1674 |
role = gr.Textbox(label="Role", placeholder="ML Engineer", scale=2)
|
| 1675 |
start = gr.Button("Create Session", variant="primary", scale=1)
|
| 1676 |
status = gr.Textbox(label="Session Status", interactive=False, elem_id="status_box")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1677 |
|
| 1678 |
with gr.Tabs():
|
| 1679 |
with gr.Tab("Live"):
|
|
@@ -1740,6 +2574,11 @@ with gr.Blocks(elem_id="app-shell") as demo:
|
|
| 1740 |
report = gr.Textbox(label="Evaluation Report", lines=16, interactive=False, elem_id="report_box")
|
| 1741 |
|
| 1742 |
start.click(start_session, inputs=[company, role], outputs=[session_id, status])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1743 |
transcribe_coach.click(
|
| 1744 |
transcribe_and_coach,
|
| 1745 |
inputs=[session_id, mic, live_transcript],
|
|
@@ -1748,8 +2587,8 @@ with gr.Blocks(elem_id="app-shell") as demo:
|
|
| 1748 |
)
|
| 1749 |
mic.stream(
|
| 1750 |
stream_live_transcript,
|
| 1751 |
-
inputs=[mic, live_transcript, stream_state],
|
| 1752 |
-
outputs=[live_transcript, answer_card, stream_status, stream_state, last_state],
|
| 1753 |
queue=True,
|
| 1754 |
)
|
| 1755 |
mic.stop_recording(
|
|
@@ -1761,7 +2600,8 @@ with gr.Blocks(elem_id="app-shell") as demo:
|
|
| 1761 |
if not HF_SPACE_MODE:
|
| 1762 |
start_live.click(
|
| 1763 |
start_backend_live_transcript,
|
| 1764 |
-
|
|
|
|
| 1765 |
queue=True,
|
| 1766 |
)
|
| 1767 |
stop_live.click(
|
|
@@ -1772,13 +2612,13 @@ with gr.Blocks(elem_id="app-shell") as demo:
|
|
| 1772 |
coach.click(
|
| 1773 |
process_typed_transcript,
|
| 1774 |
inputs=[session_id, live_transcript],
|
| 1775 |
-
outputs=[live_transcript, answer_card, log, last_state],
|
| 1776 |
queue=True,
|
| 1777 |
)
|
| 1778 |
call_coaching.click(
|
| 1779 |
call_coaching_from_transcript,
|
| 1780 |
-
inputs=[live_transcript, card_monitor_state],
|
| 1781 |
-
outputs=[answer_card, last_state, card_monitor_state],
|
| 1782 |
queue=True,
|
| 1783 |
)
|
| 1784 |
live_transcript.change(
|
|
|
|
| 1 |
import asyncio
|
| 2 |
import csv
|
| 3 |
+
import hashlib
|
| 4 |
import html
|
| 5 |
import json
|
| 6 |
import os
|
|
|
|
| 16 |
from agents.evaluator import EvaluationAgent
|
| 17 |
from agents.hf_chat import HuggingFaceChatModel
|
| 18 |
from agents.topic_pattern import TopicPatternAgent
|
|
|
|
| 19 |
from config import (
|
| 20 |
APP_HOST,
|
| 21 |
APP_PORT,
|
|
|
|
| 25 |
STREAMING_WHISPER_MODEL,
|
| 26 |
TOPIC_PATTERN_MODEL,
|
| 27 |
)
|
|
|
|
|
|
|
|
|
|
| 28 |
from db.queries import (
|
| 29 |
+
add_exchange,
|
| 30 |
add_evaluation,
|
| 31 |
+
add_transcript,
|
| 32 |
append_exchange_answer,
|
| 33 |
clear_all_tables,
|
| 34 |
create_session,
|
| 35 |
list_all_evaluations,
|
| 36 |
list_evaluations,
|
| 37 |
list_exchanges,
|
| 38 |
+
update_exchange_answer,
|
| 39 |
)
|
| 40 |
from db.schema import init_db
|
| 41 |
from graph import coach_graph
|
| 42 |
+
from nodes.audio import LiveAudioTranscriber, transcribe_audio_array, transcribe_audio_file, warmup_transcriber
|
| 43 |
from prompts import (
|
| 44 |
CLARIFICATION_CHECK_SYSTEM_PROMPT,
|
| 45 |
CLARIFICATION_CHECK_USER_PROMPT,
|
|
|
|
| 47 |
COACHING_GUIDANCE_USER_PROMPT,
|
| 48 |
MULTI_EXCHANGE_EXTRACTOR_SYSTEM_PROMPT,
|
| 49 |
MULTI_EXCHANGE_EXTRACTOR_USER_PROMPT,
|
| 50 |
+
QUESTION_LIST_DETECTOR_SYSTEM_PROMPT,
|
| 51 |
+
QUESTION_LIST_DETECTOR_USER_PROMPT,
|
| 52 |
+
QUESTION_DETECTOR_SYSTEM_PROMPT,
|
| 53 |
+
QUESTION_DETECTOR_USER_PROMPT,
|
| 54 |
TRANSCRIPT_NORMALIZER_REPAIR_SYSTEM_PROMPT,
|
| 55 |
TRANSCRIPT_NORMALIZER_REPAIR_USER_PROMPT,
|
| 56 |
TRANSCRIPT_NORMALIZER_SYSTEM_PROMPT,
|
|
|
|
| 166 |
font-weight: 650 !important;
|
| 167 |
}
|
| 168 |
#status_box textarea,
|
| 169 |
+
#model_status_box textarea,
|
| 170 |
#stream_status_box textarea {
|
| 171 |
color: var(--accent-strong) !important;
|
| 172 |
font-size: 13px !important;
|
|
|
|
| 195 |
}
|
| 196 |
#live_transcript_box textarea {
|
| 197 |
min-height: 330px !important;
|
| 198 |
+
max-height: 520px !important;
|
| 199 |
+
overflow-y: auto !important;
|
| 200 |
+
resize: vertical !important;
|
| 201 |
}
|
| 202 |
#log_box textarea,
|
| 203 |
#report_box textarea {
|
|
|
|
| 206 |
#status_box textarea {
|
| 207 |
min-height: 34px !important;
|
| 208 |
}
|
| 209 |
+
#model_status_box textarea {
|
| 210 |
+
min-height: 34px !important;
|
| 211 |
+
}
|
| 212 |
#stream_status_box textarea {
|
| 213 |
min-height: 72px !important;
|
| 214 |
font-size: 12px !important;
|
|
|
|
| 312 |
"Estimation": "#a855f7",
|
| 313 |
}
|
| 314 |
|
| 315 |
+
LIVE_CARD_LLM_TIMEOUT_SECONDS = 15
|
| 316 |
+
LIVE_QUESTION_CONTEXT_LINES = 10
|
| 317 |
+
LIVE_QUESTION_CONTEXT_CHARS = 2200
|
| 318 |
+
LIVE_QUESTION_PAUSE_SECONDS = 1.5
|
|
|
|
|
|
|
| 319 |
BROWSER_STREAM_STEP_SECONDS = 2.0
|
| 320 |
BROWSER_STREAM_CONTEXT_SECONDS = 12.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
|
| 322 |
evaluator = EvaluationAgent()
|
| 323 |
general_llm = HuggingFaceChatModel(GENERAL_LLM_MODEL)
|
| 324 |
topic_pattern_agent = TopicPatternAgent()
|
| 325 |
live_audio = LiveAudioTranscriber()
|
| 326 |
topic_model_warmup_task: asyncio.Task | None = None
|
| 327 |
+
model_warmup_task: asyncio.Task | None = None
|
| 328 |
+
model_status: dict[str, str] = {
|
| 329 |
+
"General LLM": "not loaded",
|
| 330 |
+
"Topic/steps model": "not loaded",
|
| 331 |
+
"Speech-to-text": "not loaded",
|
| 332 |
+
}
|
| 333 |
|
| 334 |
|
| 335 |
def ensure_topic_model_warmup() -> None:
|
|
|
|
| 341 |
)
|
| 342 |
|
| 343 |
|
| 344 |
+
def render_model_status() -> str:
|
| 345 |
+
statuses = dict(model_status)
|
| 346 |
+
if general_llm.is_loaded:
|
| 347 |
+
statuses["General LLM"] = "loaded"
|
| 348 |
+
if topic_pattern_agent.is_loaded:
|
| 349 |
+
statuses["Topic/steps model"] = "loaded"
|
| 350 |
+
elif not topic_pattern_agent.enabled:
|
| 351 |
+
statuses["Topic/steps model"] = "disabled"
|
| 352 |
+
return "\n".join(f"{name}: {status}" for name, status in statuses.items())
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def all_models_ready() -> bool:
|
| 356 |
+
return (
|
| 357 |
+
general_llm.is_loaded
|
| 358 |
+
and (topic_pattern_agent.is_loaded or not topic_pattern_agent.enabled)
|
| 359 |
+
and model_status.get("Speech-to-text") == "loaded"
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def render_startup_status() -> str:
|
| 364 |
+
if all_models_ready():
|
| 365 |
+
return "All models loaded."
|
| 366 |
+
return "Loading models..."
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def live_detector_timeout_message(detector_name: str = "Question detector") -> str:
|
| 370 |
+
if all_models_ready():
|
| 371 |
+
return f"{detector_name} is taking longer than expected. Keeping the transcript live; try again in a moment."
|
| 372 |
+
return f"{detector_name} is still loading. Try again after startup status shows All models loaded."
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
async def warmup_all_models():
|
| 376 |
+
global model_warmup_task
|
| 377 |
+
if model_warmup_task and not model_warmup_task.done():
|
| 378 |
+
yield render_startup_status()
|
| 379 |
+
return
|
| 380 |
+
|
| 381 |
+
model_warmup_task = asyncio.current_task()
|
| 382 |
+
model_status["General LLM"] = "loading"
|
| 383 |
+
model_status["Topic/steps model"] = "loading" if topic_pattern_agent.enabled else "disabled"
|
| 384 |
+
model_status["Speech-to-text"] = "loading"
|
| 385 |
+
yield render_startup_status()
|
| 386 |
+
|
| 387 |
+
try:
|
| 388 |
+
await general_llm.warmup()
|
| 389 |
+
model_status["General LLM"] = "loaded"
|
| 390 |
+
except Exception as exc:
|
| 391 |
+
model_status["General LLM"] = f"error: {exc}"
|
| 392 |
+
yield render_startup_status()
|
| 393 |
+
|
| 394 |
+
if topic_pattern_agent.enabled:
|
| 395 |
+
try:
|
| 396 |
+
await topic_pattern_agent.warmup()
|
| 397 |
+
model_status["Topic/steps model"] = "loaded"
|
| 398 |
+
except Exception as exc:
|
| 399 |
+
model_status["Topic/steps model"] = f"error: {exc}"
|
| 400 |
+
yield render_startup_status()
|
| 401 |
+
|
| 402 |
+
try:
|
| 403 |
+
await warmup_transcriber(
|
| 404 |
+
model=STREAMING_WHISPER_MODEL,
|
| 405 |
+
backend="transformers" if HF_SPACE_MODE else None,
|
| 406 |
+
)
|
| 407 |
+
model_status["Speech-to-text"] = "loaded"
|
| 408 |
+
except Exception as exc:
|
| 409 |
+
model_status["Speech-to-text"] = f"error: {exc}"
|
| 410 |
+
yield render_startup_status() if all_models_ready() else render_model_status()
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def ensure_model_warmup_background() -> None:
|
| 414 |
+
global model_warmup_task
|
| 415 |
+
if model_warmup_task and not model_warmup_task.done():
|
| 416 |
+
return
|
| 417 |
+
if all_models_ready():
|
| 418 |
+
return
|
| 419 |
+
|
| 420 |
+
async def consume_warmup() -> None:
|
| 421 |
+
async for _ in warmup_all_models():
|
| 422 |
+
pass
|
| 423 |
+
|
| 424 |
+
model_warmup_task = asyncio.create_task(consume_warmup())
|
| 425 |
+
|
| 426 |
+
|
| 427 |
async def start_session(company: str, role: str) -> tuple[int, str]:
|
| 428 |
+
ensure_model_warmup_background()
|
| 429 |
await init_db()
|
| 430 |
session_id = await create_session(company=company, role=role)
|
| 431 |
return session_id, f"Session {session_id} started"
|
|
|
|
| 486 |
"model_unavailable": True,
|
| 487 |
"message": (
|
| 488 |
"Topic/steps model unavailable. Expected Hugging Face model "
|
|
|
|
| 489 |
f"{TOPIC_PATTERN_MODEL}.{suffix}"
|
|
|
|
|
|
|
|
|
|
| 490 |
),
|
| 491 |
}
|
| 492 |
|
|
|
|
| 620 |
normalized = normalize_normalizer_payload(item)
|
| 621 |
if not normalized["is_target"] or not normalized["complete"] or not normalized["question"]:
|
| 622 |
continue
|
| 623 |
+
if not is_target_coaching_question(normalized["question"], item):
|
| 624 |
+
continue
|
| 625 |
cleaned.append(normalized)
|
| 626 |
return cleaned
|
| 627 |
|
| 628 |
|
| 629 |
+
def dedupe_extracted_exchanges(exchanges: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 630 |
+
deduped: list[dict[str, Any]] = []
|
| 631 |
+
for exchange in exchanges:
|
| 632 |
+
question = normalize_llm_text(str(exchange.get("question", "")))
|
| 633 |
+
answer = normalize_llm_text(str(exchange.get("answer", "")))
|
| 634 |
+
if not question or question_looks_incomplete(question):
|
| 635 |
+
continue
|
| 636 |
+
|
| 637 |
+
current = {**exchange, "question": question, "answer": answer}
|
| 638 |
+
current_key = canonical_question_key(question)
|
| 639 |
+
duplicate_index = -1
|
| 640 |
+
for index, existing in enumerate(deduped):
|
| 641 |
+
existing_key = canonical_question_key(str(existing.get("question", "")))
|
| 642 |
+
if question_keys_are_similar(current_key, existing_key):
|
| 643 |
+
duplicate_index = index
|
| 644 |
+
break
|
| 645 |
+
|
| 646 |
+
if duplicate_index == -1:
|
| 647 |
+
deduped.append(current)
|
| 648 |
+
continue
|
| 649 |
+
|
| 650 |
+
existing = deduped[duplicate_index]
|
| 651 |
+
if len(question.split()) > len(str(existing.get("question", "")).split()):
|
| 652 |
+
existing["question"] = question
|
| 653 |
+
if len(answer) > len(str(existing.get("answer", ""))):
|
| 654 |
+
existing["answer"] = answer
|
| 655 |
+
return deduped
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
def repair_missing_answers_from_transcript(
|
| 659 |
+
exchanges: list[dict[str, Any]],
|
| 660 |
+
transcript: str,
|
| 661 |
+
replace_existing: bool = False,
|
| 662 |
+
) -> list[dict[str, Any]]:
|
| 663 |
+
if not exchanges or not transcript.strip():
|
| 664 |
+
return exchanges
|
| 665 |
+
|
| 666 |
+
repaired: list[dict[str, Any]] = []
|
| 667 |
+
cursor = 0
|
| 668 |
+
spans: list[tuple[int, int]] = []
|
| 669 |
+
for exchange in exchanges:
|
| 670 |
+
span = ordered_question_match_span(transcript, str(exchange.get("question", "")), cursor)
|
| 671 |
+
spans.append(span)
|
| 672 |
+
if span[1] > 0:
|
| 673 |
+
cursor = span[1]
|
| 674 |
+
|
| 675 |
+
for index, exchange in enumerate(exchanges):
|
| 676 |
+
item = dict(exchange)
|
| 677 |
+
if str(item.get("answer", "")).strip() and not replace_existing:
|
| 678 |
+
repaired.append(item)
|
| 679 |
+
continue
|
| 680 |
+
|
| 681 |
+
_, question_end = spans[index]
|
| 682 |
+
if question_end < 0:
|
| 683 |
+
repaired.append(item)
|
| 684 |
+
continue
|
| 685 |
+
|
| 686 |
+
next_question_start = len(transcript)
|
| 687 |
+
for next_start, _ in spans[index + 1 :]:
|
| 688 |
+
if next_start > question_end:
|
| 689 |
+
next_question_start = next_start
|
| 690 |
+
break
|
| 691 |
+
|
| 692 |
+
answer = clean_extracted_answer_text(transcript[question_end:next_question_start])
|
| 693 |
+
if answer:
|
| 694 |
+
item["answer"] = answer
|
| 695 |
+
item["reason"] = "Recovered answer from transcript."
|
| 696 |
+
repaired.append(item)
|
| 697 |
+
return repaired
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
def clean_extracted_answer_text(text: str) -> str:
|
| 701 |
+
clean = normalize_llm_text(text.strip(" ?.:-,;"))
|
| 702 |
+
if not clean:
|
| 703 |
+
return ""
|
| 704 |
+
clean = re.sub(r"^(good|great|okay|ok)[,.\s]+(?=(yes|i|we|my|the|in|for)\b)", "", clean, flags=re.I)
|
| 705 |
+
clean = re.sub(
|
| 706 |
+
r"\b(good|great|okay|ok|thank you|thanks)(?:[.!?,\s]+(?:let'?s move to the next|next question)?)?$",
|
| 707 |
+
"",
|
| 708 |
+
clean,
|
| 709 |
+
flags=re.I,
|
| 710 |
+
)
|
| 711 |
+
clean = re.sub(
|
| 712 |
+
r"\b(that'?s great|that is great|good|great|okay|ok)[.!?,\s]+(?:let'?s go to the next question|let'?s move to the next question|next question)[.!?,\s]*$",
|
| 713 |
+
"",
|
| 714 |
+
clean,
|
| 715 |
+
flags=re.I,
|
| 716 |
+
)
|
| 717 |
+
return normalize_llm_text(clean)
|
| 718 |
+
|
| 719 |
+
|
| 720 |
def normalize_llm_text(text: str) -> str:
|
| 721 |
return re.sub(r"\s+", " ", text).strip(" -:")
|
| 722 |
|
|
|
|
| 766 |
|
| 767 |
|
| 768 |
async def is_clarification_question(previous_question: str, new_question: str, answer: str) -> bool:
|
| 769 |
+
if looks_like_interviewer_prompt(new_question) and not question_keys_are_similar(
|
| 770 |
+
canonical_question_key(previous_question),
|
| 771 |
+
canonical_question_key(new_question),
|
| 772 |
+
):
|
| 773 |
+
return False
|
| 774 |
heuristic = is_clarification_question_heuristic(previous_question, new_question)
|
| 775 |
llm_result = await is_clarification_question_with_llm(previous_question, new_question, answer)
|
| 776 |
return llm_result if llm_result is not None else heuristic
|
|
|
|
| 852 |
if audio_input is None:
|
| 853 |
return "Record audio first, then press Transcribe & Coach.", render_answer_card(), "", {}
|
| 854 |
|
| 855 |
+
transcript = await transcribe_audio_input(audio_input, use_space_stt=HF_SPACE_MODE)
|
| 856 |
if transcript.startswith("[transcription unavailable:"):
|
| 857 |
return transcript, render_answer_card(), "", {}
|
| 858 |
|
|
|
|
| 890 |
transcript = latest_stream_transcript(state, current_transcript)
|
| 891 |
return transcript, format_stream_status(state, "waiting for browser recording"), state
|
| 892 |
|
| 893 |
+
transcript = await transcribe_audio_input(audio_input, use_space_stt=HF_SPACE_MODE)
|
| 894 |
if transcript.startswith("[transcription unavailable:"):
|
| 895 |
state["last_text"] = transcript
|
| 896 |
return latest_stream_transcript(state, current_transcript), format_stream_status(state, transcript), state
|
| 897 |
|
| 898 |
+
updated_transcript = (
|
| 899 |
+
transcript
|
| 900 |
+
if HF_SPACE_MODE
|
| 901 |
+
else merge_transcript_text(latest_stream_transcript(state, current_transcript), transcript)
|
| 902 |
+
)
|
| 903 |
state["transcript"] = updated_transcript
|
| 904 |
state["last_text"] = transcript
|
| 905 |
state["transcriptions"] = int(state.get("transcriptions") or 0) + 1
|
|
|
|
| 907 |
|
| 908 |
|
| 909 |
async def stream_live_transcript(
|
| 910 |
+
session_id: int | None,
|
| 911 |
audio_input: Any,
|
| 912 |
current_transcript: str,
|
| 913 |
stream_state: dict[str, Any] | None,
|
| 914 |
+
) -> tuple[str, str, str, dict[str, Any], dict[str, Any], str]:
|
| 915 |
if audio_input is None:
|
| 916 |
state = stream_state or fresh_stream_state()
|
| 917 |
return (
|
|
|
|
| 920 |
format_stream_status(state, "waiting for microphone"),
|
| 921 |
state,
|
| 922 |
state.get("card_state") or {},
|
| 923 |
+
await render_live_log(session_id),
|
| 924 |
)
|
| 925 |
|
| 926 |
state = update_stream_state(audio_input, stream_state)
|
|
|
|
| 929 |
processed_until = int(state.get("processed_until") or 0)
|
| 930 |
available = 0 if audio_buffer is None else len(audio_buffer) - processed_until
|
| 931 |
transcript_so_far = latest_stream_transcript(state, current_transcript)
|
| 932 |
+
if HF_SPACE_MODE:
|
| 933 |
+
if audio_buffer is not None:
|
| 934 |
+
state["processed_until"] = len(audio_buffer)
|
| 935 |
+
state["last_window_seconds"] = len(audio_buffer) / sample_rate
|
| 936 |
+
state["last_rms"] = audio_rms(audio_buffer)
|
| 937 |
+
return (
|
| 938 |
+
transcript_so_far,
|
| 939 |
+
state.get("card_html") or render_answer_card({"message": "Recording. Transcript will appear after you stop."}),
|
| 940 |
+
format_stream_status(state, "recording browser audio"),
|
| 941 |
+
state,
|
| 942 |
+
state.get("card_state") or {},
|
| 943 |
+
await render_live_log(session_id),
|
| 944 |
+
)
|
| 945 |
if available < int(sample_rate * BROWSER_STREAM_STEP_SECONDS):
|
| 946 |
return (
|
| 947 |
transcript_so_far,
|
|
|
|
| 949 |
format_stream_status(state, "buffering audio"),
|
| 950 |
state,
|
| 951 |
state.get("card_state") or {},
|
| 952 |
+
await render_live_log(session_id),
|
| 953 |
)
|
| 954 |
|
| 955 |
context_samples = int(sample_rate * BROWSER_STREAM_CONTEXT_SECONDS)
|
|
|
|
| 962 |
rms = audio_rms(new_audio)
|
| 963 |
state["last_rms"] = rms
|
| 964 |
if rms < 0.003:
|
| 965 |
+
quiet_seconds = float(state.get("quiet_seconds") or 0.0) + (len(new_audio) / sample_rate)
|
| 966 |
+
state["quiet_seconds"] = quiet_seconds
|
| 967 |
+
if transcript_so_far:
|
| 968 |
+
if quiet_seconds >= LIVE_QUESTION_PAUSE_SECONDS:
|
| 969 |
+
card_html, card_state = await monitor_answer_card(
|
| 970 |
+
transcript_so_far,
|
| 971 |
+
state,
|
| 972 |
+
session_id=session_id,
|
| 973 |
+
force=False,
|
| 974 |
+
fast=True,
|
| 975 |
+
pause_check=True,
|
| 976 |
+
pause_seconds=quiet_seconds,
|
| 977 |
+
)
|
| 978 |
+
state["card_html"] = card_html
|
| 979 |
+
state["card_state"] = card_state
|
| 980 |
+
else:
|
| 981 |
+
card_html = current_card_or_status(
|
| 982 |
+
state,
|
| 983 |
+
f"Pause {quiet_seconds:.1f}s. Waiting for {LIVE_QUESTION_PAUSE_SECONDS:.0f}s before checking question.",
|
| 984 |
+
)
|
| 985 |
+
state["card_html"] = card_html
|
| 986 |
+
card_state = state.get("card_state") or {}
|
| 987 |
+
else:
|
| 988 |
+
card_html = state.get("card_html") or render_answer_card()
|
| 989 |
+
card_state = state.get("card_state") or {}
|
| 990 |
return (
|
| 991 |
transcript_so_far,
|
| 992 |
+
card_html,
|
| 993 |
format_stream_status(state, "quiet audio skipped"),
|
| 994 |
state,
|
| 995 |
+
card_state,
|
| 996 |
+
await render_live_log(session_id),
|
| 997 |
)
|
| 998 |
+
state["quiet_seconds"] = 0.0
|
| 999 |
|
| 1000 |
chunk_text = await transcribe_audio_array(
|
| 1001 |
sample_rate,
|
| 1002 |
audio_window,
|
| 1003 |
model=STREAMING_WHISPER_MODEL,
|
| 1004 |
+
backend="transformers" if HF_SPACE_MODE else None,
|
| 1005 |
temperature=0.0,
|
| 1006 |
condition_on_previous_text=False,
|
| 1007 |
compression_ratio_threshold=1.8,
|
|
|
|
| 1015 |
format_stream_status(state, chunk_text or "no speech detected yet"),
|
| 1016 |
state,
|
| 1017 |
state.get("card_state") or {},
|
| 1018 |
+
await render_live_log(session_id),
|
| 1019 |
)
|
| 1020 |
if is_repetitive_hallucination(chunk_text):
|
| 1021 |
state["rejected"] = int(state.get("rejected") or 0) + 1
|
|
|
|
| 1026 |
format_stream_status(state, "repeated-word output skipped"),
|
| 1027 |
state,
|
| 1028 |
state.get("card_state") or {},
|
| 1029 |
+
await render_live_log(session_id),
|
| 1030 |
)
|
| 1031 |
|
| 1032 |
state["transcriptions"] = int(state.get("transcriptions") or 0) + 1
|
| 1033 |
state["last_text"] = chunk_text
|
| 1034 |
updated_transcript = merge_transcript_text(transcript_so_far, chunk_text)
|
| 1035 |
state["transcript"] = updated_transcript
|
|
|
|
|
|
|
|
|
|
| 1036 |
return (
|
| 1037 |
updated_transcript,
|
| 1038 |
+
state.get("card_html") or render_answer_card({"message": "Listening for a pause before creating a card."}),
|
| 1039 |
format_stream_status(state, "transcribing"),
|
| 1040 |
state,
|
| 1041 |
+
state.get("card_state") or {},
|
| 1042 |
+
await render_live_log(session_id),
|
| 1043 |
)
|
| 1044 |
|
| 1045 |
|
| 1046 |
+
async def start_backend_live_transcript(session_id: int | None):
|
| 1047 |
+
ensure_model_warmup_background()
|
| 1048 |
await live_audio.start()
|
| 1049 |
monitor_state = fresh_card_monitor_state()
|
| 1050 |
+
card_html = monitor_state["card_html"]
|
| 1051 |
+
card_state: dict[str, Any] = {}
|
| 1052 |
+
card_task: asyncio.Task | None = None
|
| 1053 |
+
yield (
|
| 1054 |
+
"",
|
| 1055 |
+
card_html,
|
| 1056 |
+
"Capturing system audio via BlackHole/default input",
|
| 1057 |
+
card_state,
|
| 1058 |
+
monitor_state,
|
| 1059 |
+
await render_live_log(session_id),
|
| 1060 |
+
)
|
| 1061 |
+
try:
|
| 1062 |
+
async for transcript, pause_detected, pause_seconds in live_audio.transcript_stream():
|
| 1063 |
+
if card_task and card_task.done():
|
| 1064 |
+
try:
|
| 1065 |
+
card_html, card_state = card_task.result()
|
| 1066 |
+
except Exception as exc:
|
| 1067 |
+
monitor_state["last_extraction_message"] = f"Coaching card failed: {exc}"
|
| 1068 |
+
card_html = current_card_or_status(monitor_state)
|
| 1069 |
+
card_state = monitor_state.get("card_state") or {}
|
| 1070 |
+
card_task = None
|
| 1071 |
+
|
| 1072 |
+
if pause_detected and (card_task is None or card_task.done()):
|
| 1073 |
+
card_task = asyncio.create_task(
|
| 1074 |
+
monitor_answer_card(
|
| 1075 |
+
transcript,
|
| 1076 |
+
monitor_state,
|
| 1077 |
+
session_id=session_id,
|
| 1078 |
+
force=False,
|
| 1079 |
+
fast=True,
|
| 1080 |
+
pause_check=True,
|
| 1081 |
+
pause_seconds=pause_seconds,
|
| 1082 |
+
)
|
| 1083 |
+
)
|
| 1084 |
+
|
| 1085 |
+
status_message = (
|
| 1086 |
+
"Pause detected; coaching card loading in background"
|
| 1087 |
+
if card_task and not card_task.done()
|
| 1088 |
+
else "Capturing system audio via BlackHole/default input"
|
| 1089 |
+
)
|
| 1090 |
+
yield (
|
| 1091 |
+
transcript,
|
| 1092 |
+
card_html,
|
| 1093 |
+
status_message,
|
| 1094 |
+
card_state,
|
| 1095 |
+
monitor_state,
|
| 1096 |
+
await render_live_log(session_id),
|
| 1097 |
+
)
|
| 1098 |
+
finally:
|
| 1099 |
+
if card_task and not card_task.done():
|
| 1100 |
+
card_task.cancel()
|
| 1101 |
+
await asyncio.gather(card_task, return_exceptions=True)
|
| 1102 |
|
| 1103 |
|
| 1104 |
async def stop_backend_live_transcript() -> str:
|
|
|
|
| 1119 |
async def process_typed_transcript(
|
| 1120 |
session_id: int | None,
|
| 1121 |
transcript: str,
|
| 1122 |
+
) -> tuple[str, str, str, dict[str, Any], dict[str, Any]]:
|
| 1123 |
+
exchanges = dedupe_extracted_exchanges(await extract_all_interview_exchanges_with_llm(transcript))
|
| 1124 |
if exchanges:
|
| 1125 |
cards = []
|
| 1126 |
+
card_states = []
|
| 1127 |
last_state: dict[str, Any] = {}
|
| 1128 |
log = ""
|
| 1129 |
for exchange in exchanges:
|
| 1130 |
+
existing_exchange = (
|
| 1131 |
+
await find_existing_exchange(session_id, exchange["question"])
|
| 1132 |
+
if session_id
|
| 1133 |
+
else None
|
| 1134 |
+
)
|
| 1135 |
+
if existing_exchange:
|
| 1136 |
+
answer = exchange.get("answer", "").strip()
|
| 1137 |
+
existing_answer = str(existing_exchange.get("answer", "")).strip()
|
| 1138 |
+
if answer and len(answer) > len(existing_answer):
|
| 1139 |
+
await update_exchange_answer(int(existing_exchange["id"]), answer)
|
| 1140 |
+
log = await render_log(session_id)
|
| 1141 |
+
display_answer = answer if answer and len(answer) > len(existing_answer) else existing_answer
|
| 1142 |
+
last_state = {
|
| 1143 |
+
"session_id": session_id,
|
| 1144 |
+
"exchange_id": existing_exchange["id"],
|
| 1145 |
+
"question": existing_exchange["question"],
|
| 1146 |
+
"answer": display_answer,
|
| 1147 |
+
"framework": existing_exchange.get("framework_used", "General"),
|
| 1148 |
+
"skipped_duplicate": True,
|
| 1149 |
+
}
|
| 1150 |
+
classification = await classify_topic_and_steps(str(existing_exchange["question"]))
|
| 1151 |
+
if not classification.get("model_unavailable"):
|
| 1152 |
+
framework_steps = classification["steps"]
|
| 1153 |
+
last_state.update(
|
| 1154 |
+
{
|
| 1155 |
+
"framework": classification["type"],
|
| 1156 |
+
"pattern": classification.get("pattern", classification["type"]),
|
| 1157 |
+
"steps": await generate_coaching_cues(
|
| 1158 |
+
str(existing_exchange["question"]),
|
| 1159 |
+
classification["type"],
|
| 1160 |
+
classification.get("pattern", classification["type"]),
|
| 1161 |
+
framework_steps,
|
| 1162 |
+
),
|
| 1163 |
+
"framework_steps": framework_steps,
|
| 1164 |
+
"confidence": classification["confidence"],
|
| 1165 |
+
}
|
| 1166 |
+
)
|
| 1167 |
+
cards.append(render_card(last_state))
|
| 1168 |
+
card_states.append(last_state)
|
| 1169 |
+
continue
|
| 1170 |
+
|
| 1171 |
card, log, last_state = await coach_question(
|
| 1172 |
session_id,
|
| 1173 |
exchange["question"],
|
| 1174 |
exchange.get("answer", ""),
|
| 1175 |
)
|
| 1176 |
cards.append(card)
|
| 1177 |
+
card_states.append(last_state)
|
| 1178 |
session_id = last_state.get("session_id", session_id)
|
| 1179 |
last_state["processed_exchanges"] = exchanges
|
| 1180 |
+
card_html = render_cards(cards)
|
| 1181 |
+
monitor_state = fresh_card_monitor_state()
|
| 1182 |
+
monitor_state["cards"] = card_states[-4:]
|
| 1183 |
+
monitor_state["card_html"] = card_html
|
| 1184 |
+
monitor_state["card_state"] = last_state
|
| 1185 |
+
if card_states:
|
| 1186 |
+
monitor_state["last_question"] = str(card_states[-1].get("question", ""))
|
| 1187 |
+
monitor_state["last_question_hash"] = question_hash_key(monitor_state["last_question"])
|
| 1188 |
+
return transcript, card_html, log, last_state, monitor_state
|
| 1189 |
|
| 1190 |
normalized = await normalize_interview_exchange_with_llm(transcript)
|
| 1191 |
if (
|
|
|
|
| 1199 |
normalized.get("question", ""),
|
| 1200 |
normalized.get("answer", ""),
|
| 1201 |
)
|
| 1202 |
+
monitor_state = fresh_card_monitor_state()
|
| 1203 |
+
monitor_state["cards"] = [state]
|
| 1204 |
+
monitor_state["card_html"] = card
|
| 1205 |
+
monitor_state["card_state"] = state
|
| 1206 |
+
monitor_state["last_question"] = str(state.get("question", ""))
|
| 1207 |
+
monitor_state["last_question_hash"] = question_hash_key(monitor_state["last_question"])
|
| 1208 |
+
return transcript, card, log, state, monitor_state
|
| 1209 |
|
| 1210 |
state = {
|
| 1211 |
"message": (
|
|
|
|
| 1214 |
else general_llm_unavailable_message()
|
| 1215 |
)
|
| 1216 |
}
|
| 1217 |
+
return transcript, render_answer_card(state), state["message"], state, fresh_card_monitor_state()
|
| 1218 |
|
| 1219 |
|
| 1220 |
def clear_live_state() -> tuple[str, str, dict[str, Any], str, dict[str, Any]]:
|
|
|
|
| 1241 |
async def monitor_answer_card(
|
| 1242 |
transcript: str,
|
| 1243 |
monitor_state: dict[str, Any] | None,
|
| 1244 |
+
session_id: int | None = None,
|
| 1245 |
force: bool = False,
|
| 1246 |
fast: bool = True,
|
| 1247 |
+
pause_check: bool = False,
|
| 1248 |
+
pause_seconds: float = 0.0,
|
| 1249 |
) -> tuple[str, dict[str, Any]]:
|
| 1250 |
monitor_state = monitor_state or fresh_card_monitor_state()
|
| 1251 |
+
if fast and not (force or pause_check):
|
| 1252 |
+
monitor_state["last_extraction_message"] = "Listening for the interviewer to finish the question."
|
| 1253 |
+
return current_card_or_status(monitor_state), monitor_state.get("card_state") or {}
|
| 1254 |
+
|
| 1255 |
+
question = await detect_live_question_from_transcript(
|
| 1256 |
+
transcript,
|
| 1257 |
+
monitor_state,
|
| 1258 |
+
pause_seconds=pause_seconds,
|
| 1259 |
+
)
|
| 1260 |
if not question:
|
| 1261 |
message = monitor_state.get("last_extraction_message", "")
|
| 1262 |
+
card_html = current_card_or_status(monitor_state, message)
|
| 1263 |
+
monitor_state["card_html"] = card_html
|
| 1264 |
return card_html, monitor_state.get("card_state") or {}
|
| 1265 |
|
| 1266 |
+
question_hash = question_hash_key(question)
|
| 1267 |
+
if not has_new_live_question(question, monitor_state):
|
| 1268 |
+
monitor_state["last_extraction_message"] = "Latest detected question is already shown."
|
| 1269 |
+
existing_card_state = monitor_state.get("card_state") or {}
|
| 1270 |
+
if session_id and existing_card_state and not existing_card_state.get("exchange_id"):
|
| 1271 |
+
exchange_id = await persist_live_question(session_id, transcript, existing_card_state)
|
| 1272 |
+
existing_card_state["session_id"] = session_id
|
| 1273 |
+
existing_card_state["exchange_id"] = exchange_id
|
| 1274 |
+
monitor_state["card_state"] = existing_card_state
|
| 1275 |
+
return current_card_or_status(monitor_state), monitor_state.get("card_state") or {}
|
| 1276 |
|
| 1277 |
result = await classify_topic_and_steps(question)
|
| 1278 |
if result.get("model_unavailable"):
|
| 1279 |
monitor_state["last_extraction_message"] = result["message"]
|
| 1280 |
+
card_html = current_card_or_status(monitor_state, result["message"])
|
| 1281 |
+
monitor_state["card_html"] = card_html
|
| 1282 |
+
return card_html, {}
|
|
|
|
|
|
|
|
|
|
| 1283 |
|
| 1284 |
cues = await generate_coaching_cues(
|
| 1285 |
question,
|
|
|
|
| 1289 |
)
|
| 1290 |
card_state = {
|
| 1291 |
"question": question,
|
| 1292 |
+
"question_hash": question_hash,
|
| 1293 |
"framework": result["type"],
|
| 1294 |
"pattern": result.get("pattern", result["type"]),
|
| 1295 |
"steps": cues,
|
|
|
|
| 1297 |
"confidence": result["confidence"],
|
| 1298 |
"needs_review": result["confidence"] < 0.6,
|
| 1299 |
}
|
| 1300 |
+
if session_id:
|
| 1301 |
+
exchange_id = await persist_live_question(session_id, transcript, card_state)
|
| 1302 |
+
card_state["session_id"] = session_id
|
| 1303 |
+
card_state["exchange_id"] = exchange_id
|
| 1304 |
+
cards = update_card_history(monitor_state, card_state)
|
| 1305 |
+
card_html = render_cards(
|
| 1306 |
+
[
|
| 1307 |
+
render_card(card, flash=card.get("question_hash") == question_hash)
|
| 1308 |
+
for card in cards
|
| 1309 |
+
]
|
| 1310 |
+
)
|
| 1311 |
monitor_state["last_question"] = question
|
| 1312 |
+
monitor_state["last_question_hash"] = question_hash
|
| 1313 |
monitor_state["card_html"] = card_html
|
| 1314 |
monitor_state["card_state"] = card_state
|
| 1315 |
return card_html, card_state
|
| 1316 |
|
| 1317 |
|
| 1318 |
+
async def persist_live_question(session_id: int, transcript: str, card_state: dict[str, Any]) -> int:
|
| 1319 |
+
existing_exchange_id = await find_existing_exchange_id(session_id, card_state["question"])
|
| 1320 |
+
if existing_exchange_id:
|
| 1321 |
+
await backfill_empty_exchange_answers(session_id, transcript)
|
| 1322 |
+
return existing_exchange_id
|
| 1323 |
+
|
| 1324 |
+
await add_transcript(
|
| 1325 |
+
session_id=session_id,
|
| 1326 |
+
raw_text=transcript,
|
| 1327 |
+
labelled={"question": card_state["question"], "source": "live_detector"},
|
| 1328 |
+
)
|
| 1329 |
+
exchange_id = await add_exchange(
|
| 1330 |
+
session_id=session_id,
|
| 1331 |
+
question=card_state["question"],
|
| 1332 |
+
answer="",
|
| 1333 |
+
framework_used=card_state.get("framework", "General"),
|
| 1334 |
+
)
|
| 1335 |
+
await backfill_empty_exchange_answers(session_id, transcript)
|
| 1336 |
+
return exchange_id
|
| 1337 |
+
|
| 1338 |
+
|
| 1339 |
+
async def backfill_empty_exchange_answers(session_id: int, transcript: str) -> None:
|
| 1340 |
+
if not transcript.strip():
|
| 1341 |
+
return
|
| 1342 |
+
exchanges = await list_exchanges(session_id)
|
| 1343 |
+
if not exchanges:
|
| 1344 |
+
return
|
| 1345 |
+
|
| 1346 |
+
repair_input = [
|
| 1347 |
+
{
|
| 1348 |
+
"question": str(exchange.get("question", "")),
|
| 1349 |
+
"answer": str(exchange.get("answer", "")),
|
| 1350 |
+
"is_target": True,
|
| 1351 |
+
"complete": True,
|
| 1352 |
+
"exchange_id": exchange.get("id"),
|
| 1353 |
+
}
|
| 1354 |
+
for exchange in exchanges
|
| 1355 |
+
]
|
| 1356 |
+
repaired = repair_missing_answers_from_transcript(repair_input, transcript)
|
| 1357 |
+
for original, fixed in zip(exchanges, repaired):
|
| 1358 |
+
if str(original.get("answer", "")).strip():
|
| 1359 |
+
continue
|
| 1360 |
+
answer = str(fixed.get("answer", "")).strip()
|
| 1361 |
+
if answer:
|
| 1362 |
+
await update_exchange_answer(int(original["id"]), answer)
|
| 1363 |
+
|
| 1364 |
+
|
| 1365 |
+
async def find_existing_exchange_id(session_id: int, question: str) -> int | None:
|
| 1366 |
+
existing = await find_existing_exchange(session_id, question)
|
| 1367 |
+
return int(existing["id"]) if existing else None
|
| 1368 |
+
|
| 1369 |
+
|
| 1370 |
+
async def find_existing_exchange(session_id: int, question: str) -> dict[str, Any] | None:
|
| 1371 |
+
question_key = canonical_question_key(question)
|
| 1372 |
+
exchanges = await list_exchanges(session_id)
|
| 1373 |
+
for exchange in exchanges:
|
| 1374 |
+
existing_key = canonical_question_key(str(exchange.get("question", "")))
|
| 1375 |
+
if question_keys_are_similar(question_key, existing_key):
|
| 1376 |
+
return exchange
|
| 1377 |
+
return None
|
| 1378 |
+
|
| 1379 |
+
|
| 1380 |
+
async def render_live_log(session_id: int | None) -> str:
|
| 1381 |
+
if not session_id:
|
| 1382 |
+
return "Create a session to save live exchanges."
|
| 1383 |
+
return await render_log(session_id)
|
| 1384 |
+
|
| 1385 |
+
|
| 1386 |
+
def current_card_or_status(state: dict[str, Any], message: str = "") -> str:
|
| 1387 |
+
if state.get("card_state"):
|
| 1388 |
+
return state.get("card_html") or render_card(state["card_state"])
|
| 1389 |
+
return render_answer_card({"message": message or state.get("last_extraction_message", "")})
|
| 1390 |
+
|
| 1391 |
+
|
| 1392 |
+
def update_card_history(state: dict[str, Any], card_state: dict[str, Any], limit: int = 4) -> list[dict[str, Any]]:
|
| 1393 |
+
question_hash = str(card_state.get("question_hash", ""))
|
| 1394 |
+
cards = [
|
| 1395 |
+
card
|
| 1396 |
+
for card in state.get("cards", [])
|
| 1397 |
+
if isinstance(card, dict) and str(card.get("question_hash", "")) != question_hash
|
| 1398 |
+
]
|
| 1399 |
+
cards.insert(0, card_state)
|
| 1400 |
+
cards = cards[:limit]
|
| 1401 |
+
state["cards"] = cards
|
| 1402 |
+
return cards
|
| 1403 |
+
|
| 1404 |
+
|
| 1405 |
async def update_live_card_from_transcript(
|
| 1406 |
transcript: str,
|
| 1407 |
monitor_state: dict[str, Any] | None,
|
|
|
|
| 1410 |
|
| 1411 |
|
| 1412 |
async def call_coaching_from_transcript(
|
| 1413 |
+
session_id: int | None,
|
| 1414 |
transcript: str,
|
| 1415 |
monitor_state: dict[str, Any] | None,
|
| 1416 |
+
) -> tuple[str, dict[str, Any], dict[str, Any], str]:
|
| 1417 |
+
ensure_model_warmup_background()
|
| 1418 |
+
monitor_state = monitor_state or fresh_card_monitor_state()
|
| 1419 |
+
if not transcript.strip():
|
| 1420 |
+
monitor_state = fresh_card_monitor_state()
|
| 1421 |
+
return render_answer_card(), {}, monitor_state, await render_live_log(session_id)
|
| 1422 |
+
|
| 1423 |
+
card_html, card_state = await monitor_answer_card(
|
| 1424 |
+
transcript,
|
| 1425 |
+
monitor_state,
|
| 1426 |
+
session_id=session_id,
|
| 1427 |
+
force=True,
|
| 1428 |
+
fast=True,
|
| 1429 |
+
)
|
| 1430 |
+
return card_html, card_state, monitor_state, await render_live_log(session_id)
|
| 1431 |
|
| 1432 |
|
| 1433 |
async def refresh_live_card_from_transcript(
|
|
|
|
| 1436 |
force: bool,
|
| 1437 |
) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
| 1438 |
monitor_state = monitor_state or fresh_card_monitor_state()
|
| 1439 |
+
if not force and not monitor_state.get("cards") and not monitor_state.get("last_question"):
|
| 1440 |
+
return gr.skip(), gr.skip(), monitor_state
|
| 1441 |
if not transcript.strip():
|
| 1442 |
monitor_state = fresh_card_monitor_state()
|
| 1443 |
return render_answer_card(), {}, monitor_state
|
|
|
|
| 1495 |
def fresh_card_monitor_state() -> dict[str, Any]:
|
| 1496 |
return {
|
| 1497 |
"last_question": "",
|
| 1498 |
+
"last_question_hash": "",
|
| 1499 |
"card_html": render_answer_card(),
|
| 1500 |
"card_state": {},
|
| 1501 |
"cards": [],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1502 |
}
|
| 1503 |
|
| 1504 |
|
| 1505 |
+
async def detect_live_question_from_transcript(
|
| 1506 |
transcript: str,
|
| 1507 |
state: dict[str, Any],
|
| 1508 |
+
pause_seconds: float = 0.0,
|
| 1509 |
) -> str | None:
|
| 1510 |
+
previous_question = str(state.get("last_question") or "").strip()
|
| 1511 |
+
question = await detect_live_question_from_excerpt(
|
| 1512 |
+
recent_transcript_excerpt(transcript),
|
| 1513 |
+
state,
|
| 1514 |
+
previous_question,
|
| 1515 |
+
pause_seconds,
|
| 1516 |
+
"transcript tail",
|
| 1517 |
+
)
|
| 1518 |
+
if question or not previous_question:
|
| 1519 |
+
return question
|
| 1520 |
+
|
| 1521 |
+
after_previous = transcript_after_question(transcript, previous_question)
|
| 1522 |
+
if after_previous.strip() == transcript.strip():
|
| 1523 |
+
return await detect_latest_question_from_list(
|
| 1524 |
+
recent_transcript_excerpt(transcript),
|
| 1525 |
+
state,
|
| 1526 |
+
previous_question,
|
| 1527 |
+
)
|
| 1528 |
+
|
| 1529 |
+
question = await detect_live_question_from_excerpt(
|
| 1530 |
+
recent_transcript_excerpt(after_previous),
|
| 1531 |
+
state,
|
| 1532 |
+
previous_question,
|
| 1533 |
+
pause_seconds,
|
| 1534 |
+
"after previous question",
|
| 1535 |
+
)
|
| 1536 |
+
if question:
|
| 1537 |
+
return question
|
| 1538 |
+
|
| 1539 |
+
return await detect_latest_question_from_list(
|
| 1540 |
+
recent_transcript_excerpt(after_previous),
|
| 1541 |
+
state,
|
| 1542 |
+
previous_question,
|
| 1543 |
+
)
|
| 1544 |
+
|
| 1545 |
+
|
| 1546 |
+
async def detect_live_question_from_excerpt(
|
| 1547 |
+
excerpt: str,
|
| 1548 |
+
state: dict[str, Any],
|
| 1549 |
+
previous_question: str,
|
| 1550 |
+
pause_seconds: float,
|
| 1551 |
+
source_label: str,
|
| 1552 |
+
) -> str | None:
|
| 1553 |
+
if len(excerpt.split()) < 4:
|
| 1554 |
+
state["last_extraction_message"] = f"Not enough transcript after {source_label} to detect a new question."
|
| 1555 |
+
return None
|
| 1556 |
+
|
| 1557 |
+
prompt = QUESTION_DETECTOR_USER_PROMPT.format(
|
| 1558 |
+
transcript=excerpt,
|
| 1559 |
+
pause_seconds=pause_seconds,
|
| 1560 |
+
previous_question=previous_question or "None",
|
| 1561 |
+
)
|
| 1562 |
+
try:
|
| 1563 |
+
response = await asyncio.wait_for(
|
| 1564 |
+
general_llm.generate(
|
| 1565 |
+
QUESTION_DETECTOR_SYSTEM_PROMPT,
|
| 1566 |
+
prompt,
|
| 1567 |
+
max_new_tokens=256,
|
| 1568 |
+
),
|
| 1569 |
+
timeout=LIVE_CARD_LLM_TIMEOUT_SECONDS,
|
| 1570 |
+
)
|
| 1571 |
+
except asyncio.TimeoutError:
|
| 1572 |
+
state["last_extraction_message"] = live_detector_timeout_message("Question detector")
|
| 1573 |
+
return None
|
| 1574 |
+
|
| 1575 |
+
if not response:
|
| 1576 |
+
details = f" Details: {general_llm.last_error}" if general_llm.last_error else ""
|
| 1577 |
+
state["last_extraction_message"] = (
|
| 1578 |
+
f"General LLM unavailable. Expected Hugging Face model {GENERAL_LLM_MODEL}.{details}"
|
| 1579 |
+
)
|
| 1580 |
+
return None
|
| 1581 |
+
|
| 1582 |
+
try:
|
| 1583 |
+
result = json.loads(extract_json_object(response))
|
| 1584 |
+
except Exception as exc:
|
| 1585 |
+
preview = response.replace("\n", " ")[:300]
|
| 1586 |
+
general_llm.last_error = f"Question detector returned non-JSON output: {exc}. Output preview: {preview}"
|
| 1587 |
+
state["last_extraction_message"] = "Question detector returned invalid JSON."
|
| 1588 |
+
return None
|
| 1589 |
+
|
| 1590 |
+
if not bool(result.get("question_detected")):
|
| 1591 |
+
if pause_seconds >= LIVE_QUESTION_PAUSE_SECONDS:
|
| 1592 |
+
state["last_extraction_message"] = (
|
| 1593 |
+
f"{pause_seconds:.1f}s pause reached, but no complete interviewer question was found in the transcript window."
|
| 1594 |
+
)
|
| 1595 |
+
else:
|
| 1596 |
+
state["last_extraction_message"] = "Listening for the interviewer to finish a question."
|
| 1597 |
return None
|
| 1598 |
|
| 1599 |
+
return validate_detected_question(result, excerpt, state, source_label)
|
| 1600 |
+
|
| 1601 |
+
|
| 1602 |
+
async def detect_latest_question_from_list(
|
| 1603 |
+
excerpt: str,
|
| 1604 |
+
state: dict[str, Any],
|
| 1605 |
+
previous_question: str,
|
| 1606 |
+
) -> str | None:
|
| 1607 |
+
if len(excerpt.split()) < 4:
|
| 1608 |
+
return None
|
| 1609 |
+
|
| 1610 |
+
prompt = QUESTION_LIST_DETECTOR_USER_PROMPT.format(
|
| 1611 |
+
transcript=excerpt,
|
| 1612 |
+
previous_question=previous_question or "None",
|
| 1613 |
+
)
|
| 1614 |
+
try:
|
| 1615 |
+
response = await asyncio.wait_for(
|
| 1616 |
+
general_llm.generate(
|
| 1617 |
+
QUESTION_LIST_DETECTOR_SYSTEM_PROMPT,
|
| 1618 |
+
prompt,
|
| 1619 |
+
max_new_tokens=700,
|
| 1620 |
+
),
|
| 1621 |
+
timeout=LIVE_CARD_LLM_TIMEOUT_SECONDS,
|
| 1622 |
+
)
|
| 1623 |
+
except asyncio.TimeoutError:
|
| 1624 |
+
state["last_extraction_message"] = live_detector_timeout_message("Question list detector")
|
| 1625 |
+
return None
|
| 1626 |
+
|
| 1627 |
+
if not response:
|
| 1628 |
+
return None
|
| 1629 |
+
|
| 1630 |
+
try:
|
| 1631 |
+
result = json.loads(extract_json_object(response))
|
| 1632 |
+
except Exception as exc:
|
| 1633 |
+
preview = response.replace("\n", " ")[:300]
|
| 1634 |
+
general_llm.last_error = f"Question list detector returned non-JSON output: {exc}. Output preview: {preview}"
|
| 1635 |
+
state["last_extraction_message"] = "Question list detector returned invalid JSON."
|
| 1636 |
+
return None
|
| 1637 |
+
|
| 1638 |
+
questions = result.get("questions", [])
|
| 1639 |
+
if not isinstance(questions, list):
|
| 1640 |
+
return None
|
| 1641 |
+
|
| 1642 |
+
for item in reversed(questions):
|
| 1643 |
+
if not isinstance(item, dict):
|
| 1644 |
+
continue
|
| 1645 |
+
question = validate_detected_question(item, excerpt, state, "question list fallback")
|
| 1646 |
+
if question:
|
| 1647 |
+
return question
|
| 1648 |
+
|
| 1649 |
+
state["last_extraction_message"] = "Question list fallback found no new valid interviewer question."
|
| 1650 |
+
return None
|
| 1651 |
+
|
| 1652 |
+
|
| 1653 |
+
def validate_detected_question(
|
| 1654 |
+
result: dict[str, Any],
|
| 1655 |
+
excerpt: str,
|
| 1656 |
+
state: dict[str, Any],
|
| 1657 |
+
source_label: str,
|
| 1658 |
+
) -> str | None:
|
| 1659 |
+
speaker = str(result.get("speaker", "unknown")).strip().lower()
|
| 1660 |
+
confidence = str(result.get("confidence", "low")).strip().lower()
|
| 1661 |
+
if speaker == "candidate":
|
| 1662 |
+
state["last_extraction_message"] = f"Detector attributed latest text to {speaker or 'unknown'}, so no card was created."
|
| 1663 |
+
return None
|
| 1664 |
+
if confidence == "low":
|
| 1665 |
+
state["last_extraction_message"] = "Question detector confidence was low, so no card was created."
|
| 1666 |
+
return None
|
| 1667 |
+
if result.get("is_target") is False:
|
| 1668 |
+
state["last_extraction_message"] = "Detected question is outside DS/ML/AI/MLOps/System Design scope."
|
| 1669 |
return None
|
| 1670 |
|
| 1671 |
+
question_value = result.get("question")
|
| 1672 |
+
question = normalize_llm_text(str(question_value)) if question_value is not None else ""
|
| 1673 |
+
question = trim_answer_leak_from_question(question)
|
| 1674 |
+
if not question:
|
| 1675 |
+
state["last_extraction_message"] = "Question detector did not return a question."
|
| 1676 |
+
return None
|
| 1677 |
+
if looks_like_candidate_answer_fragment(question):
|
| 1678 |
+
state["last_extraction_message"] = "Detector returned candidate answer text, so no coaching card was created."
|
| 1679 |
+
return None
|
| 1680 |
+
if not looks_like_interviewer_prompt(question):
|
| 1681 |
+
state["last_extraction_message"] = "Detector returned an answer fragment, so no coaching card was created."
|
| 1682 |
+
return None
|
| 1683 |
+
if question_looks_incomplete(question):
|
| 1684 |
+
state["last_extraction_message"] = "Detector returned an incomplete question fragment, so no card was created."
|
| 1685 |
return None
|
| 1686 |
+
if not question_is_grounded_in_transcript(question, excerpt):
|
| 1687 |
+
state["last_extraction_message"] = f"Detector returned a question that was not grounded in {source_label}."
|
| 1688 |
+
return None
|
| 1689 |
+
if not is_target_coaching_question(question, result):
|
| 1690 |
+
state["last_extraction_message"] = "Detected question is not a target technical interview question."
|
| 1691 |
+
return None
|
| 1692 |
+
|
| 1693 |
+
question_hash = question_hash_key(question)
|
| 1694 |
+
if not has_new_live_question(question, state):
|
| 1695 |
+
state["last_extraction_message"] = f"No newer interviewer question detected in {source_label}."
|
| 1696 |
return None
|
| 1697 |
|
| 1698 |
state["last_extraction_message"] = ""
|
| 1699 |
+
return question
|
| 1700 |
|
| 1701 |
|
| 1702 |
+
def looks_like_candidate_answer_fragment(question: str) -> bool:
|
| 1703 |
+
clean = normalize_llm_text(question).lower().rstrip("?!. ")
|
| 1704 |
+
answer_starts = (
|
| 1705 |
+
"in supervised learning",
|
| 1706 |
+
"in unsupervised learning",
|
| 1707 |
+
"supervised learning",
|
| 1708 |
+
"unsupervised learning",
|
| 1709 |
+
"the model ",
|
| 1710 |
+
"a model ",
|
| 1711 |
+
"the key difference",
|
| 1712 |
+
"the main difference",
|
| 1713 |
+
"the main reason",
|
| 1714 |
+
"this means",
|
| 1715 |
+
"it means",
|
| 1716 |
+
"for example",
|
| 1717 |
+
"like ",
|
| 1718 |
+
"i would ",
|
| 1719 |
+
"i will ",
|
| 1720 |
+
"i have ",
|
| 1721 |
+
"i used ",
|
| 1722 |
+
"yes ",
|
| 1723 |
+
"no ",
|
| 1724 |
+
)
|
| 1725 |
+
return clean.startswith(answer_starts)
|
| 1726 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1727 |
|
| 1728 |
+
def trim_answer_leak_from_question(question: str) -> str:
|
| 1729 |
+
clean = normalize_llm_text(question)
|
| 1730 |
+
if not clean:
|
| 1731 |
+
return ""
|
|
|
|
| 1732 |
|
| 1733 |
+
trailing_answer_tokens = (" yes", " yeah", " yep", " no", " nope", " sure", " okay", " ok")
|
| 1734 |
+
lowered = clean.lower().rstrip("?.!, ")
|
| 1735 |
+
for token in trailing_answer_tokens:
|
| 1736 |
+
if lowered.endswith(token):
|
| 1737 |
+
clean = clean[: -len(token)].rstrip(" ?.!,")
|
| 1738 |
+
break
|
| 1739 |
+
|
| 1740 |
+
answer_starts = (
|
| 1741 |
+
" yes i ",
|
| 1742 |
+
" yes, i ",
|
| 1743 |
+
" yeah i ",
|
| 1744 |
+
" sure i ",
|
| 1745 |
+
" no i ",
|
| 1746 |
+
" i have ",
|
| 1747 |
+
" i've ",
|
| 1748 |
+
" i used ",
|
| 1749 |
+
" i would ",
|
| 1750 |
+
" i will ",
|
| 1751 |
+
" we used ",
|
| 1752 |
+
" we have ",
|
| 1753 |
+
)
|
| 1754 |
+
lowered = f" {clean.lower()} "
|
| 1755 |
+
cut_at = -1
|
| 1756 |
+
for marker in answer_starts:
|
| 1757 |
+
index = lowered.find(marker)
|
| 1758 |
+
if index > 0:
|
| 1759 |
+
cut_at = index
|
| 1760 |
+
break
|
| 1761 |
+
if cut_at > 0 and len(clean[:cut_at].split()) >= 5:
|
| 1762 |
+
clean = clean[:cut_at].rstrip(" ?.!,")
|
| 1763 |
+
|
| 1764 |
+
if clean and not clean.endswith("?"):
|
| 1765 |
+
clean = f"{clean.rstrip('.')}?"
|
| 1766 |
+
return normalize_llm_text(clean)
|
| 1767 |
+
|
| 1768 |
+
|
| 1769 |
+
def is_target_coaching_question(question: str, result: dict[str, Any] | None = None) -> bool:
|
| 1770 |
+
result = result or {}
|
| 1771 |
+
domain = str(result.get("domain", "")).strip().lower()
|
| 1772 |
+
target_domains = {
|
| 1773 |
+
"data_science",
|
| 1774 |
+
"machine_learning",
|
| 1775 |
+
"ai_engineering",
|
| 1776 |
+
"mlops",
|
| 1777 |
+
"statistics",
|
| 1778 |
+
"analytics",
|
| 1779 |
+
"coding",
|
| 1780 |
+
"algorithms",
|
| 1781 |
+
"system_design",
|
| 1782 |
+
}
|
| 1783 |
+
if domain in target_domains:
|
| 1784 |
+
return True
|
| 1785 |
+
if domain == "other" or result.get("is_target") is False:
|
| 1786 |
+
return False
|
| 1787 |
+
|
| 1788 |
+
text = normalize_llm_text(question).lower()
|
| 1789 |
+
domain_terms = (
|
| 1790 |
+
"accuracy",
|
| 1791 |
+
"algorithm",
|
| 1792 |
+
"analytics",
|
| 1793 |
+
"api",
|
| 1794 |
+
"classification",
|
| 1795 |
+
"clustering",
|
| 1796 |
+
"coding",
|
| 1797 |
+
"data",
|
| 1798 |
+
"database",
|
| 1799 |
+
"deployment",
|
| 1800 |
+
"drift",
|
| 1801 |
+
"embedding",
|
| 1802 |
+
"evaluation",
|
| 1803 |
+
"feature",
|
| 1804 |
+
"fraud",
|
| 1805 |
+
"inference",
|
| 1806 |
+
"latency",
|
| 1807 |
+
"learning",
|
| 1808 |
+
"llm",
|
| 1809 |
+
"machine",
|
| 1810 |
+
"metric",
|
| 1811 |
+
"ml",
|
| 1812 |
+
"model",
|
| 1813 |
+
"pipeline",
|
| 1814 |
+
"precision",
|
| 1815 |
+
"production",
|
| 1816 |
+
"recall",
|
| 1817 |
+
"recommendation",
|
| 1818 |
+
"recommender",
|
| 1819 |
+
"regression",
|
| 1820 |
+
"scaling",
|
| 1821 |
+
"scalable",
|
| 1822 |
+
"statistics",
|
| 1823 |
+
"supervised",
|
| 1824 |
+
"system",
|
| 1825 |
+
"system design",
|
| 1826 |
+
"training",
|
| 1827 |
+
"unsupervised",
|
| 1828 |
+
"xgboost",
|
| 1829 |
+
)
|
| 1830 |
+
non_domain_terms = (
|
| 1831 |
+
"this app",
|
| 1832 |
+
"this tool",
|
| 1833 |
+
"how does it work here",
|
| 1834 |
+
"what does it mean",
|
| 1835 |
+
"extract relevant questions",
|
| 1836 |
+
"extract the required relevant questions",
|
| 1837 |
+
"coaching card",
|
| 1838 |
+
"session log",
|
| 1839 |
+
"transcript",
|
| 1840 |
)
|
| 1841 |
+
return any(term in text for term in domain_terms) and not any(term in text for term in non_domain_terms)
|
| 1842 |
|
|
|
|
|
|
|
|
|
|
| 1843 |
|
| 1844 |
+
def recent_transcript_excerpt(
|
| 1845 |
+
transcript: str,
|
| 1846 |
+
max_lines: int = LIVE_QUESTION_CONTEXT_LINES,
|
| 1847 |
+
max_chars: int = LIVE_QUESTION_CONTEXT_CHARS,
|
| 1848 |
+
) -> str:
|
| 1849 |
+
lines = [line.strip() for line in transcript.splitlines() if line.strip()]
|
| 1850 |
+
excerpt = "\n".join(lines[-max_lines:]) if lines else transcript.strip()
|
| 1851 |
+
if len(excerpt) > max_chars:
|
| 1852 |
+
excerpt = excerpt[-max_chars:]
|
| 1853 |
+
return excerpt.strip()
|
| 1854 |
+
|
| 1855 |
+
|
| 1856 |
+
def transcript_after_question(transcript: str, question: str) -> str:
|
| 1857 |
+
if not question:
|
| 1858 |
+
return transcript
|
| 1859 |
+
transcript_lower = transcript.lower()
|
| 1860 |
+
question_lower = question.lower().rstrip(" ?.")
|
| 1861 |
+
position = transcript_lower.rfind(question_lower)
|
| 1862 |
+
if position >= 0:
|
| 1863 |
+
return transcript[position + len(question_lower) :].strip(" ?.:-,;") or transcript
|
| 1864 |
+
|
| 1865 |
+
match_end = ordered_question_match_end(transcript, question)
|
| 1866 |
+
if match_end > 0:
|
| 1867 |
+
return transcript[match_end:].strip(" ?.:-,;") or transcript
|
| 1868 |
+
return transcript
|
| 1869 |
+
|
| 1870 |
+
|
| 1871 |
+
def ordered_question_match_span(transcript: str, question: str, start_at: int = 0) -> tuple[int, int]:
|
| 1872 |
+
transcript_tokens = [
|
| 1873 |
+
(match.group(0).lower(), match.start(), match.end())
|
| 1874 |
+
for match in re.finditer(r"[a-z0-9]+", transcript.lower())
|
| 1875 |
+
if match.end() >= start_at
|
| 1876 |
+
]
|
| 1877 |
+
question_tokens = [
|
| 1878 |
+
token
|
| 1879 |
+
for token in re.findall(r"[a-z0-9]+", question.lower())
|
| 1880 |
+
if token not in {"is", "the", "a", "an", "and", "or"}
|
| 1881 |
+
]
|
| 1882 |
+
if len(question_tokens) < 3:
|
| 1883 |
+
return -1, -1
|
| 1884 |
+
|
| 1885 |
+
best_partial: tuple[int, int, int] | None = None
|
| 1886 |
+
minimum_match = max(3, int(len(question_tokens) * 0.8))
|
| 1887 |
+
for start_index, (token, start, end) in enumerate(transcript_tokens):
|
| 1888 |
+
if token != question_tokens[0]:
|
| 1889 |
+
continue
|
| 1890 |
+
question_index = 1
|
| 1891 |
+
matched = 1
|
| 1892 |
+
match_end = end
|
| 1893 |
+
for next_token, _, next_end in transcript_tokens[start_index + 1 :]:
|
| 1894 |
+
if question_index >= len(question_tokens):
|
| 1895 |
+
break
|
| 1896 |
+
if next_token == question_tokens[question_index]:
|
| 1897 |
+
matched += 1
|
| 1898 |
+
question_index += 1
|
| 1899 |
+
match_end = next_end
|
| 1900 |
+
if matched == len(question_tokens):
|
| 1901 |
+
return start, match_end
|
| 1902 |
+
if matched >= minimum_match and (
|
| 1903 |
+
best_partial is None or matched > best_partial[0]
|
| 1904 |
+
):
|
| 1905 |
+
best_partial = (matched, start, match_end)
|
| 1906 |
+
|
| 1907 |
+
if best_partial:
|
| 1908 |
+
return best_partial[1], best_partial[2]
|
| 1909 |
+
return -1, -1
|
| 1910 |
+
|
| 1911 |
+
|
| 1912 |
+
def ordered_question_match_end(transcript: str, question: str) -> int:
|
| 1913 |
+
transcript_tokens = [
|
| 1914 |
+
(match.group(0).lower(), match.start(), match.end())
|
| 1915 |
+
for match in re.finditer(r"[a-z0-9]+", transcript.lower())
|
| 1916 |
+
]
|
| 1917 |
+
question_tokens = [
|
| 1918 |
+
token
|
| 1919 |
+
for token in re.findall(r"[a-z0-9]+", question.lower())
|
| 1920 |
+
if token not in {"is", "the", "a", "an", "and", "or"}
|
| 1921 |
+
]
|
| 1922 |
+
if len(question_tokens) < 3:
|
| 1923 |
+
return -1
|
| 1924 |
+
|
| 1925 |
+
needed = len(question_tokens) if len(question_tokens) <= 6 else 6
|
| 1926 |
+
for start_index, (token, start, _) in enumerate(transcript_tokens):
|
| 1927 |
+
if token != question_tokens[0]:
|
| 1928 |
+
continue
|
| 1929 |
+
question_index = 1
|
| 1930 |
+
matched = 1
|
| 1931 |
+
end = start
|
| 1932 |
+
for next_token, _, next_end in transcript_tokens[start_index + 1 :]:
|
| 1933 |
+
if question_index >= len(question_tokens):
|
| 1934 |
+
break
|
| 1935 |
+
if next_token == question_tokens[question_index]:
|
| 1936 |
+
matched += 1
|
| 1937 |
+
question_index += 1
|
| 1938 |
+
end = next_end
|
| 1939 |
+
if matched >= needed:
|
| 1940 |
+
return end
|
| 1941 |
+
if matched >= needed:
|
| 1942 |
+
return end
|
| 1943 |
+
return -1
|
| 1944 |
+
|
| 1945 |
+
|
| 1946 |
+
def question_hash_key(question: str) -> str:
|
| 1947 |
+
return hashlib.md5(canonical_question_key(question).encode("utf-8")).hexdigest()
|
| 1948 |
+
|
| 1949 |
+
|
| 1950 |
+
def canonical_question_key(question: str) -> str:
|
| 1951 |
+
text = normalize_llm_text(question).lower()
|
| 1952 |
+
replacements = {
|
| 1953 |
+
"what's": "what is",
|
| 1954 |
+
"whats": "what is",
|
| 1955 |
+
"you're": "you are",
|
| 1956 |
+
"you've": "you have",
|
| 1957 |
+
"can't": "cannot",
|
| 1958 |
+
}
|
| 1959 |
+
for source, target in replacements.items():
|
| 1960 |
+
text = text.replace(source, target)
|
| 1961 |
+
words = re.findall(r"[a-z0-9]+", text)
|
| 1962 |
+
stop_words = {"a", "an", "the", "please", "quick", "one", "question"}
|
| 1963 |
+
words = [word for word in words if word not in stop_words]
|
| 1964 |
+
return " ".join(remove_adjacent_duplicates(words))
|
| 1965 |
|
| 1966 |
|
| 1967 |
+
def remove_adjacent_duplicates(words: list[str]) -> list[str]:
|
|
|
|
| 1968 |
cleaned: list[str] = []
|
| 1969 |
for word in words:
|
| 1970 |
+
if cleaned and cleaned[-1] == word:
|
|
|
|
| 1971 |
continue
|
| 1972 |
cleaned.append(word)
|
| 1973 |
+
return cleaned
|
| 1974 |
|
| 1975 |
|
| 1976 |
+
def question_looks_incomplete(question: str) -> bool:
|
| 1977 |
+
words = re.findall(r"[a-z0-9]+", question.lower())
|
| 1978 |
+
if len(words) < 5:
|
|
|
|
| 1979 |
return True
|
| 1980 |
+
return words[-1] in {"and", "or", "between", "with", "of", "to", "for", "in", "on", "the", "a", "an"}
|
| 1981 |
+
|
| 1982 |
+
|
| 1983 |
+
def looks_like_interviewer_prompt(text: str) -> bool:
|
| 1984 |
+
clean = normalize_llm_text(text).lower()
|
| 1985 |
+
promptish = re.sub(r"[,:;]", "", clean)
|
| 1986 |
+
prompt_starts = (
|
| 1987 |
+
"what ",
|
| 1988 |
+
"what's ",
|
| 1989 |
+
"why ",
|
| 1990 |
+
"how ",
|
| 1991 |
+
"when ",
|
| 1992 |
+
"where ",
|
| 1993 |
+
"which ",
|
| 1994 |
+
"who ",
|
| 1995 |
+
"have you ",
|
| 1996 |
+
"have i ",
|
| 1997 |
+
"do you ",
|
| 1998 |
+
"did you ",
|
| 1999 |
+
"are you ",
|
| 2000 |
+
"can you ",
|
| 2001 |
+
"could you ",
|
| 2002 |
+
"would you ",
|
| 2003 |
+
"should you ",
|
| 2004 |
+
"explain ",
|
| 2005 |
+
"describe ",
|
| 2006 |
+
"tell me ",
|
| 2007 |
+
"walk me through ",
|
| 2008 |
+
"compare ",
|
| 2009 |
+
"design ",
|
| 2010 |
+
"solve ",
|
| 2011 |
+
"evaluate ",
|
| 2012 |
+
"reason about ",
|
| 2013 |
+
"discuss ",
|
| 2014 |
+
"show me ",
|
| 2015 |
+
"give me ",
|
| 2016 |
+
"build ",
|
| 2017 |
+
"implement ",
|
| 2018 |
+
)
|
| 2019 |
+
if promptish.startswith(prompt_starts):
|
| 2020 |
return True
|
|
|
|
| 2021 |
|
| 2022 |
+
setup_starts = (
|
| 2023 |
+
"your model ",
|
| 2024 |
+
"you have ",
|
| 2025 |
+
"given ",
|
| 2026 |
+
"suppose ",
|
| 2027 |
+
"imagine ",
|
| 2028 |
+
"let's say ",
|
| 2029 |
+
"lets say ",
|
| 2030 |
+
"in production ",
|
| 2031 |
+
"in a production ",
|
| 2032 |
+
"for a ",
|
| 2033 |
+
)
|
| 2034 |
+
question_clauses = (
|
| 2035 |
+
" what ",
|
| 2036 |
+
" how ",
|
| 2037 |
+
" why ",
|
| 2038 |
+
" which ",
|
| 2039 |
+
" when ",
|
| 2040 |
+
" where ",
|
| 2041 |
+
" who ",
|
| 2042 |
+
" is this ",
|
| 2043 |
+
" is that ",
|
| 2044 |
+
" is it ",
|
| 2045 |
+
" are they ",
|
| 2046 |
+
" do you ",
|
| 2047 |
+
" does it ",
|
| 2048 |
+
" can you ",
|
| 2049 |
+
" could you ",
|
| 2050 |
+
" would you ",
|
| 2051 |
+
" should you ",
|
| 2052 |
+
" have you ",
|
| 2053 |
+
)
|
| 2054 |
+
return promptish.startswith(setup_starts) and any(clause in f" {promptish}" for clause in question_clauses)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2055 |
|
| 2056 |
|
| 2057 |
+
def question_is_grounded_in_transcript(question: str, transcript: str) -> bool:
|
| 2058 |
+
question_tokens = content_tokens(question)
|
| 2059 |
+
transcript_tokens = content_tokens(transcript)
|
| 2060 |
+
if len(question_tokens) < 2:
|
| 2061 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2062 |
|
| 2063 |
+
transcript_text = " ".join(transcript_tokens)
|
| 2064 |
+
opening = " ".join(question_tokens[:2])
|
| 2065 |
+
if opening and opening in transcript_text:
|
| 2066 |
+
return True
|
| 2067 |
|
| 2068 |
+
if question_tokens[0] not in transcript_tokens:
|
|
|
|
|
|
|
|
|
|
| 2069 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2070 |
|
| 2071 |
+
overlap = len(set(question_tokens) & set(transcript_tokens))
|
| 2072 |
+
return overlap / max(len(set(question_tokens)), 1) >= 0.65
|
| 2073 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2074 |
|
| 2075 |
+
def content_tokens(text: str) -> list[str]:
|
| 2076 |
+
stop_words = {"a", "an", "the", "is", "are", "was", "were", "to", "of", "in", "on", "for", "and", "or"}
|
| 2077 |
+
return [
|
| 2078 |
+
token
|
| 2079 |
+
for token in re.findall(r"[a-z0-9]+", text.lower())
|
| 2080 |
+
if token not in stop_words
|
| 2081 |
+
]
|
| 2082 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2083 |
|
| 2084 |
+
def has_new_live_question(question: str, state: dict[str, Any]) -> bool:
|
| 2085 |
+
question_key = canonical_question_key(question)
|
| 2086 |
+
if not question_key:
|
| 2087 |
+
return False
|
| 2088 |
+
if question_keys_are_similar(question_key, canonical_question_key(str(state.get("last_question", "")))):
|
| 2089 |
+
return False
|
| 2090 |
+
return not any(
|
| 2091 |
+
question_keys_are_similar(question_key, canonical_question_key(str(card.get("question", ""))))
|
| 2092 |
+
for card in state.get("cards", [])
|
| 2093 |
+
if isinstance(card, dict)
|
| 2094 |
+
)
|
| 2095 |
|
| 2096 |
+
|
| 2097 |
+
def question_keys_are_similar(left_key: str, right_key: str) -> bool:
|
| 2098 |
+
if not left_key or not right_key:
|
| 2099 |
+
return False
|
| 2100 |
+
if left_key == right_key:
|
| 2101 |
+
return True
|
| 2102 |
+
left_words = left_key.split()
|
| 2103 |
+
right_words = right_key.split()
|
| 2104 |
+
if len(left_words) < 5 or len(right_words) < 5:
|
| 2105 |
+
return False
|
| 2106 |
+
left = set(left_words)
|
| 2107 |
+
right = set(right_words)
|
| 2108 |
+
overlap = len(left & right)
|
| 2109 |
+
containment = overlap / max(min(len(left), len(right)), 1)
|
| 2110 |
+
jaccard = overlap / max(len(left | right), 1)
|
| 2111 |
+
return containment >= 0.86 or jaccard >= 0.78
|
| 2112 |
|
| 2113 |
|
| 2114 |
def extract_json_object(text: str) -> str:
|
|
|
|
| 2156 |
"rejected": 0,
|
| 2157 |
"last_rms": 0.0,
|
| 2158 |
"last_window_seconds": 0.0,
|
| 2159 |
+
"quiet_seconds": 0.0,
|
| 2160 |
"last_text": "",
|
| 2161 |
}
|
| 2162 |
|
|
|
|
| 2501 |
role = gr.Textbox(label="Role", placeholder="ML Engineer", scale=2)
|
| 2502 |
start = gr.Button("Create Session", variant="primary", scale=1)
|
| 2503 |
status = gr.Textbox(label="Session Status", interactive=False, elem_id="status_box")
|
| 2504 |
+
model_status_box = gr.Textbox(
|
| 2505 |
+
label="Startup Status",
|
| 2506 |
+
value=render_startup_status(),
|
| 2507 |
+
interactive=False,
|
| 2508 |
+
lines=1,
|
| 2509 |
+
elem_id="model_status_box",
|
| 2510 |
+
)
|
| 2511 |
|
| 2512 |
with gr.Tabs():
|
| 2513 |
with gr.Tab("Live"):
|
|
|
|
| 2574 |
report = gr.Textbox(label="Evaluation Report", lines=16, interactive=False, elem_id="report_box")
|
| 2575 |
|
| 2576 |
start.click(start_session, inputs=[company, role], outputs=[session_id, status])
|
| 2577 |
+
demo.load(
|
| 2578 |
+
warmup_all_models,
|
| 2579 |
+
outputs=[model_status_box],
|
| 2580 |
+
queue=True,
|
| 2581 |
+
)
|
| 2582 |
transcribe_coach.click(
|
| 2583 |
transcribe_and_coach,
|
| 2584 |
inputs=[session_id, mic, live_transcript],
|
|
|
|
| 2587 |
)
|
| 2588 |
mic.stream(
|
| 2589 |
stream_live_transcript,
|
| 2590 |
+
inputs=[session_id, mic, live_transcript, stream_state],
|
| 2591 |
+
outputs=[live_transcript, answer_card, stream_status, stream_state, last_state, log],
|
| 2592 |
queue=True,
|
| 2593 |
)
|
| 2594 |
mic.stop_recording(
|
|
|
|
| 2600 |
if not HF_SPACE_MODE:
|
| 2601 |
start_live.click(
|
| 2602 |
start_backend_live_transcript,
|
| 2603 |
+
inputs=[session_id],
|
| 2604 |
+
outputs=[live_transcript, answer_card, stream_status, last_state, card_monitor_state, log],
|
| 2605 |
queue=True,
|
| 2606 |
)
|
| 2607 |
stop_live.click(
|
|
|
|
| 2612 |
coach.click(
|
| 2613 |
process_typed_transcript,
|
| 2614 |
inputs=[session_id, live_transcript],
|
| 2615 |
+
outputs=[live_transcript, answer_card, log, last_state, card_monitor_state],
|
| 2616 |
queue=True,
|
| 2617 |
)
|
| 2618 |
call_coaching.click(
|
| 2619 |
call_coaching_from_transcript,
|
| 2620 |
+
inputs=[session_id, live_transcript, card_monitor_state],
|
| 2621 |
+
outputs=[answer_card, last_state, card_monitor_state, log],
|
| 2622 |
queue=True,
|
| 2623 |
)
|
| 2624 |
live_transcript.change(
|
article.md
CHANGED
|
@@ -10,6 +10,11 @@ The app also helps after the interview. It extracts question-answer pairs from n
|
|
| 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.
|
|
@@ -37,13 +42,8 @@ The project uses a multi-model approach. Instead of forcing one model to handle
|
|
| 37 |
|
| 38 |
| Model | Approx Size | Purpose |
|
| 39 |
| --- | ---: | --- |
|
| 40 |
-
| `mlx-community/whisper-
|
| 41 |
-
| `mlx-community/whisper-small-mlx` | ~244M parameters | Higher-quality local transcription when needed |
|
| 42 |
-
<<<<<<< HEAD
|
| 43 |
| `build-small-hackathon/interview-coach-3b` | 3B base model plus LoRA adapter | Fine-tuned topic and pattern detection |
|
| 44 |
-
=======
|
| 45 |
-
| `vadirajkrishna/interview-coach-3b` | 3B base model plus LoRA adapter | Fine-tuned topic and pattern detection |
|
| 46 |
-
>>>>>>> c0f39ad (initial commit - InterviewCopilotLocal)
|
| 47 |
| `Qwen/Qwen2.5-3B-Instruct` | ~3B parameters | General reasoning, coaching hint generation, transcript cleanup, and evaluation |
|
| 48 |
| SQLite | Local database | Session, exchange, and evaluation storage |
|
| 49 |
|
|
@@ -138,6 +138,34 @@ The fix was to make the evaluator contract explicit:
|
|
| 138 |
|
| 139 |
This made the evaluation more faithful to what the candidate actually said.
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
## Local-First Runtime
|
| 142 |
|
| 143 |
The entire system can run locally on a Mac. That is a major part of the design.
|
|
|
|
| 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 |
+
Project links:
|
| 14 |
+
|
| 15 |
+
- Hugging Face Space README: https://huggingface.co/spaces/build-small-hackathon/interview-copilot-local/blob/main/README.md
|
| 16 |
+
- Demo video: https://www.loom.com/share/d44244e43927423b9be237fbb207a65b
|
| 17 |
+
|
| 18 |
## Architecture
|
| 19 |
|
| 20 |
At a high level, Interview Coach has two paths: a fast live coaching path and a slower post-session evaluation path.
|
|
|
|
| 42 |
|
| 43 |
| Model | Approx Size | Purpose |
|
| 44 |
| --- | ---: | --- |
|
| 45 |
+
| `mlx-community/whisper-large-v3-turbo` | ~809M parameters | Higher-quality local MLX transcription for live audio |
|
|
|
|
|
|
|
| 46 |
| `build-small-hackathon/interview-coach-3b` | 3B base model plus LoRA adapter | Fine-tuned topic and pattern detection |
|
|
|
|
|
|
|
|
|
|
| 47 |
| `Qwen/Qwen2.5-3B-Instruct` | ~3B parameters | General reasoning, coaching hint generation, transcript cleanup, and evaluation |
|
| 48 |
| SQLite | Local database | Session, exchange, and evaluation storage |
|
| 49 |
|
|
|
|
| 138 |
|
| 139 |
This made the evaluation more faithful to what the candidate actually said.
|
| 140 |
|
| 141 |
+
## Challenge 5: Model Loading Without Freezing the First Question
|
| 142 |
+
|
| 143 |
+
Another practical challenge was model startup.
|
| 144 |
+
|
| 145 |
+
The app uses multiple local models, and loading them lazily during the first live question made the product feel slow. The first question is often the most important moment in the demo, but that was exactly when model weights were being loaded into memory.
|
| 146 |
+
|
| 147 |
+
The fix was to move model warmup into application initialization. When the Gradio app opens, it starts loading the general instruction model, the fine-tuned topic/pattern model, and the speech-to-text model in the background. The UI shows a simple startup status at the top and changes to “All models loaded” when the app is ready.
|
| 148 |
+
|
| 149 |
+
This keeps the complexity away from the user. They do not need to know which model is loading or press a separate warmup button. They just wait for the ready message and then start the interview flow.
|
| 150 |
+
|
| 151 |
+
I also had to make coaching-card generation non-blocking. Earlier, when the card was being generated, live transcription could pause because the same flow was waiting on the LLM call. The current version lets transcription continue while the coaching card loads in the background. That makes the app feel much more natural during a live conversation.
|
| 152 |
+
|
| 153 |
+
## Challenge 6: Running on Hugging Face Spaces
|
| 154 |
+
|
| 155 |
+
Running locally and running on Hugging Face Spaces are not the same environment.
|
| 156 |
+
|
| 157 |
+
Locally, the app can use MLX Whisper on Apple Silicon and system audio routing. On Spaces, the browser microphone is the realistic input path, and model loading, caching, and hardware behavior are different.
|
| 158 |
+
|
| 159 |
+
The main Spaces challenges were:
|
| 160 |
+
|
| 161 |
+
- Model dependencies must be declared clearly in `requirements.txt`.
|
| 162 |
+
- The Space needs enough time and memory to download and cache model snapshots.
|
| 163 |
+
- ZeroGPU has different CUDA behavior, so code must avoid direct low-level CUDA initialization outside the supported execution path.
|
| 164 |
+
- Browser microphone recordings behave differently from local system audio capture.
|
| 165 |
+
- Live transcription can be slower on constrained hardware, so the UI needs clear status messages instead of appearing frozen.
|
| 166 |
+
|
| 167 |
+
The app now separates local and Space runtime behavior where needed. Locally it can use MLX-based transcription and system audio. On Spaces it uses browser audio and avoids assumptions that only hold on a Mac. This made the demo more portable, even though the best real-time experience is still the local Mac setup.
|
| 168 |
+
|
| 169 |
## Local-First Runtime
|
| 170 |
|
| 171 |
The entire system can run locally on a Mac. That is a major part of the design.
|
config.py
CHANGED
|
@@ -7,8 +7,8 @@ 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-
|
| 11 |
-
STREAMING_WHISPER_MODEL = "mlx-community/whisper-
|
| 12 |
HF_SPACE_MODE = bool(os.environ.get("SPACE_ID")) or os.environ.get("INTERVIEW_COACH_RUNTIME") == "space"
|
| 13 |
if HF_SPACE_MODE:
|
| 14 |
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
|
@@ -16,11 +16,7 @@ STT_BACKEND = os.environ.get("INTERVIEW_COACH_STT_BACKEND", "transformers" if HF
|
|
| 16 |
HF_WHISPER_MODEL = os.environ.get("INTERVIEW_COACH_HF_WHISPER_MODEL", "openai/whisper-small")
|
| 17 |
APP_HOST = os.environ.get("INTERVIEW_COACH_HOST", "0.0.0.0" if HF_SPACE_MODE else "127.0.0.1")
|
| 18 |
APP_PORT = 7860
|
| 19 |
-
<<<<<<< HEAD
|
| 20 |
TOPIC_PATTERN_MODEL = os.environ.get("INTERVIEW_COACH_TOPIC_PATTERN_MODEL", "build-small-hackathon/interview-coach-3b")
|
| 21 |
-
=======
|
| 22 |
-
TOPIC_PATTERN_MODEL = os.environ.get("INTERVIEW_COACH_TOPIC_PATTERN_MODEL", "vadirajkrishna/interview-coach-3b")
|
| 23 |
-
>>>>>>> c0f39ad (initial commit - InterviewCopilotLocal)
|
| 24 |
TOPIC_PATTERN_BASE_MODEL = os.environ.get("INTERVIEW_COACH_TOPIC_PATTERN_BASE_MODEL", GENERAL_LLM_MODEL)
|
| 25 |
HF_LOCAL_FILES_ONLY = os.environ.get("INTERVIEW_COACH_HF_LOCAL_FILES_ONLY", "0").strip().lower() in {
|
| 26 |
"1",
|
|
|
|
| 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 = os.environ.get("INTERVIEW_COACH_MLX_WHISPER_MODEL", "mlx-community/whisper-large-v3-turbo")
|
| 11 |
+
STREAMING_WHISPER_MODEL = os.environ.get("INTERVIEW_COACH_STREAMING_WHISPER_MODEL", "mlx-community/whisper-small-mlx")
|
| 12 |
HF_SPACE_MODE = bool(os.environ.get("SPACE_ID")) or os.environ.get("INTERVIEW_COACH_RUNTIME") == "space"
|
| 13 |
if HF_SPACE_MODE:
|
| 14 |
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
|
|
|
| 16 |
HF_WHISPER_MODEL = os.environ.get("INTERVIEW_COACH_HF_WHISPER_MODEL", "openai/whisper-small")
|
| 17 |
APP_HOST = os.environ.get("INTERVIEW_COACH_HOST", "0.0.0.0" if HF_SPACE_MODE else "127.0.0.1")
|
| 18 |
APP_PORT = 7860
|
|
|
|
| 19 |
TOPIC_PATTERN_MODEL = os.environ.get("INTERVIEW_COACH_TOPIC_PATTERN_MODEL", "build-small-hackathon/interview-coach-3b")
|
|
|
|
|
|
|
|
|
|
| 20 |
TOPIC_PATTERN_BASE_MODEL = os.environ.get("INTERVIEW_COACH_TOPIC_PATTERN_BASE_MODEL", GENERAL_LLM_MODEL)
|
| 21 |
HF_LOCAL_FILES_ONLY = os.environ.get("INTERVIEW_COACH_HF_LOCAL_FILES_ONLY", "0").strip().lower() in {
|
| 22 |
"1",
|
nodes/audio.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import asyncio
|
| 2 |
import os
|
|
|
|
| 3 |
import shutil
|
| 4 |
from concurrent.futures import ThreadPoolExecutor
|
| 5 |
from typing import Any
|
|
@@ -10,12 +11,13 @@ 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 =
|
| 14 |
-
OVERLAP_SECONDS = 0.
|
| 15 |
CHUNK_SAMPLES = int(CHUNK_SECONDS * SAMPLE_RATE)
|
| 16 |
OVERLAP_SAMPLES = int(OVERLAP_SECONDS * SAMPLE_RATE)
|
| 17 |
-
QUEUE_MAX_SIZE =
|
| 18 |
SILENCE_RMS_THRESHOLD = 0.003
|
|
|
|
| 19 |
|
| 20 |
executor = ThreadPoolExecutor(max_workers=2)
|
| 21 |
_asr_pipeline = None
|
|
@@ -53,10 +55,12 @@ class LiveAudioTranscriber:
|
|
| 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()
|
|
@@ -71,15 +75,30 @@ class LiveAudioTranscriber:
|
|
| 71 |
async def stop(self) -> None:
|
| 72 |
self.stop_event.set()
|
| 73 |
if self.capture_task:
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
self.capture_task = None
|
|
|
|
| 76 |
|
| 77 |
async def transcript_stream(self):
|
|
|
|
|
|
|
|
|
|
| 78 |
transcript = ""
|
| 79 |
-
|
|
|
|
|
|
|
| 80 |
try:
|
| 81 |
-
chunk = await asyncio.wait_for(
|
| 82 |
except asyncio.TimeoutError:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
continue
|
| 84 |
|
| 85 |
try:
|
|
@@ -88,7 +107,9 @@ class LiveAudioTranscriber:
|
|
| 88 |
text = f"[live transcription error: {exc}]"
|
| 89 |
if text:
|
| 90 |
transcript = merge_chunk_text(transcript, text)
|
| 91 |
-
|
|
|
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
async def capture_audio(
|
|
@@ -140,6 +161,14 @@ def enqueue_chunk_nowait(audio_queue: asyncio.Queue[np.ndarray], chunk: np.ndarr
|
|
| 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 ""
|
|
@@ -238,6 +267,29 @@ async def transcribe_audio_array(
|
|
| 238 |
)
|
| 239 |
|
| 240 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
def _transcribe_audio_file_sync(
|
| 242 |
audio_path: str,
|
| 243 |
model: str,
|
|
@@ -253,8 +305,8 @@ def _transcribe_audio_file_sync(
|
|
| 253 |
|
| 254 |
result = mlx_whisper.transcribe(audio_path, path_or_hf_repo=model)
|
| 255 |
if isinstance(result, dict):
|
| 256 |
-
return str(result.get("text", ""))
|
| 257 |
-
return str(result)
|
| 258 |
except Exception as exc:
|
| 259 |
return f"[transcription unavailable: {exc}]"
|
| 260 |
|
|
@@ -282,8 +334,8 @@ def _transcribe_audio_array_sync(
|
|
| 282 |
**(decode_options or {}),
|
| 283 |
)
|
| 284 |
if isinstance(result, dict):
|
| 285 |
-
return str(result.get("text", ""))
|
| 286 |
-
return str(result)
|
| 287 |
except Exception as exc:
|
| 288 |
return f"[transcription unavailable: {exc}]"
|
| 289 |
|
|
@@ -303,12 +355,51 @@ def _transcribe_with_transformers(audio_input: Any, model: str) -> str:
|
|
| 303 |
},
|
| 304 |
)
|
| 305 |
if isinstance(result, dict):
|
| 306 |
-
return str(result.get("text", ""))
|
| 307 |
-
return str(result)
|
| 308 |
except Exception as exc:
|
| 309 |
return f"[transcription unavailable: {exc}]"
|
| 310 |
|
| 311 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
def get_asr_pipeline(model: str):
|
| 313 |
global _asr_pipeline
|
| 314 |
if _asr_pipeline is None:
|
|
|
|
| 1 |
import asyncio
|
| 2 |
import os
|
| 3 |
+
import re
|
| 4 |
import shutil
|
| 5 |
from concurrent.futures import ThreadPoolExecutor
|
| 6 |
from typing import Any
|
|
|
|
| 11 |
from state import CoachState
|
| 12 |
|
| 13 |
SAMPLE_RATE = 16000
|
| 14 |
+
CHUNK_SECONDS = 4
|
| 15 |
+
OVERLAP_SECONDS = 0.75
|
| 16 |
CHUNK_SAMPLES = int(CHUNK_SECONDS * SAMPLE_RATE)
|
| 17 |
OVERLAP_SAMPLES = int(OVERLAP_SECONDS * SAMPLE_RATE)
|
| 18 |
+
QUEUE_MAX_SIZE = 3
|
| 19 |
SILENCE_RMS_THRESHOLD = 0.003
|
| 20 |
+
QUESTION_PAUSE_SECONDS = 1.5
|
| 21 |
|
| 22 |
executor = ThreadPoolExecutor(max_workers=2)
|
| 23 |
_asr_pipeline = None
|
|
|
|
| 55 |
self.audio_queue: asyncio.Queue[np.ndarray] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
|
| 56 |
self.stop_event = asyncio.Event()
|
| 57 |
self.capture_task: asyncio.Task | None = None
|
| 58 |
+
self.stream_id = 0
|
| 59 |
|
| 60 |
async def start(self) -> None:
|
| 61 |
if self.capture_task and not self.capture_task.done():
|
| 62 |
await self.stop()
|
| 63 |
+
self.stream_id += 1
|
| 64 |
self.stop_event = asyncio.Event()
|
| 65 |
self.audio_queue = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
|
| 66 |
device_index = get_input_device()
|
|
|
|
| 75 |
async def stop(self) -> None:
|
| 76 |
self.stop_event.set()
|
| 77 |
if self.capture_task:
|
| 78 |
+
try:
|
| 79 |
+
await asyncio.wait_for(self.capture_task, timeout=2.0)
|
| 80 |
+
except asyncio.TimeoutError:
|
| 81 |
+
self.capture_task.cancel()
|
| 82 |
+
await asyncio.gather(self.capture_task, return_exceptions=True)
|
| 83 |
self.capture_task = None
|
| 84 |
+
drain_queue(self.audio_queue)
|
| 85 |
|
| 86 |
async def transcript_stream(self):
|
| 87 |
+
stream_id = self.stream_id
|
| 88 |
+
stop_event = self.stop_event
|
| 89 |
+
audio_queue = self.audio_queue
|
| 90 |
transcript = ""
|
| 91 |
+
last_text_at = asyncio.get_running_loop().time()
|
| 92 |
+
last_pause_transcript = ""
|
| 93 |
+
while not stop_event.is_set() and stream_id == self.stream_id:
|
| 94 |
try:
|
| 95 |
+
chunk = await asyncio.wait_for(audio_queue.get(), timeout=1.0)
|
| 96 |
except asyncio.TimeoutError:
|
| 97 |
+
now = asyncio.get_running_loop().time()
|
| 98 |
+
pause_seconds = now - last_text_at
|
| 99 |
+
if transcript and transcript != last_pause_transcript and pause_seconds >= QUESTION_PAUSE_SECONDS:
|
| 100 |
+
last_pause_transcript = transcript
|
| 101 |
+
yield transcript, True, pause_seconds
|
| 102 |
continue
|
| 103 |
|
| 104 |
try:
|
|
|
|
| 107 |
text = f"[live transcription error: {exc}]"
|
| 108 |
if text:
|
| 109 |
transcript = merge_chunk_text(transcript, text)
|
| 110 |
+
last_text_at = asyncio.get_running_loop().time()
|
| 111 |
+
last_pause_transcript = ""
|
| 112 |
+
yield transcript, False, 0.0
|
| 113 |
|
| 114 |
|
| 115 |
async def capture_audio(
|
|
|
|
| 161 |
audio_queue.put_nowait(chunk)
|
| 162 |
|
| 163 |
|
| 164 |
+
def drain_queue(audio_queue: asyncio.Queue[np.ndarray]) -> None:
|
| 165 |
+
while True:
|
| 166 |
+
try:
|
| 167 |
+
audio_queue.get_nowait()
|
| 168 |
+
except asyncio.QueueEmpty:
|
| 169 |
+
break
|
| 170 |
+
|
| 171 |
+
|
| 172 |
async def transcribe_chunk(audio: np.ndarray, model: str = WHISPER_MODEL) -> str:
|
| 173 |
if audio.size == 0 or is_silent(audio):
|
| 174 |
return ""
|
|
|
|
| 267 |
)
|
| 268 |
|
| 269 |
|
| 270 |
+
async def warmup_transcriber(
|
| 271 |
+
model: str = WHISPER_MODEL,
|
| 272 |
+
backend: str | None = None,
|
| 273 |
+
hf_model: str = HF_WHISPER_MODEL,
|
| 274 |
+
) -> None:
|
| 275 |
+
await asyncio.to_thread(_warmup_transcriber_sync, model, backend, hf_model)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _warmup_transcriber_sync(
|
| 279 |
+
model: str,
|
| 280 |
+
backend: str | None = None,
|
| 281 |
+
hf_model: str = HF_WHISPER_MODEL,
|
| 282 |
+
) -> None:
|
| 283 |
+
if (backend or STT_BACKEND) == "transformers":
|
| 284 |
+
get_asr_pipeline(hf_model)
|
| 285 |
+
return
|
| 286 |
+
|
| 287 |
+
import mlx_whisper
|
| 288 |
+
|
| 289 |
+
waveform = np.zeros(SAMPLE_RATE, dtype=np.float32)
|
| 290 |
+
mlx_whisper.transcribe(waveform, path_or_hf_repo=model, verbose=False)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
def _transcribe_audio_file_sync(
|
| 294 |
audio_path: str,
|
| 295 |
model: str,
|
|
|
|
| 305 |
|
| 306 |
result = mlx_whisper.transcribe(audio_path, path_or_hf_repo=model)
|
| 307 |
if isinstance(result, dict):
|
| 308 |
+
return clean_transcript_text(str(result.get("text", "")))
|
| 309 |
+
return clean_transcript_text(str(result))
|
| 310 |
except Exception as exc:
|
| 311 |
return f"[transcription unavailable: {exc}]"
|
| 312 |
|
|
|
|
| 334 |
**(decode_options or {}),
|
| 335 |
)
|
| 336 |
if isinstance(result, dict):
|
| 337 |
+
return clean_transcript_text(str(result.get("text", "")))
|
| 338 |
+
return clean_transcript_text(str(result))
|
| 339 |
except Exception as exc:
|
| 340 |
return f"[transcription unavailable: {exc}]"
|
| 341 |
|
|
|
|
| 355 |
},
|
| 356 |
)
|
| 357 |
if isinstance(result, dict):
|
| 358 |
+
return clean_transcript_text(str(result.get("text", "")))
|
| 359 |
+
return clean_transcript_text(str(result))
|
| 360 |
except Exception as exc:
|
| 361 |
return f"[transcription unavailable: {exc}]"
|
| 362 |
|
| 363 |
|
| 364 |
+
def clean_transcript_text(text: str) -> str:
|
| 365 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 366 |
+
text = normalize_percentage_phrases(text)
|
| 367 |
+
text = remove_adjacent_numeric_stutters(text)
|
| 368 |
+
text = re.sub(r"\b(\w+)(?:\s+\1\b)+", r"\1", text, flags=re.IGNORECASE)
|
| 369 |
+
text = re.sub(r"\b(\d+)(?:\s+\1\b)+", r"\1", text)
|
| 370 |
+
return text
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def normalize_percentage_phrases(text: str) -> str:
|
| 374 |
+
text = re.sub(r"\b(\d+(?:\.\d+)?)\s*(?:percent|percentage)\b", r"\1%", text, flags=re.IGNORECASE)
|
| 375 |
+
text = re.sub(r"\b(\d+(?:\.\d+)?)\s+%\b", r"\1%", text)
|
| 376 |
+
return text
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def remove_adjacent_numeric_stutters(text: str) -> str:
|
| 380 |
+
tokens = text.split()
|
| 381 |
+
cleaned: list[str] = []
|
| 382 |
+
for token in tokens:
|
| 383 |
+
current_number = normalized_number_token(token)
|
| 384 |
+
previous_number = normalized_number_token(cleaned[-1]) if cleaned else ""
|
| 385 |
+
if current_number and current_number == previous_number:
|
| 386 |
+
cleaned[-1] = prefer_percentage_token(cleaned[-1], token)
|
| 387 |
+
continue
|
| 388 |
+
cleaned.append(token)
|
| 389 |
+
return " ".join(cleaned)
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def normalized_number_token(token: str) -> str:
|
| 393 |
+
match = re.fullmatch(r"(\d+(?:\.\d+)?)(?:%|[.,!?;:]*)", token.strip())
|
| 394 |
+
return match.group(1) if match else ""
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def prefer_percentage_token(left: str, right: str) -> str:
|
| 398 |
+
if "%" in right and "%" not in left:
|
| 399 |
+
return right
|
| 400 |
+
return left
|
| 401 |
+
|
| 402 |
+
|
| 403 |
def get_asr_pipeline(model: str):
|
| 404 |
global _asr_pipeline
|
| 405 |
if _asr_pipeline is None:
|
prompts.py
CHANGED
|
@@ -29,6 +29,96 @@ Fine-tuned coarse steps, for context only:
|
|
| 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.
|
|
@@ -96,8 +186,8 @@ Return only valid JSON with keys:
|
|
| 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,
|
| 101 |
|
| 102 |
Rules:
|
| 103 |
- Extract the interviewer's actual target question, not a candidate clarification.
|
|
@@ -203,14 +293,19 @@ Target questions are only Data Science, Machine Learning, AI Engineering, MLOps,
|
|
| 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.
|
|
@@ -221,7 +316,11 @@ JSON: {"exchanges":[{"question":"You have a dataset where 95% of transactions ar
|
|
| 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}
|
|
|
|
| 29 |
|
| 30 |
Generate question-specific coaching hints the candidate can glance at while answering."""
|
| 31 |
|
| 32 |
+
QUESTION_DETECTOR_SYSTEM_PROMPT = """You analyze live interview transcripts to detect interviewer questions.
|
| 33 |
+
Return valid JSON only. Do not include explanations."""
|
| 34 |
+
|
| 35 |
+
QUESTION_DETECTOR_USER_PROMPT = """You are analyzing a live interview transcript to detect interview questions.
|
| 36 |
+
|
| 37 |
+
Given the transcript excerpt below, scan backward from the end and determine the latest complete interviewer question.
|
| 38 |
+
|
| 39 |
+
A question is detected when:
|
| 40 |
+
- Starts with: what, how, why, when, where, which, who, have, has, had,
|
| 41 |
+
can, could, would, should, do, does, did, tell me, describe, explain,
|
| 42 |
+
walk me through, give me an example, imagine, suppose, let's say
|
| 43 |
+
- Is directed at the candidate (not small talk or acknowledgement)
|
| 44 |
+
- Is complete — not a half sentence still in progress
|
| 45 |
+
- Is a target technical interview question about Data Science, Machine Learning,
|
| 46 |
+
AI Engineering, MLOps, statistics, analytics, coding, algorithms, or System Design
|
| 47 |
+
|
| 48 |
+
NOT a question:
|
| 49 |
+
- Interviewer acknowledgements: "good", "great", "okay", "I see"
|
| 50 |
+
- Candidate answers
|
| 51 |
+
- App/tool/meta discussion, such as "how does it work here?" or
|
| 52 |
+
"what does it mean to extract relevant questions?"
|
| 53 |
+
- Interview logistics, process discussion, or generic conversation
|
| 54 |
+
- Examples inside candidate answers, such as "like classifying emails..." or "like grouping customers..."
|
| 55 |
+
- Filler or thinking out loud: "so...", "right...", "hmm"
|
| 56 |
+
- Incomplete sentences cut off mid-thought
|
| 57 |
+
|
| 58 |
+
Additional live-interview context:
|
| 59 |
+
- Previous shown question: {previous_question}
|
| 60 |
+
- Observed silence after this transcript: {pause_seconds:.1f} seconds
|
| 61 |
+
- First identify all complete interviewer questions in this excerpt in chronological order.
|
| 62 |
+
- Then return the last complete interviewer question nearest the end of the transcript.
|
| 63 |
+
- Always prefer the question closest to the end of the transcript, not the first question in the excerpt.
|
| 64 |
+
- If the end of the transcript is a candidate answer, scan backward to the interviewer question immediately before that answer.
|
| 65 |
+
- If Previous shown question is not None, do not return it again; detect only a newer interviewer question after it.
|
| 66 |
+
- If Previous shown question is None, recover the first complete interviewer question even if the candidate answer already follows.
|
| 67 |
+
- If the candidate answer has started, return the interviewer question immediately before that answer.
|
| 68 |
+
- Do not return answer conclusions such as "the key difference is...", "the main reason is...", "this means...", or "the model finds...".
|
| 69 |
+
- Do not rewrite candidate answer text into a new question. For example, if the transcript says "In supervised learning, the model trains on labeled data", do not return "Can you explain how this differs from unsupervised learning?"
|
| 70 |
+
- Do not infer a new prompt from examples inside the answer. For example, do not turn "like grouping customers" into "give me an example of unsupervised learning".
|
| 71 |
+
- Clean fragmented STT wording into one faithful question without adding facts from the answer.
|
| 72 |
+
|
| 73 |
+
Transcript:
|
| 74 |
+
{transcript}
|
| 75 |
+
|
| 76 |
+
Respond in JSON only, no explanation:
|
| 77 |
+
{{
|
| 78 |
+
"question_detected": true or false,
|
| 79 |
+
"question": "clean version of the question or null",
|
| 80 |
+
"speaker": "interviewer or candidate or unknown",
|
| 81 |
+
"confidence": "high or medium or low",
|
| 82 |
+
"is_target": true or false,
|
| 83 |
+
"domain": "data_science, machine_learning, ai_engineering, mlops, statistics, analytics, coding, algorithms, system_design, or other"
|
| 84 |
+
}}"""
|
| 85 |
+
|
| 86 |
+
QUESTION_LIST_DETECTOR_SYSTEM_PROMPT = """You extract interviewer questions from noisy live interview transcripts.
|
| 87 |
+
Return valid JSON only. Do not include explanations."""
|
| 88 |
+
|
| 89 |
+
QUESTION_LIST_DETECTOR_USER_PROMPT = """You are analyzing a live interview transcript.
|
| 90 |
+
|
| 91 |
+
Extract every complete interviewer question in chronological order.
|
| 92 |
+
|
| 93 |
+
Rules:
|
| 94 |
+
- Only include questions asked by the interviewer and directed at the candidate.
|
| 95 |
+
- Only include target technical interview questions about Data Science, Machine Learning, AI Engineering, MLOps, statistics, analytics, coding, algorithms, or System Design.
|
| 96 |
+
- Exclude app/tool/meta discussion, interview logistics, process discussion, and generic conversation.
|
| 97 |
+
- Ignore candidate answers, examples inside answers, filler, acknowledgements, and small talk.
|
| 98 |
+
- Clean fragmented STT wording into one faithful question.
|
| 99 |
+
- Do not invent questions from answer content.
|
| 100 |
+
- Do not include incomplete fragments.
|
| 101 |
+
- If a previous shown question is provided, still include it if it appears, but the caller will choose a newer one.
|
| 102 |
+
|
| 103 |
+
Previous shown question:
|
| 104 |
+
{previous_question}
|
| 105 |
+
|
| 106 |
+
Transcript:
|
| 107 |
+
{transcript}
|
| 108 |
+
|
| 109 |
+
Respond in JSON only:
|
| 110 |
+
{{
|
| 111 |
+
"questions": [
|
| 112 |
+
{{
|
| 113 |
+
"question": "clean question",
|
| 114 |
+
"speaker": "interviewer or candidate or unknown",
|
| 115 |
+
"confidence": "high or medium or low",
|
| 116 |
+
"is_target": true or false,
|
| 117 |
+
"domain": "data_science, machine_learning, ai_engineering, mlops, statistics, analytics, coding, algorithms, system_design, or other"
|
| 118 |
+
}}
|
| 119 |
+
]
|
| 120 |
+
}}"""
|
| 121 |
+
|
| 122 |
EVALUATOR_SYSTEM_PROMPT = """You are a senior ML interview evaluator.
|
| 123 |
You are NOT generating a perfect answer.
|
| 124 |
You are assessing whether the candidate demonstrated structured thinking.
|
|
|
|
| 186 |
"complete": boolean,
|
| 187 |
"reason": string.
|
| 188 |
|
| 189 |
+
Target questions are only Data Science, Machine Learning, AI Engineering, MLOps, statistics, analytics, coding, algorithms, or System Design interview questions.
|
| 190 |
+
Reject greetings, logistics, small talk, background discussion, interviewer transitions, generic interview setup, app/tool/meta discussion, and process questions about extracting questions.
|
| 191 |
|
| 192 |
Rules:
|
| 193 |
- Extract the interviewer's actual target question, not a candidate clarification.
|
|
|
|
| 293 |
Rules:
|
| 294 |
- Extract every target interviewer question in chronological order.
|
| 295 |
- Do not only extract the latest question.
|
| 296 |
+
- Yes/no experience prompts are valid interview questions when technical, for example "Have you worked with any supervised learning algorithms?"
|
| 297 |
- Each candidate answer belongs to the target question immediately before it.
|
| 298 |
+
- A new exchange can start after interviewer transitions such as "good", "let's move to the next", or "next question".
|
| 299 |
- Preserve candidate answers fully. Do not summarize, rewrite conceptually, improve, or add missing ideas.
|
| 300 |
- Do not shorten the candidate answer to only the final/direct sentence. Keep definitions, reasoning, examples, caveats, and explanatory setup.
|
| 301 |
+
- Keep the candidate's full spoken answer even when it has grammar errors or repeated fragments; only remove obvious STT noise and duplicated adjacent words.
|
| 302 |
+
- Never replace a long candidate answer with a polished summary.
|
| 303 |
- The answer ends only when the next interviewer question, interviewer transition, or transcript end begins.
|
| 304 |
- Only clean obvious STT noise: repeated words, broken punctuation, filler fragments, and spelling/word recognition mistakes when meaning is clear.
|
| 305 |
- Correct obvious ML/STT word errors when context is clear, such as "Frot" -> "fraud" and "data trips/drips" -> "data drift".
|
| 306 |
- Never use candidate answer content to invent or expand the interviewer question.
|
| 307 |
- If the interviewer question is noisy but inferable, reconstruct the shortest faithful question.
|
| 308 |
+
- If the same question appears as fragments or repeated repairs, return one clean version only.
|
| 309 |
- If a target question has no answer, return an empty string for "answer".
|
| 310 |
- Exclude greetings, logistics, transitions, interviewer praise, and non-target small talk.
|
| 311 |
- Keep "reason" under 20 words.
|
|
|
|
| 316 |
|
| 317 |
Example:
|
| 318 |
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."
|
| 319 |
+
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."}]}
|
| 320 |
+
|
| 321 |
+
Example:
|
| 322 |
+
Transcript: "What's the difference between supervised and unsupervised machine learning. In supervised learning the model trains on labeled data. In unsupervised learning there are no labels. Good. Have you worked with any supervised learning algorithms? Yes, I have used logistic regression for binary classification and gradient boosting models like XGBoost."
|
| 323 |
+
JSON: {"exchanges":[{"question":"What's the difference between supervised and unsupervised machine learning?","answer":"In supervised learning the model trains on labeled data. In unsupervised learning there are no labels.","is_target":true,"complete":true,"reason":"Extracted first ML question."},{"question":"Have you worked with any supervised learning algorithms?","answer":"Yes, I have used logistic regression for binary classification and gradient boosting models like XGBoost.","is_target":true,"complete":true,"reason":"Extracted technical experience question."}]}"""
|
| 324 |
|
| 325 |
MULTI_EXCHANGE_EXTRACTOR_USER_PROMPT = """Transcript:
|
| 326 |
{transcript}
|