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

Update app/main.py

Browse files
Files changed (1) hide show
  1. app/main.py +472 -564
app/main.py CHANGED
@@ -2,18 +2,17 @@ import json
2
  import os
3
  import re
4
  import tempfile
5
- from pathlib import Path
6
  from statistics import mean
7
- from typing import Any, Dict, List, Optional, Tuple
8
 
9
  import requests
10
  from fastapi import FastAPI, File, Form, UploadFile
11
  from fastapi.middleware.cors import CORSMiddleware
 
12
  from pydantic import BaseModel
13
 
14
- APP_VERSION = "4.0.0"
15
 
16
- app = FastAPI(title="Japanese AI Interview API", version=APP_VERSION)
17
  app.add_middleware(
18
  CORSMiddleware,
19
  allow_origins=["*"],
@@ -22,37 +21,43 @@ app.add_middleware(
22
  allow_headers=["*"],
23
  )
24
 
 
25
  HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
26
- ASR_MODEL = os.getenv("ASR_MODEL", "openai/whisper-large-v3")
27
  CHAT_MODEL = os.getenv("CHAT_MODEL", "Qwen/Qwen2.5-7B-Instruct-1M")
 
 
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
- MAX_QUESTION_LIMIT = int(os.getenv("MAX_QUESTION_LIMIT", "20"))
31
  LLM_TIMEOUT_SECONDS = int(os.getenv("LLM_TIMEOUT_SECONDS", "90"))
32
- ASR_TIMEOUT_SECONDS = int(os.getenv("ASR_TIMEOUT_SECONDS", "180"))
33
 
34
- ROLE_BANK_PATH = Path(__file__).resolve().parents[1] / "role_bank.json"
35
- ROLE_BANK: Dict[str, Any] = json.loads(ROLE_BANK_PATH.read_text(encoding="utf-8"))
36
-
37
- REPEAT_PROMPTS = [
38
- "すみません、音が聞せんでした。マイクを確認して、もう一度お願いします。",
39
- "声が小さいです。もう少し大きい声で、もう一度お願いします。",
40
- "まだ音がうまく入りません。マイクを近づけて、もう一度お願します。",
 
 
 
 
41
  ]
42
 
 
 
 
43
  class StartRequest(BaseModel):
44
  session_uuid: str
45
- job_role: str = "construction"
46
- question_count: int = 10
47
 
48
 
49
  @app.get("/")
50
  def root() -> Dict[str, Any]:
51
  return {
52
  "ok": True,
53
- "service": "jp-role-interview",
54
- "version": APP_VERSION,
55
- "routes": ["/health", "/roles", "/start", "/answer"],
56
  }
57
 
58
 
@@ -60,74 +65,42 @@ def root() -> Dict[str, Any]:
60
  def health() -> Dict[str, Any]:
61
  return {
62
  "ok": True,
63
- "service": "jp-role-interview",
64
- "version": APP_VERSION,
65
- "hf_token_set": bool(HF_TOKEN),
66
- "asr_model": ASR_MODEL,
67
  "chat_model": CHAT_MODEL,
68
- "role_count": len(ROLE_BANK),
69
- "native_asr": False,
70
- "uses_hf_serverless_asr": True,
71
  }
72
 
73
 
74
- @app.get("/roles")
75
- def roles() -> Dict[str, Any]:
76
- items = []
77
- for key, role in ROLE_BANK.items():
78
- items.append({
79
- "role_key": key,
80
- "english_name": role["english_name"],
81
- "japanese_name": role["japanese_name"],
82
- "question_count": role["question_count"],
83
- "min_questions": role["min_questions"],
84
- "max_questions": role["max_questions"],
85
- })
86
- return {"ok": True, "roles": items}
87
-
88
-
89
  @app.post("/start")
90
  def start_interview(payload: StartRequest) -> Dict[str, Any]:
91
- role_key = payload.job_role if payload.job_role in ROLE_BANK else "construction"
92
- role = ROLE_BANK[role_key]
93
- question_count = max(role["min_questions"], min(payload.question_count, role["max_questions"], MAX_QUESTION_LIMIT))
94
-
95
- opening_question = f"{role['intro_jp']} まず、お名前を教えてください。"
96
- first_question = get_question_by_id(role, f"{role_key}_common_name_1")
97
- if first_question:
98
- opening_question = f"{role['intro_jp']} {first_question['jp']}"
99
-
100
  memory = {
101
- "session_uuid": payload.session_uuid,
102
- "job_role": role_key,
103
- "job_role_en": role["english_name"],
104
- "job_role_jp": role["japanese_name"],
105
- "question_count_target": question_count,
106
  "candidate_name": None,
107
  "country_name": None,
108
  "age": None,
109
  "reason_for_japan": None,
110
  "occupation": None,
111
  "japanese_level": None,
112
- "experience_status": "unknown",
113
  "answers_so_far": [],
114
- "asked_question_ids": [first_question["id"]] if first_question else [],
115
- "asked_themes": ["intro"],
116
- "low_score_count": 0,
117
- "no_sound_count": 0,
118
- "repeat_count": 0,
119
- "question_pool_size": role["question_count"],
120
- "interview_status": "running",
121
  }
122
-
123
  return {
124
  "ok": True,
125
  "session_uuid": payload.session_uuid,
126
- "job_role": role_key,
127
  "question_no": 1,
128
- "question_count": question_count,
129
- "question_id": first_question["id"] if first_question else f"{role_key}_common_name_1",
130
- "question_jp": opening_question,
 
131
  "memory": memory,
132
  "is_finished": False,
133
  "speak_now": True,
@@ -138,303 +111,468 @@ def start_interview(payload: StartRequest) -> Dict[str, Any]:
138
  async def answer_interview(
139
  session_uuid: str = Form(...),
140
  question_no: int = Form(...),
141
- question_count: int = Form(10),
142
- question_id: str = Form(""),
143
  question_jp: str = Form(...),
144
  memory_json: str = Form("{}"),
145
  audio: UploadFile = File(...),
146
  ) -> Dict[str, Any]:
147
  memory = safe_json_loads(memory_json)
148
- role_key = memory.get("job_role") or "construction"
149
- role = ROLE_BANK.get(role_key, ROLE_BANK["construction"])
150
- question_count = max(role["min_questions"], min(question_count, role["max_questions"], MAX_QUESTION_LIMIT))
151
-
152
- audio_bytes = await audio.read()
153
- transcript = ""
154
- asr_error = None
155
- if audio_bytes:
156
- try:
157
- transcript = transcribe_audio_with_hf(audio_bytes, audio.filename or "audio.webm")
158
- except Exception as exc:
159
- asr_error = str(exc)
160
 
 
161
  if not transcript.strip():
162
- memory["no_sound_count"] = int(memory.get("no_sound_count", 0)) + 1
163
- memory["repeat_count"] = int(memory.get("repeat_count", 0)) + 1
164
- repeat_idx = min(memory["no_sound_count"] - 1, len(REPEAT_PROMPTS) - 1)
165
- repeat_prompt = REPEAT_PROMPTS[repeat_idx]
166
- if memory["no_sound_count"] >= 2 and question_no >= role["min_questions"]:
167
- result = build_final_result(memory, role, force_fail=True, summary_jp="音声が聞こえないため、面接を終了しました。")
168
- return {
169
- "ok": True,
170
- "is_finished": True,
171
- "session_uuid": session_uuid,
172
- "question_no": question_no,
173
- "question_count": question_count,
174
- "transcript_jp": "",
175
- "answer_score": 0,
176
- "feedback_jp": repeat_prompt,
177
- "memory": memory,
178
- "llm_used": False,
179
- "result": result,
180
- }
181
  return {
182
  "ok": True,
183
  "is_finished": False,
184
  "needs_repeat": True,
185
  "session_uuid": session_uuid,
186
  "question_no": question_no,
187
- "question_count": question_count,
188
- "question_id": question_id,
189
  "question_jp": question_jp,
190
  "transcript_jp": "",
191
  "answer_score": 0,
192
  "feedback_jp": repeat_prompt,
 
193
  "memory": memory,
194
  "next_question_no": question_no,
195
- "next_question_id": question_id,
196
  "next_question_jp": question_jp,
197
  "speak_now": True,
198
- "asr_error": asr_error,
199
  "llm_used": False,
200
  }
201
 
202
- memory["no_sound_count"] = 0
203
-
204
  answer_turn = {
205
  "question_no": question_no,
206
- "question_id": question_id,
207
  "question_jp": question_jp,
208
  "answer_text_jp": transcript,
209
  }
210
- history = list(memory.get("answers_so_far", []))
211
  history.append(answer_turn)
212
 
213
- candidate_questions = select_candidate_questions(role, memory, transcript)
214
  llm_used = False
215
  evaluation: Dict[str, Any]
216
  if HF_TOKEN:
217
  try:
218
  evaluation = run_llm_turn(
219
- role=role,
220
  question_no=question_no,
221
  question_count=question_count,
222
- question_id=question_id,
223
  current_question=question_jp,
224
  transcript=transcript,
225
  memory=memory,
226
  history=history,
227
- candidate_questions=candidate_questions,
228
  )
229
  llm_used = True
230
  except Exception as exc:
231
- evaluation = fallback_turn_evaluation(role, memory, transcript, candidate_questions, error=str(exc))
 
 
 
 
 
 
 
 
232
  else:
233
- evaluation = fallback_turn_evaluation(role, memory, transcript, candidate_questions, error="HF_TOKEN is not set.")
 
 
 
 
 
 
 
 
234
 
235
  profile_update = evaluation.get("profile_update", {})
236
  merged_memory = merge_memory(memory, profile_update)
237
  merged_memory["answers_so_far"] = history
238
- merged_memory["asked_themes"] = merge_unique(memory.get("asked_themes", []), evaluation.get("asked_themes", []))
239
- if evaluation.get("next_question_id"):
240
- merged_memory["asked_question_ids"] = merge_unique(memory.get("asked_question_ids", []), [evaluation["next_question_id"]])
241
 
242
- answer_score = clamp_int(evaluation.get("answer_score", heuristic_score(transcript, role)), 0, 10)
243
  feedback_jp = clean_text(evaluation.get("feedback_jp")) or default_feedback(answer_score)
244
-
245
  history[-1]["answer_score"] = answer_score
246
  history[-1]["feedback_jp"] = feedback_jp
247
- history[-1]["question_theme"] = evaluation.get("question_theme")
248
- history[-1]["keywords_matched"] = keyword_matches(transcript, role["expected_keywords"])
249
-
250
- merged_memory["experience_status"] = detect_experience_status(merged_memory, transcript)
251
- merged_memory["low_score_count"] = int(memory.get("low_score_count", 0)) + (1 if answer_score <= 3 else 0)
252
-
253
- should_finish = decide_finish(
254
- role=role,
255
- memory=merged_memory,
256
- question_no=question_no,
257
- question_count=question_count,
258
- answer_score=answer_score,
259
- )
260
 
261
  if should_finish:
262
- result = run_final_evaluation_if_possible(merged_memory, role, llm_used=llm_used)
 
 
 
 
 
 
 
 
263
  return {
264
  "ok": True,
265
  "is_finished": True,
266
  "session_uuid": session_uuid,
267
  "question_no": question_no,
268
- "question_count": question_count,
269
  "transcript_jp": transcript,
270
  "answer_score": answer_score,
271
  "feedback_jp": feedback_jp,
272
  "memory": merged_memory,
273
  "llm_used": llm_used,
274
- "result": result,
275
  }
276
 
277
  next_question_no = question_no + 1
278
- next_question_id = clean_text(evaluation.get("next_question_id"))
279
  next_question_jp = clean_text(evaluation.get("next_question_jp"))
 
 
280
 
281
- if not next_question_id or not next_question_jp:
282
- chosen = candidate_questions[0] if candidate_questions else choose_any_unused_question(role, merged_memory)
283
- next_question_id = chosen["id"]
284
- next_question_jp = chosen["jp"]
285
 
286
  return {
287
  "ok": True,
288
  "is_finished": False,
289
  "session_uuid": session_uuid,
290
  "question_no": question_no,
291
- "question_count": question_count,
292
  "transcript_jp": transcript,
293
  "answer_score": answer_score,
294
  "feedback_jp": feedback_jp,
 
295
  "memory": merged_memory,
296
  "llm_used": llm_used,
297
  "next_question_no": next_question_no,
298
- "next_question_id": next_question_id,
299
  "next_question_jp": next_question_jp,
300
  "speak_now": True,
301
  }
302
 
303
 
304
- def transcribe_audio_with_hf(audio_bytes: bytes, filename: str) -> str:
305
- if not HF_TOKEN:
306
- raise RuntimeError("HF_TOKEN is missing for ASR.")
307
- url = f"{HF_INFERENCE_BASE}/{ASR_MODEL}"
308
- headers = {
309
- "Authorization": f"Bearer {HF_TOKEN}",
310
- "Content-Type": guess_mime_type(filename),
311
- }
312
- response = requests.post(url, headers=headers, data=audio_bytes, timeout=ASR_TIMEOUT_SECONDS)
313
- response.raise_for_status()
314
- data = response.json()
315
- if isinstance(data, dict):
316
- text = data.get("text") or data.get("generated_text") or ""
317
- return normalize_text(text)
318
- if isinstance(data, list) and data:
319
- # fallback for some provider formats
320
- text = data[0].get("text", "") if isinstance(data[0], dict) else ""
 
321
  return normalize_text(text)
322
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
 
324
 
325
- def guess_mime_type(filename: str) -> str:
326
- name = (filename or "").lower()
327
- if name.endswith(".wav"):
328
- return "audio/wav"
329
- if name.endswith(".mp3"):
330
- return "audio/mpeg"
331
- if name.endswith(".m4a"):
332
- return "audio/mp4"
333
- if name.endswith(".ogg"):
334
- return "audio/ogg"
335
- return "audio/webm"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
 
337
 
338
  def run_llm_turn(
339
- role: Dict[str, Any],
340
  question_no: int,
341
  question_count: int,
342
- question_id: str,
343
  current_question: str,
344
  transcript: str,
345
  memory: Dict[str, Any],
346
  history: List[Dict[str, Any]],
347
- candidate_questions: List[Dict[str, Any]],
348
  ) -> Dict[str, Any]:
349
  system_prompt = (
350
- "You are a Japanese interviewer for working visa practice. "
351
- "Use simple N4-level Japanese. "
352
- "Ask ONE realistic interview question at a time. "
353
- "Stay inside the selected job role. "
354
- "If the answer is weak or unclear, you may ask a short repeat or clarification question. "
355
- "If the candidate is failing badly and the minimum number of questions has been reached, you may end the interview early. "
356
  "Return ONLY valid JSON."
357
  )
 
358
  payload = {
359
- "role_key": role["role_key"],
360
- "role_name_jp": role["japanese_name"],
361
  "question_no": question_no,
362
- "question_count_target": question_count,
363
- "current_question_id": question_id,
364
  "current_question_jp": current_question,
365
- "candidate_answer_jp": transcript,
366
- "memory": {
367
  "candidate_name": memory.get("candidate_name"),
368
  "country_name": memory.get("country_name"),
369
  "age": memory.get("age"),
370
  "reason_for_japan": memory.get("reason_for_japan"),
371
  "occupation": memory.get("occupation"),
372
  "japanese_level": memory.get("japanese_level"),
373
- "experience_status": memory.get("experience_status"),
374
- "low_score_count": memory.get("low_score_count", 0),
375
- "asked_themes": memory.get("asked_themes", []),
376
  },
377
- "history_tail": history[-6:],
378
- "candidate_questions": [
379
- {
380
- "id": q["id"],
381
- "jp": q["jp"],
382
- "theme": q["theme"],
383
- "branch": q["branch"],
384
- "stage": q["stage"],
385
- "expected_keywords": q.get("expected_keywords", []),
386
- }
387
- for q in candidate_questions[:12]
388
  ],
389
- "schema": {
390
  "answer_score": "integer 0-10",
391
- "feedback_jp": "one short Japanese sentence",
392
- "question_theme": "theme string",
393
- "asked_themes": ["array"],
394
  "profile_update": {
395
  "candidate_name": "string or null",
396
  "country_name": "string or null",
397
- "age": "integer or null",
398
  "reason_for_japan": "string or null",
399
  "occupation": "string or null",
400
  "japanese_level": "string or null",
401
- "experience_status": "yes or no or unknown"
402
  },
403
  "continue_interview": "boolean",
404
- "next_question_id": "question id from candidate_questions or empty when ending",
405
- "next_question_jp": "question text from candidate_questions or empty when ending"
406
  },
407
  "rules": [
408
- "Use only the provided candidate_questions for next_question_id.",
409
- "Do not repeat a question unless clarification is needed.",
410
- "Keep the question natural and interview-like.",
411
- "Use the candidate name if known.",
412
- "If question_no is already at target, continue_interview must be false."
 
 
413
  ],
414
  }
 
415
  raw = call_hf_chat_json(system_prompt=system_prompt, user_payload=payload)
416
  result = normalize_llm_turn_result(raw)
 
417
  result["profile_update"] = merge_memory(
418
- maybe_extract_basic_profile(memory, transcript, question_no),
419
  result.get("profile_update", {}),
420
  )
421
  if question_no >= question_count:
422
  result["continue_interview"] = False
423
- result["next_question_id"] = ""
424
  result["next_question_jp"] = ""
425
  return result
426
 
427
 
428
  def normalize_llm_turn_result(raw: Dict[str, Any]) -> Dict[str, Any]:
429
- profile = raw.get("profile_update", {}) if isinstance(raw.get("profile_update"), dict) else {}
430
- asked_themes = raw.get("asked_themes", [])
431
- if not isinstance(asked_themes, list):
432
- asked_themes = []
 
 
 
 
433
  return {
434
  "answer_score": clamp_int(raw.get("answer_score", 6), 0, 10),
435
  "feedback_jp": clean_text(raw.get("feedback_jp")),
436
- "question_theme": clean_text(raw.get("question_theme")) or None,
437
- "asked_themes": [clean_text(x) for x in asked_themes if clean_text(x)],
438
  "profile_update": {
439
  "candidate_name": normalize_optional_text(profile.get("candidate_name")),
440
  "country_name": normalize_optional_text(profile.get("country_name")),
@@ -442,27 +580,41 @@ def normalize_llm_turn_result(raw: Dict[str, Any]) -> Dict[str, Any]:
442
  "reason_for_japan": normalize_optional_text(profile.get("reason_for_japan")),
443
  "occupation": normalize_optional_text(profile.get("occupation")),
444
  "japanese_level": normalize_optional_text(profile.get("japanese_level")),
445
- "experience_status": normalize_optional_text(profile.get("experience_status")),
446
  },
447
  "continue_interview": bool(raw.get("continue_interview", True)),
448
- "next_question_id": clean_text(raw.get("next_question_id")),
449
  "next_question_jp": clean_text(raw.get("next_question_jp")),
450
  }
451
 
452
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  def call_hf_chat_json(system_prompt: str, user_payload: Dict[str, Any]) -> Dict[str, Any]:
454
  if not HF_TOKEN:
455
  raise RuntimeError("HF_TOKEN is missing.")
 
456
  body = {
457
  "model": CHAT_MODEL,
458
  "messages": [
459
  {"role": "system", "content": system_prompt},
460
  {"role": "user", "content": json.dumps(user_payload, ensure_ascii=False)},
461
  ],
462
- "temperature": 0.35,
463
- "max_tokens": 900,
464
  "response_format": {"type": "json_object"},
465
  }
 
466
  response = requests.post(
467
  HF_ROUTER_URL,
468
  headers={
@@ -474,432 +626,188 @@ def call_hf_chat_json(system_prompt: str, user_payload: Dict[str, Any]) -> Dict[
474
  )
475
  response.raise_for_status()
476
  data = response.json()
477
- content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
 
 
 
 
 
478
  if not content:
479
- raise RuntimeError("HF router returned empty content.")
 
480
  parsed = json.loads(content)
481
  if not isinstance(parsed, dict):
482
- raise RuntimeError("HF router did not return a JSON object.")
483
  return parsed
484
 
485
 
486
- def select_candidate_questions(role: Dict[str, Any], memory: Dict[str, Any], transcript: str) -> List[Dict[str, Any]]:
487
- questions = role["questions"]
488
- asked_ids = set(memory.get("asked_question_ids", []))
489
- asked_themes = set(memory.get("asked_themes", []))
490
- experience_status = detect_experience_status(memory, transcript)
491
- priority: List[Tuple[int, Dict[str, Any]]] = []
492
-
493
- for q in questions:
494
- if q["id"] in asked_ids or q["stage"] == "closing":
495
- continue
496
- score = 0
497
- theme = q["theme"]
498
- stage = q["stage"]
499
- branch = q["branch"]
500
-
501
- if stage == "screening":
502
- score += 30
503
- if stage == "role":
504
- score += 20
505
- if stage == "followup":
506
- score += 10
507
-
508
- if theme not in asked_themes:
509
- score += 15
510
- else:
511
- score -= 6
512
-
513
- if branch == "yes_exp" and experience_status == "yes":
514
- score += 18
515
- elif branch == "no_exp" and experience_status == "no":
516
- score += 18
517
- elif branch in {"yes_exp", "no_exp"} and experience_status == "unknown":
518
- score -= 10
519
-
520
- if theme == "experience" and experience_status == "unknown":
521
- score += 14
522
-
523
- if theme in {"intro", "motivation", "language"} and len(memory.get("answers_so_far", [])) < 4:
524
- score += 18
525
-
526
- if theme in {"safety", "teamwork", "reliability"} and len(memory.get("answers_so_far", [])) >= 4:
527
- score += 10
528
-
529
- if keyword_matches(transcript, q.get("expected_keywords", [])):
530
- score += 4
531
-
532
- priority.append((score, q))
533
-
534
- priority.sort(key=lambda x: x[0], reverse=True)
535
- picked = [q for _, q in priority[:12]]
536
- if not picked:
537
- chosen = choose_any_unused_question(role, memory)
538
- return [chosen] if chosen else []
539
- return picked
540
-
541
-
542
- def fallback_turn_evaluation(
543
- role: Dict[str, Any],
544
- memory: Dict[str, Any],
545
- transcript: str,
546
- candidate_questions: List[Dict[str, Any]],
547
- error: str,
548
  ) -> Dict[str, Any]:
549
- profile_update = maybe_extract_basic_profile(memory, transcript, len(memory.get("answers_so_far", [])))
550
- if "experience_status" not in profile_update:
551
- profile_update["experience_status"] = detect_experience_status(memory, transcript)
552
-
553
- score = heuristic_score(transcript, role)
554
- next_q = candidate_questions[0] if candidate_questions else choose_any_unused_question(role, memory)
555
- continue_interview = True
556
- if int(memory.get("low_score_count", 0)) >= 2 and len(memory.get("answers_so_far", [])) >= role["min_questions"]:
557
- continue_interview = False
558
-
559
- return {
560
- "answer_score": score,
561
- "feedback_jp": default_feedback(score),
562
- "profile_update": profile_update,
563
- "continue_interview": continue_interview,
564
- "next_question_id": next_q["id"] if next_q else "",
565
- "next_question_jp": next_q["jp"] if next_q else "",
566
- "question_theme": next_q["theme"] if next_q else "",
567
- "asked_themes": [next_q["theme"]] if next_q else [],
568
- "debug_error": error,
569
- }
570
-
571
-
572
- def decide_finish(role: Dict[str, Any], memory: Dict[str, Any], question_no: int, question_count: int, answer_score: int) -> bool:
573
- if question_no >= question_count:
574
- return True
575
- if int(memory.get("no_sound_count", 0)) >= 2 and question_no >= role["min_questions"]:
576
- return True
577
- if int(memory.get("low_score_count", 0)) >= 3 and question_no >= role["min_questions"]:
578
- return True
579
- return False
580
-
581
-
582
- def run_final_evaluation_if_possible(merged_memory: Dict[str, Any], role: Dict[str, Any], llm_used: bool) -> Dict[str, Any]:
583
  if llm_used and HF_TOKEN:
584
  try:
585
- return run_llm_final_evaluation(merged_memory, role)
586
  except Exception:
587
  pass
588
- return build_final_result(merged_memory, role, force_fail=False)
589
 
590
 
591
- def run_llm_final_evaluation(merged_memory: Dict[str, Any], role: Dict[str, Any]) -> Dict[str, Any]:
592
- history = merged_memory.get("answers_so_far", [])
593
  system_prompt = (
594
- "You are a Japanese interview evaluator for N4-level job interview practice. "
595
- "Review the interview fairly. "
596
- "Return ONLY valid JSON. "
597
- "Use short Japanese only for summary_jp and closing_message_jp."
598
  )
599
  payload = {
600
- "role_key": role["role_key"],
601
- "role_name_jp": role["japanese_name"],
602
- "profile_memory": {
603
- "candidate_name": merged_memory.get("candidate_name"),
604
- "country_name": merged_memory.get("country_name"),
605
- "age": merged_memory.get("age"),
606
- "reason_for_japan": merged_memory.get("reason_for_japan"),
607
- "occupation": merged_memory.get("occupation"),
608
- "japanese_level": merged_memory.get("japanese_level"),
609
- "experience_status": merged_memory.get("experience_status"),
610
- },
611
  "history": history,
612
- "schema": {
613
  "summary_jp": "short Japanese summary",
614
  "overall_score": "integer 0-100",
615
  "scores": {
616
- "fluency": "1-10",
617
- "grammar": "1-10",
618
- "confidence": "1-10",
619
- "relevance": "1-10",
620
- "role_fit": "1-10"
621
  },
622
  "pass_fail": "PASS or FAIL",
623
- "strengths": ["short strings"],
624
- "weaknesses": ["short strings"],
625
- "tips": ["short strings"],
626
- "closing_message_jp": "thank you message in Japanese"
627
  },
 
 
 
 
 
628
  }
629
  raw = call_hf_chat_json(system_prompt=system_prompt, user_payload=payload)
 
 
 
630
  raw_scores = raw.get("scores", {}) if isinstance(raw.get("scores"), dict) else {}
 
631
  return {
632
  "candidate_name": merged_memory.get("candidate_name"),
633
  "country_name": merged_memory.get("country_name"),
634
  "age": merged_memory.get("age"),
635
- "job_role": merged_memory.get("job_role"),
636
- "job_role_jp": merged_memory.get("job_role_jp"),
637
- "summary_jp": clean_text(raw.get("summary_jp")) or "面接が完了しました。",
638
  "total_questions": len(history),
639
- "overall_score": clamp_int(raw.get("overall_score", 65), 0, 100),
640
  "scores": {
641
  "fluency": clamp_int(raw_scores.get("fluency", 6), 1, 10),
642
  "grammar": clamp_int(raw_scores.get("grammar", 6), 1, 10),
643
  "confidence": clamp_int(raw_scores.get("confidence", 6), 1, 10),
644
- "relevance": clamp_int(raw_scores.get("relevance", 6), 1, 10),
645
- "role_fit": clamp_int(raw_scores.get("role_fit", 6), 1, 10),
646
  },
647
  "pass_fail": "PASS" if str(raw.get("pass_fail", "PASS")).upper() == "PASS" else "FAIL",
648
  "strengths": ensure_string_list(raw.get("strengths")),
649
  "weaknesses": ensure_string_list(raw.get("weaknesses")),
650
  "tips": ensure_string_list(raw.get("tips")),
651
- "closing_message_jp": clean_text(raw.get("closing_message_jp")) or random_closing(role),
652
  "answers": history,
653
  }
654
 
655
 
656
- def build_final_result(merged_memory: Dict[str, Any], role: Dict[str, Any], force_fail: bool = False, summary_jp: str = "") -> Dict[str, Any]:
657
- history = merged_memory.get("answers_so_far", [])
658
- scores = [int(item.get("answer_score", 0)) for item in history] or [0]
659
- avg_10 = round(mean(scores), 1)
660
- overall_score = clamp_int(avg_10 * 10, 0, 100)
661
- if force_fail:
662
- overall_score = min(overall_score, 39)
663
- pass_fail = "PASS" if overall_score >= 60 and not force_fail else "FAIL"
664
 
665
  strengths: List[str] = []
666
  weaknesses: List[str] = []
667
  tips: List[str] = []
668
 
669
  if merged_memory.get("candidate_name"):
670
- strengths.append("Basic self introduction was understood.")
671
  else:
672
  weaknesses.append("Name was not clearly understood.")
673
 
674
- if merged_memory.get("experience_status") == "yes":
675
- strengths.append("Candidate shared job-related experience.")
676
- elif merged_memory.get("experience_status") == "no":
677
- weaknesses.append("No direct experience was explained clearly.")
678
-
679
  if overall_score >= 70:
680
- strengths.append("Answers were mostly clear and relevant.")
681
  else:
682
- weaknesses.append("Several answers were short or unclear.")
683
 
684
- tips.extend([
685
- "Use one or two extra sentences in each answer.",
686
- "Use polite endings like です and ます.",
687
- "Answer slowly and clearly.",
688
- ])
689
 
690
  return {
691
  "candidate_name": merged_memory.get("candidate_name"),
692
  "country_name": merged_memory.get("country_name"),
693
  "age": merged_memory.get("age"),
694
- "job_role": merged_memory.get("job_role"),
695
- "job_role_jp": merged_memory.get("job_role_jp"),
696
- "summary_jp": summary_jp or "面接が完了しました。おつかれさまでした。",
697
  "total_questions": len(history),
698
  "overall_score": overall_score,
699
  "scores": {
700
- "fluency": clamp_int(round(avg_10), 1, 10),
701
- "grammar": clamp_int(round(avg_10 - 1), 1, 10),
702
- "confidence": clamp_int(round(avg_10), 1, 10),
703
- "relevance": clamp_int(round(avg_10 + 1), 1, 10),
704
- "role_fit": clamp_int(round(avg_10), 1, 10),
705
  },
706
  "pass_fail": pass_fail,
707
  "strengths": strengths,
708
  "weaknesses": weaknesses,
709
  "tips": tips,
710
- "closing_message_jp": random_closing(role),
711
  "answers": history,
712
  }
713
 
714
 
715
- def random_closing(role: Dict[str, Any]) -> str:
716
- for q in role["questions"]:
717
- if q["stage"] == "closing":
718
- return q["jp"]
719
- return f"本日の{role['japanese_name']}の面接練習はここまでです。ご参加ありがとうございました。"
720
-
721
-
722
- def choose_any_unused_question(role: Dict[str, Any], memory: Dict[str, Any]) -> Optional[Dict[str, Any]]:
723
- asked_ids = set(memory.get("asked_question_ids", []))
724
- for q in role["questions"]:
725
- if q["id"] not in asked_ids and q["stage"] != "closing":
726
- return q
727
- return None
728
-
729
-
730
- def get_question_by_id(role: Dict[str, Any], question_id: str) -> Optional[Dict[str, Any]]:
731
- for q in role["questions"]:
732
- if q["id"] == question_id:
733
- return q
734
- return None
735
-
736
-
737
- def normalize_text(text: str) -> str:
738
- return re.sub(r"\s+", " ", (text or "")).strip()
739
-
740
-
741
- def clean_text(value: Any) -> str:
742
- return normalize_text(str(value or ""))
743
-
744
-
745
- def normalize_optional_text(value: Any) -> Optional[str]:
746
- value = clean_text(value)
747
- return value or None
748
-
749
-
750
- def normalize_optional_int(value: Any) -> Optional[int]:
751
- try:
752
- if value in (None, ""):
753
- return None
754
- return int(value)
755
- except Exception:
756
- return None
757
-
758
-
759
- def merge_unique(old_values: List[Any], new_values: List[Any]) -> List[Any]:
760
- result = list(old_values or [])
761
- for item in new_values or []:
762
- if item not in result:
763
- result.append(item)
764
- return result
765
-
766
-
767
- def safe_json_loads(value: str) -> Dict[str, Any]:
768
- try:
769
- parsed = json.loads(value or "{}")
770
- return parsed if isinstance(parsed, dict) else {}
771
- except json.JSONDecodeError:
772
- return {}
773
-
774
-
775
- def clamp_int(value: Any, low: int, high: int) -> int:
776
- try:
777
- return max(low, min(high, int(round(float(value)))))
778
- except Exception:
779
- return low
780
-
781
-
782
- def merge_memory(memory: Dict[str, Any], memory_update: Dict[str, Any]) -> Dict[str, Any]:
783
- merged = dict(memory or {})
784
- for key, value in (memory_update or {}).items():
785
- if value not in (None, "", [], {}):
786
- merged[key] = value
787
- return merged
788
-
789
-
790
- def maybe_extract_basic_profile(memory: Dict[str, Any], transcript: str, question_no: int) -> Dict[str, Any]:
791
- update: Dict[str, Any] = {}
792
- text = transcript.strip()
793
-
794
- if not memory.get("candidate_name"):
795
- name = extract_name(text)
796
- if question_no <= 2 and name:
797
- update["candidate_name"] = name
798
-
799
- if not memory.get("country_name"):
800
- country = extract_country(text)
801
- if country:
802
- update["country_name"] = country
803
-
804
- if not memory.get("age"):
805
- age = extract_age(text)
806
- if age:
807
- update["age"] = age
808
-
809
- if not memory.get("reason_for_japan") and any(x in text for x in ["日本", "働きたい", "行きたい", "勉強"]):
810
- update["reason_for_japan"] = text[:80]
811
 
812
- if not memory.get("occupation") and any(x in text for x in ["仕事", "働いて", "学生", "勉強"]):
813
- update["occupation"] = text[:80]
814
-
815
- if not memory.get("japanese_level") and any(x in text for x in ["日本語", "勉強", "年", "ヶ月", "少し"]):
816
- update["japanese_level"] = text[:80]
817
-
818
- update["experience_status"] = detect_experience_status(memory, text)
819
- return update
820
-
821
-
822
- def detect_experience_status(memory: Dict[str, Any], text: str) -> str:
823
- current = str(memory.get("experience_status", "unknown"))
824
- low = (text or "").strip()
825
- yes_markers = ["あります", "しました", "働いたことがあります", "経験があります", "やったことがあります"]
826
- no_markers = ["ありません", "ないです", "したことがありません", "経験がありませ��", "ない"]
827
- if any(x in low for x in yes_markers):
828
- return "yes"
829
- if any(x in low for x in no_markers):
830
- return "no"
831
- return current if current in {"yes", "no"} else "unknown"
832
-
833
-
834
- def extract_name(text: str) -> Optional[str]:
835
- value = text.replace("私は", "").replace("わたしは", "").replace("ぼくは", "")
836
- value = value.replace("です", "").replace("と申します", "").replace("といいます", "").strip(" 。")
837
- if not value or len(value) > 30:
838
- return None
839
- return value
840
 
 
 
 
841
 
842
- def extract_country(text: str) -> Optional[str]:
843
- known = ["ネパール", "日本", "インド", "バングラデシュ", "スリランカ", "ベトナム", "中国", "ミャンマー", "フィリピン", "インドネシア"]
844
- for item in known:
845
- if item in text:
846
- return item
847
- match = re.search(r"(.+?)から来ました", text)
848
- if match:
849
- return match.group(1).strip(" 。")
850
- return None
851
 
 
 
 
852
 
853
- def extract_age(text: str) -> Optional[int]:
854
- match = re.search(r"(\d{1,2})", text)
855
- if match:
856
- return int(match.group(1))
857
- return None
858
 
 
 
 
859
 
860
- def keyword_matches(text: str, keywords: List[str]) -> List[str]:
861
- hits = []
862
- for kw in keywords:
863
- if kw and kw in text:
864
- hits.append(kw)
865
- return hits
866
 
 
867
 
868
- def heuristic_score(transcript: str, role: Dict[str, Any]) -> int:
869
- text = transcript.strip()
870
- if not text:
871
- return 0
872
- score = 3
873
- if len(text) >= 6:
874
- score += 1
875
- if len(text) >= 12:
876
- score += 1
877
- if "です" in text or "ます" in text:
878
- score += 1
879
- hits = len(keyword_matches(text, role["expected_keywords"]))
880
- if hits >= 1:
881
- score += 1
882
- if hits >= 2:
883
- score += 1
884
- if len(text) >= 25:
885
- score += 1
886
- return min(score, 10)
887
 
 
 
 
 
888
 
889
- def default_feedback(score: int) -> str:
890
- if score >= 8:
891
- return "とても良いです。自然に答えられています。"
892
- if score >= 6:
893
- return "良いです。もう少し長く、ていねいに話すともっと良くなります。"
894
- if score >= 4:
895
- return "意味は伝わりますが、少し短いです。完全な文で話してみましょう。"
896
- return "短すぎるか、内容が分かりにくいです。もう少し詳しく話してください。"
897
 
 
 
 
 
 
 
 
898
 
899
  def ensure_string_list(value: Any) -> List[str]:
900
  if not isinstance(value, list):
901
  return []
902
- result = []
903
  for item in value:
904
  text = clean_text(item)
905
  if text:
 
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,
18
  allow_origins=["*"],
 
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
+
34
+ FALLBACK_TOPIC_QUESTIONS = [
35
+ ("name", "お名前を教えてください。"),
36
+ ("country", "どの国から来ました。"),
37
+ ("age", "年齢は何歳です。"),
38
+ ("reason_for_japan", "日本へ行きた理由は何で。"),
39
+ ("occupation", "今は仕事をしていますか。それとも勉強していますか。"),
40
+ ("japanese_level", "日本語をどのくらい勉強しましたか。"),
41
+ ("strength", "自分の長所を一つ話してください。"),
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"
51
+ question_count: Optional[int] = None
52
 
53
 
54
  @app.get("/")
55
  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
 
63
 
 
65
  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,
84
  "age": None,
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,
 
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,
141
+ "speech_text_jp": repeat_prompt,
142
  "memory": memory,
143
  "next_question_no": question_no,
 
144
  "next_question_jp": question_jp,
145
  "speak_now": True,
 
146
  "llm_used": False,
147
  }
148
 
 
 
149
  answer_turn = {
150
  "question_no": question_no,
 
151
  "question_jp": question_jp,
152
  "answer_text_jp": transcript,
153
  }
154
+ history: List[Dict[str, Any]] = list(memory.get("answers_so_far", []))
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,
167
  history=history,
 
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)
193
  merged_memory["answers_so_far"] = history
194
+ merged_memory["asked_topics"] = merge_topics(memory.get("asked_topics", []), evaluation.get("asked_topics", []))
 
 
195
 
196
+ answer_score = clamp_int(evaluation.get("answer_score", heuristic_score(transcript)), 0, 10)
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,
226
  }
227
 
228
  next_question_no = question_no + 1
 
229
  next_question_jp = clean_text(evaluation.get("next_question_jp"))
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,
 
248
  "next_question_jp": next_question_jp,
249
  "speak_now": True,
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:
280
+ return re.sub(r"\s+", " ", (text or "")).strip()
281
+
282
+
283
+ def clean_text(text: Any) -> str:
284
+ return normalize_text(str(text or ""))
285
+
286
+
287
+ def clamp_int(value: Any, low: int, high: int) -> int:
288
+ try:
289
+ return max(low, min(high, int(round(float(value)))))
290
+ except Exception:
291
+ return low
292
+
293
+
294
+ def merge_topics(old_topics: List[str], new_topics: List[str]) -> List[str]:
295
+ result = list(old_topics or [])
296
+ for item in new_topics or []:
297
+ item = str(item).strip()
298
+ if item and item not in result:
299
+ result.append(item)
300
+ return result
301
+
302
+
303
+ def merge_memory(memory: Dict[str, Any], memory_update: Dict[str, Any]) -> Dict[str, Any]:
304
+ merged = dict(memory or {})
305
+ for key, value in (memory_update or {}).items():
306
+ if value not in (None, "", [], {}):
307
+ merged[key] = value
308
+ return merged
309
+
310
+
311
+ def heuristic_score(transcript: str) -> int:
312
+ text = transcript.strip()
313
+ if not text:
314
+ return 0
315
+ score = 4
316
+ if len(text) >= 6:
317
+ score += 1
318
+ if len(text) >= 12:
319
+ score += 1
320
+ if "です" in text or "ます" in text:
321
+ score += 1
322
+ if len(text) >= 20:
323
+ score += 1
324
+ return min(score, 10)
325
 
326
 
327
+ def default_feedback(score: int) -> str:
328
+ if score >= 8:
329
+ return "とても良いです。自然に答えられています。"
330
+ if score >= 6:
331
+ return "良いです。もう少し長く、ていねいに話すともっと良くなります。"
332
+ if score >= 4:
333
+ return "意味は伝わりますが、短いです。完全な文で答えてみましょう。"
334
+ return "短すぎるか、内容が分かりにくいです。もう少し詳しく話してください。"
335
+
336
+
337
+ def safe_json_loads(value: str) -> Dict[str, Any]:
338
+ try:
339
+ parsed = json.loads(value or "{}")
340
+ return parsed if isinstance(parsed, dict) else {}
341
+ except json.JSONDecodeError:
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
+
349
+ if question_no == 1 and not memory.get("candidate_name"):
350
+ name = extract_name(text)
351
+ if name:
352
+ update["candidate_name"] = name
353
+
354
+ if not memory.get("country_name"):
355
+ country = extract_country(text)
356
+ if country:
357
+ update["country_name"] = country
358
+
359
+ if not memory.get("age"):
360
+ age = extract_age(text)
361
+ if age:
362
+ update["age"] = age
363
+
364
+ if not memory.get("reason_for_japan") and any(x in text for x in ["日本", "行きたい", "働きたい", "勉強", "仕事"]):
365
+ update["reason_for_japan"] = text[:80]
366
+
367
+ if not memory.get("occupation") and any(x in text for x in ["仕事", "働いて", "学生", "勉強"]):
368
+ update["occupation"] = text[:80]
369
+
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
+
382
+ def extract_name(text: str) -> Optional[str]:
383
+ value = text.replace("私は", "").replace("わたしは", "").replace("ぼくは", "")
384
+ value = value.replace("です", "").replace("と申します", "").replace("といいます", "").strip(" 。")
385
+ if not value or len(value) > 30:
386
+ return None
387
+ return value
388
+
389
+
390
+ def extract_country(text: str) -> Optional[str]:
391
+ known = ["ネパール", "日本", "インド", "バングラデシュ", "スリランカ", "ベトナム", "中国", "ミャンマー", "フィリピン", "インドネシア"]
392
+ for item in known:
393
+ if item in text:
394
+ return item
395
+ match = re.search(r"(.+?)から来ました", text)
396
+ if match:
397
+ return match.group(1).strip(" 。")
398
+ return None
399
+
400
+
401
+ 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":
462
+ asked_topics.append("name")
463
+ elif key == "country_name":
464
+ asked_topics.append("country")
465
+ elif key == "age":
466
+ asked_topics.append("age")
467
+ else:
468
+ asked_topics.append(key)
469
+
470
+ return {
471
+ "answer_score": heuristic_score(transcript),
472
+ "feedback_jp": default_feedback(heuristic_score(transcript)),
473
+ "profile_update": profile_update,
474
+ "continue_interview": True,
475
+ "next_question_jp": choose_fallback_next_question(merge_memory(memory, profile_update), history),
476
+ "question_topic": None,
477
+ "asked_topics": asked_topics,
478
+ "debug_error": error,
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. "
493
+ "Do not repeat answered topics unless clarification is needed. "
494
+ "Keep questions polite, short, and realistic. "
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": {
505
  "candidate_name": memory.get("candidate_name"),
506
  "country_name": memory.get("country_name"),
507
  "age": memory.get("age"),
508
  "reason_for_japan": memory.get("reason_for_japan"),
509
  "occupation": memory.get("occupation"),
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",
525
+ "question_topic": "short English snake_case or null",
526
+ "asked_topics": ["array of short topic ids"],
527
  "profile_update": {
528
  "candidate_name": "string or null",
529
  "country_name": "string or null",
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
 
562
  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")),
574
+ "question_topic": clean_text(raw.get("question_topic")) or None,
575
+ "asked_topics": [str(x).strip() for x in asked_topics if str(x).strip()],
576
  "profile_update": {
577
  "candidate_name": normalize_optional_text(profile.get("candidate_name")),
578
  "country_name": normalize_optional_text(profile.get("country_name")),
 
580
  "reason_for_japan": normalize_optional_text(profile.get("reason_for_japan")),
581
  "occupation": normalize_optional_text(profile.get("occupation")),
582
  "japanese_level": normalize_optional_text(profile.get("japanese_level")),
 
583
  },
584
  "continue_interview": bool(raw.get("continue_interview", True)),
 
585
  "next_question_jp": clean_text(raw.get("next_question_jp")),
586
  }
587
 
588
 
589
+ def normalize_optional_text(value: Any) -> Optional[str]:
590
+ value = clean_text(value)
591
+ return value or None
592
+
593
+
594
+ def normalize_optional_int(value: Any) -> Optional[int]:
595
+ try:
596
+ if value in (None, ""):
597
+ return None
598
+ return int(value)
599
+ except Exception:
600
+ return None
601
+
602
+
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": [
610
  {"role": "system", "content": system_prompt},
611
  {"role": "user", "content": json.dumps(user_payload, ensure_ascii=False)},
612
  ],
613
+ "temperature": 0.4,
614
+ "max_tokens": 700,
615
  "response_format": {"type": "json_object"},
616
  }
617
+
618
  response = requests.post(
619
  HF_ROUTER_URL,
620
  headers={
 
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)
652
  except Exception:
653
  pass
654
+ return build_fallback_final_result(merged_memory, history)
655
 
656
 
657
+ def run_llm_final_evaluation(merged_memory: Dict[str, Any], history: List[Dict[str, Any]]) -> Dict[str, Any]:
 
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,
 
 
 
 
 
 
 
 
 
 
666
  "history": history,
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),
703
  "confidence": clamp_int(raw_scores.get("confidence", 6), 1, 10),
704
+ "relevance": clamp_int(raw_scores.get("relevance", 7), 1, 10),
 
705
  },
706
  "pass_fail": "PASS" if str(raw.get("pass_fail", "PASS")).upper() == "PASS" else "FAIL",
707
  "strengths": ensure_string_list(raw.get("strengths")),
708
  "weaknesses": ensure_string_list(raw.get("weaknesses")),
709
  "tips": ensure_string_list(raw.get("tips")),
 
710
  "answers": history,
711
  }
712
 
713
 
714
+ def build_fallback_final_result(merged_memory: Dict[str, Any], history: List[Dict[str, Any]]) -> Dict[str, Any]:
715
+ scores = [int(item.get("answer_score", 0)) for item in history]
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"),
741
  "age": merged_memory.get("age"),
742
+ "summary_jp": "面接が完了しました。おつかれさまでした。",
 
 
743
  "total_questions": len(history),
744
  "overall_score": overall_score,
745
  "scores": {
746
+ "fluency": clamp_int(round(avg_score_10), 1, 10),
747
+ "grammar": clamp_int(round(avg_score_10 - 1), 1, 10),
748
+ "confidence": clamp_int(round(avg_score_10), 1, 10),
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 []
810
+ result: List[str] = []
811
  for item in value:
812
  text = clean_text(item)
813
  if text: