bipinbudhathoki commited on
Commit
01c9745
·
verified ·
1 Parent(s): 71e133a

Update app/main.py

Browse files
Files changed (1) hide show
  1. app/main.py +146 -288
app/main.py CHANGED
@@ -1,17 +1,16 @@
 
1
  import json
2
  import os
3
  import re
4
- import tempfile
5
  from statistics import mean
6
  from typing import Any, Dict, List, Optional
7
 
8
  import requests
9
  from fastapi import FastAPI, File, Form, UploadFile
10
  from fastapi.middleware.cors import CORSMiddleware
11
- from faster_whisper import WhisperModel
12
  from pydantic import BaseModel
13
 
14
- app = FastAPI(title="Japanese AI Interview API", version="2.0.0")
15
 
16
  app.add_middleware(
17
  CORSMiddleware,
@@ -21,13 +20,15 @@ app.add_middleware(
21
  allow_headers=["*"],
22
  )
23
 
24
- ASR_MODEL_NAME = os.getenv("ASR_MODEL", "small")
25
  HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
 
26
  CHAT_MODEL = os.getenv("CHAT_MODEL", "Qwen/Qwen2.5-7B-Instruct-1M")
27
  MAX_DYNAMIC_QUESTIONS = int(os.getenv("MAX_DYNAMIC_QUESTIONS", "10"))
28
  MIN_DYNAMIC_QUESTIONS = int(os.getenv("MIN_DYNAMIC_QUESTIONS", "3"))
29
  HF_ROUTER_URL = os.getenv("HF_ROUTER_URL", "https://router.huggingface.co/v1/chat/completions")
 
30
  LLM_TIMEOUT_SECONDS = int(os.getenv("LLM_TIMEOUT_SECONDS", "90"))
 
31
 
32
  OPENING_QUESTION = "こんにちは。本日は面接に来ていただきありがとうございます。まず、お名前を教えてください。"
33
 
@@ -42,9 +43,6 @@ FALLBACK_TOPIC_QUESTIONS = [
42
  ("experience", "これまでの仕事や勉強の経験について少し話してください。"),
43
  ]
44
 
45
- _model: Optional[WhisperModel] = None
46
-
47
-
48
  class StartRequest(BaseModel):
49
  session_uuid: str
50
  interview_type: str = "jp_dynamic"
@@ -56,7 +54,7 @@ def root() -> Dict[str, Any]:
56
  return {
57
  "ok": True,
58
  "service": "jp-interview",
59
- "version": "2.0.0",
60
  "routes": ["/health", "/start", "/answer"],
61
  }
62
 
@@ -66,18 +64,21 @@ def health() -> Dict[str, Any]:
66
  return {
67
  "ok": True,
68
  "service": "jp-interview",
69
- "version": "2.0.0",
70
  "asr_model": ASR_MODEL_NAME,
71
  "llm_enabled": bool(HF_TOKEN),
72
  "chat_model": CHAT_MODEL,
73
  "auto_question_mode": True,
74
  "min_dynamic_questions": MIN_DYNAMIC_QUESTIONS,
75
  "max_dynamic_questions": MAX_DYNAMIC_QUESTIONS,
 
76
  }
77
 
78
 
79
  @app.post("/start")
80
  def start_interview(payload: StartRequest) -> Dict[str, Any]:
 
 
81
  memory = {
82
  "candidate_name": None,
83
  "country_name": None,
@@ -85,25 +86,25 @@ def start_interview(payload: StartRequest) -> Dict[str, Any]:
85
  "reason_for_japan": None,
86
  "occupation": None,
87
  "japanese_level": None,
88
- "ready_confirmed": None,
89
  "answers_so_far": [],
90
  "asked_topics": ["name"],
91
  "interview_type": payload.interview_type,
92
  "auto_question_mode": True,
93
- "min_questions": MIN_DYNAMIC_QUESTIONS,
94
- "max_questions": MAX_DYNAMIC_QUESTIONS,
 
 
95
  }
96
  return {
97
  "ok": True,
98
  "session_uuid": payload.session_uuid,
99
  "question_no": 1,
100
- "question_count": MAX_DYNAMIC_QUESTIONS,
101
  "question_jp": OPENING_QUESTION,
102
- "speech_text_jp": OPENING_QUESTION,
103
- "plan_text_jp": "質問数はあなたのパフォーマンスによって自動で決まります。",
104
  "memory": memory,
105
  "is_finished": False,
106
  "speak_now": True,
 
107
  }
108
 
109
 
@@ -111,30 +112,49 @@ def start_interview(payload: StartRequest) -> Dict[str, Any]:
111
  async def answer_interview(
112
  session_uuid: str = Form(...),
113
  question_no: int = Form(...),
114
- question_count: int = Form(0),
115
  question_jp: str = Form(...),
116
  memory_json: str = Form("{}"),
117
  audio: UploadFile = File(...),
118
  ) -> Dict[str, Any]:
119
  memory = safe_json_loads(memory_json)
120
- question_count = max(
121
- int(memory.get("min_questions", MIN_DYNAMIC_QUESTIONS)),
122
- min(int(memory.get("max_questions", MAX_DYNAMIC_QUESTIONS)), int(memory.get("max_questions", MAX_DYNAMIC_QUESTIONS)))
123
- )
124
  memory.setdefault("answers_so_far", [])
125
  memory.setdefault("asked_topics", [])
 
 
126
 
127
  transcript = await transcribe_upload(audio)
 
128
  if not transcript.strip():
129
- repeat_prompt = build_repeat_prompt(memory, question_jp)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  return {
131
  "ok": True,
132
  "is_finished": False,
133
  "needs_repeat": True,
134
  "session_uuid": session_uuid,
135
  "question_no": question_no,
136
- "question_count": int(merged_memory.get("max_questions", question_count)),
137
- "question_jp": question_jp,
138
  "transcript_jp": "",
139
  "answer_score": 0,
140
  "feedback_jp": repeat_prompt,
@@ -146,6 +166,8 @@ async def answer_interview(
146
  "llm_used": False,
147
  }
148
 
 
 
149
  answer_turn = {
150
  "question_no": question_no,
151
  "question_jp": question_jp,
@@ -155,12 +177,10 @@ async def answer_interview(
155
  history.append(answer_turn)
156
 
157
  llm_used = False
158
- evaluation: Dict[str, Any]
159
  if HF_TOKEN:
160
  try:
161
  evaluation = run_llm_turn(
162
  question_no=question_no,
163
- question_count=question_count,
164
  current_question=question_jp,
165
  transcript=transcript,
166
  memory=memory,
@@ -168,25 +188,9 @@ async def answer_interview(
168
  )
169
  llm_used = True
170
  except Exception as exc:
171
- evaluation = fallback_turn_evaluation(
172
- question_no=question_no,
173
- question_count=question_count,
174
- current_question=question_jp,
175
- transcript=transcript,
176
- memory=memory,
177
- history=history,
178
- error=str(exc),
179
- )
180
  else:
181
- evaluation = fallback_turn_evaluation(
182
- question_no=question_no,
183
- question_count=question_count,
184
- current_question=question_jp,
185
- transcript=transcript,
186
- memory=memory,
187
- history=history,
188
- error="HF_TOKEN is not set.",
189
- )
190
 
191
  profile_update = evaluation.get("profile_update", {})
192
  merged_memory = merge_memory(memory, profile_update)
@@ -197,29 +201,37 @@ async def answer_interview(
197
  feedback_jp = clean_text(evaluation.get("feedback_jp")) or default_feedback(answer_score)
198
  history[-1]["answer_score"] = answer_score
199
  history[-1]["feedback_jp"] = feedback_jp
200
- history[-1]["question_topic"] = evaluation.get("question_topic")
201
-
202
- should_finish = should_finish_dynamically(merged_memory, answer_score, question_no, bool(evaluation.get("continue_interview") is False))
203
-
204
- if should_finish:
205
- final_result = run_final_evaluation_if_possible(
206
- merged_memory=merged_memory,
207
- history=history,
208
- llm_used=llm_used,
209
- )
210
- if merged_memory.get("candidate_name"):
211
- final_result["closing_message_jp"] = f'{merged_memory.get("candidate_name")}さん、本日の面接練習はここまでです。ご参加ありがとうございました。'
212
- else:
213
- final_result["closing_message_jp"] = "本日の面接練習はここまでです。ご参加ありがとうございました。"
 
 
 
 
 
 
 
 
214
  return {
215
  "ok": True,
216
  "is_finished": True,
217
  "session_uuid": session_uuid,
218
  "question_no": question_no,
219
- "question_count": int(merged_memory.get("max_questions", question_count)),
220
  "transcript_jp": transcript,
221
  "answer_score": answer_score,
222
  "feedback_jp": feedback_jp,
 
223
  "memory": merged_memory,
224
  "llm_used": llm_used,
225
  "result": final_result,
@@ -230,18 +242,20 @@ async def answer_interview(
230
  if not next_question_jp:
231
  next_question_jp = choose_fallback_next_question(merged_memory, history)
232
 
233
- speech_text_jp = build_speech_text(merged_memory, feedback_jp, next_question_jp)
 
 
 
234
 
235
  return {
236
  "ok": True,
237
  "is_finished": False,
238
  "session_uuid": session_uuid,
239
  "question_no": question_no,
240
- "question_count": int(merged_memory.get("max_questions", question_count)),
241
  "transcript_jp": transcript,
242
  "answer_score": answer_score,
243
  "feedback_jp": feedback_jp,
244
- "speech_text_jp": speech_text_jp,
245
  "memory": merged_memory,
246
  "llm_used": llm_used,
247
  "next_question_no": next_question_no,
@@ -250,30 +264,42 @@ async def answer_interview(
250
  }
251
 
252
 
253
- def get_model() -> WhisperModel:
254
- global _model
255
- if _model is None:
256
- _model = WhisperModel(ASR_MODEL_NAME, device="cpu", compute_type="int8")
257
- return _model
 
 
 
 
 
 
 
 
 
 
 
258
 
 
 
 
 
 
 
259
 
260
- async def transcribe_upload(audio: UploadFile) -> str:
261
- suffix = os.path.splitext(audio.filename or "upload.webm")[1] or ".webm"
262
- with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
263
- content = await audio.read()
264
- tmp.write(content)
265
- temp_path = tmp.name
266
 
267
- try:
268
- model = get_model()
269
- segments, _info = model.transcribe(temp_path, language="ja", vad_filter=True)
270
- text = " ".join(seg.text.strip() for seg in segments).strip()
271
- return normalize_text(text)
272
- finally:
273
- try:
274
- os.remove(temp_path)
275
- except OSError:
276
- pass
 
277
 
278
 
279
  def normalize_text(text: str) -> str:
@@ -342,7 +368,7 @@ def safe_json_loads(value: str) -> Dict[str, Any]:
342
  return {}
343
 
344
 
345
- def maybe_extract_basic_profile(memory: Dict[str, Any], transcript: str, question_no: int, current_question: str = "") -> Dict[str, Any]:
346
  update: Dict[str, Any] = {}
347
  text = transcript.strip()
348
 
@@ -370,12 +396,6 @@ def maybe_extract_basic_profile(memory: Dict[str, Any], transcript: str, questio
370
  if not memory.get("japanese_level") and any(x in text for x in ["日本語", "勉強", "年", "ヶ月", "少し"]):
371
  update["japanese_level"] = text[:80]
372
 
373
- if "準備はできていますか" in current_question:
374
- if any(x in text for x in ["はい", "大丈夫", "準備でき", "できます"]):
375
- update["ready_confirmed"] = True
376
- elif any(x in text for x in ["いいえ", "まだ", "少し", "できていません"]):
377
- update["ready_confirmed"] = False
378
-
379
  return update
380
 
381
 
@@ -402,60 +422,31 @@ def extract_age(text: str) -> Optional[int]:
402
  match = re.search(r"(\d{1,2})", text)
403
  if match:
404
  return int(match.group(1))
405
- kanji_map = {"一":1,"二":2,"三":3,"四":4,"五":5,"六":6,"七":7,"八":8,"九":9}
406
- # very simple fallback like 二十五
407
- km = re.search(r"([二三四五六七八九]?十?[一二三四五六七八九]?)歳", text)
408
- if km:
409
- s = km.group(1)
410
- if s == "十":
411
- return 10
412
- total = 0
413
- if "十" in s:
414
- parts = s.split("十")
415
- total += (kanji_map.get(parts[0], 1) if parts[0] else 1) * 10
416
- if len(parts) > 1 and parts[1]:
417
- total += kanji_map.get(parts[1], 0)
418
- return total or None
419
- return kanji_map.get(s)
420
  return None
421
 
422
 
423
  def choose_fallback_next_question(memory: Dict[str, Any], history: List[Dict[str, Any]]) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  name = memory.get("candidate_name") or "あなた"
425
-
426
- if not memory.get("candidate_name"):
427
- return "お名前を教えてください。"
428
-
429
- if memory.get("ready_confirmed") is None:
430
- return f"{name}さん、ありがとうございます。面接の準備はできていますか。"
431
-
432
- if not memory.get("country_name"):
433
- return f"{name}さん、ありがとうございます。どこの国から来ましたか。"
434
- if not memory.get("age"):
435
- return f"{name}さん、年齢は何歳ですか。"
436
- if not memory.get("reason_for_japan"):
437
- return f"{name}さん、日本へ行きたい理由は何ですか。"
438
- if not memory.get("occupation"):
439
- return f"{name}さん、今は仕事をしていますか。それとも勉強していますか。"
440
- if not memory.get("japanese_level"):
441
- return f"{name}さん、日本語はどのくらい勉強しましたか。"
442
-
443
- if len(history) < 8:
444
- return f"{name}さん、これまでの仕事や勉強の経験について少し話してください。"
445
-
446
  return f"{name}さん、最後に自分の強みを一つ話してください。"
447
 
448
 
449
- def fallback_turn_evaluation(
450
- question_no: int,
451
- question_count: int,
452
- current_question: str,
453
- transcript: str,
454
- memory: Dict[str, Any],
455
- history: List[Dict[str, Any]],
456
- error: str,
457
- ) -> Dict[str, Any]:
458
- profile_update = maybe_extract_basic_profile(memory, transcript, question_no, current_question)
459
  asked_topics = []
460
  for key in profile_update.keys():
461
  if key == "candidate_name":
@@ -479,14 +470,7 @@ def fallback_turn_evaluation(
479
  }
480
 
481
 
482
- def run_llm_turn(
483
- question_no: int,
484
- question_count: int,
485
- current_question: str,
486
- transcript: str,
487
- memory: Dict[str, Any],
488
- history: List[Dict[str, Any]],
489
- ) -> Dict[str, Any]:
490
  system_prompt = (
491
  "You are a Japanese mock interview examiner for learners. "
492
  "Always think about the candidate's previous answers and ask ONE natural next interview question in Japanese. "
@@ -495,10 +479,8 @@ def run_llm_turn(
495
  "Use Japanese for next_question_jp and feedback_jp. "
496
  "Return ONLY valid JSON."
497
  )
498
-
499
  payload = {
500
  "question_no": question_no,
501
- "question_count": int(merged_memory.get("max_questions", question_count)),
502
  "current_question_jp": current_question,
503
  "user_answer_jp": transcript,
504
  "profile_memory": {
@@ -510,15 +492,7 @@ def run_llm_turn(
510
  "japanese_level": memory.get("japanese_level"),
511
  },
512
  "history": history,
513
- "required_topics": [
514
- "name",
515
- "country",
516
- "age",
517
- "reason_for_japan",
518
- "occupation_or_study",
519
- "japanese_level",
520
- "strength_or_experience",
521
- ],
522
  "output_schema": {
523
  "answer_score": "integer 0-10",
524
  "feedback_jp": "short Japanese feedback",
@@ -530,32 +504,15 @@ def run_llm_turn(
530
  "age": "number or null",
531
  "reason_for_japan": "string or null",
532
  "occupation": "string or null",
533
- "japanese_level": "string or null",
534
  },
535
  "continue_interview": "boolean",
536
- "next_question_jp": "string when continue_interview is true, else empty string",
537
- },
538
- "rules": [
539
- "If the answer is unclear, ask a short clarification question.",
540
- "Use the candidate name in the next question if known.",
541
- "Do not ask more than one question.",
542
- "The interview length is dynamic based on performance.",
543
- "Weak performance may end after 3 to 5 questions.",
544
- "Good performance may continue longer, up to the internal max.",
545
- "Do not mention JSON or scoring in Japanese to the candidate.",
546
- ],
547
  }
548
-
549
  raw = call_hf_chat_json(system_prompt=system_prompt, user_payload=payload)
550
  result = normalize_llm_turn_result(raw)
551
-
552
- result["profile_update"] = merge_memory(
553
- maybe_extract_basic_profile(memory, transcript, question_no, current_question),
554
- result.get("profile_update", {}),
555
- )
556
- if question_no >= question_count:
557
- result["continue_interview"] = False
558
- result["next_question_jp"] = ""
559
  return result
560
 
561
 
@@ -563,11 +520,9 @@ def normalize_llm_turn_result(raw: Dict[str, Any]) -> Dict[str, Any]:
563
  profile = raw.get("profile_update", {})
564
  if not isinstance(profile, dict):
565
  profile = {}
566
-
567
  asked_topics = raw.get("asked_topics", [])
568
  if not isinstance(asked_topics, list):
569
  asked_topics = []
570
-
571
  return {
572
  "answer_score": clamp_int(raw.get("answer_score", 6), 0, 10),
573
  "feedback_jp": clean_text(raw.get("feedback_jp")),
@@ -603,7 +558,6 @@ def normalize_optional_int(value: Any) -> Optional[int]:
603
  def call_hf_chat_json(system_prompt: str, user_payload: Dict[str, Any]) -> Dict[str, Any]:
604
  if not HF_TOKEN:
605
  raise RuntimeError("HF_TOKEN is missing.")
606
-
607
  body = {
608
  "model": CHAT_MODEL,
609
  "messages": [
@@ -614,38 +568,24 @@ def call_hf_chat_json(system_prompt: str, user_payload: Dict[str, Any]) -> Dict[
614
  "max_tokens": 700,
615
  "response_format": {"type": "json_object"},
616
  }
617
-
618
  response = requests.post(
619
  HF_ROUTER_URL,
620
- headers={
621
- "Authorization": f"Bearer {HF_TOKEN}",
622
- "Content-Type": "application/json",
623
- },
624
  json=body,
625
  timeout=LLM_TIMEOUT_SECONDS,
626
  )
627
  response.raise_for_status()
628
  data = response.json()
629
-
630
- content = (
631
- data.get("choices", [{}])[0]
632
- .get("message", {})
633
- .get("content", "")
634
- )
635
  if not content:
636
  raise RuntimeError("HF router returned an empty completion.")
637
-
638
  parsed = json.loads(content)
639
  if not isinstance(parsed, dict):
640
  raise RuntimeError("HF router response was not a JSON object.")
641
  return parsed
642
 
643
 
644
- def run_final_evaluation_if_possible(
645
- merged_memory: Dict[str, Any],
646
- history: List[Dict[str, Any]],
647
- llm_used: bool,
648
- ) -> Dict[str, Any]:
649
  if llm_used and HF_TOKEN:
650
  try:
651
  return run_llm_final_evaluation(merged_memory, history)
@@ -658,8 +598,7 @@ def run_llm_final_evaluation(merged_memory: Dict[str, Any], history: List[Dict[s
658
  system_prompt = (
659
  "You are a Japanese interview evaluator. "
660
  "Review the interview history and return ONLY valid JSON. "
661
- "Use short Japanese for summary_jp. "
662
- "Strengths, weaknesses, and tips should be simple English bullet phrases to help the app UI."
663
  )
664
  payload = {
665
  "profile_memory": merged_memory,
@@ -667,36 +606,22 @@ def run_llm_final_evaluation(merged_memory: Dict[str, Any], history: List[Dict[s
667
  "output_schema": {
668
  "summary_jp": "short Japanese summary",
669
  "overall_score": "integer 0-100",
670
- "scores": {
671
- "fluency": "integer 1-10",
672
- "grammar": "integer 1-10",
673
- "confidence": "integer 1-10",
674
- "relevance": "integer 1-10",
675
- },
676
  "pass_fail": "PASS or FAIL",
677
- "strengths": ["array of short strings"],
678
- "weaknesses": ["array of short strings"],
679
- "tips": ["array of short strings"],
680
- },
681
- "rules": [
682
- "Be fair to beginner learners.",
683
- "Do not invent long stories.",
684
- "Base the result on the actual history only.",
685
- ],
686
  }
687
  raw = call_hf_chat_json(system_prompt=system_prompt, user_payload=payload)
688
-
689
- summary_jp = clean_text(raw.get("summary_jp")) or "面接が完了しました。"
690
- overall_score = clamp_int(raw.get("overall_score", 65), 0, 100)
691
  raw_scores = raw.get("scores", {}) if isinstance(raw.get("scores"), dict) else {}
692
-
693
  return {
694
  "candidate_name": merged_memory.get("candidate_name"),
695
  "country_name": merged_memory.get("country_name"),
696
  "age": merged_memory.get("age"),
697
- "summary_jp": summary_jp,
698
  "total_questions": len(history),
699
- "overall_score": overall_score,
700
  "scores": {
701
  "fluency": clamp_int(raw_scores.get("fluency", 6), 1, 10),
702
  "grammar": clamp_int(raw_scores.get("grammar", 6), 1, 10),
@@ -716,25 +641,6 @@ def build_fallback_final_result(merged_memory: Dict[str, Any], history: List[Dic
716
  avg_score_10 = round(mean(scores), 1) if scores else 0.0
717
  overall_score = clamp_int(avg_score_10 * 10, 0, 100)
718
  pass_fail = "PASS" if overall_score >= 60 else "FAIL"
719
-
720
- strengths: List[str] = []
721
- weaknesses: List[str] = []
722
- tips: List[str] = []
723
-
724
- if merged_memory.get("candidate_name"):
725
- strengths.append("Self introduction was understood.")
726
- else:
727
- weaknesses.append("Name was not clearly understood.")
728
-
729
- if overall_score >= 70:
730
- strengths.append("Basic answers were mostly clear.")
731
- else:
732
- weaknesses.append("Some answers were too short or unclear.")
733
-
734
- tips.append("Use one or two extra sentences in each answer.")
735
- tips.append("Try polite endings like です and ます.")
736
- tips.append("Practice speaking slowly and clearly.")
737
-
738
  return {
739
  "candidate_name": merged_memory.get("candidate_name"),
740
  "country_name": merged_memory.get("country_name"),
@@ -749,61 +655,13 @@ def build_fallback_final_result(merged_memory: Dict[str, Any], history: List[Dic
749
  "relevance": clamp_int(round(avg_score_10 + 1), 1, 10),
750
  },
751
  "pass_fail": pass_fail,
752
- "strengths": strengths,
753
- "weaknesses": weaknesses,
754
- "tips": tips,
755
  "answers": history,
756
  }
757
 
758
 
759
-
760
- def should_finish_dynamically(memory: Dict[str, Any], answer_score: int, question_no: int, llm_requested_finish: bool) -> bool:
761
- history = list(memory.get("answers_so_far", []))
762
- scores = [int(x.get("answer_score", 0)) for x in history if x.get("answer_score") is not None]
763
- avg_score = mean(scores) if scores else float(answer_score)
764
- min_questions = int(memory.get("min_questions", MIN_DYNAMIC_QUESTIONS))
765
- max_questions = int(memory.get("max_questions", MAX_DYNAMIC_QUESTIONS))
766
-
767
- # Never finish before minimum questions unless there is a serious technical issue handled elsewhere
768
- if question_no < min_questions:
769
- return False
770
-
771
- if llm_requested_finish:
772
- return True
773
-
774
- # Weak candidate: finish early
775
- if question_no >= min_questions and avg_score < 3.8:
776
- return True
777
-
778
- # Medium candidate: end around 6 questions
779
- if question_no >= 6 and avg_score < 5.6:
780
- return True
781
-
782
- # Good candidate: continue a bit longer
783
- if question_no >= 8 and avg_score < 7.0:
784
- return True
785
-
786
- # Strong candidate: allow up to max
787
- if question_no >= max_questions:
788
- return True
789
-
790
- return False
791
-
792
-
793
- def build_repeat_prompt(memory: Dict[str, Any], question_jp: str) -> str:
794
- name = memory.get("candidate_name")
795
- prefix = f"{name}さん、" if name else ""
796
- return f"{prefix}声が小さいです。もう少し大きい声で、もう一度お願いします。もう一度聞きます。{question_jp}"
797
-
798
-
799
- def build_speech_text(memory: Dict[str, Any], feedback_jp: str, next_question_jp: str) -> str:
800
- name = memory.get("candidate_name")
801
- if next_question_jp.startswith("すみません") or next_question_jp.startswith("声が小さい"):
802
- return next_question_jp
803
- if name and name not in next_question_jp:
804
- return f"{name}さん、ありがとうございます。{next_question_jp}"
805
- return f"{feedback_jp} {next_question_jp}".strip()
806
-
807
  def ensure_string_list(value: Any) -> List[str]:
808
  if not isinstance(value, list):
809
  return []
 
1
+
2
  import json
3
  import os
4
  import re
 
5
  from statistics import mean
6
  from typing import Any, Dict, List, Optional
7
 
8
  import requests
9
  from fastapi import FastAPI, File, Form, UploadFile
10
  from fastapi.middleware.cors import CORSMiddleware
 
11
  from pydantic import BaseModel
12
 
13
+ app = FastAPI(title="Japanese AI Interview API", version="2.1.0")
14
 
15
  app.add_middleware(
16
  CORSMiddleware,
 
20
  allow_headers=["*"],
21
  )
22
 
 
23
  HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
24
+ ASR_MODEL_NAME = os.getenv("ASR_MODEL", "openai/whisper-large-v3")
25
  CHAT_MODEL = os.getenv("CHAT_MODEL", "Qwen/Qwen2.5-7B-Instruct-1M")
26
  MAX_DYNAMIC_QUESTIONS = int(os.getenv("MAX_DYNAMIC_QUESTIONS", "10"))
27
  MIN_DYNAMIC_QUESTIONS = int(os.getenv("MIN_DYNAMIC_QUESTIONS", "3"))
28
  HF_ROUTER_URL = os.getenv("HF_ROUTER_URL", "https://router.huggingface.co/v1/chat/completions")
29
+ HF_INFERENCE_BASE = os.getenv("HF_INFERENCE_BASE", "https://router.huggingface.co/hf-inference/models")
30
  LLM_TIMEOUT_SECONDS = int(os.getenv("LLM_TIMEOUT_SECONDS", "90"))
31
+ ASR_TIMEOUT_SECONDS = int(os.getenv("ASR_TIMEOUT_SECONDS", "180"))
32
 
33
  OPENING_QUESTION = "こんにちは。本日は面接に来ていただきありがとうございます。まず、お名前を教えてください。"
34
 
 
43
  ("experience", "これまでの仕事や勉強の経験について少し話してください。"),
44
  ]
45
 
 
 
 
46
  class StartRequest(BaseModel):
47
  session_uuid: str
48
  interview_type: str = "jp_dynamic"
 
54
  return {
55
  "ok": True,
56
  "service": "jp-interview",
57
+ "version": "2.1.0",
58
  "routes": ["/health", "/start", "/answer"],
59
  }
60
 
 
64
  return {
65
  "ok": True,
66
  "service": "jp-interview",
67
+ "version": "2.1.0",
68
  "asr_model": ASR_MODEL_NAME,
69
  "llm_enabled": bool(HF_TOKEN),
70
  "chat_model": CHAT_MODEL,
71
  "auto_question_mode": True,
72
  "min_dynamic_questions": MIN_DYNAMIC_QUESTIONS,
73
  "max_dynamic_questions": MAX_DYNAMIC_QUESTIONS,
74
+ "uses_native_faster_whisper": False,
75
  }
76
 
77
 
78
  @app.post("/start")
79
  def start_interview(payload: StartRequest) -> Dict[str, Any]:
80
+ min_q = MIN_DYNAMIC_QUESTIONS
81
+ max_q = MAX_DYNAMIC_QUESTIONS
82
  memory = {
83
  "candidate_name": None,
84
  "country_name": None,
 
86
  "reason_for_japan": None,
87
  "occupation": None,
88
  "japanese_level": None,
 
89
  "answers_so_far": [],
90
  "asked_topics": ["name"],
91
  "interview_type": payload.interview_type,
92
  "auto_question_mode": True,
93
+ "min_questions": min_q,
94
+ "max_questions": max_q,
95
+ "low_score_count": 0,
96
+ "no_sound_count": 0,
97
  }
98
  return {
99
  "ok": True,
100
  "session_uuid": payload.session_uuid,
101
  "question_no": 1,
102
+ "question_count_mode": "auto",
103
  "question_jp": OPENING_QUESTION,
 
 
104
  "memory": memory,
105
  "is_finished": False,
106
  "speak_now": True,
107
+ "speech_text_jp": OPENING_QUESTION,
108
  }
109
 
110
 
 
112
  async def answer_interview(
113
  session_uuid: str = Form(...),
114
  question_no: int = Form(...),
115
+ question_count: Optional[int] = Form(None),
116
  question_jp: str = Form(...),
117
  memory_json: str = Form("{}"),
118
  audio: UploadFile = File(...),
119
  ) -> Dict[str, Any]:
120
  memory = safe_json_loads(memory_json)
 
 
 
 
121
  memory.setdefault("answers_so_far", [])
122
  memory.setdefault("asked_topics", [])
123
+ memory.setdefault("low_score_count", 0)
124
+ memory.setdefault("no_sound_count", 0)
125
 
126
  transcript = await transcribe_upload(audio)
127
+
128
  if not transcript.strip():
129
+ memory["no_sound_count"] = int(memory.get("no_sound_count", 0)) + 1
130
+ candidate_name = memory.get("candidate_name")
131
+ repeat_prompt = "声が小さいです。もう少し大きい声で、もう一度お願いします。"
132
+ if candidate_name:
133
+ repeat_prompt = f"{candidate_name}さん、声が小さいです。もう少し大きい声で、もう一度お願いします。"
134
+
135
+ if memory["no_sound_count"] >= 2 and len(memory.get("answers_so_far", [])) >= MIN_DYNAMIC_QUESTIONS:
136
+ result = build_fallback_final_result(memory, memory.get("answers_so_far", []))
137
+ result["closing_message_jp"] = "音声が聞こえないため、面接を終了します。ありがとうございました。"
138
+ return {
139
+ "ok": True,
140
+ "is_finished": True,
141
+ "session_uuid": session_uuid,
142
+ "question_no": question_no,
143
+ "transcript_jp": "",
144
+ "answer_score": 0,
145
+ "feedback_jp": repeat_prompt,
146
+ "speech_text_jp": repeat_prompt,
147
+ "memory": memory,
148
+ "llm_used": False,
149
+ "result": result,
150
+ }
151
+
152
  return {
153
  "ok": True,
154
  "is_finished": False,
155
  "needs_repeat": True,
156
  "session_uuid": session_uuid,
157
  "question_no": question_no,
 
 
158
  "transcript_jp": "",
159
  "answer_score": 0,
160
  "feedback_jp": repeat_prompt,
 
166
  "llm_used": False,
167
  }
168
 
169
+ memory["no_sound_count"] = 0
170
+
171
  answer_turn = {
172
  "question_no": question_no,
173
  "question_jp": question_jp,
 
177
  history.append(answer_turn)
178
 
179
  llm_used = False
 
180
  if HF_TOKEN:
181
  try:
182
  evaluation = run_llm_turn(
183
  question_no=question_no,
 
184
  current_question=question_jp,
185
  transcript=transcript,
186
  memory=memory,
 
188
  )
189
  llm_used = True
190
  except Exception as exc:
191
+ evaluation = fallback_turn_evaluation(question_no, transcript, memory, history, error=str(exc))
 
 
 
 
 
 
 
 
192
  else:
193
+ evaluation = fallback_turn_evaluation(question_no, transcript, memory, history, error="HF_TOKEN is not set.")
 
 
 
 
 
 
 
 
194
 
195
  profile_update = evaluation.get("profile_update", {})
196
  merged_memory = merge_memory(memory, profile_update)
 
201
  feedback_jp = clean_text(evaluation.get("feedback_jp")) or default_feedback(answer_score)
202
  history[-1]["answer_score"] = answer_score
203
  history[-1]["feedback_jp"] = feedback_jp
204
+
205
+ if answer_score <= 3:
206
+ merged_memory["low_score_count"] = int(memory.get("low_score_count", 0)) + 1
207
+ else:
208
+ merged_memory["low_score_count"] = 0
209
+
210
+ total_answers = len(history)
211
+ auto_finish = False
212
+ if total_answers >= MIN_DYNAMIC_QUESTIONS:
213
+ avg_score = mean([int(x.get("answer_score", 0)) for x in history])
214
+ if merged_memory["low_score_count"] >= 2 or avg_score < 3.5:
215
+ auto_finish = True
216
+ elif total_answers >= MAX_DYNAMIC_QUESTIONS:
217
+ auto_finish = True
218
+ elif avg_score >= 7 and total_answers < MAX_DYNAMIC_QUESTIONS:
219
+ auto_finish = False
220
+ elif total_answers >= 6 and avg_score < 6:
221
+ auto_finish = True
222
+
223
+ if auto_finish:
224
+ final_result = run_final_evaluation_if_possible(merged_memory, history, llm_used=llm_used)
225
+ final_result["closing_message_jp"] = "本日の面接練習はここまでです。ご参加ありがとうございました。"
226
  return {
227
  "ok": True,
228
  "is_finished": True,
229
  "session_uuid": session_uuid,
230
  "question_no": question_no,
 
231
  "transcript_jp": transcript,
232
  "answer_score": answer_score,
233
  "feedback_jp": feedback_jp,
234
+ "speech_text_jp": final_result["closing_message_jp"],
235
  "memory": merged_memory,
236
  "llm_used": llm_used,
237
  "result": final_result,
 
242
  if not next_question_jp:
243
  next_question_jp = choose_fallback_next_question(merged_memory, history)
244
 
245
+ candidate_name = merged_memory.get("candidate_name")
246
+ speech_text = next_question_jp
247
+ if candidate_name and question_no == 1:
248
+ speech_text = f"{candidate_name}さん、ありがとうございます。面接の準備はできていますか。"
249
 
250
  return {
251
  "ok": True,
252
  "is_finished": False,
253
  "session_uuid": session_uuid,
254
  "question_no": question_no,
 
255
  "transcript_jp": transcript,
256
  "answer_score": answer_score,
257
  "feedback_jp": feedback_jp,
258
+ "speech_text_jp": speech_text,
259
  "memory": merged_memory,
260
  "llm_used": llm_used,
261
  "next_question_no": next_question_no,
 
264
  }
265
 
266
 
267
+ async def transcribe_upload(audio: UploadFile) -> str:
268
+ if not HF_TOKEN:
269
+ return ""
270
+ filename = audio.filename or "upload.webm"
271
+ content = await audio.read()
272
+ if not content:
273
+ return ""
274
+
275
+ url = f"{HF_INFERENCE_BASE}/{ASR_MODEL_NAME}"
276
+ headers = {
277
+ "Authorization": f"Bearer {HF_TOKEN}",
278
+ "Content-Type": guess_mime_type(filename),
279
+ }
280
+ response = requests.post(url, headers=headers, data=content, timeout=ASR_TIMEOUT_SECONDS)
281
+ response.raise_for_status()
282
+ data = response.json()
283
 
284
+ text = ""
285
+ if isinstance(data, dict):
286
+ text = data.get("text") or data.get("generated_text") or ""
287
+ elif isinstance(data, list) and data and isinstance(data[0], dict):
288
+ text = data[0].get("text", "")
289
+ return normalize_text(text)
290
 
 
 
 
 
 
 
291
 
292
+ def guess_mime_type(filename: str) -> str:
293
+ name = (filename or "").lower()
294
+ if name.endswith(".wav"):
295
+ return "audio/wav"
296
+ if name.endswith(".mp3"):
297
+ return "audio/mpeg"
298
+ if name.endswith(".m4a"):
299
+ return "audio/mp4"
300
+ if name.endswith(".ogg"):
301
+ return "audio/ogg"
302
+ return "audio/webm"
303
 
304
 
305
  def normalize_text(text: str) -> str:
 
368
  return {}
369
 
370
 
371
+ def maybe_extract_basic_profile(memory: Dict[str, Any], transcript: str, question_no: int) -> Dict[str, Any]:
372
  update: Dict[str, Any] = {}
373
  text = transcript.strip()
374
 
 
396
  if not memory.get("japanese_level") and any(x in text for x in ["日本語", "勉強", "年", "ヶ月", "少し"]):
397
  update["japanese_level"] = text[:80]
398
 
 
 
 
 
 
 
399
  return update
400
 
401
 
 
422
  match = re.search(r"(\d{1,2})", text)
423
  if match:
424
  return int(match.group(1))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  return None
426
 
427
 
428
  def choose_fallback_next_question(memory: Dict[str, Any], history: List[Dict[str, Any]]) -> str:
429
+ for topic, question in FALLBACK_TOPIC_QUESTIONS:
430
+ if topic == "name" and not memory.get("candidate_name"):
431
+ return question
432
+ if topic == "country" and not memory.get("country_name"):
433
+ return question
434
+ if topic == "age" and not memory.get("age"):
435
+ return question
436
+ if topic == "reason_for_japan" and not memory.get("reason_for_japan"):
437
+ return question
438
+ if topic == "occupation" and not memory.get("occupation"):
439
+ return question
440
+ if topic == "japanese_level" and not memory.get("japanese_level"):
441
+ return question
442
+ if topic in ["strength", "experience"] and len(history) < 8:
443
+ return question
444
  name = memory.get("candidate_name") or "あなた"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  return f"{name}さん、最後に自分の強みを一つ話してください。"
446
 
447
 
448
+ def fallback_turn_evaluation(question_no: int, transcript: str, memory: Dict[str, Any], history: List[Dict[str, Any]], error: str) -> Dict[str, Any]:
449
+ profile_update = maybe_extract_basic_profile(memory, transcript, question_no)
 
 
 
 
 
 
 
 
450
  asked_topics = []
451
  for key in profile_update.keys():
452
  if key == "candidate_name":
 
470
  }
471
 
472
 
473
+ def run_llm_turn(question_no: int, current_question: str, transcript: str, memory: Dict[str, Any], history: List[Dict[str, Any]]) -> Dict[str, Any]:
 
 
 
 
 
 
 
474
  system_prompt = (
475
  "You are a Japanese mock interview examiner for learners. "
476
  "Always think about the candidate's previous answers and ask ONE natural next interview question in Japanese. "
 
479
  "Use Japanese for next_question_jp and feedback_jp. "
480
  "Return ONLY valid JSON."
481
  )
 
482
  payload = {
483
  "question_no": question_no,
 
484
  "current_question_jp": current_question,
485
  "user_answer_jp": transcript,
486
  "profile_memory": {
 
492
  "japanese_level": memory.get("japanese_level"),
493
  },
494
  "history": history,
495
+ "required_topics": ["name","country","reason_for_japan","occupation_or_study","japanese_level","strength_or_experience"],
 
 
 
 
 
 
 
 
496
  "output_schema": {
497
  "answer_score": "integer 0-10",
498
  "feedback_jp": "short Japanese feedback",
 
504
  "age": "number or null",
505
  "reason_for_japan": "string or null",
506
  "occupation": "string or null",
507
+ "japanese_level": "string or null"
508
  },
509
  "continue_interview": "boolean",
510
+ "next_question_jp": "string"
511
+ }
 
 
 
 
 
 
 
 
 
512
  }
 
513
  raw = call_hf_chat_json(system_prompt=system_prompt, user_payload=payload)
514
  result = normalize_llm_turn_result(raw)
515
+ result["profile_update"] = merge_memory(maybe_extract_basic_profile(memory, transcript, question_no), result.get("profile_update", {}))
 
 
 
 
 
 
 
516
  return result
517
 
518
 
 
520
  profile = raw.get("profile_update", {})
521
  if not isinstance(profile, dict):
522
  profile = {}
 
523
  asked_topics = raw.get("asked_topics", [])
524
  if not isinstance(asked_topics, list):
525
  asked_topics = []
 
526
  return {
527
  "answer_score": clamp_int(raw.get("answer_score", 6), 0, 10),
528
  "feedback_jp": clean_text(raw.get("feedback_jp")),
 
558
  def call_hf_chat_json(system_prompt: str, user_payload: Dict[str, Any]) -> Dict[str, Any]:
559
  if not HF_TOKEN:
560
  raise RuntimeError("HF_TOKEN is missing.")
 
561
  body = {
562
  "model": CHAT_MODEL,
563
  "messages": [
 
568
  "max_tokens": 700,
569
  "response_format": {"type": "json_object"},
570
  }
 
571
  response = requests.post(
572
  HF_ROUTER_URL,
573
+ headers={"Authorization": f"Bearer {HF_TOKEN}", "Content-Type": "application/json"},
 
 
 
574
  json=body,
575
  timeout=LLM_TIMEOUT_SECONDS,
576
  )
577
  response.raise_for_status()
578
  data = response.json()
579
+ content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
 
 
 
 
 
580
  if not content:
581
  raise RuntimeError("HF router returned an empty completion.")
 
582
  parsed = json.loads(content)
583
  if not isinstance(parsed, dict):
584
  raise RuntimeError("HF router response was not a JSON object.")
585
  return parsed
586
 
587
 
588
+ def run_final_evaluation_if_possible(merged_memory: Dict[str, Any], history: List[Dict[str, Any]], llm_used: bool) -> Dict[str, Any]:
 
 
 
 
589
  if llm_used and HF_TOKEN:
590
  try:
591
  return run_llm_final_evaluation(merged_memory, history)
 
598
  system_prompt = (
599
  "You are a Japanese interview evaluator. "
600
  "Review the interview history and return ONLY valid JSON. "
601
+ "Use short Japanese for summary_jp."
 
602
  )
603
  payload = {
604
  "profile_memory": merged_memory,
 
606
  "output_schema": {
607
  "summary_jp": "short Japanese summary",
608
  "overall_score": "integer 0-100",
609
+ "scores": {"fluency":"integer 1-10","grammar":"integer 1-10","confidence":"integer 1-10","relevance":"integer 1-10"},
 
 
 
 
 
610
  "pass_fail": "PASS or FAIL",
611
+ "strengths": ["array"],
612
+ "weaknesses": ["array"],
613
+ "tips": ["array"]
614
+ }
 
 
 
 
 
615
  }
616
  raw = call_hf_chat_json(system_prompt=system_prompt, user_payload=payload)
 
 
 
617
  raw_scores = raw.get("scores", {}) if isinstance(raw.get("scores"), dict) else {}
 
618
  return {
619
  "candidate_name": merged_memory.get("candidate_name"),
620
  "country_name": merged_memory.get("country_name"),
621
  "age": merged_memory.get("age"),
622
+ "summary_jp": clean_text(raw.get("summary_jp")) or "面接が完了しました。",
623
  "total_questions": len(history),
624
+ "overall_score": clamp_int(raw.get("overall_score", 65), 0, 100),
625
  "scores": {
626
  "fluency": clamp_int(raw_scores.get("fluency", 6), 1, 10),
627
  "grammar": clamp_int(raw_scores.get("grammar", 6), 1, 10),
 
641
  avg_score_10 = round(mean(scores), 1) if scores else 0.0
642
  overall_score = clamp_int(avg_score_10 * 10, 0, 100)
643
  pass_fail = "PASS" if overall_score >= 60 else "FAIL"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
644
  return {
645
  "candidate_name": merged_memory.get("candidate_name"),
646
  "country_name": merged_memory.get("country_name"),
 
655
  "relevance": clamp_int(round(avg_score_10 + 1), 1, 10),
656
  },
657
  "pass_fail": pass_fail,
658
+ "strengths": ["Basic answers were captured."] if merged_memory.get("candidate_name") else [],
659
+ "weaknesses": ["Some answers were too short."] if overall_score < 70 else [],
660
+ "tips": ["Speak slowly and clearly.", "Use one extra sentence in each answer."],
661
  "answers": history,
662
  }
663
 
664
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665
  def ensure_string_list(value: Any) -> List[str]:
666
  if not isinstance(value, list):
667
  return []