Spaces:
Running
Running
Update app.py
Browse files
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 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 67 |
-
|
| 68 |
-
|
| 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.
|
| 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 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 549 |
-
.replace("__MANUAL__", "true" if manual else "false")) if
|
| 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 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 585 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 ""
|