bipinbudhathoki commited on
Commit
5aafae3
·
verified ·
1 Parent(s): b7ff12a

Update app/main.py

Browse files
Files changed (1) hide show
  1. app/main.py +603 -185
app/main.py CHANGED
@@ -2,16 +2,18 @@
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=["*"],
@@ -20,99 +22,186 @@ app.add_middleware(
20
  allow_headers=["*"],
21
  )
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
@@ -121,18 +210,27 @@ class StartRequest(BaseModel):
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
 
@@ -140,31 +238,55 @@ def health() -> Dict[str, Any]:
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,
@@ -176,194 +298,490 @@ def start_interview(payload: StartRequest) -> Dict[str, Any]:
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
 
315
  def guess_mime_type(filename: str) -> str:
316
- name = (filename or "").lower()
317
- if name.endswith(".wav"):
318
  return "audio/wav"
319
- if name.endswith(".mp3"):
320
  return "audio/mpeg"
321
- if name.endswith(".m4a"):
322
  return "audio/mp4"
323
- if name.endswith(".ogg"):
324
  return "audio/ogg"
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
 
361
 
362
- def default_feedback(score: int) -> str:
363
  if score >= 8:
364
  return "とても良いです。自然に答えられています。"
365
  if score >= 6:
366
  return "良いです。もう少し長く、ていねいに話すともっと良くなります。"
367
  if score >= 4:
368
- return "意味は伝わりますが、少し短いです。完全な文で話してみましょう。"
369
  return "短すぎるか、内容が分かりにくいです。もう少し詳しく話してください。"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import json
3
  import os
4
  import re
5
+ import tempfile
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 = "5.0.0"
15
 
16
+ app = FastAPI(title="Japanese Role Interview API", version=APP_VERSION)
17
  app.add_middleware(
18
  CORSMiddleware,
19
  allow_origins=["*"],
 
22
  allow_headers=["*"],
23
  )
24
 
25
+ # -----------------------------
26
+ # Config
27
+ # -----------------------------
28
  HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
29
+ HF_ROUTER_URL = os.getenv("HF_ROUTER_URL", "https://router.huggingface.co/v1/chat/completions")
30
  HF_INFERENCE_BASE = os.getenv("HF_INFERENCE_BASE", "https://router.huggingface.co/hf-inference/models")
31
+ ASR_MODEL = os.getenv("ASR_MODEL", "openai/whisper-large-v3")
32
+ CHAT_MODEL = os.getenv("CHAT_MODEL", "Qwen/Qwen2.5-7B-Instruct-1M")
33
+ USE_FASTER_WHISPER = os.getenv("USE_FASTER_WHISPER", "true").lower() in {"1", "true", "yes", "on"}
34
+ FASTER_WHISPER_MODEL = os.getenv("FASTER_WHISPER_MODEL", "small")
35
+ MAX_QUESTION_LIMIT = int(os.getenv("MAX_QUESTION_LIMIT", "20"))
36
+ ASR_TIMEOUT_SECONDS = int(os.getenv("ASR_TIMEOUT_SECONDS", "180"))
37
+ LLM_TIMEOUT_SECONDS = int(os.getenv("LLM_TIMEOUT_SECONDS", "90"))
38
+
39
+ _REPEAT_PROMPTS = [
40
+ "声が小さいです。もう少し大きい声で、もう一度お願いします。",
41
+ "まだ音がはっきり聞こえません。マイクを確認して、もう一度お願いします。",
42
+ "音がうまく入っていません。マイクを近づけて、もう一度お願いします。",
43
+ ]
44
+
45
+ _LOCAL_ASR_MODEL = None
46
 
47
  ROLE_BANK: Dict[str, Dict[str, Any]] = {
48
  "construction": {
49
+ "english_name": "Construction",
50
+ "japanese_name": "建設",
51
+ "intro_jp": "こんにちは。建設の仕事の面接練習を始めます。よろしくお願いします。",
52
+ "min_questions": 3,
53
+ "max_questions": 20,
54
+ "expected_keywords": ["安全", "ヘルメット", "現場", "工具", "体力", "チーム", "ルール"],
55
  "questions": [
56
+ {"id":"name","theme":"intro","stage":"screening","jp":"お名前を教えてください。","branch":"all"},
57
+ {"id":"country","theme":"intro","stage":"screening","jp":"どこの国から来ましたか。","branch":"all"},
58
+ {"id":"reason","theme":"motivation","stage":"screening","jp":"日本へ行きたい理由は何ですか。","branch":"all"},
59
+ {"id":"japanese","theme":"language","stage":"screening","jp":"日本語はどくらい勉強しましたか。","branch":"all"},
60
+ {"id":"ready_check","theme":"intro","stage":"screening","jp":"面接の準備はできていますか。","branch":"all"},
61
+ {"id":"experience_gate","theme":"experience","stage":"role","jp":"建設の仕事をしたことがありますか。","branch":"all"},
62
+ {"id":"exp_yes_detail","theme":"experience","stage":"role","jp":"どんな建設の仕事をししたか。","branch":"yes_exp"},
63
+ {"id":"exp_years","theme":"experience","stage":"role","jp":"その仕事は何年ぐらいしましたか。","branch":"yes_exp"},
64
+ {"id":"exp_no_motivation","theme":"motivation","stage":"role","jp":"経験がなくても、建設の仕事を勉強してがんばれますか。","branch":"no_exp"},
65
+ {"id":"physical","theme":"role_fit","stage":"role","jp":"体力が必要な仕事ですが、大丈夫ですか。","branch":"all"},
66
+ {"id":"safety","theme":"safety","stage":"role","jp":"危ない場所で働くとき、何に気をつけますか。","branch":"all"},
67
+ {"id":"teamwork","theme":"teamwork","stage":"role","jp":"チームで仕事をするとき、大切なことは何ですか。","branch":"all"},
68
+ {"id":"tools","theme":"role_fit","stage":"followup","jp":"工具を使う仕事に興味はありますか。","branch":"all"},
69
+ {"id":"morning","theme":"schedule","stage":"followup","jp":"朝早い仕事や外の仕事はできますか。","branch":"all"},
70
+ {"id":"report","theme":"teamwork","stage":"followup","jp":"分からないとき、先輩にすぐ相談できますか。","branch":"all"},
71
+ {"id":"mistake","theme":"reliability","stage":"followup","jp":"仕事でミスをしたら、どうしますか。","branch":"all"},
72
+ {"id":"strength","theme":"personality","stage":"followup","jp":"建設の仕事に向いている自分の長所を一つ話してください。","branch":"all"},
73
+ {"id":"closing","theme":"closing","stage":"closing","jp":"本日の建設の面接練習はここまでです。ご参加ありがとうございました。","branch":"all"},
74
+ ],
75
  },
76
  "restaurant_konbini": {
77
+ "english_name": "Restaurant / Konbini",
78
+ "japanese_name": "外食・コンビニ",
79
+ "intro_jp": "こんにちは。外食・コンビニの仕事の面接練習を始めます。よろしくお願いします。",
80
+ "min_questions": 3,
81
+ "max_questions": 20,
82
+ "expected_keywords": ["接客", "レジ", "お客様", "笑顔", "ていねい", "品出し", "掃除"],
83
  "questions": [
84
+ {"id":"name","theme":"intro","stage":"screening","jp":"お名前を教えてください。","branch":"all"},
85
+ {"id":"country","theme":"intro","stage":"screening","jp":"どこの国から来ましたか。","branch":"all"},
86
+ {"id":"reason","theme":"motivation","stage":"screening","jp":"日本へ行きたい理由は何ですか。","branch":"all"},
87
+ {"id":"japanese","theme":"language","stage":"screening","jp":"日本語はどくらい勉強しましたか。","branch":"all"},
88
+ {"id":"ready_check","theme":"intro","stage":"screening","jp":"面接準備はできていますか。","branch":"all"},
89
+ {"id":"experience_gate","theme":"experience","stage":"role","jp":"レストランやコンビニで働いた経験はありますか。","branch":"all"},
90
+ {"id":"exp_yes_detail","theme":"experience","stage":"role","jp":"どんな仕事をししたか。レジ、接客、品出しなどを話してください。","branch":"yes_exp"},
91
+ {"id":"exp_no_motivation","theme":"motivation","stage":"role","jp":"経験がなくも、接客勉強する気持ちはありますか。","branch":"no_exp"},
92
+ {"id":"customer","theme":"service","stage":"role","jp":"お客様には、どんな話し方をしたいですか。","branch":"all"},
93
+ {"id":"busy","theme":"role_fit","stage":"role","jp":"忙しい時間でも落ち着いて働けますか。","branch":"all"},
94
+ {"id":"cleanliness","theme":"service","stage":"role","jp":"お店の清潔さは大切ですか。なぜですか。","branch":"all"},
95
+ {"id":"shift","theme":"schedule","stage":"followup","jp":"立ち仕事やシフト勤務は大丈夫ですか。","branch":"all"},
96
+ {"id":"mistake","theme":"reliability","stage":"followup","jp":"注文やレジで間違えたら、どうしますか。","branch":"all"},
97
+ {"id":"teamwork","theme":"teamwork","stage":"followup","jp":"ほかのスタッフと協力できますか。","branch":"all"},
98
+ {"id":"strength","theme":"personality","stage":"followup","jp":"この仕事に向いている自分の長所を一つ話してください。","branch":"all"},
99
+ {"id":"closing","theme":"closing","stage":"closing","jp":"本日の外食・コンビニの面接練習はここまでです。ご参加ありがとうございました。","branch":"all"},
100
+ ],
101
  },
102
  "nursing_care": {
103
+ "english_name": "Nursing Care",
104
+ "japanese_name": "介護",
105
+ "intro_jp": "こんにちは。介護の仕事の面接練習を始めます。よろしくお願いします。",
106
+ "min_questions": 3,
107
+ "max_questions": 20,
108
+ "expected_keywords": ["介護", "やさしい", "利用者", "お年寄り", "清潔", "手伝う", "責任"],
109
  "questions": [
110
+ {"id":"name","theme":"intro","stage":"screening","jp":"お名前を教えてください。","branch":"all"},
111
+ {"id":"country","theme":"intro","stage":"screening","jp":"どこの国から来ましたか。","branch":"all"},
112
+ {"id":"reason","theme":"motivation","stage":"screening","jp":"日本へ行きたい理由は何ですか。","branch":"all"},
113
+ {"id":"japanese","theme":"language","stage":"screening","jp":"日本語はどくらい勉強しましたか。","branch":"all"},
114
+ {"id":"ready_check","theme":"intro","stage":"screening","jp":"面接の準備はできていますか。","branch":"all"},
115
+ {"id":"experience_gate","theme":"experience","stage":"role","jp":"介護の仕事をしたことがありますか。","branch":"all"},
116
+ {"id":"exp_yes_detail","theme":"experience","stage":"role","jp":"んな介護の仕事をしましたか。","branch":"yes_exp"},
117
+ {"id":"exp_no_motivation","theme":"motivation","stage":"role","jp":"経験がなくても、介護の勉強をしてがんばれますか。","branch":"no_exp"},
118
+ {"id":"kindness","theme":"service","stage":"role","jp":"お年寄りや利用者さんに、やさしく話せますか。","branch":"all"},
119
+ {"id":"cleanliness","theme":"safety","stage":"role","jp":"介護の仕事で清潔さは大切ですか。なぜですか。","branch":"all"},
120
+ {"id":"communication","theme":"service","stage":"role","jp":"利用者さんが困っていたら、まず何をしますか。","branch":"all"},
121
+ {"id":"hard_work","theme":"role_fit","stage":"followup","jp":"大変な仕事でも、落ち着いて続けられますか。","branch":"all"},
122
+ {"id":"teamwork","theme":"teamwork","stage":"followup","jp":"スタッフと協力できますか。","branch":"all"},
123
+ {"id":"report","theme":"teamwork","stage":"followup","jp":"報告・連絡・相談はできますか。","branch":"all"},
124
+ {"id":"strength","theme":"personality","stage":"followup","jp":"介護の仕事に向いている自分の長所を一つ話してください。","branch":"all"},
125
+ {"id":"closing","theme":"closing","stage":"closing","jp":"本日の介護の面接練習はここまでです。ご参加ありがとうございました。","branch":"all"},
126
+ ],
127
  },
128
  "hotel_accommodation": {
129
+ "english_name": "Hotel / Accommodation",
130
+ "japanese_name": "宿泊",
131
+ "intro_jp": "こんにちは。宿泊・ホテルの仕事の面接練習を始めます。よろしくお願いします。",
132
+ "min_questions": 3,
133
+ "max_questions": 20,
134
+ "expected_keywords": ["ホテル", "お客様", "ていねい", "笑顔", "掃除", "フロント", "案内"],
135
  "questions": [
136
+ {"id":"name","theme":"intro","stage":"screening","jp":"お名前を教えてください。","branch":"all"},
137
+ {"id":"country","theme":"intro","stage":"screening","jp":"どこの国から来ましたか。","branch":"all"},
138
+ {"id":"reason","theme":"motivation","stage":"screening","jp":"日本へ行きたい理由は何ですか。","branch":"all"},
139
+ {"id":"japanese","theme":"language","stage":"screening","jp":"日本語はどくらい勉強しましたか。","branch":"all"},
140
+ {"id":"ready_check","theme":"intro","stage":"screening","jp":"面接の準備はできていますか。","branch":"all"},
141
+ {"id":"experience_gate","theme":"experience","stage":"role","jp":"ホテル宿泊の仕事をしたことがありますか。","branch":"all"},
142
+ {"id":"exp_yes_detail","theme":"experience","stage":"role","jp":"どんな仕事をしましたか。フロント、掃除、ベッドメイクなどを話してください。","branch":"yes_exp"},
143
+ {"id":"exp_no_motivation","theme":"motivation","stage":"role","jp":"経験がなくても、ホテルの仕事を勉強してがんばれますか。","branch":"no_exp"},
144
+ {"id":"customer","theme":"service","stage":"role","jp":"お客様に話すとき、どんなことを大切にしますか。","branch":"all"},
145
+ {"id":"cleanliness","theme":"service","stage":"role","jp":"部屋の掃除や整理整頓は好きですか。","branch":"all"},
146
+ {"id":"busy","theme":"role_fit","stage":"role","jp":"忙しい時間でも落ち着いて働けますか。","branch":"all"},
147
+ {"id":"teamwork","theme":"teamwork","stage":"followup","jp":"スタッフと協力して働くことはできますか。","branch":"all"},
148
+ {"id":"shift","theme":"schedule","stage":"followup","jp":"夜や朝のシフトは大丈夫ですか。","branch":"all"},
149
+ {"id":"strength","theme":"personality","stage":"followup","jp":"ホテルの仕事に向いている自分の長所を一つ話してください。","branch":"all"},
150
+ {"id":"closing","theme":"closing","stage":"closing","jp":"本日の宿泊の面接練習はここまでです。ご参加ありがとうございました。","branch":"all"},
151
+ ],
152
  },
153
  "agriculture": {
154
+ "english_name": "Agriculture",
155
+ "japanese_name": "農業",
156
+ "intro_jp": "こんにちは。農業の仕事の面接練習を始めます。よろしくお願いします。",
157
+ "min_questions": 3,
158
+ "max_questions": 20,
159
+ "expected_keywords": ["農業", "畑", "体力", "朝", "収穫", "外", "時間"],
160
  "questions": [
161
+ {"id":"name","theme":"intro","stage":"screening","jp":"お名前を教えてください。","branch":"all"},
162
+ {"id":"country","theme":"intro","stage":"screening","jp":"どこの国から来ましたか。","branch":"all"},
163
+ {"id":"reason","theme":"motivation","stage":"screening","jp":"日本へ行きたい理由は何ですか。","branch":"all"},
164
+ {"id":"japanese","theme":"language","stage":"screening","jp":"日本語はどくらい勉強しましたか。","branch":"all"},
165
+ {"id":"ready_check","theme":"intro","stage":"screening","jp":"面接の準備はできていますか。","branch":"all"},
166
+ {"id":"experience_gate","theme":"experience","stage":"role","jp":"農業の仕事をしたことがありますか。","branch":"all"},
167
+ {"id":"exp_yes_detail","theme":"experience","stage":"role","jp":"どんな農業の仕事をししたか。","branch":"yes_exp"},
168
+ {"id":"exp_no_motivation","theme":"motivation","stage":"role","jp":"経験がなくても、農業を勉強してがんばれますか。","branch":"no_exp"},
169
+ {"id":"outside_work","theme":"role_fit","stage":"role","jp":"外で長い時間働くことは大丈夫ですか。","branch":"all"},
170
+ {"id":"early_morning","theme":"schedule","stage":"role","jp":"朝早い仕事でも時間を守れますか。","branch":"all"},
171
+ {"id":"physical","theme":"role_fit","stage":"role","jp":"体力に自信はありますか。","branch":"all"},
172
+ {"id":"weather","theme":"role_fit","stage":"followup","jp":"暑い日や寒い日でも、まじめに働けますか。","branch":"all"},
173
+ {"id":"teamwork","theme":"teamwork","stage":"followup","jp":"ほかの人と一緒に働けますか。","branch":"all"},
174
+ {"id":"strength","theme":"personality","stage":"followup","jp":"農業の仕事に向いている自分の長所を一つ話してください。","branch":"all"},
175
+ {"id":"closing","theme":"closing","stage":"closing","jp":"本日の農業の面接練習はここまでです。ご参加ありがとうございました。","branch":"all"},
176
+ ],
177
  },
178
  "manufacturing": {
179
+ "english_name": "Manufacturing",
180
+ "japanese_name": "製造業",
181
+ "intro_jp": "こんにちは。製造業の仕事の面接練習を始めます。よろしくお願いします。",
182
+ "min_questions": 3,
183
+ "max_questions": 20,
184
+ "expected_keywords": ["工場", "安全", "正確", "確認", "時間", "ルール", "集中"],
185
  "questions": [
186
+ {"id":"name","theme":"intro","stage":"screening","jp":"お名前を教えてください。","branch":"all"},
187
+ {"id":"country","theme":"intro","stage":"screening","jp":"どこの国から来ましたか。","branch":"all"},
188
+ {"id":"reason","theme":"motivation","stage":"screening","jp":"日本へ行きたい理由は何ですか。","branch":"all"},
189
+ {"id":"japanese","theme":"language","stage":"screening","jp":"日本語はどくらい勉強しましたか。","branch":"all"},
190
+ {"id":"ready_check","theme":"intro","stage":"screening","jp":"面接の準備はできていますか。","branch":"all"},
191
+ {"id":"experience_gate","theme":"experience","stage":"role","jp":"工場や製造の仕事したことがありますか。","branch":"all"},
192
+ {"id":"exp_yes_detail","theme":"experience","stage":"role","jp":"どんな製造の仕事したか。","branch":"yes_exp"},
193
+ {"id":"exp_no_motivation","theme":"motivation","stage":"role","jp":"経験がなくても、製造の仕事を勉強してがんばれますか。","branch":"no_exp"},
194
+ {"id":"accuracy","theme":"role_fit","stage":"role","jp":"ミスを少なくするために、何を大切にしますか。","branch":"all"},
195
+ {"id":"safety","theme":"safety","stage":"role","jp":"機械を使うとき、安全のために何をしますか。","branch":"all"},
196
+ {"id":"time","theme":"schedule","stage":"role","jp":"時間を守って、同じ作業を続けることはできますか。","branch":"all"},
197
+ {"id":"quality","theme":"reliability","stage":"followup","jp":"品質を守ることは大切ですか。なぜですか。","branch":"all"},
198
+ {"id":"teamwork","theme":"teamwork","stage":"followup","jp":"チームで協力できますか。","branch":"all"},
199
+ {"id":"strength","theme":"personality","stage":"followup","jp":"製造業の仕事に向いている自分の長所を一つ話してください。","branch":"all"},
200
+ {"id":"closing","theme":"closing","stage":"closing","jp":"本日の製造業の面接練習はここまでです。ご参加ありがとうございました。","branch":"all"},
201
+ ],
202
  },
203
  }
204
 
 
 
205
 
206
  class StartRequest(BaseModel):
207
  session_uuid: str
 
210
 
211
  @app.get("/")
212
  def root() -> Dict[str, Any]:
213
+ return {
214
+ "ok": True,
215
+ "service": "jp-role-interview",
216
+ "version": APP_VERSION,
217
+ "routes": ["/health", "/roles", "/start", "/answer"],
218
+ }
219
 
220
 
221
  @app.get("/health")
222
  def health() -> Dict[str, Any]:
223
+ asr_backend = "hf_api"
224
+ if USE_FASTER_WHISPER:
225
+ asr_backend = "faster_whisper_then_hf_api"
226
  return {
227
  "ok": True,
228
+ "service": "jp-role-interview",
229
+ "version": APP_VERSION,
230
  "hf_token_set": bool(HF_TOKEN),
231
+ "asr_backend": asr_backend,
232
+ "chat_model": CHAT_MODEL,
233
+ "role_count": len(ROLE_BANK),
234
  }
235
 
236
 
 
238
  def roles() -> Dict[str, Any]:
239
  return {
240
  "ok": True,
241
+ "roles": [
242
+ {
243
+ "key": key,
244
+ "english_name": cfg["english_name"],
245
+ "japanese_name": cfg["japanese_name"],
246
+ "min_questions": cfg["min_questions"],
247
+ "max_questions": cfg["max_questions"],
248
+ }
249
+ for key, cfg in ROLE_BANK.items()
250
+ ],
251
  }
252
 
253
 
254
  @app.post("/start")
255
  def start_interview(payload: StartRequest) -> Dict[str, Any]:
256
+ role_key = normalize_role_key(payload.job_role)
257
+ role_cfg = ROLE_BANK[role_key]
258
+ first_q = get_question(role_cfg, "name")
259
+ opening = f"{role_cfg['intro_jp']} {first_q['jp']}"
260
+
261
  memory = {
262
  "job_role": role_key,
263
+ "job_role_en": role_cfg["english_name"],
264
+ "job_role_jp": role_cfg["japanese_name"],
265
  "candidate_name": None,
266
+ "country_name": None,
267
+ "age": None,
268
+ "reason_for_japan": None,
269
+ "occupation": None,
270
+ "japanese_level": None,
271
+ "experience_state": "unknown",
272
  "answers_so_far": [],
273
+ "asked_question_ids": [first_q["id"]],
274
+ "asked_themes": [first_q["theme"]],
275
+ "low_score_streak": 0,
276
  "no_sound_count": 0,
277
+ "min_questions": role_cfg["min_questions"],
278
+ "max_questions": min(role_cfg["max_questions"], MAX_QUESTION_LIMIT),
279
+ "auto_question_mode": True,
280
  }
281
+
282
  return {
283
  "ok": True,
284
  "session_uuid": payload.session_uuid,
285
  "job_role": role_key,
286
+ "job_role_label": f"{role_cfg['english_name']} / {role_cfg['japanese_name']}",
287
  "question_no": 1,
288
+ "question_id": first_q["id"],
289
+ "question_jp": first_q["jp"],
290
  "speech_text_jp": opening,
291
  "memory": memory,
292
  "is_finished": False,
 
298
  async def answer_interview(
299
  session_uuid: str = Form(...),
300
  question_no: int = Form(...),
301
+ question_id: str = Form(...),
302
  question_jp: str = Form(...),
303
  memory_json: str = Form("{}"),
304
  audio: UploadFile = File(...),
305
  ) -> Dict[str, Any]:
306
  memory = safe_json_loads(memory_json)
307
+ role_key = normalize_role_key(memory.get("job_role"))
308
+ role_cfg = ROLE_BANK[role_key]
309
 
310
+ transcript, asr_backend, asr_error = await transcribe_upload(audio)
 
 
 
 
 
 
311
 
312
  if not transcript.strip():
313
  memory["no_sound_count"] = int(memory.get("no_sound_count", 0)) + 1
314
+ name = memory.get("candidate_name")
315
+ spoken = build_repeat_prompt(name, memory["no_sound_count"])
316
+ should_finish = memory["no_sound_count"] >= 2 and question_no >= role_cfg["min_questions"]
317
+ if should_finish:
318
+ result = build_final_result(role_cfg, memory, force_fail=True, summary_jp="音声が聞こえないため、面接を終了しました。")
319
+ return {
320
+ "ok": True,
321
+ "is_finished": True,
322
+ "session_uuid": session_uuid,
323
+ "job_role": role_key,
324
+ "transcript_jp": "",
325
+ "answer_score": 0,
326
+ "feedback_jp": spoken,
327
+ "speech_text_jp": spoken,
328
+ "memory": memory,
329
+ "asr_backend": asr_backend,
330
+ "asr_error": asr_error,
331
+ "result": result,
332
+ }
333
  return {
334
  "ok": True,
335
  "is_finished": False,
336
  "needs_repeat": True,
337
  "session_uuid": session_uuid,
338
+ "job_role": role_key,
339
  "question_no": question_no,
340
+ "question_id": question_id,
341
  "question_jp": question_jp,
342
+ "speech_text_jp": spoken,
343
  "transcript_jp": "",
344
  "answer_score": 0,
345
+ "feedback_jp": spoken,
346
  "memory": memory,
347
  "next_question_no": question_no,
348
+ "next_question_id": question_id,
349
  "next_question_jp": question_jp,
350
+ "asr_backend": asr_backend,
351
+ "asr_error": asr_error,
352
  "speak_now": True,
353
  }
354
 
355
+ memory["no_sound_count"] = 0
356
+ profile_update = maybe_extract_basic_profile(memory, transcript, question_id)
357
+ memory = merge_memory(memory, profile_update)
 
358
 
359
+ score = score_answer(role_cfg, question_id, transcript)
360
+ feedback = build_feedback(score)
 
 
 
361
 
362
+ answers = list(memory.get("answers_so_far", []))
363
+ answers.append({
364
  "question_no": question_no,
365
+ "question_id": question_id,
366
  "question_jp": question_jp,
367
  "answer_text_jp": transcript,
368
  "answer_score": score,
369
+ "feedback_jp": feedback,
370
  })
371
+ memory["answers_so_far"] = answers
372
+
373
+ if score <= 3:
374
+ memory["low_score_streak"] = int(memory.get("low_score_streak", 0)) + 1
375
+ else:
376
+ memory["low_score_streak"] = 0
377
+
378
+ should_finish = decide_finish(role_cfg, memory, question_no, score)
379
+ if should_finish:
380
+ result = build_final_result(role_cfg, memory)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  return {
382
  "ok": True,
383
  "is_finished": True,
384
  "session_uuid": session_uuid,
385
+ "job_role": role_key,
386
  "question_no": question_no,
387
  "transcript_jp": transcript,
388
  "answer_score": score,
389
+ "feedback_jp": feedback,
390
+ "speech_text_jp": result["closing_message_jp"],
391
  "memory": memory,
392
+ "asr_backend": asr_backend,
393
+ "asr_error": asr_error,
394
  "result": result,
395
  }
396
 
397
+ next_q = select_next_question(role_cfg, memory)
398
+ next_no = question_no + 1
399
+ if next_q["id"] not in memory["asked_question_ids"]:
400
+ memory["asked_question_ids"].append(next_q["id"])
401
+ if next_q["theme"] not in memory["asked_themes"]:
402
+ memory["asked_themes"].append(next_q["theme"])
403
+
404
+ spoken_next = next_q["jp"]
405
+ if next_q["id"] == "ready_check" and memory.get("candidate_name"):
406
+ spoken_next = f"{memory['candidate_name']}さん、ありがとうございます。{next_q['jp']}"
407
 
408
  return {
409
  "ok": True,
410
  "is_finished": False,
411
  "session_uuid": session_uuid,
412
+ "job_role": role_key,
413
  "question_no": question_no,
414
  "transcript_jp": transcript,
415
  "answer_score": score,
416
+ "feedback_jp": feedback,
417
+ "speech_text_jp": spoken_next,
418
  "memory": memory,
419
+ "asr_backend": asr_backend,
420
+ "asr_error": asr_error,
421
+ "next_question_no": next_no,
422
+ "next_question_id": next_q["id"],
423
+ "next_question_jp": next_q["jp"],
424
  "speak_now": True,
425
  }
426
 
427
 
428
+ def normalize_role_key(value: Any) -> str:
429
+ key = str(value or "construction").strip().lower()
430
+ aliases = {
431
+ "restaurant": "restaurant_konbini",
432
+ "konbini": "restaurant_konbini",
433
+ "nursing": "nursing_care",
434
+ "care": "nursing_care",
435
+ "hotel": "hotel_accommodation",
436
+ "accommodation": "hotel_accommodation",
437
+ }
438
+ key = aliases.get(key, key)
439
+ return key if key in ROLE_BANK else "construction"
440
+
441
+
442
+ def get_question(role_cfg: Dict[str, Any], qid: str) -> Dict[str, Any]:
443
+ for q in role_cfg["questions"]:
444
+ if q["id"] == qid:
445
+ return q
446
+ return role_cfg["questions"][0]
447
+
448
+
449
+ def build_repeat_prompt(name: Optional[str], count: int) -> str:
450
+ idx = max(0, min(count - 1, len(_REPEAT_PROMPTS) - 1))
451
+ base = _REPEAT_PROMPTS[idx]
452
+ if name:
453
+ return f"{name}さん、{base}"
454
+ return base
455
+
456
+
457
+ async def transcribe_upload(audio: UploadFile) -> Tuple[str, str, Optional[str]]:
458
+ content = await audio.read()
459
+ filename = audio.filename or "answer.webm"
460
+
461
+ if USE_FASTER_WHISPER:
462
+ try:
463
+ text = transcribe_with_faster_whisper(content, filename)
464
+ return normalize_text(text), "faster_whisper", None
465
+ except Exception as exc:
466
+ if HF_TOKEN:
467
+ try:
468
+ text = transcribe_with_hf_api(content, filename)
469
+ return normalize_text(text), "hf_api_fallback", str(exc)
470
+ except Exception as exc2:
471
+ return "", "hf_api_fallback_failed", f"{exc} | {exc2}"
472
+ return "", "faster_whisper_failed", str(exc)
473
+
474
+ if HF_TOKEN:
475
+ try:
476
+ text = transcribe_with_hf_api(content, filename)
477
+ return normalize_text(text), "hf_api", None
478
+ except Exception as exc:
479
+ return "", "hf_api_failed", str(exc)
480
+
481
+ return "", "no_asr_backend", "Neither faster-whisper nor HF API is available."
482
+
483
+
484
+ def transcribe_with_faster_whisper(content: bytes, filename: str) -> str:
485
+ global _LOCAL_ASR_MODEL
486
+ from faster_whisper import WhisperModel # lazy import
487
+
488
+ suffix = os.path.splitext(filename)[1] or ".webm"
489
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
490
+ tmp.write(content)
491
+ temp_path = tmp.name
492
+
493
+ try:
494
+ if _LOCAL_ASR_MODEL is None:
495
+ _LOCAL_ASR_MODEL = WhisperModel(FASTER_WHISPER_MODEL, device="cpu", compute_type="int8")
496
+ segments, _info = _LOCAL_ASR_MODEL.transcribe(temp_path, language="ja", vad_filter=True)
497
+ return " ".join(seg.text.strip() for seg in segments).strip()
498
+ finally:
499
+ try:
500
+ os.remove(temp_path)
501
+ except OSError:
502
+ pass
503
+
504
+
505
+ def transcribe_with_hf_api(content: bytes, filename: str) -> str:
506
  url = f"{HF_INFERENCE_BASE}/{ASR_MODEL}"
507
  headers = {
508
  "Authorization": f"Bearer {HF_TOKEN}",
509
  "Content-Type": guess_mime_type(filename),
510
  }
511
+ response = requests.post(url, headers=headers, data=content, timeout=ASR_TIMEOUT_SECONDS)
512
  response.raise_for_status()
513
  data = response.json()
514
  if isinstance(data, dict):
515
+ return str(data.get("text") or data.get("generated_text") or "")
516
  if isinstance(data, list) and data and isinstance(data[0], dict):
517
+ return str(data[0].get("text") or "")
518
  return ""
519
 
520
 
521
  def guess_mime_type(filename: str) -> str:
522
+ lower = (filename or "").lower()
523
+ if lower.endswith(".wav"):
524
  return "audio/wav"
525
+ if lower.endswith(".mp3"):
526
  return "audio/mpeg"
527
+ if lower.endswith(".m4a"):
528
  return "audio/mp4"
529
+ if lower.endswith(".ogg"):
530
  return "audio/ogg"
531
  return "audio/webm"
532
 
533
 
534
+ def maybe_extract_basic_profile(memory: Dict[str, Any], transcript: str, question_id: str) -> Dict[str, Any]:
535
+ text = normalize_text(transcript)
536
+ update: Dict[str, Any] = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
 
538
+ if question_id == "name" and not memory.get("candidate_name"):
539
+ name = extract_name(text)
540
+ if name:
541
+ update["candidate_name"] = name
542
+
543
+ if question_id == "country" and not memory.get("country_name"):
544
+ country = extract_country(text)
545
+ if country:
546
+ update["country_name"] = country
547
+
548
+ if question_id == "reason" and not memory.get("reason_for_japan") and len(text) >= 4:
549
+ update["reason_for_japan"] = text[:120]
550
+
551
+ if question_id == "japanese" and not memory.get("japanese_level") and len(text) >= 4:
552
+ update["japanese_level"] = text[:120]
553
+
554
+ if question_id == "experience_gate":
555
+ update["experience_state"] = detect_experience_state(text, memory.get("experience_state", "unknown"))
556
+
557
+ age = extract_age(text)
558
+ if age and not memory.get("age"):
559
+ update["age"] = age
560
+
561
+ return update
562
+
563
+
564
+ def detect_experience_state(text: str, current: str) -> str:
565
+ yes_markers = ["あります", "しました", "経験があります", "働いたことがあります"]
566
+ no_markers = ["ありません", "ないです", "経験がありません", "したことがありません"]
567
+ if any(m in text for m in yes_markers):
568
+ return "yes"
569
+ if any(m in text for m in no_markers):
570
+ return "no"
571
+ return current if current in {"yes", "no"} else "unknown"
572
+
573
+
574
+ def select_next_question(role_cfg: Dict[str, Any], memory: Dict[str, Any]) -> Dict[str, Any]:
575
+ asked_ids = set(memory.get("asked_question_ids", []))
576
+ experience_state = memory.get("experience_state", "unknown")
577
+ answers = memory.get("answers_so_far", [])
578
+ avg = mean([a.get("answer_score", 0) for a in answers]) if answers else 0
579
+
580
+ # fixed screening order
581
+ for qid in ["country", "reason", "japanese", "ready_check", "experience_gate"]:
582
+ q = get_question(role_cfg, qid)
583
+ if q["id"] not in asked_ids:
584
+ return q
585
+
586
+ # branch by experience
587
+ branch_order = []
588
+ if experience_state == "yes":
589
+ branch_order = ["exp_yes_detail", "exp_years"]
590
+ elif experience_state == "no":
591
+ branch_order = ["exp_no_motivation"]
592
+
593
+ for qid in branch_order:
594
+ q = get_question(role_cfg, qid)
595
+ if q["id"] not in asked_ids:
596
+ return q
597
+
598
+ # weaker user gets simpler role questions first
599
+ if avg < 4.5:
600
+ simple_ids = ["physical", "busy", "kindness", "cleanliness", "outside_work", "time", "customer", "safety", "teamwork"]
601
+ for qid in simple_ids:
602
+ try:
603
+ q = get_question(role_cfg, qid)
604
+ if q["id"] not in asked_ids:
605
+ return q
606
+ except Exception:
607
+ pass
608
+
609
+ # normal role/followup path
610
+ for q in role_cfg["questions"]:
611
+ if q["id"] in asked_ids:
612
+ continue
613
+ if q["stage"] == "closing":
614
+ continue
615
+ if q["branch"] == "yes_exp" and experience_state != "yes":
616
+ continue
617
+ if q["branch"] == "no_exp" and experience_state != "no":
618
+ continue
619
+ return q
620
+
621
+ return get_question(role_cfg, "closing")
622
+
623
+
624
+ def score_answer(role_cfg: Dict[str, Any], question_id: str, transcript: str) -> int:
625
+ text = normalize_text(transcript)
626
  if not text:
627
  return 0
628
+ score = 3
629
+ if len(text) >= 4:
630
  score += 1
631
+ if len(text) >= 10:
632
+ score += 1
633
+ if len(text) >= 20:
634
  score += 1
635
  if "です" in text or "ます" in text:
636
  score += 1
637
+ role_hits = sum(1 for kw in role_cfg["expected_keywords"] if kw in text)
638
+ score += min(2, role_hits)
639
+ if question_id == "name" and extract_name(text):
640
  score += 1
641
+ if question_id == "country" and extract_country(text):
642
+ score += 1
643
+ if question_id == "experience_gate" and detect_experience_state(text, "unknown") != "unknown":
644
+ score += 1
645
+ return max(0, min(score, 10))
646
 
647
 
648
+ def build_feedback(score: int) -> str:
649
  if score >= 8:
650
  return "とても良いです。自然に答えられています。"
651
  if score >= 6:
652
  return "良いです。もう少し長く、ていねいに話すともっと良くなります。"
653
  if score >= 4:
654
+ return "意味は伝わりますが、短いです。完全な文で答えてみましょう。"
655
  return "短すぎるか、内容が分かりにくいです。もう少し詳しく話してください。"
656
+
657
+
658
+ def decide_finish(role_cfg: Dict[str, Any], memory: Dict[str, Any], question_no: int, score: int) -> bool:
659
+ answers = memory.get("answers_so_far", [])
660
+ avg = mean([a.get("answer_score", 0) for a in answers]) if answers else 0
661
+ min_q = int(memory.get("min_questions", role_cfg["min_questions"]))
662
+ max_q = int(memory.get("max_questions", role_cfg["max_questions"]))
663
+
664
+ if question_no >= max_q:
665
+ return True
666
+ if question_no >= min_q and memory.get("low_score_streak", 0) >= 2:
667
+ return True
668
+ if question_no >= min_q and len(answers) >= 3 and avg < 3.5:
669
+ return True
670
+ if question_no >= 10 and avg >= 6:
671
+ # good candidate can continue; otherwise finish around middle
672
+ return False
673
+ if question_no >= 8 and avg < 5.5:
674
+ return True
675
+ return False
676
+
677
+
678
+ def build_final_result(role_cfg: Dict[str, Any], memory: Dict[str, Any], force_fail: bool = False, summary_jp: str = "") -> Dict[str, Any]:
679
+ answers = list(memory.get("answers_so_far", []))
680
+ scores = [int(a.get("answer_score", 0)) for a in answers] or [0]
681
+ avg = mean(scores)
682
+ overall_score = max(0, min(100, int(round(avg * 10))))
683
+ if force_fail:
684
+ overall_score = min(overall_score, 39)
685
+ pass_fail = "PASS" if overall_score >= 60 and not force_fail else "FAIL"
686
+
687
+ strengths: List[str] = []
688
+ weaknesses: List[str] = []
689
+ tips: List[str] = []
690
+
691
+ if memory.get("candidate_name"):
692
+ strengths.append("Self introduction was understood.")
693
+ else:
694
+ weaknesses.append("Name was not clearly understood.")
695
+
696
+ if memory.get("experience_state") == "yes":
697
+ strengths.append("Role experience was communicated.")
698
+ elif memory.get("experience_state") == "no":
699
+ weaknesses.append("No direct role experience was explained clearly.")
700
+
701
+ if overall_score >= 70:
702
+ strengths.append("Answers were mostly clear and relevant.")
703
+ else:
704
+ weaknesses.append("Several answers were too short or unclear.")
705
+
706
+ tips.extend([
707
+ "Use one or two extra sentences in each answer.",
708
+ "Use polite endings like です and ます.",
709
+ "Speak a little louder and more clearly.",
710
+ ])
711
+
712
+ closing = get_question(role_cfg, "closing")["jp"]
713
+ return {
714
+ "candidate_name": memory.get("candidate_name"),
715
+ "country_name": memory.get("country_name"),
716
+ "age": memory.get("age"),
717
+ "job_role": memory.get("job_role"),
718
+ "job_role_en": role_cfg["english_name"],
719
+ "job_role_jp": role_cfg["japanese_name"],
720
+ "summary_jp": summary_jp or f"{role_cfg['japanese_name']}の面接練習が完了しました。",
721
+ "closing_message_jp": closing,
722
+ "total_questions": len(answers),
723
+ "overall_score": overall_score,
724
+ "scores": {
725
+ "fluency": clamp_int(round(avg), 1, 10),
726
+ "grammar": clamp_int(round(avg - 1), 1, 10),
727
+ "confidence": clamp_int(round(avg), 1, 10),
728
+ "relevance": clamp_int(round(avg + 1), 1, 10),
729
+ "role_fit": clamp_int(round(avg), 1, 10),
730
+ },
731
+ "pass_fail": pass_fail,
732
+ "strengths": strengths[:4],
733
+ "weaknesses": weaknesses[:4],
734
+ "tips": tips[:5],
735
+ "answers": answers,
736
+ }
737
+
738
+
739
+ def merge_memory(memory: Dict[str, Any], update: Dict[str, Any]) -> Dict[str, Any]:
740
+ merged = dict(memory or {})
741
+ for k, v in (update or {}).items():
742
+ if v not in (None, "", [], {}):
743
+ merged[k] = v
744
+ return merged
745
+
746
+
747
+ def normalize_text(text: str) -> str:
748
+ return re.sub(r"\s+", " ", (text or "")).strip()
749
+
750
+
751
+ def safe_json_loads(value: str) -> Dict[str, Any]:
752
+ try:
753
+ obj = json.loads(value or "{}")
754
+ return obj if isinstance(obj, dict) else {}
755
+ except Exception:
756
+ return {}
757
+
758
+
759
+ def extract_name(text: str) -> Optional[str]:
760
+ value = text.replace("私は", "").replace("わたしは", "").replace("ぼくは", "")
761
+ value = value.replace("です", "").replace("と申します", "").replace("といいます", "").strip(" 。")
762
+ if not value or len(value) > 30:
763
+ return None
764
+ return value
765
+
766
+
767
+ def extract_country(text: str) -> Optional[str]:
768
+ known = ["ネパール", "日本", "インド", "バングラデシュ", "スリランカ", "ベトナム", "中国", "ミャンマー", "フィリピン", "インドネシア"]
769
+ for k in known:
770
+ if k in text:
771
+ return k
772
+ m = re.search(r"(.+?)から来ました", text)
773
+ if m:
774
+ return m.group(1).strip(" 。")
775
+ return None
776
+
777
+
778
+ def extract_age(text: str) -> Optional[int]:
779
+ m = re.search(r"(\d{1,2})", text)
780
+ return int(m.group(1)) if m else None
781
+
782
+
783
+ def clamp_int(value: Any, low: int, high: int) -> int:
784
+ try:
785
+ return max(low, min(high, int(round(float(value)))))
786
+ except Exception:
787
+ return low