vadirajkrishna Codex commited on
Commit
9899af7
·
unverified ·
1 Parent(s): 1926418

final changes

Browse files

Co-authored-by: Codex <codex@openai.com>

Files changed (8) hide show
  1. README.md +5 -0
  2. agents/hf_chat.py +7 -0
  3. agents/topic_pattern.py +10 -0
  4. app.py +1114 -274
  5. article.md +34 -6
  6. config.py +2 -6
  7. nodes/audio.py +104 -13
  8. 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
- GENERIC_EXTRACTION_MAX_WINDOW_CHARS = 1800
305
- GENERIC_EXTRACTION_MIN_LLM_DELTA_CHARS = 80
306
- LIVE_CARD_ANALYSIS_MIN_DELTA_CHARS = 30
307
- LIVE_FAST_QUESTION_WINDOW_CHARS = 420
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
- ensure_topic_model_warmup()
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=True)
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=True)
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 = merge_transcript_text(latest_stream_transcript(state, current_transcript), 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
- state.get("card_html") or render_answer_card(),
805
  format_stream_status(state, "quiet audio skipped"),
806
  state,
807
- state.get("card_state") or {},
 
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
- ensure_topic_model_warmup()
858
  await live_audio.start()
859
  monitor_state = fresh_card_monitor_state()
860
- async for transcript in live_audio.transcript_stream():
861
- card_html, card_state = await monitor_answer_card(
862
- transcript,
863
- monitor_state,
864
- force=False,
865
- fast=True,
866
- )
867
- yield (
868
- transcript,
869
- card_html,
870
- "Capturing system audio via BlackHole/default input",
871
- card_state,
872
- monitor_state,
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
- return transcript, render_cards(cards), log, last_state
 
 
 
 
 
 
 
 
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
- return transcript, card, log, state
 
 
 
 
 
 
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
- question = extract_fast_live_question_candidate(transcript, monitor_state, force=force)
965
- else:
966
- question = await extract_target_question_from_transcript(transcript, monitor_state, force=force)
 
 
 
 
 
967
  if not question:
968
  message = monitor_state.get("last_extraction_message", "")
969
- card_html = monitor_state.get("card_html") or render_answer_card({"message": message})
 
970
  return card_html, monitor_state.get("card_state") or {}
971
 
972
- question_key = question_dedupe_key(question)
973
- if is_duplicate_live_question(question_key, monitor_state):
974
- return monitor_state.get("card_html") or render_answer_card(), monitor_state.get("card_state") or {}
975
- if question_key == monitor_state.get("last_non_target_question_key"):
976
- return monitor_state.get("card_html") or render_answer_card(), monitor_state.get("card_state") or {}
 
 
 
 
 
977
 
978
  result = await classify_topic_and_steps(question)
979
  if result.get("model_unavailable"):
980
  monitor_state["last_extraction_message"] = result["message"]
981
- return monitor_state.get("card_html") or render_answer_card({"message": result["message"]}), {}
982
- if fast and result.get("type") not in LIVE_TARGET_FRAMEWORKS:
983
- monitor_state["last_non_target_question"] = question
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
- "question_key": question_key,
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
- cards = monitor_state.setdefault("cards", [])
1005
- if not any(questions_are_similar(question_key, str(card.get("question_key", ""))) for card in cards):
1006
- cards.append(card_state)
1007
- card_html = render_cards([render_card(card, flash=card.get("question") == question) for card in cards])
 
 
 
 
 
 
 
1008
  monitor_state["last_question"] = question
1009
- monitor_state["last_question_key"] = question_key
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
- ensure_topic_model_warmup()
1027
- return await refresh_live_card_from_transcript(transcript, monitor_state, force=True)
 
 
 
 
 
 
 
 
 
 
 
 
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
- "last_question_key": "",
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 extract_fast_live_question_candidate(
1106
  transcript: str,
1107
  state: dict[str, Any],
1108
- force: bool = False,
1109
  ) -> str | None:
1110
- clean = normalize_llm_text(transcript)
1111
- if len(clean.split()) < 5:
1112
- state["last_extraction_message"] = "Listening for a complete DS/ML/AI/System Design question."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1113
  return None
1114
 
1115
- last_checked_until = int(state.get("fast_last_checked_until") or 0)
1116
- if not force and len(clean) - last_checked_until < LIVE_FAST_MIN_DELTA_CHARS:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1117
  return None
1118
 
1119
- state["fast_last_checked_until"] = len(clean)
1120
- candidate = clean_fast_live_question(clean[-LIVE_FAST_QUESTION_WINDOW_CHARS:])
1121
- if len(candidate.split()) < 5:
1122
- state["last_extraction_message"] = "Listening for a complete DS/ML/AI/System Design question."
 
 
 
 
 
 
 
 
 
 
1123
  return None
1124
- if not force and not live_question_looks_complete(candidate):
1125
- state["last_extraction_message"] = "Listening for the interviewer to finish the question."
 
 
 
 
 
 
 
 
1126
  return None
1127
 
1128
  state["last_extraction_message"] = ""
1129
- return candidate
1130
 
1131
 
1132
- def clean_fast_live_question(window: str) -> str:
1133
- candidate = f" {normalize_llm_text(window)} "
1134
- lowered = candidate.lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- lowered = f" {candidate.lower()} "
1142
- answer_positions = [lowered.find(marker) for marker in LIVE_ANSWER_STARTS]
1143
- answer_positions = [position for position in answer_positions if position > 6]
1144
- if answer_positions:
1145
- candidate = candidate[: min(answer_positions)].strip()
1146
 
1147
- candidate = re.sub(
1148
- r"^(?:first|second|third|next|another)(?:\s+(?:question|one))?\s*[:,.-]?\s*",
1149
- "",
1150
- candidate,
1151
- flags=re.IGNORECASE,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1152
  )
1153
- candidate = re.sub(r"^(you have)\.\s+\1\b", r"\1", candidate, flags=re.IGNORECASE)
1154
 
1155
- question_mark = candidate.rfind("?")
1156
- if question_mark >= 0:
1157
- candidate = candidate[: question_mark + 1]
1158
 
1159
- candidate = remove_fast_transcript_repeats(candidate)
1160
- candidate = candidate.strip(" .,-:")
1161
- if candidate and not candidate.endswith("?"):
1162
- candidate = f"{candidate}?"
1163
- return candidate
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1164
 
1165
 
1166
- def remove_fast_transcript_repeats(text: str) -> str:
1167
- words = text.split()
1168
  cleaned: list[str] = []
1169
  for word in words:
1170
- normalized = word.lower().strip(".,?!:;")
1171
- if cleaned and normalized == cleaned[-1].lower().strip(".,?!:;"):
1172
  continue
1173
  cleaned.append(word)
1174
- return " ".join(cleaned)
1175
 
1176
 
1177
- def live_question_looks_complete(question: str) -> bool:
1178
- clean = normalize_llm_text(question).rstrip("?")
1179
- lowered = clean.lower()
1180
- if len(clean.split()) >= 22 and any(re.search(pattern, lowered) for pattern in LIVE_COMPLETION_PATTERNS):
1181
  return True
1182
- if len(clean.split()) >= 12 and lowered.endswith(("?", " right", " correct", " adequate", " enough")):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1183
  return True
1184
- return False
1185
 
1186
-
1187
- def question_dedupe_key(question: str) -> str:
1188
- words = re.findall(r"[a-z0-9]+", question.lower())
1189
- stop_words = {
1190
- "a",
1191
- "an",
1192
- "and",
1193
- "are",
1194
- "as",
1195
- "be",
1196
- "briefly",
1197
- "can",
1198
- "could",
1199
- "do",
1200
- "does",
1201
- "explain",
1202
- "for",
1203
- "how",
1204
- "i",
1205
- "in",
1206
- "is",
1207
- "it",
1208
- "me",
1209
- "of",
1210
- "please",
1211
- "question",
1212
- "tell",
1213
- "the",
1214
- "to",
1215
- "we",
1216
- "what",
1217
- "would",
1218
- "you",
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 is_duplicate_live_question(question_key: str, state: dict[str, Any]) -> bool:
1226
- if not question_key:
 
 
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
- def questions_are_similar(left_key: str, right_key: str) -> bool:
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
- last_llm_until = int(state.get("generic_last_llm_until") or 0)
1259
- if (
1260
- not force
1261
- and len(clean) - last_llm_until < GENERIC_EXTRACTION_MIN_LLM_DELTA_CHARS
1262
- and "?" not in clean[last_llm_until:]
1263
- ):
1264
- return None
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
- question = str(result.get("question", "")).strip()
1280
- if not question or question == state.get("generic_last_extracted_question"):
1281
- return None
 
 
 
 
 
 
 
 
1282
 
1283
- state["generic_last_extracted_question"] = question
1284
- state["last_extraction_message"] = ""
1285
- return question
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- outputs=[live_transcript, answer_card, stream_status, last_state, card_monitor_state],
 
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-tiny` | ~39M parameters | Fast streaming transcription for live audio |
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-small-mlx"
11
- STREAMING_WHISPER_MODEL = "mlx-community/whisper-tiny"
12
  HF_SPACE_MODE = bool(os.environ.get("SPACE_ID")) or os.environ.get("INTERVIEW_COACH_RUNTIME") == "space"
13
  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 = 3
14
- OVERLAP_SECONDS = 0.5
15
  CHUNK_SAMPLES = int(CHUNK_SECONDS * SAMPLE_RATE)
16
  OVERLAP_SAMPLES = int(OVERLAP_SECONDS * SAMPLE_RATE)
17
- QUEUE_MAX_SIZE = 10
18
  SILENCE_RMS_THRESHOLD = 0.003
 
19
 
20
  executor = ThreadPoolExecutor(max_workers=2)
21
  _asr_pipeline = None
@@ -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
- await self.capture_task
 
 
 
 
75
  self.capture_task = None
 
76
 
77
  async def transcript_stream(self):
 
 
 
78
  transcript = ""
79
- while not self.stop_event.is_set():
 
 
80
  try:
81
- chunk = await asyncio.wait_for(self.audio_queue.get(), timeout=1.0)
82
  except asyncio.TimeoutError:
 
 
 
 
 
83
  continue
84
 
85
  try:
@@ -88,7 +107,9 @@ class LiveAudioTranscriber:
88
  text = f"[live transcription error: {exc}]"
89
  if text:
90
  transcript = merge_chunk_text(transcript, text)
91
- yield transcript
 
 
92
 
93
 
94
  async def capture_audio(
@@ -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", "")).strip()
257
- return str(result).strip()
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", "")).strip()
286
- return str(result).strip()
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", "")).strip()
307
- return str(result).strip()
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, and generic interview setup.
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}