bipinbudhathoki commited on
Commit
2c18966
·
verified ·
1 Parent(s): 9366332

Create app/main.py

Browse files
Files changed (1) hide show
  1. app/main.py +907 -0
app/main.py ADDED
@@ -0,0 +1,907 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=["*"],
20
+ allow_credentials=False,
21
+ allow_methods=["*"],
22
+ allow_headers=["*"],
23
+ )
24
+
25
+ HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
26
+ ASR_MODEL = os.getenv("ASR_MODEL", "openai/whisper-large-v3")
27
+ CHAT_MODEL = os.getenv("CHAT_MODEL", "Qwen/Qwen2.5-7B-Instruct-1M")
28
+ HF_ROUTER_URL = os.getenv("HF_ROUTER_URL", "https://router.huggingface.co/v1/chat/completions")
29
+ HF_INFERENCE_BASE = os.getenv("HF_INFERENCE_BASE", "https://router.huggingface.co/hf-inference/models")
30
+ MAX_QUESTION_LIMIT = int(os.getenv("MAX_QUESTION_LIMIT", "20"))
31
+ LLM_TIMEOUT_SECONDS = int(os.getenv("LLM_TIMEOUT_SECONDS", "90"))
32
+ ASR_TIMEOUT_SECONDS = int(os.getenv("ASR_TIMEOUT_SECONDS", "180"))
33
+
34
+ ROLE_BANK_PATH = Path(__file__).resolve().parents[1] / "role_bank.json"
35
+ ROLE_BANK: Dict[str, Any] = json.loads(ROLE_BANK_PATH.read_text(encoding="utf-8"))
36
+
37
+ REPEAT_PROMPTS = [
38
+ "すみません、音が聞こえませんでした。マイクを確認して、もう一度お願いします。",
39
+ "声が小さいです。もう少し大きい声で、もう一度お願いします。",
40
+ "まだ音がうまく入りません。マイクを近づけて、もう一度お願いします。",
41
+ ]
42
+
43
+ class StartRequest(BaseModel):
44
+ session_uuid: str
45
+ job_role: str = "construction"
46
+ question_count: int = 10
47
+
48
+
49
+ @app.get("/")
50
+ def root() -> Dict[str, Any]:
51
+ return {
52
+ "ok": True,
53
+ "service": "jp-role-interview",
54
+ "version": APP_VERSION,
55
+ "routes": ["/health", "/roles", "/start", "/answer"],
56
+ }
57
+
58
+
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,
134
+ }
135
+
136
+
137
+ @app.post("/answer")
138
+ async def answer_interview(
139
+ session_uuid: str = Form(...),
140
+ question_no: int = Form(...),
141
+ question_count: int = Form(10),
142
+ question_id: str = Form(""),
143
+ question_jp: str = Form(...),
144
+ memory_json: str = Form("{}"),
145
+ audio: UploadFile = File(...),
146
+ ) -> Dict[str, Any]:
147
+ memory = safe_json_loads(memory_json)
148
+ role_key = memory.get("job_role") or "construction"
149
+ role = ROLE_BANK.get(role_key, ROLE_BANK["construction"])
150
+ question_count = max(role["min_questions"], min(question_count, role["max_questions"], MAX_QUESTION_LIMIT))
151
+
152
+ audio_bytes = await audio.read()
153
+ transcript = ""
154
+ asr_error = None
155
+ if audio_bytes:
156
+ try:
157
+ transcript = transcribe_audio_with_hf(audio_bytes, audio.filename or "audio.webm")
158
+ except Exception as exc:
159
+ asr_error = str(exc)
160
+
161
+ if not transcript.strip():
162
+ memory["no_sound_count"] = int(memory.get("no_sound_count", 0)) + 1
163
+ memory["repeat_count"] = int(memory.get("repeat_count", 0)) + 1
164
+ repeat_idx = min(memory["no_sound_count"] - 1, len(REPEAT_PROMPTS) - 1)
165
+ repeat_prompt = REPEAT_PROMPTS[repeat_idx]
166
+ if memory["no_sound_count"] >= 2 and question_no >= role["min_questions"]:
167
+ result = build_final_result(memory, role, force_fail=True, summary_jp="音声が聞こえないため、面接を終了しました。")
168
+ return {
169
+ "ok": True,
170
+ "is_finished": True,
171
+ "session_uuid": session_uuid,
172
+ "question_no": question_no,
173
+ "question_count": question_count,
174
+ "transcript_jp": "",
175
+ "answer_score": 0,
176
+ "feedback_jp": repeat_prompt,
177
+ "memory": memory,
178
+ "llm_used": False,
179
+ "result": result,
180
+ }
181
+ return {
182
+ "ok": True,
183
+ "is_finished": False,
184
+ "needs_repeat": True,
185
+ "session_uuid": session_uuid,
186
+ "question_no": question_no,
187
+ "question_count": question_count,
188
+ "question_id": question_id,
189
+ "question_jp": question_jp,
190
+ "transcript_jp": "",
191
+ "answer_score": 0,
192
+ "feedback_jp": repeat_prompt,
193
+ "memory": memory,
194
+ "next_question_no": question_no,
195
+ "next_question_id": question_id,
196
+ "next_question_jp": question_jp,
197
+ "speak_now": True,
198
+ "asr_error": asr_error,
199
+ "llm_used": False,
200
+ }
201
+
202
+ memory["no_sound_count"] = 0
203
+
204
+ answer_turn = {
205
+ "question_no": question_no,
206
+ "question_id": question_id,
207
+ "question_jp": question_jp,
208
+ "answer_text_jp": transcript,
209
+ }
210
+ history = list(memory.get("answers_so_far", []))
211
+ history.append(answer_turn)
212
+
213
+ candidate_questions = select_candidate_questions(role, memory, transcript)
214
+ llm_used = False
215
+ evaluation: Dict[str, Any]
216
+ if HF_TOKEN:
217
+ try:
218
+ evaluation = run_llm_turn(
219
+ role=role,
220
+ question_no=question_no,
221
+ question_count=question_count,
222
+ question_id=question_id,
223
+ current_question=question_jp,
224
+ transcript=transcript,
225
+ memory=memory,
226
+ history=history,
227
+ candidate_questions=candidate_questions,
228
+ )
229
+ llm_used = True
230
+ except Exception as exc:
231
+ evaluation = fallback_turn_evaluation(role, memory, transcript, candidate_questions, error=str(exc))
232
+ else:
233
+ evaluation = fallback_turn_evaluation(role, memory, transcript, candidate_questions, error="HF_TOKEN is not set.")
234
+
235
+ profile_update = evaluation.get("profile_update", {})
236
+ merged_memory = merge_memory(memory, profile_update)
237
+ merged_memory["answers_so_far"] = history
238
+ merged_memory["asked_themes"] = merge_unique(memory.get("asked_themes", []), evaluation.get("asked_themes", []))
239
+ if evaluation.get("next_question_id"):
240
+ merged_memory["asked_question_ids"] = merge_unique(memory.get("asked_question_ids", []), [evaluation["next_question_id"]])
241
+
242
+ answer_score = clamp_int(evaluation.get("answer_score", heuristic_score(transcript, role)), 0, 10)
243
+ feedback_jp = clean_text(evaluation.get("feedback_jp")) or default_feedback(answer_score)
244
+
245
+ history[-1]["answer_score"] = answer_score
246
+ history[-1]["feedback_jp"] = feedback_jp
247
+ history[-1]["question_theme"] = evaluation.get("question_theme")
248
+ history[-1]["keywords_matched"] = keyword_matches(transcript, role["expected_keywords"])
249
+
250
+ merged_memory["experience_status"] = detect_experience_status(merged_memory, transcript)
251
+ merged_memory["low_score_count"] = int(memory.get("low_score_count", 0)) + (1 if answer_score <= 3 else 0)
252
+
253
+ should_finish = decide_finish(
254
+ role=role,
255
+ memory=merged_memory,
256
+ question_no=question_no,
257
+ question_count=question_count,
258
+ answer_score=answer_score,
259
+ )
260
+
261
+ if should_finish:
262
+ result = run_final_evaluation_if_possible(merged_memory, role, llm_used=llm_used)
263
+ return {
264
+ "ok": True,
265
+ "is_finished": True,
266
+ "session_uuid": session_uuid,
267
+ "question_no": question_no,
268
+ "question_count": question_count,
269
+ "transcript_jp": transcript,
270
+ "answer_score": answer_score,
271
+ "feedback_jp": feedback_jp,
272
+ "memory": merged_memory,
273
+ "llm_used": llm_used,
274
+ "result": result,
275
+ }
276
+
277
+ next_question_no = question_no + 1
278
+ next_question_id = clean_text(evaluation.get("next_question_id"))
279
+ next_question_jp = clean_text(evaluation.get("next_question_jp"))
280
+
281
+ if not next_question_id or not next_question_jp:
282
+ chosen = candidate_questions[0] if candidate_questions else choose_any_unused_question(role, merged_memory)
283
+ next_question_id = chosen["id"]
284
+ next_question_jp = chosen["jp"]
285
+
286
+ return {
287
+ "ok": True,
288
+ "is_finished": False,
289
+ "session_uuid": session_uuid,
290
+ "question_no": question_no,
291
+ "question_count": question_count,
292
+ "transcript_jp": transcript,
293
+ "answer_score": answer_score,
294
+ "feedback_jp": feedback_jp,
295
+ "memory": merged_memory,
296
+ "llm_used": llm_used,
297
+ "next_question_no": next_question_no,
298
+ "next_question_id": next_question_id,
299
+ "next_question_jp": next_question_jp,
300
+ "speak_now": True,
301
+ }
302
+
303
+
304
+ def transcribe_audio_with_hf(audio_bytes: bytes, filename: str) -> str:
305
+ if not HF_TOKEN:
306
+ raise RuntimeError("HF_TOKEN is missing for ASR.")
307
+ url = f"{HF_INFERENCE_BASE}/{ASR_MODEL}"
308
+ headers = {
309
+ "Authorization": f"Bearer {HF_TOKEN}",
310
+ "Content-Type": guess_mime_type(filename),
311
+ }
312
+ response = requests.post(url, headers=headers, data=audio_bytes, timeout=ASR_TIMEOUT_SECONDS)
313
+ response.raise_for_status()
314
+ data = response.json()
315
+ if isinstance(data, dict):
316
+ text = data.get("text") or data.get("generated_text") or ""
317
+ return normalize_text(text)
318
+ if isinstance(data, list) and data:
319
+ # fallback for some provider formats
320
+ text = data[0].get("text", "") if isinstance(data[0], dict) else ""
321
+ return normalize_text(text)
322
+ return ""
323
+
324
+
325
+ def guess_mime_type(filename: str) -> str:
326
+ name = (filename or "").lower()
327
+ if name.endswith(".wav"):
328
+ return "audio/wav"
329
+ if name.endswith(".mp3"):
330
+ return "audio/mpeg"
331
+ if name.endswith(".m4a"):
332
+ return "audio/mp4"
333
+ if name.endswith(".ogg"):
334
+ return "audio/ogg"
335
+ return "audio/webm"
336
+
337
+
338
+ def run_llm_turn(
339
+ role: Dict[str, Any],
340
+ question_no: int,
341
+ question_count: int,
342
+ question_id: str,
343
+ current_question: str,
344
+ transcript: str,
345
+ memory: Dict[str, Any],
346
+ history: List[Dict[str, Any]],
347
+ candidate_questions: List[Dict[str, Any]],
348
+ ) -> Dict[str, Any]:
349
+ system_prompt = (
350
+ "You are a Japanese interviewer for working visa practice. "
351
+ "Use simple N4-level Japanese. "
352
+ "Ask ONE realistic interview question at a time. "
353
+ "Stay inside the selected job role. "
354
+ "If the answer is weak or unclear, you may ask a short repeat or clarification question. "
355
+ "If the candidate is failing badly and the minimum number of questions has been reached, you may end the interview early. "
356
+ "Return ONLY valid JSON."
357
+ )
358
+ payload = {
359
+ "role_key": role["role_key"],
360
+ "role_name_jp": role["japanese_name"],
361
+ "question_no": question_no,
362
+ "question_count_target": question_count,
363
+ "current_question_id": question_id,
364
+ "current_question_jp": current_question,
365
+ "candidate_answer_jp": transcript,
366
+ "memory": {
367
+ "candidate_name": memory.get("candidate_name"),
368
+ "country_name": memory.get("country_name"),
369
+ "age": memory.get("age"),
370
+ "reason_for_japan": memory.get("reason_for_japan"),
371
+ "occupation": memory.get("occupation"),
372
+ "japanese_level": memory.get("japanese_level"),
373
+ "experience_status": memory.get("experience_status"),
374
+ "low_score_count": memory.get("low_score_count", 0),
375
+ "asked_themes": memory.get("asked_themes", []),
376
+ },
377
+ "history_tail": history[-6:],
378
+ "candidate_questions": [
379
+ {
380
+ "id": q["id"],
381
+ "jp": q["jp"],
382
+ "theme": q["theme"],
383
+ "branch": q["branch"],
384
+ "stage": q["stage"],
385
+ "expected_keywords": q.get("expected_keywords", []),
386
+ }
387
+ for q in candidate_questions[:12]
388
+ ],
389
+ "schema": {
390
+ "answer_score": "integer 0-10",
391
+ "feedback_jp": "one short Japanese sentence",
392
+ "question_theme": "theme string",
393
+ "asked_themes": ["array"],
394
+ "profile_update": {
395
+ "candidate_name": "string or null",
396
+ "country_name": "string or null",
397
+ "age": "integer or null",
398
+ "reason_for_japan": "string or null",
399
+ "occupation": "string or null",
400
+ "japanese_level": "string or null",
401
+ "experience_status": "yes or no or unknown"
402
+ },
403
+ "continue_interview": "boolean",
404
+ "next_question_id": "question id from candidate_questions or empty when ending",
405
+ "next_question_jp": "question text from candidate_questions or empty when ending"
406
+ },
407
+ "rules": [
408
+ "Use only the provided candidate_questions for next_question_id.",
409
+ "Do not repeat a question unless clarification is needed.",
410
+ "Keep the question natural and interview-like.",
411
+ "Use the candidate name if known.",
412
+ "If question_no is already at target, continue_interview must be false."
413
+ ],
414
+ }
415
+ raw = call_hf_chat_json(system_prompt=system_prompt, user_payload=payload)
416
+ result = normalize_llm_turn_result(raw)
417
+ result["profile_update"] = merge_memory(
418
+ maybe_extract_basic_profile(memory, transcript, question_no),
419
+ result.get("profile_update", {}),
420
+ )
421
+ if question_no >= question_count:
422
+ result["continue_interview"] = False
423
+ result["next_question_id"] = ""
424
+ result["next_question_jp"] = ""
425
+ return result
426
+
427
+
428
+ def normalize_llm_turn_result(raw: Dict[str, Any]) -> Dict[str, Any]:
429
+ profile = raw.get("profile_update", {}) if isinstance(raw.get("profile_update"), dict) else {}
430
+ asked_themes = raw.get("asked_themes", [])
431
+ if not isinstance(asked_themes, list):
432
+ asked_themes = []
433
+ return {
434
+ "answer_score": clamp_int(raw.get("answer_score", 6), 0, 10),
435
+ "feedback_jp": clean_text(raw.get("feedback_jp")),
436
+ "question_theme": clean_text(raw.get("question_theme")) or None,
437
+ "asked_themes": [clean_text(x) for x in asked_themes if clean_text(x)],
438
+ "profile_update": {
439
+ "candidate_name": normalize_optional_text(profile.get("candidate_name")),
440
+ "country_name": normalize_optional_text(profile.get("country_name")),
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
+
888
+
889
+ def default_feedback(score: int) -> str:
890
+ if score >= 8:
891
+ return "とても良いです。自然に答えられています。"
892
+ if score >= 6:
893
+ return "良いです。もう少し長く、ていねいに話すともっと良くなります。"
894
+ if score >= 4:
895
+ return "意味は伝わりますが、少し短いです。完全な文で話してみましょう。"
896
+ return "短すぎるか、内容が分かりにくいです。もう少し詳しく話してください。"
897
+
898
+
899
+ def ensure_string_list(value: Any) -> List[str]:
900
+ if not isinstance(value, list):
901
+ return []
902
+ result = []
903
+ for item in value:
904
+ text = clean_text(item)
905
+ if text:
906
+ result.append(text)
907
+ return result