bipinbudhathoki commited on
Commit
24a5724
ยท
verified ยท
1 Parent(s): 01c9745

Update app/main.py

Browse files
Files changed (1) hide show
  1. app/main.py +584 -350
app/main.py CHANGED
@@ -1,17 +1,19 @@
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,
17
  allow_origins=["*"],
@@ -21,41 +23,36 @@ app.add_middleware(
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
-
35
- FALLBACK_TOPIC_QUESTIONS = [
36
- ("name", "ใŠๅๅ‰ใ‚’ๆ•™ใˆใฆใใ ใ•ใ„ใ€‚"),
37
- ("country", "ใฉใ“ใฎๅ›ฝใ‹ใ‚‰ๆฅใพใ—ใŸใ‹ใ€‚"),
38
- ("age", "ๅนด้ฝขใฏไฝ•ๆญณใงใ™ใ‹ใ€‚"),
39
- ("reason_for_japan", "ๆ—ฅๆœฌใธ่กŒใใŸใ„็†็”ฑใฏไฝ•ใงใ™ใ‹ใ€‚"),
40
- ("occupation", "ไปŠใฏไป•ไบ‹ใ‚’ใ—ใฆใ„ใพใ™ใ‹ใ€‚ใใ‚Œใจใ‚‚ๅ‹‰ๅผทใ—ใฆใ„ใพใ™ใ‹ใ€‚"),
41
- ("japanese_level", "ๆ—ฅๆœฌ่ชžใ‚’ใฉใฎใใ‚‰ใ„ๅ‹‰ๅผทใ—ใพใ—ใŸใ‹ใ€‚"),
42
- ("strength", "่‡ชๅˆ†ใฎ้•ทๆ‰€ใ‚’ไธ€ใค่ฉฑใ—ใฆใใ ใ•ใ„ใ€‚"),
43
- ("experience", "ใ“ใ‚Œใพใงใฎไป•ไบ‹ใ‚„ๅ‹‰ๅผทใฎ็ตŒ้จ“ใซใคใ„ใฆๅฐ‘ใ—่ฉฑใ—ใฆใใ ใ•ใ„ใ€‚"),
44
  ]
45
 
46
  class StartRequest(BaseModel):
47
  session_uuid: str
48
- interview_type: str = "jp_dynamic"
49
- question_count: Optional[int] = None
50
 
51
 
52
  @app.get("/")
53
  def root() -> Dict[str, Any]:
54
  return {
55
  "ok": True,
56
- "service": "jp-interview",
57
- "version": "2.1.0",
58
- "routes": ["/health", "/start", "/answer"],
59
  }
60
 
61
 
@@ -63,48 +60,77 @@ def root() -> Dict[str, Any]:
63
  def health() -> Dict[str, Any]:
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,
85
  "age": 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,57 +138,64 @@ def start_interview(payload: StartRequest) -> Dict[str, Any]:
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,
161
- "speech_text_jp": repeat_prompt,
162
  "memory": memory,
163
  "next_question_no": question_no,
 
164
  "next_question_jp": question_jp,
165
  "speak_now": True,
 
166
  "llm_used": False,
167
  }
168
 
@@ -170,123 +203,123 @@ async def answer_interview(
170
 
171
  answer_turn = {
172
  "question_no": question_no,
 
173
  "question_jp": question_jp,
174
  "answer_text_jp": transcript,
175
  }
176
- history: List[Dict[str, Any]] = list(memory.get("answers_so_far", []))
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,
187
  history=history,
 
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)
197
  merged_memory["answers_so_far"] = history
198
- merged_memory["asked_topics"] = merge_topics(memory.get("asked_topics", []), evaluation.get("asked_topics", []))
 
 
199
 
200
- answer_score = clamp_int(evaluation.get("answer_score", heuristic_score(transcript)), 0, 10)
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,
238
  }
239
 
240
  next_question_no = question_no + 1
 
241
  next_question_jp = clean_text(evaluation.get("next_question_jp"))
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,
 
262
  "next_question_jp": next_question_jp,
263
  "speak_now": True,
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:
@@ -302,232 +335,106 @@ def guess_mime_type(filename: str) -> str:
302
  return "audio/webm"
303
 
304
 
305
- def normalize_text(text: str) -> str:
306
- return re.sub(r"\s+", " ", (text or "")).strip()
307
-
308
-
309
- def clean_text(text: Any) -> str:
310
- return normalize_text(str(text or ""))
311
-
312
-
313
- def clamp_int(value: Any, low: int, high: int) -> int:
314
- try:
315
- return max(low, min(high, int(round(float(value)))))
316
- except Exception:
317
- return low
318
-
319
-
320
- def merge_topics(old_topics: List[str], new_topics: List[str]) -> List[str]:
321
- result = list(old_topics or [])
322
- for item in new_topics or []:
323
- item = str(item).strip()
324
- if item and item not in result:
325
- result.append(item)
326
- return result
327
-
328
-
329
- def merge_memory(memory: Dict[str, Any], memory_update: Dict[str, Any]) -> Dict[str, Any]:
330
- merged = dict(memory or {})
331
- for key, value in (memory_update or {}).items():
332
- if value not in (None, "", [], {}):
333
- merged[key] = value
334
- return merged
335
-
336
-
337
- def heuristic_score(transcript: str) -> int:
338
- text = transcript.strip()
339
- if not text:
340
- return 0
341
- score = 4
342
- if len(text) >= 6:
343
- score += 1
344
- if len(text) >= 12:
345
- score += 1
346
- if "ใงใ™" in text or "ใพใ™" in text:
347
- score += 1
348
- if len(text) >= 20:
349
- score += 1
350
- return min(score, 10)
351
-
352
-
353
- def default_feedback(score: int) -> str:
354
- if score >= 8:
355
- return "ใจใฆใ‚‚่‰ฏใ„ใงใ™ใ€‚่‡ช็„ถใซ็ญ”ใˆใ‚‰ใ‚Œใฆใ„ใพใ™ใ€‚"
356
- if score >= 6:
357
- return "่‰ฏใ„ใงใ™ใ€‚ใ‚‚ใ†ๅฐ‘ใ—้•ทใใ€ใฆใ„ใญใ„ใซ่ฉฑใ™ใจใ‚‚ใฃใจ่‰ฏใใชใ‚Šใพใ™ใ€‚"
358
- if score >= 4:
359
- return "ๆ„ๅ‘ณใฏไผใ‚ใ‚Šใพใ™ใŒใ€็Ÿญใ„ใงใ™ใ€‚ๅฎŒๅ…จใชๆ–‡ใง็ญ”ใˆใฆใฟใพใ—ใ‚‡ใ†ใ€‚"
360
- return "็Ÿญใ™ใŽใ‚‹ใ‹ใ€ๅ†…ๅฎนใŒๅˆ†ใ‹ใ‚Šใซใใ„ใงใ™ใ€‚ใ‚‚ใ†ๅฐ‘ใ—่ฉณใ—ใ่ฉฑใ—ใฆใใ ใ•ใ„ใ€‚"
361
-
362
-
363
- def safe_json_loads(value: str) -> Dict[str, Any]:
364
- try:
365
- parsed = json.loads(value or "{}")
366
- return parsed if isinstance(parsed, dict) else {}
367
- except json.JSONDecodeError:
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
-
375
- if question_no == 1 and not memory.get("candidate_name"):
376
- name = extract_name(text)
377
- if name:
378
- update["candidate_name"] = name
379
-
380
- if not memory.get("country_name"):
381
- country = extract_country(text)
382
- if country:
383
- update["country_name"] = country
384
-
385
- if not memory.get("age"):
386
- age = extract_age(text)
387
- if age:
388
- update["age"] = age
389
-
390
- if not memory.get("reason_for_japan") and any(x in text for x in ["ๆ—ฅๆœฌ", "่กŒใใŸใ„", "ๅƒใใŸใ„", "ๅ‹‰ๅผท", "ไป•ไบ‹"]):
391
- update["reason_for_japan"] = text[:80]
392
-
393
- if not memory.get("occupation") and any(x in text for x in ["ไป•ไบ‹", "ๅƒใ„ใฆ", "ๅญฆ็”Ÿ", "ๅ‹‰ๅผท"]):
394
- update["occupation"] = text[:80]
395
-
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
-
402
- def extract_name(text: str) -> Optional[str]:
403
- value = text.replace("็งใฏ", "").replace("ใ‚ใŸใ—ใฏ", "").replace("ใผใใฏ", "")
404
- value = value.replace("ใงใ™", "").replace("ใจ็”ณใ—ใพใ™", "").replace("ใจใ„ใ„ใพใ™", "").strip(" ใ€‚")
405
- if not value or len(value) > 30:
406
- return None
407
- return value
408
-
409
-
410
- def extract_country(text: str) -> Optional[str]:
411
- known = ["ใƒใƒ‘ใƒผใƒซ", "ๆ—ฅๆœฌ", "ใ‚คใƒณใƒ‰", "ใƒใƒณใ‚ฐใƒฉใƒ‡ใ‚ทใƒฅ", "ใ‚นใƒชใƒฉใƒณใ‚ซ", "ใƒ™ใƒˆใƒŠใƒ ", "ไธญๅ›ฝ", "ใƒŸใƒฃใƒณใƒžใƒผ", "ใƒ•ใ‚ฃใƒชใƒ”ใƒณ", "ใ‚คใƒณใƒ‰ใƒใ‚ทใ‚ข"]
412
- for item in known:
413
- if item in text:
414
- return item
415
- match = re.search(r"(.+?)ใ‹ใ‚‰ๆฅใพใ—ใŸ", text)
416
- if match:
417
- return match.group(1).strip(" ใ€‚")
418
- return None
419
-
420
-
421
- def extract_age(text: str) -> Optional[int]:
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":
453
- asked_topics.append("name")
454
- elif key == "country_name":
455
- asked_topics.append("country")
456
- elif key == "age":
457
- asked_topics.append("age")
458
- else:
459
- asked_topics.append(key)
460
-
461
- return {
462
- "answer_score": heuristic_score(transcript),
463
- "feedback_jp": default_feedback(heuristic_score(transcript)),
464
- "profile_update": profile_update,
465
- "continue_interview": True,
466
- "next_question_jp": choose_fallback_next_question(merge_memory(memory, profile_update), history),
467
- "question_topic": None,
468
- "asked_topics": asked_topics,
469
- "debug_error": error,
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. "
477
- "Do not repeat answered topics unless clarification is needed. "
478
- "Keep questions polite, short, and realistic. "
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": {
487
  "candidate_name": memory.get("candidate_name"),
488
  "country_name": memory.get("country_name"),
489
  "age": memory.get("age"),
490
  "reason_for_japan": memory.get("reason_for_japan"),
491
  "occupation": memory.get("occupation"),
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",
499
- "question_topic": "short English snake_case or null",
500
- "asked_topics": ["array of short topic ids"],
501
  "profile_update": {
502
  "candidate_name": "string or null",
503
  "country_name": "string or null",
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
 
519
  def normalize_llm_turn_result(raw: Dict[str, Any]) -> Dict[str, Any]:
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")),
529
- "question_topic": clean_text(raw.get("question_topic")) or None,
530
- "asked_topics": [str(x).strip() for x in asked_topics if str(x).strip()],
531
  "profile_update": {
532
  "candidate_name": normalize_optional_text(profile.get("candidate_name")),
533
  "country_name": normalize_optional_text(profile.get("country_name")),
@@ -535,26 +442,14 @@ def normalize_llm_turn_result(raw: Dict[str, Any]) -> Dict[str, Any]:
535
  "reason_for_japan": normalize_optional_text(profile.get("reason_for_japan")),
536
  "occupation": normalize_optional_text(profile.get("occupation")),
537
  "japanese_level": normalize_optional_text(profile.get("japanese_level")),
 
538
  },
539
  "continue_interview": bool(raw.get("continue_interview", True)),
 
540
  "next_question_jp": clean_text(raw.get("next_question_jp")),
541
  }
542
 
543
 
544
- def normalize_optional_text(value: Any) -> Optional[str]:
545
- value = clean_text(value)
546
- return value or None
547
-
548
-
549
- def normalize_optional_int(value: Any) -> Optional[int]:
550
- try:
551
- if value in (None, ""):
552
- return None
553
- return int(value)
554
- except Exception:
555
- return None
556
-
557
-
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.")
@@ -564,13 +459,16 @@ def call_hf_chat_json(system_prompt: str, user_payload: Dict[str, Any]) -> Dict[
564
  {"role": "system", "content": system_prompt},
565
  {"role": "user", "content": json.dumps(user_payload, ensure_ascii=False)},
566
  ],
567
- "temperature": 0.4,
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
  )
@@ -578,40 +476,155 @@ def call_hf_chat_json(system_prompt: str, user_payload: Dict[str, Any]) -> Dict[
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)
592
  except Exception:
593
  pass
594
- return build_fallback_final_result(merged_memory, history)
595
 
596
 
597
- def run_llm_final_evaluation(merged_memory: Dict[str, Any], history: List[Dict[str, Any]]) -> Dict[str, Any]:
 
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,
 
 
 
 
 
 
 
 
 
 
605
  "history": history,
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 {}
@@ -619,6 +632,8 @@ def run_llm_final_evaluation(merged_memory: Dict[str, Any], history: List[Dict[s
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),
@@ -626,46 +641,265 @@ def run_llm_final_evaluation(merged_memory: Dict[str, Any], history: List[Dict[s
626
  "fluency": clamp_int(raw_scores.get("fluency", 6), 1, 10),
627
  "grammar": clamp_int(raw_scores.get("grammar", 6), 1, 10),
628
  "confidence": clamp_int(raw_scores.get("confidence", 6), 1, 10),
629
- "relevance": clamp_int(raw_scores.get("relevance", 7), 1, 10),
 
630
  },
631
  "pass_fail": "PASS" if str(raw.get("pass_fail", "PASS")).upper() == "PASS" else "FAIL",
632
  "strengths": ensure_string_list(raw.get("strengths")),
633
  "weaknesses": ensure_string_list(raw.get("weaknesses")),
634
  "tips": ensure_string_list(raw.get("tips")),
 
635
  "answers": history,
636
  }
637
 
638
 
639
- def build_fallback_final_result(merged_memory: Dict[str, Any], history: List[Dict[str, Any]]) -> Dict[str, Any]:
640
- scores = [int(item.get("answer_score", 0)) for item in history]
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"),
647
  "age": merged_memory.get("age"),
648
- "summary_jp": "้ขๆŽฅใŒๅฎŒไบ†ใ—ใพใ—ใŸใ€‚ใŠใคใ‹ใ‚Œใ•ใพใงใ—ใŸใ€‚",
 
 
649
  "total_questions": len(history),
650
  "overall_score": overall_score,
651
  "scores": {
652
- "fluency": clamp_int(round(avg_score_10), 1, 10),
653
- "grammar": clamp_int(round(avg_score_10 - 1), 1, 10),
654
- "confidence": clamp_int(round(avg_score_10), 1, 10),
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 []
668
- result: List[str] = []
669
  for item in value:
670
  text = clean_text(item)
671
  if text:
 
 
1
  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=["*"],
 
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
  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,
 
134
  }
135
 
136
 
 
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
 
 
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:
 
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
  "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.")
 
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={
469
+ "Authorization": f"Bearer {HF_TOKEN}",
470
+ "Content-Type": "application/json",
471
+ },
472
  json=body,
473
  timeout=LLM_TIMEOUT_SECONDS,
474
  )
 
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 {}
 
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),
 
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: