muddasser commited on
Commit
def57c5
·
verified ·
1 Parent(s): 1f3ee71

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +84 -33
app.py CHANGED
@@ -9,6 +9,8 @@ Secrets / environment:
9
  INTERVIEWS_DB_PATH optional, path of the SQLite file. Point it at a PERSISTENT
10
  volume in production, otherwise the data is lost on redeploy.
11
  AZURE_SPEECH_KEY / AZURE_SPEECH_REGION optional, better TTS voices
 
 
12
  """
13
  import os, io, re, json, hmac, html, base64, sqlite3, asyncio, logging, secrets, random
14
  from contextlib import closing
@@ -30,6 +32,7 @@ COMPANY = {"en": "Bin Dawood Holdings", "ar": "بن داود القابضة"}
30
  DB_PATH = os.environ.get("INTERVIEWS_DB_PATH", "interviews.db")
31
  LOGO_PATH = "logo.png"
32
  PHOTO_PATH = "agent_photo.jpg"
 
33
  LLM_MODEL = "openai/gpt-oss-120b"
34
  STT_MODEL = "whisper-large-v3"
35
  MIN_AUDIO_BYTES = 12000
@@ -45,11 +48,14 @@ AZURE_LOCALES = {"en": "en-US", "ar": "ar-SA"}
45
 
46
  SPOKEN = {
47
  "en": {
48
- "greeting": ("Welcome to the Bin Dawood AI Bot interview. Hello {name}, I'm {agent}, an AI "
49
- "assistant, and I will be conducting your first-round interview for the "
50
- "{title} position at {company}. I will ask you {n} questions. Take your time "
51
- "with each answer, and feel free to speak or type. Are you ready? "
52
- "Can we start?"),
 
 
 
53
  "lets_begin": "Great, let's begin.",
54
  "not_ready": "No problem, take your time. Just let me know when you're ready to start.",
55
  "question": "Question {i} of {n}. {q}",
@@ -63,10 +69,10 @@ SPOKEN = {
63
  "to you for the next steps. Thank you again, and goodbye."),
64
  },
65
  "ar": {
66
- "greeting": ("أهلاً بكم في مقابلة بوت بن داود للذكاء الاصطناعي. مرحباً {name}، أنا {agent}، "
67
- "مساعدة ذكاء اصطناعي، وسأجري معكم المقابلة الأولى لوظيفة {title} في {company}. "
68
- "سأطرح عليكم {n} أسئلة، وخذوا وقتكم في كل إجابة، ويمكنكم التحدث أو الكتابة "
69
- "كما تفضلون. هل أنتم مستعدون؟ هل يمكننا أن نبدأ؟"),
70
  "lets_begin": "رائع، لنبدأ.",
71
  "not_ready": "لا بأس، خذوا وقتكم. أخبروني عندما تكونون مستعدين للبدء.",
72
  "question": "السؤال {i} من {n}. {q}",
@@ -92,8 +98,7 @@ NOTICE = {
92
  "Please do not share sensitive personal information (for example health details, religion, "
93
  "or ID numbers) in your answers.",
94
  "You may stop at any time. Contact HR to ask for access to, or deletion of, your data.",
95
- "Please turn your volume on. After you start, tap the 🔊 button next to the avatar once "
96
- "to hear the first message.",
97
  ], "I have read the above and agree to take this interview."),
98
  "ar": ("قبل أن نبدأ", [
99
  "تتم هذه المقابلة بواسطة مساعد ذكاء اصطناعي وليس بواسطة موظف.",
@@ -104,7 +109,7 @@ NOTICE = {
104
  "القرارات الوظيفية يتخذها أشخاص وليس الذكاء الاصطناعي.",
105
  "يرجى عدم ذكر معلومات شخصية حساسة (مثل التفاصيل الصحية أو الدين أو أرقام الهوية) في إجاباتكم.",
106
  "يمكنكم التوقف في أي وقت، ويمكنكم التواصل مع الموارد البشرية لطلب الاطلاع على بياناتكم أو حذفها.",
107
- "يرجى تشغيل الصوت. بعد البدء، اضغطوا على زر 🔊 بجانب الصورة مرة واحدة لسماع الرسالة الأولى.",
108
  ], "قرأتُ ما سبق وأوافق على إجراء هذه المقابلة."),
109
  }
110
 
@@ -325,6 +330,24 @@ def tts_b64(text: str, lang: str) -> str:
325
  return base64.b64encode(data).decode() if data else ""
326
 
327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  # ══════════════════════════════════════════════════════════════════════════
329
  # LLM TASKS
330
  # ══════════════════════════════════════════════════════════════════════════
@@ -527,26 +550,43 @@ padding:9px 18px;font-size:.85rem;font-weight:600;cursor:pointer;}
527
  <button class="tap" id="tap">🔊 Tap to hear · اضغط للاستماع</button></div>
528
  <script>__SCRIPT__</script></body></html>"""
529
 
 
530
  AUDIO_JS = """(function(){
531
  var a=document.getElementById('a'),c=document.getElementById('av'),
532
  r=document.getElementById('ring'),s=document.getElementById('status'),
533
  t=document.getElementById('tap');
534
- a.src='data:audio/mp3;base64,__AUDIO__';
535
- a.addEventListener('play',function(){t.style.display='none';c.classList.add('speaking');r.classList.add('active');s.textContent='Speaking...';});
536
- function stop(){c.classList.remove('speaking');r.classList.remove('active');s.textContent='Listening';}
537
- a.addEventListener('ended',stop);a.addEventListener('pause',stop);
538
- function p(){var x=a.play();if(x&&x.catch)x.catch(function(){t.style.display='inline-block';s.textContent='Tap to hear';});}
539
- t.addEventListener('click',function(){a.play().catch(function(){});});
540
- if(__MANUAL__){t.style.display='inline-block';s.textContent='Tap to hear';}
541
- else if(document.readyState==='complete')p();else window.addEventListener('load',p);})();"""
542
-
543
-
544
- def render_avatar(img_b64: str, name: str, audio_b64: str = "", manual: bool = False) -> str:
545
- """manual=True skips autoplay and shows the tap button (browsers block the first autoplay)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  img = (f'<img src="data:image/jpeg;base64,{img_b64}" alt="{html.escape(name)}">'
547
  if img_b64 else '<div class="ph">👩‍💼</div>')
548
- script = (AUDIO_JS.replace("__AUDIO__", audio_b64)
549
- .replace("__MANUAL__", "true" if manual else "false")) if audio_b64 else ""
550
  return (AVATAR_TEMPLATE.replace("__IMG__", img)
551
  .replace("__NAME__", html.escape(name)).replace("__SCRIPT__", script))
552
 
@@ -574,15 +614,24 @@ def begin_interview(inv, pos):
574
  "INSERT INTO interviews(invite_code, status, consent_at, started_at, questions_json) "
575
  "VALUES (?,?,?,?,?)",
576
  (inv["code"], "in_progress", now_iso(), now_iso(), json.dumps(questions, ensure_ascii=False)))
577
- greeting = SPOKEN[lang]["greeting"].format(
578
- name=inv["candidate_name"], agent=AGENT_NAME[lang], title=pos["title"],
579
- company=COMPANY[lang], n=len(questions))
580
- save_turn(iid, "interviewer", "greeting", None, greeting)
 
 
 
581
  ss.iv = {"id": iid, "lang": lang, "name": inv["candidate_name"], "title": pos["title"],
582
  "questions": questions, "q_index": 0, "followups_used": 0, "done": False,
583
  "stage": "ready"}
584
- ss.pending_audio = tts_b64(greeting, lang)
585
- ss.manual_audio = True # first audio of the session: browsers block autoplay, show tap button
 
 
 
 
 
 
586
  st.rerun()
587
 
588
 
@@ -597,7 +646,7 @@ def resume_interview(inv, pos, row):
597
  "questions": questions, "q_index": min(row["q_index"], len(questions) - 1),
598
  "followups_used": row["followups_used"], "done": False, "stage": stage}
599
  ss.pending_audio = tts_b64(last[-1]["content"], lang) if last else ""
600
- ss.manual_audio = True # fresh page load: no prior user gesture, autoplay will be blocked
601
  st.rerun()
602
 
603
 
@@ -1044,6 +1093,8 @@ def main():
1044
  initial_sidebar_state="collapsed")
1045
  st.markdown(CSS, unsafe_allow_html=True)
1046
  init_db()
 
 
1047
 
1048
  logo = load_b64(LOGO_PATH)
1049
  logo_html = f'<img class="hero-logo" src="data:image/png;base64,{logo}" alt="logo">' if logo else ""
 
9
  INTERVIEWS_DB_PATH optional, path of the SQLite file. Point it at a PERSISTENT
10
  volume in production, otherwise the data is lost on redeploy.
11
  AZURE_SPEECH_KEY / AZURE_SPEECH_REGION optional, better TTS voices
12
+ Pre-generated audio:
13
+ welcome_audio/interview_welcome_{en,ar}.mp3 generate with gen_welcome.py and commit them
14
  """
15
  import os, io, re, json, hmac, html, base64, sqlite3, asyncio, logging, secrets, random
16
  from contextlib import closing
 
32
  DB_PATH = os.environ.get("INTERVIEWS_DB_PATH", "interviews.db")
33
  LOGO_PATH = "logo.png"
34
  PHOTO_PATH = "agent_photo.jpg"
35
+ WELCOME_DIR = Path("welcome_audio") # pre-generated intro clips, committed to the repo
36
  LLM_MODEL = "openai/gpt-oss-120b"
37
  STT_MODEL = "whisper-large-v3"
38
  MIN_AUDIO_BYTES = 12000
 
48
 
49
  SPOKEN = {
50
  "en": {
51
+ # Generic opening: pre-recorded once (see gen_welcome.py). If you change this text,
52
+ # delete welcome_audio/*.mp3 and regenerate so the audio matches what is on screen.
53
+ "greeting_intro": "Welcome to the Bin Dawood AI Bot interview. I'm Reem, an AI assistant.",
54
+ # Personalised part: always generated live.
55
+ "greeting_body": ("Hello {name}, I will be conducting your first-round interview for the "
56
+ "{title} position at {company}. I will ask you {n} questions. Take your "
57
+ "time with each answer, and feel free to speak or type. Are you ready? "
58
+ "Can we start?"),
59
  "lets_begin": "Great, let's begin.",
60
  "not_ready": "No problem, take your time. Just let me know when you're ready to start.",
61
  "question": "Question {i} of {n}. {q}",
 
69
  "to you for the next steps. Thank you again, and goodbye."),
70
  },
71
  "ar": {
72
+ "greeting_intro": "أهلاً بكم في مقابلة بوت بن داود للذكاء الاصطناعي. أنا ريم، مساعدة ذكاء اصطناعي.",
73
+ "greeting_body": ("مرحباً {name}، سأجري معكم المقابلة الأولى لوظيفة {title} في {company}. "
74
+ "سأطرح عليكم {n} أسئلة، وخذوا وقتكم في كل إجابة، ويمكنكم التحدث أو الكتابة "
75
+ "كما تفضلون. هل أنتم مستعدون؟ هل يمكننا أن نبدأ؟"),
76
  "lets_begin": "رائع، لنبدأ.",
77
  "not_ready": "لا بأس، خذوا وقتكم. أخبروني عندما تكونون مستعدين للبدء.",
78
  "question": "السؤال {i} من {n}. {q}",
 
98
  "Please do not share sensitive personal information (for example health details, religion, "
99
  "or ID numbers) in your answers.",
100
  "You may stop at any time. Contact HR to ask for access to, or deletion of, your data.",
101
+ "Please turn your volume on. The interviewer will speak to you as soon as you start.",
 
102
  ], "I have read the above and agree to take this interview."),
103
  "ar": ("قبل أن نبدأ", [
104
  "تتم هذه المقابلة بواسطة مساعد ذكاء اصطناعي وليس بواسطة موظف.",
 
109
  "القرارات الوظيفية يتخذها أشخاص وليس الذكاء الاصطناعي.",
110
  "يرجى عدم ذكر معلومات شخصية حساسة (مثل التفاصيل الصحية أو الدين أو أرقام الهوية) في إجاباتكم.",
111
  "يمكنكم التوقف في أي وقت، ويمكنكم التواصل مع الموارد البشرية لطلب الاطلاع على بياناتكم أو حذفها.",
112
+ "يرجى تشغيل الصوت. ستتحدث إليكم المحاورة بمجرد البدء.",
113
  ], "قرأتُ ما سبق وأوافق على إجراء هذه المقابلة."),
114
  }
115
 
 
330
  return base64.b64encode(data).decode() if data else ""
331
 
332
 
333
+ def welcome_clip_b64(lang: str) -> str:
334
+ """Pre-generated intro clip (committed to the repo). If the file is missing, try to
335
+ generate it once; if that fails too, return '' and the caller speaks the full greeting."""
336
+ p = WELCOME_DIR / f"interview_welcome_{lang}.mp3"
337
+ if not p.exists():
338
+ text = SPOKEN[lang]["greeting_intro"]
339
+ data = _azure_tts(text, lang) or _edge_tts(text, lang) or _gtts(text, lang)
340
+ if not data:
341
+ return ""
342
+ try:
343
+ WELCOME_DIR.mkdir(exist_ok=True)
344
+ p.write_bytes(data)
345
+ except OSError as e:
346
+ log.warning("could not cache welcome clip: %s", e)
347
+ return base64.b64encode(data).decode()
348
+ return base64.b64encode(p.read_bytes()).decode()
349
+
350
+
351
  # ══════════════════════════════════════════════════════════════════════════
352
  # LLM TASKS
353
  # ══════════════════════════════════════════════════════════════════════════
 
550
  <button class="tap" id="tap">🔊 Tap to hear · اضغط للاستماع</button></div>
551
  <script>__SCRIPT__</script></body></html>"""
552
 
553
+ # Plays a queue of base64 mp3 clips one after another (e.g. pre-recorded intro + live part).
554
  AUDIO_JS = """(function(){
555
  var a=document.getElementById('a'),c=document.getElementById('av'),
556
  r=document.getElementById('ring'),s=document.getElementById('status'),
557
  t=document.getElementById('tap');
558
+ var srcs=__AUDIOS__.map(function(b64){
559
+ var bin=atob(b64),u=new Uint8Array(bin.length);
560
+ for(var k=0;k<bin.length;k++)u[k]=bin.charCodeAt(k);
561
+ return URL.createObjectURL(new Blob([u],{type:'audio/mpeg'}));
562
+ });
563
+ var i=0;
564
+ function load(n){a.src=srcs[n];a.preload='auto';}
565
+ function speaking(){t.style.display='none';c.classList.add('speaking');r.classList.add('active');s.textContent='Speaking...';}
566
+ function idle(){c.classList.remove('speaking');r.classList.remove('active');s.textContent='Listening';}
567
+ function needTap(){t.style.display='inline-block';s.textContent='Tap to hear';}
568
+ a.addEventListener('play',speaking);
569
+ a.addEventListener('ended',function(){
570
+ if(i+1<srcs.length){i++;load(i);a.play().catch(needTap);}else{idle();}
571
+ });
572
+ a.addEventListener('pause',function(){if(!a.ended&&i+1>=srcs.length)idle();});
573
+ a.addEventListener('error',function(){s.textContent='Audio failed to load';});
574
+ t.addEventListener('click',function(){a.play().catch(function(e){s.textContent='Cannot play: '+e.name;});});
575
+ load(0);
576
+ if(__MANUAL__){needTap();}
577
+ else{var go=function(){var x=a.play();if(x&&x.catch)x.catch(needTap);};
578
+ if(document.readyState==='complete')go();else window.addEventListener('load',go);}
579
+ })();"""
580
+
581
+
582
+ def render_avatar(img_b64: str, name: str, audio=None, manual: bool = False) -> str:
583
+ """`audio` is a base64 string or a list of base64 strings (played in order).
584
+ manual=True skips autoplay and shows the tap button."""
585
+ clips = [audio] if isinstance(audio, str) and audio else [c for c in (audio or []) if c]
586
  img = (f'<img src="data:image/jpeg;base64,{img_b64}" alt="{html.escape(name)}">'
587
  if img_b64 else '<div class="ph">👩‍💼</div>')
588
+ script = (AUDIO_JS.replace("__AUDIOS__", json.dumps(clips))
589
+ .replace("__MANUAL__", "true" if manual else "false")) if clips else ""
590
  return (AVATAR_TEMPLATE.replace("__IMG__", img)
591
  .replace("__NAME__", html.escape(name)).replace("__SCRIPT__", script))
592
 
 
614
  "INSERT INTO interviews(invite_code, status, consent_at, started_at, questions_json) "
615
  "VALUES (?,?,?,?,?)",
616
  (inv["code"], "in_progress", now_iso(), now_iso(), json.dumps(questions, ensure_ascii=False)))
617
+
618
+ S = SPOKEN[lang]
619
+ intro = S["greeting_intro"]
620
+ body = S["greeting_body"].format(
621
+ name=inv["candidate_name"], title=pos["title"], company=COMPANY[lang], n=len(questions))
622
+ greeting = intro + " " + body
623
+ save_turn(iid, "interviewer", "greeting", None, greeting) # full text is what HR and the candidate see
624
  ss.iv = {"id": iid, "lang": lang, "name": inv["candidate_name"], "title": pos["title"],
625
  "questions": questions, "q_index": 0, "followups_used": 0, "done": False,
626
  "stage": "ready"}
627
+
628
+ # Pre-recorded intro first; then only the personalised part live.
629
+ # If the clip is unavailable, speak the whole greeting live instead.
630
+ clip = welcome_clip_b64(lang)
631
+ live = tts_b64(body if clip else greeting, lang)
632
+ ss.pending_audio = [c for c in (clip, live) if c]
633
+ ss.manual_audio = False # the candidate just clicked "Start", so autoplay is allowed;
634
+ # the iframe falls back to the tap button if the browser refuses
635
  st.rerun()
636
 
637
 
 
646
  "questions": questions, "q_index": min(row["q_index"], len(questions) - 1),
647
  "followups_used": row["followups_used"], "done": False, "stage": stage}
648
  ss.pending_audio = tts_b64(last[-1]["content"], lang) if last else ""
649
+ ss.manual_audio = False # the candidate just clicked "Resume"
650
  st.rerun()
651
 
652
 
 
1093
  initial_sidebar_state="collapsed")
1094
  st.markdown(CSS, unsafe_allow_html=True)
1095
  init_db()
1096
+ for _lang in LANGS: # creates welcome_audio/*.mp3 on first run if missing
1097
+ welcome_clip_b64(_lang)
1098
 
1099
  logo = load_b64(LOGO_PATH)
1100
  logo_html = f'<img class="hero-logo" src="data:image/png;base64,{logo}" alt="logo">' if logo else ""