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

Update app/main.py

Browse files
Files changed (1) hide show
  1. app/main.py +183 -721
app/main.py CHANGED
@@ -1,19 +1,17 @@
 
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=["*"],
@@ -24,110 +22,150 @@ app.add_middleware(
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
 
59
  @app.get("/health")
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,187 +176,139 @@ 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
 
@@ -335,553 +325,36 @@ 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")),
441
- "age": normalize_optional_int(profile.get("age")),
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={
469
- "Authorization": f"Bearer {HF_TOKEN}",
470
- "Content-Type": "application/json",
471
- },
472
- json=body,
473
- timeout=LLM_TIMEOUT_SECONDS,
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
 
@@ -894,14 +367,3 @@ def default_feedback(score: int) -> str:
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:
906
- result.append(text)
907
- return result
 
1
+
2
  import json
3
  import os
4
  import re
 
 
5
  from statistics import mean
6
+ from typing import Any, Dict, List
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="category-fix-1.0")
14
 
 
15
  app.add_middleware(
16
  CORSMiddleware,
17
  allow_origins=["*"],
 
22
 
23
  HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
24
  ASR_MODEL = os.getenv("ASR_MODEL", "openai/whisper-large-v3")
 
 
25
  HF_INFERENCE_BASE = os.getenv("HF_INFERENCE_BASE", "https://router.huggingface.co/hf-inference/models")
 
 
 
26
 
27
+ ROLE_BANK: Dict[str, Dict[str, Any]] = {
28
+ "construction": {
29
+ "label": "Construction / ๅปบ่จญ",
30
+ "intro": "ใ“ใ‚“ใซใกใฏใ€‚ๅปบ่จญใฎไป•ไบ‹ใฎ้ขๆŽฅ็ทด็ฟ’ใ‚’ๅง‹ใ‚ใพใ™ใ€‚ใ‚ˆใ‚ใ—ใใŠ้ก˜ใ„ใ—ใพใ™ใ€‚",
31
+ "questions": [
32
+ "ใŠๅๅ‰ใ‚’ๆ•™ใˆใฆใใ ใ•ใ„ใ€‚",
33
+ "ใฉใ“ใฎๅ›ฝใ‹ใ‚‰ๆฅใพใ—ใŸใ‹ใ€‚",
34
+ "ๆ—ฅๆœฌใธ่กŒใใŸใ„็†็”ฑใฏไฝ•ใงใ™ใ‹ใ€‚",
35
+ "ๅปบ่จญใฎไป•ไบ‹ใ‚’ใ—ใŸใ“ใจใŒใ‚ใ‚Šใพใ™ใ‹ใ€‚",
36
+ "ๅฑใชใ„ๅ ดๆ‰€ใงๅƒใใจใใ€ไฝ•ใซๆฐ—ใ‚’ใคใ‘ใพใ™ใ‹ใ€‚",
37
+ "ใƒใƒผใƒ ใงๅƒใใ“ใจใฏใงใใพใ™ใ‹ใ€‚",
38
+ "ไฝ“ๅŠ›ใซ่‡ชไฟกใฏใ‚ใ‚Šใพใ™ใ‹ใ€‚",
39
+ "ๆœ€ๅพŒใซใ€ๅปบ่จญใฎไป•ไบ‹ใงใŒใ‚“ใฐใ‚ŠใŸใ„ใ“ใจใ‚’่ฉฑใ—ใฆใใ ใ•ใ„ใ€‚"
40
+ ]
41
+ },
42
+ "restaurant_konbini": {
43
+ "label": "Restaurant / Konbini / ๅค–้ฃŸใƒปใ‚ณใƒณใƒ“ใƒ‹",
44
+ "intro": "ใ“ใ‚“ใซใกใฏใ€‚ๅค–้ฃŸใƒปใ‚ณใƒณใƒ“ใƒ‹ใฎไป•ไบ‹ใฎ้ขๆŽฅ็ทด็ฟ’ใ‚’ๅง‹ใ‚ใพใ™ใ€‚ใ‚ˆใ‚ใ—ใใŠ้ก˜ใ„ใ—ใพใ™ใ€‚",
45
+ "questions": [
46
+ "ใŠๅๅ‰ใ‚’ๆ•™ใˆใฆใใ ใ•ใ„ใ€‚",
47
+ "ใฉใ“ใฎๅ›ฝใ‹ใ‚‰ๆฅใพใ—ใŸใ‹ใ€‚",
48
+ "ๆ—ฅๆœฌใธ่กŒใใŸใ„็†็”ฑใฏไฝ•ใงใ™ใ‹ใ€‚",
49
+ "ๆŽฅๅฎขใฎไป•ไบ‹ใ‚’ใ—ใŸใ“ใจใŒใ‚ใ‚Šใพใ™ใ‹ใ€‚",
50
+ "ใŠๅฎขๆง˜ใซใฏใฉใฎใ‚ˆใ†ใซ่ฉฑใ—ใพใ™ใ‹ใ€‚",
51
+ "ๅฟ™ใ—ใ„ๆ™‚้–“ใงใ‚‚่ฝใก็€ใ„ใฆๅƒใ‘ใพใ™ใ‹ใ€‚",
52
+ "็ฌ‘้ก”ใงๆŽฅๅฎขใงใใพใ™ใ‹ใ€‚",
53
+ "ๆœ€ๅพŒใซใ€ใ“ใฎไป•ไบ‹ใซๅ‘ใ„ใฆใ„ใ‚‹่‡ชๅˆ†ใฎ้•ทๆ‰€ใ‚’่ฉฑใ—ใฆใใ ใ•ใ„ใ€‚"
54
+ ]
55
+ },
56
+ "nursing_care": {
57
+ "label": "Nursing Care / ไป‹่ญท",
58
+ "intro": "ใ“ใ‚“ใซใกใฏใ€‚ไป‹่ญทใฎไป•ไบ‹ใฎ้ขๆŽฅ็ทด็ฟ’ใ‚’ๅง‹ใ‚ใพใ™ใ€‚ใ‚ˆใ‚ใ—ใใŠ้ก˜ใ„ใ—ใพใ™ใ€‚",
59
+ "questions": [
60
+ "ใŠๅๅ‰ใ‚’ๆ•™ใˆใฆใใ ใ•ใ„ใ€‚",
61
+ "ใฉใ“ใฎๅ›ฝใ‹ใ‚‰ๆฅใพใ—ใŸใ‹ใ€‚",
62
+ "ๆ—ฅๆœฌใธ่กŒใใŸใ„็†็”ฑใฏไฝ•ใงใ™ใ‹ใ€‚",
63
+ "ไป‹่ญทใฎไป•ไบ‹ใ‚’ใ—ใŸใ“ใจใŒใ‚ใ‚Šใพใ™ใ‹ใ€‚",
64
+ "ใŠๅนดๅฏ„ใ‚Šใ‚„ๅˆฉ็”จ่€…ใ•ใ‚“ใซใ‚„ใ•ใ—ใ่ฉฑใ›ใพใ™ใ‹ใ€‚",
65
+ "ไป‹่ญทใฎไป•ไบ‹ใงๆธ…ๆฝ”ใ•ใฏๅคงๅˆ‡ใงใ™ใ‹ใ€‚ใชใœใงใ™ใ‹ใ€‚",
66
+ "ๅˆฉ็”จ่€…ใ•ใ‚“ใŒๅ›ฐใฃใฆใ„ใŸใ‚‰ใ€ใฉใ†ใ—ใพใ™ใ‹ใ€‚",
67
+ "ๆœ€ๅพŒใซใ€ไป‹่ญทใฎไป•ไบ‹ใงๅคงๅˆ‡ใ ใจๆ€ใ†ใ“ใจใ‚’่ฉฑใ—ใฆใใ ใ•ใ„ใ€‚"
68
+ ]
69
+ },
70
+ "hotel_accommodation": {
71
+ "label": "Hotel / Accommodation / ๅฎฟๆณŠ",
72
+ "intro": "ใ“ใ‚“ใซใกใฏใ€‚ๅฎฟๆณŠใฎไป•ไบ‹ใฎ้ขๆŽฅ็ทด็ฟ’ใ‚’ๅง‹ใ‚ใพใ™ใ€‚ใ‚ˆใ‚ใ—ใใŠ้ก˜ใ„ใ—ใพใ™ใ€‚",
73
+ "questions": [
74
+ "ใŠๅๅ‰ใ‚’ๆ•™ใˆใฆใใ ใ•ใ„ใ€‚",
75
+ "ใฉใ“ใฎๅ›ฝใ‹ใ‚‰ๆฅใพใ—ใŸใ‹ใ€‚",
76
+ "ๆ—ฅๆœฌใธ่กŒใใŸใ„็†็”ฑใฏไฝ•ใงใ™ใ‹ใ€‚",
77
+ "ใƒ›ใƒ†ใƒซใฎไป•ไบ‹ใ‚’ใ—ใŸใ“ใจใŒใ‚ใ‚Šใพใ™ใ‹ใ€‚",
78
+ "ใŠๅฎขๆง˜ใซใฆใ„ใญใ„ใซ่ฉฑใ›ใพใ™ใ‹ใ€‚",
79
+ "ๆŽƒ้™คใ‚„ใƒ™ใƒƒใƒ‰ใƒกใ‚คใ‚ฏใฏใงใใพใ™ใ‹ใ€‚",
80
+ "ๅฟ™ใ—ใ„ๆ™‚้–“ใงใ‚‚่ฝใก็€ใ„ใฆๅƒใ‘ใพใ™ใ‹ใ€‚",
81
+ "ๆœ€ๅพŒใซใ€ใƒ›ใƒ†ใƒซใฎไป•ไบ‹ใง่‡ชๅˆ†ใฎ้•ทๆ‰€ใ‚’่ฉฑใ—ใฆใใ ใ•ใ„ใ€‚"
82
+ ]
83
+ },
84
+ "agriculture": {
85
+ "label": "Agriculture / ่พฒๆฅญ",
86
+ "intro": "ใ“ใ‚“ใซใกใฏใ€‚่พฒๆฅญใฎไป•ไบ‹ใฎ้ขๆŽฅ็ทด็ฟ’ใ‚’ๅง‹ใ‚ใพใ™ใ€‚ใ‚ˆใ‚ใ—ใใŠ้ก˜ใ„ใ—ใพใ™ใ€‚",
87
+ "questions": [
88
+ "ใŠๅๅ‰ใ‚’ๆ•™ใˆใฆใใ ใ•ใ„ใ€‚",
89
+ "ใฉใ“ใฎๅ›ฝใ‹ใ‚‰ๆฅใพใ—ใŸใ‹ใ€‚",
90
+ "ๆ—ฅๆœฌใธ่กŒใใŸใ„็†็”ฑใฏไฝ•ใงใ™ใ‹ใ€‚",
91
+ "่พฒๆฅญใฎไป•ไบ‹ใ‚’ใ—ใŸใ“ใจใŒใ‚ใ‚Šใพใ™ใ‹ใ€‚",
92
+ "ๅค–ใงๅƒใใ“ใจใฏๅคงไธˆๅคซใงใ™ใ‹ใ€‚",
93
+ "ๆœๆ—ฉใ„ไป•ไบ‹ใงใ‚‚ๆ™‚้–“ใ‚’ๅฎˆใ‚Œใพใ™ใ‹ใ€‚",
94
+ "ไฝ“ๅŠ›ใซ่‡ชไฟกใฏใ‚ใ‚Šใพใ™ใ‹ใ€‚",
95
+ "ๆœ€ๅพŒใซใ€่พฒๆฅญใฎไป•ไบ‹ใงใŒใ‚“ใฐใ‚ŠใŸใ„ใ“ใจใ‚’่ฉฑใ—ใฆใใ ใ•ใ„ใ€‚"
96
+ ]
97
+ },
98
+ "manufacturing": {
99
+ "label": "Manufacturing / ่ฃฝ้€ ๆฅญ",
100
+ "intro": "ใ“ใ‚“ใซใกใฏใ€‚่ฃฝ้€ ๆฅญใฎไป•ไบ‹ใฎ้ขๆŽฅ็ทด็ฟ’ใ‚’ๅง‹ใ‚ใพใ™ใ€‚ใ‚ˆใ‚ใ—ใใŠ้ก˜ใ„ใ—ใพใ™ใ€‚",
101
+ "questions": [
102
+ "ใŠๅๅ‰ใ‚’ๆ•™ใˆใฆใใ ใ•ใ„ใ€‚",
103
+ "ใฉใ“ใฎๅ›ฝใ‹ใ‚‰ๆฅใพใ—ใŸใ‹ใ€‚",
104
+ "ๆ—ฅๆœฌใธ่กŒใใŸใ„็†็”ฑใฏไฝ•ใงใ™ใ‹ใ€‚",
105
+ "ๅทฅๅ ดใฎไป•ไบ‹ใ‚’ใ—ใŸใ“ใจใŒใ‚ใ‚Šใพใ™ใ‹ใ€‚",
106
+ "ๅฎ‰ๅ…จใƒซใƒผใƒซใ‚’ๅฎˆใ‚‹ใ“ใจใฏใงใใพใ™ใ‹ใ€‚",
107
+ "ๅŒใ˜ไฝœๆฅญใ‚’ๆญฃ็ขบใซ็ถšใ‘ใ‚‰ใ‚Œใพใ™ใ‹ใ€‚",
108
+ "ๆ™‚้–“ใ‚’ๅฎˆใ‚‹ใ“ใจใฏใงใใพใ™ใ‹ใ€‚",
109
+ "ๆœ€ๅพŒใซใ€่ฃฝ้€ ๆฅญใฎไป•ไบ‹ใง่‡ชๅˆ†ใฎ้•ทๆ‰€ใ‚’่ฉฑใ—ใฆใใ ใ•ใ„ใ€‚"
110
+ ]
111
+ },
112
+ }
113
+
114
+ REPEAT_PROMPT = "ๅฃฐใŒๅฐใ•ใ„ใงใ™ใ€‚ใ‚‚ใ†ๅฐ‘ใ—ๅคงใใ„ๅฃฐใงใ€ใ‚‚ใ†ไธ€ๅบฆใŠ้ก˜ใ„ใ—ใพใ™ใ€‚"
115
 
 
 
 
 
 
116
 
117
  class StartRequest(BaseModel):
118
  session_uuid: str
119
  job_role: str = "construction"
 
120
 
121
 
122
  @app.get("/")
123
  def root() -> Dict[str, Any]:
124
+ return {"ok": True, "service": "jp-interview", "version": "category-fix-1.0"}
 
 
 
 
 
125
 
126
 
127
  @app.get("/health")
128
  def health() -> Dict[str, Any]:
129
  return {
130
  "ok": True,
131
+ "service": "jp-interview",
132
+ "version": "category-fix-1.0",
133
  "hf_token_set": bool(HF_TOKEN),
134
  "asr_model": ASR_MODEL,
135
+ "roles": list(ROLE_BANK.keys()),
 
 
 
136
  }
137
 
138
 
139
  @app.get("/roles")
140
  def roles() -> Dict[str, Any]:
141
+ return {
142
+ "ok": True,
143
+ "roles": [{"key": key, "label": cfg["label"]} for key, cfg in ROLE_BANK.items()]
144
+ }
 
 
 
 
 
 
 
145
 
146
 
147
  @app.post("/start")
148
  def start_interview(payload: StartRequest) -> Dict[str, Any]:
149
  role_key = payload.job_role if payload.job_role in ROLE_BANK else "construction"
150
+ cfg = ROLE_BANK[role_key]
 
 
 
 
 
 
 
151
  memory = {
 
152
  "job_role": role_key,
153
+ "job_role_label": cfg["label"],
 
 
154
  "candidate_name": None,
 
 
 
 
 
 
155
  "answers_so_far": [],
 
 
156
  "low_score_count": 0,
157
  "no_sound_count": 0,
158
+ "question_count_mode": "auto",
 
 
159
  }
160
+ opening = f"{cfg['intro']} {cfg['questions'][0]}"
161
  return {
162
  "ok": True,
163
  "session_uuid": payload.session_uuid,
164
  "job_role": role_key,
165
+ "job_role_label": cfg["label"],
166
  "question_no": 1,
167
+ "question_jp": cfg["questions"][0],
168
+ "speech_text_jp": opening,
 
169
  "memory": memory,
170
  "is_finished": False,
171
  "speak_now": True,
 
176
  async def answer_interview(
177
  session_uuid: str = Form(...),
178
  question_no: int = Form(...),
 
 
179
  question_jp: str = Form(...),
180
  memory_json: str = Form("{}"),
181
  audio: UploadFile = File(...),
182
  ) -> Dict[str, Any]:
183
  memory = safe_json_loads(memory_json)
184
+ role_key = memory.get("job_role") if memory.get("job_role") in ROLE_BANK else "construction"
185
+ cfg = ROLE_BANK[role_key]
 
186
 
187
  audio_bytes = await audio.read()
188
  transcript = ""
189
+ if audio_bytes and HF_TOKEN:
 
190
  try:
191
  transcript = transcribe_audio_with_hf(audio_bytes, audio.filename or "audio.webm")
192
+ except Exception:
193
+ transcript = ""
194
 
195
  if not transcript.strip():
196
  memory["no_sound_count"] = int(memory.get("no_sound_count", 0)) + 1
197
+ speech_text = REPEAT_PROMPT
198
+ if memory.get("candidate_name"):
199
+ speech_text = f"{memory['candidate_name']}ใ•ใ‚“ใ€{REPEAT_PROMPT}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  return {
201
  "ok": True,
202
  "is_finished": False,
203
  "needs_repeat": True,
204
  "session_uuid": session_uuid,
205
  "question_no": question_no,
 
 
206
  "question_jp": question_jp,
207
+ "speech_text_jp": speech_text,
208
  "transcript_jp": "",
209
  "answer_score": 0,
210
+ "feedback_jp": REPEAT_PROMPT,
211
  "memory": memory,
212
  "next_question_no": question_no,
 
213
  "next_question_jp": question_jp,
214
  "speak_now": True,
 
 
215
  }
216
 
217
+ if question_no == 1 and not memory.get("candidate_name"):
218
+ name = extract_name(transcript)
219
+ if name:
220
+ memory["candidate_name"] = name
221
 
222
+ score = heuristic_score(transcript)
223
+ if score <= 3:
224
+ memory["low_score_count"] = int(memory.get("low_score_count", 0)) + 1
225
+ else:
226
+ memory["low_score_count"] = 0
227
+
228
+ history = list(memory.get("answers_so_far", []))
229
+ history.append({
230
  "question_no": question_no,
 
231
  "question_jp": question_jp,
232
  "answer_text_jp": transcript,
233
+ "answer_score": score,
234
+ })
235
+ memory["answers_so_far"] = history
236
+
237
+ # automatic interview length
238
+ min_questions = 3
239
+ max_questions = len(cfg["questions"])
240
+ avg_score = mean([x.get("answer_score", 0) for x in history])
241
+
242
+ finish_now = False
243
+ if question_no >= min_questions and memory["low_score_count"] >= 2:
244
+ finish_now = True
245
+ elif question_no >= min_questions and avg_score < 3.5:
246
+ finish_now = True
247
+ elif question_no >= max_questions:
248
+ finish_now = True
249
+ elif question_no >= 5 and avg_score < 5:
250
+ finish_now = True
251
+
252
+ if finish_now:
253
+ closing = f"ๆœฌๆ—ฅใฎ{cfg['label'].split('/')[1].strip() if '/' in cfg['label'] else cfg['label']}ใฎ้ขๆŽฅ็ทด็ฟ’ใฏใ“ใ“ใพใงใงใ™ใ€‚ใ”ๅ‚ๅŠ ใ‚ใ‚ŠใŒใจใ†ใ”ใ–ใ„ใพใ—ใŸใ€‚"
254
+ result = {
255
+ "candidate_name": memory.get("candidate_name"),
256
+ "job_role": role_key,
257
+ "job_role_label": cfg["label"],
258
+ "total_questions": len(history),
259
+ "overall_score": round(avg_score * 10),
260
+ "pass_fail": "PASS" if avg_score >= 6 else "FAIL",
261
+ "closing_message_jp": closing,
262
+ "answers": history,
263
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  return {
265
  "ok": True,
266
  "is_finished": True,
267
  "session_uuid": session_uuid,
268
  "question_no": question_no,
 
269
  "transcript_jp": transcript,
270
+ "answer_score": score,
271
+ "feedback_jp": default_feedback(score),
272
+ "speech_text_jp": closing,
273
+ "memory": memory,
274
  "result": result,
275
  }
276
 
277
  next_question_no = question_no + 1
278
+ next_question = cfg["questions"][next_question_no - 1]
279
+ speech_text = next_question
280
+ if question_no == 1 and memory.get("candidate_name"):
281
+ speech_text = f"{memory['candidate_name']}ใ•ใ‚“ใ€ใ‚ใ‚ŠใŒใจใ†ใ”ใ–ใ„ใพใ™ใ€‚้ขๆŽฅใฎๆบ–ๅ‚™ใฏใงใใฆใ„ใพใ™ใ‹ใ€‚ใงใฏใ€ๆฌกใฎ่ณชๅ•ใงใ™ใ€‚{next_question}"
 
 
 
282
 
283
  return {
284
  "ok": True,
285
  "is_finished": False,
286
  "session_uuid": session_uuid,
287
  "question_no": question_no,
 
288
  "transcript_jp": transcript,
289
+ "answer_score": score,
290
+ "feedback_jp": default_feedback(score),
291
+ "speech_text_jp": speech_text,
292
+ "memory": memory,
293
  "next_question_no": next_question_no,
294
+ "next_question_jp": next_question,
 
295
  "speak_now": True,
296
  }
297
 
298
 
299
  def transcribe_audio_with_hf(audio_bytes: bytes, filename: str) -> str:
 
 
300
  url = f"{HF_INFERENCE_BASE}/{ASR_MODEL}"
301
  headers = {
302
  "Authorization": f"Bearer {HF_TOKEN}",
303
  "Content-Type": guess_mime_type(filename),
304
  }
305
+ response = requests.post(url, headers=headers, data=audio_bytes, timeout=180)
306
  response.raise_for_status()
307
  data = response.json()
308
  if isinstance(data, dict):
309
+ return normalize_text(data.get("text") or data.get("generated_text") or "")
310
+ if isinstance(data, list) and data and isinstance(data[0], dict):
311
+ return normalize_text(data[0].get("text", ""))
 
 
 
312
  return ""
313
 
314
 
 
325
  return "audio/webm"
326
 
327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  def normalize_text(text: str) -> str:
329
  return re.sub(r"\s+", " ", (text or "")).strip()
330
 
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  def safe_json_loads(value: str) -> Dict[str, Any]:
333
  try:
334
  parsed = json.loads(value or "{}")
335
  return parsed if isinstance(parsed, dict) else {}
 
 
 
 
 
 
 
336
  except Exception:
337
+ return {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
 
339
 
340
+ def extract_name(text: str) -> str:
341
  value = text.replace("็งใฏ", "").replace("ใ‚ใŸใ—ใฏ", "").replace("ใผใใฏ", "")
342
  value = value.replace("ใงใ™", "").replace("ใจ็”ณใ—ใพใ™", "").replace("ใจใ„ใ„ใพใ™", "").strip(" ใ€‚")
343
+ return value[:30] if value else ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
 
 
 
 
 
 
345
 
346
+ def heuristic_score(text: str) -> int:
347
+ text = (text or "").strip()
 
 
 
 
 
 
 
 
 
348
  if not text:
349
  return 0
350
+ score = 4
351
  if len(text) >= 6:
352
  score += 1
353
  if len(text) >= 12:
354
  score += 1
355
  if "ใงใ™" in text or "ใพใ™" in text:
356
  score += 1
357
+ if len(text) >= 20:
 
 
 
 
 
358
  score += 1
359
  return min(score, 10)
360
 
 
367
  if score >= 4:
368
  return "ๆ„ๅ‘ณใฏไผใ‚ใ‚Šใพใ™ใŒใ€ๅฐ‘ใ—็Ÿญใ„ใงใ™ใ€‚ๅฎŒๅ…จใชๆ–‡ใง่ฉฑใ—ใฆใฟใพใ—ใ‚‡ใ†ใ€‚"
369
  return "็Ÿญใ™ใŽใ‚‹ใ‹ใ€ๅ†…ๅฎนใŒๅˆ†ใ‹ใ‚Šใซใใ„ใงใ™ใ€‚ใ‚‚ใ†ๅฐ‘ใ—่ฉณใ—ใ่ฉฑใ—ใฆใใ ใ•ใ„ใ€‚"