ILRDF-Lowking's picture
Upload app.py
57ead62 verified
Raw
History Blame Contribute Delete
16 kB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
web-voice-teacher / scripts / app.py
原住民族語 AI 對話網頁版(升級自 LINE 教學機器人)
特色:
- 捨棄 LINE Webhook,全面改用 Gradio 作為網頁介面
- 支援麥克風直接錄音
- 串接原民會 API (ASR / MT / TTS) 與 Gemini AI
- 透過 config/tribes.json 自動載入 16 族設定
啟動方式:
python scripts/app.py
"""
import os
import json
import logging
import csv
import datetime
from docx import Document
from pathlib import Path
from dotenv import load_dotenv
# ── 路徑設定 ───────────────────────────────────────────────────
SKILL_ROOT = Path(__file__).parent.parent
ENV_FILE = SKILL_ROOT / ".env"
STATIC_DIR = SKILL_ROOT / "static"
STATIC_DIR.mkdir(exist_ok=True)
load_dotenv(ENV_FILE)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("web-voice-teacher")
# ── 套件載入 ───────────────────────────────────────────────────
try:
import gradio as gr
import google.generativeai as genai
from pydub import AudioSegment
from gradio_client import Client, handle_file
except ImportError as e:
raise SystemExit(
f"缺少套件:{e}\n"
"請執行:pip install gradio google-generativeai gradio_client pydub python-dotenv"
)
# ── 環境變數 ───────────────────────────────────────────────────
GEMINI_KEY = os.getenv("GEMINI_KEY", "") or os.getenv("GOOGLE_API_KEY", "")
if not GEMINI_KEY:
log.warning("⚠️ GEMINI_KEY 未設定,AI 對話功能將無法運作!")
genai.configure(api_key=GEMINI_KEY)
# 使用 3.6-flash 模型進行快速對話
gemini_model = genai.GenerativeModel("gemini-3.6-flash")
chat_session = gemini_model.start_chat(history=[])
# ── 族別設定(讀自 config/tribes.json)────────────────────────
TRIBES_CFG_FILE = SKILL_ROOT / "config" / "tribes.json"
def load_tribe_config():
if TRIBES_CFG_FILE.exists():
with open(TRIBES_CFG_FILE, encoding="utf-8") as f:
raw = json.load(f)
return {name: {"asr": cfg["asr_id"], "mt": cfg["mt_name"]}
for name, cfg in raw["tribes"].items()}
return {}
TRIBE_CONFIG = load_tribe_config()
TRIBE_NAMES = list(TRIBE_CONFIG.keys())
# ── 原民會 AI API 客戶端 ──────────────────────────────────────
# 忽略 SSL 憑證驗證,避免 Windows 環境常見的 CERTIFICATE_VERIFY_FAILED 錯誤
import ssl
_orig_create_default_context = ssl.create_default_context
def _unverified_create_default_context(*args, **kwargs):
ctx = _orig_create_default_context(*args, **kwargs)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
ssl.create_default_context = _unverified_create_default_context
asr_client = Client("https://ai-labs.ilrdf.org.tw/sapolita-kaldi/")
tts_client = Client("https://ai-labs.ilrdf.org.tw/hnang-kari-ai-asi-sluhay/")
mt_client = Client("https://ai-labs.ilrdf.org.tw/kari-seejiq-tnpusu-ai-hmjil/")
# ══════════════════════════════════════════════════════════════
# 工具函式
# ══════════════════════════════════════════════════════════════
def get_clean_value(res):
if isinstance(res, dict) and "value" in res:
return res["value"]
if isinstance(res, list) and len(res) > 0:
return res[0]
return res
def get_ai_response(user_text: str, tribe_name: str) -> str:
prompt = (
f"你現在是與我對話的{tribe_name}族朋友。請用中文聊天。"
"規則:1.禁止教學。2.直接回答。3.限一短句。"
f"\n\n我說:{user_text}"
)
return chat_session.send_message(prompt).text.strip()
# ══════════════════════════════════════════════════════════════
# 核心對話邏輯
def get_valid_speakers(tribe):
if not tribe: return []
cfg = TRIBE_CONFIG[tribe]
try:
res = tts_client.predict(ethnicity=cfg["mt"], api_name="/lambda")
if isinstance(res, dict) and "choices" in res:
return [c[0] if isinstance(c, (list, tuple)) else c for c in res["choices"]]
except:
pass
return []
# ══════════════════════════════════════════════════════════════
def process_voice(audio_path, tribe, speaker, history, session_phrases):
if not audio_path:
return history, None, session_phrases
if not tribe:
return history + [["請先選擇族別!", ""]], None, session_phrases
cfg = TRIBE_CONFIG[tribe]
wav_path = str(STATIC_DIR / "temp_input.wav")
try:
# 1. 確保音訊格式正確
AudioSegment.from_file(audio_path).export(wav_path, format="wav")
# 2. ASR (語音 -> 族語文字)
native_in = asr_client.predict(
dialect_id=cfg["asr"],
audio_data=handle_file(wav_path),
api_name="/automatic_speech_recognition"
)
if not native_in or not native_in.strip():
return history + [["🎙️ (無聲音輸入)", "🎙️ (聽取失敗,請再說一次)"]], None, session_phrases
# 3. MT (族語 -> 中文)
go_code = get_clean_value(mt_client.predict(ethnicity=cfg["mt"], api_name="/lambda"))
zh_in = mt_client.predict(text=native_in, src_lang=go_code, tgt_lang="zho_Hant", api_name="/translate")
# 把使用者的話加入畫面
user_msg = f"[{tribe}]你:{native_in}\n💬 ({zh_in})"
history.append([user_msg, "⏳ 思考中..."])
yield history, None, session_phrases
# 4. Gemini AI 對話 (中文 -> 中文)
ai_zh = get_ai_response(zh_in, tribe)
# 5. MT (中文 -> 族語)
bk_code = get_clean_value(mt_client.predict(ethnicity=cfg["mt"], api_name="/lambda_1"))
ai_nat = get_clean_value(mt_client.predict(
text=ai_zh, src_lang="zho_Hant", tgt_lang=bk_code, api_name="/translate_1"
))
# 儲存到 Session 狀態
session_phrases.append({"tribe": tribe, "user": native_in, "native": ai_nat, "zh": ai_zh})
# 先更新畫面的 AI 回應 (純文字)
ai_msg = f"🧑‍🏫 [{tribe}] {ai_nat}\n💬 ({ai_zh})"
history[-1][1] = ai_msg
yield history, None, session_phrases
# 6. TTS (族語 -> 語音)
valid_speakers = get_valid_speakers(tribe)
if not speaker or speaker not in valid_speakers:
speaker = valid_speakers[0] if valid_speakers else get_clean_value(tts_client.predict(ethnicity=cfg["mt"], api_name="/lambda"))
tts_out = tts_client.predict(ref=speaker, gen_text_input=ai_nat, api_name="/default_speaker_tts")
# 最後附上語音
yield history, tts_out, session_phrases
except Exception as e:
log.error(f"語音處理失敗:{e}")
history.append(["❌ 系統錯誤", str(e)])
yield history, None, session_phrases
def render_phrases(session_phrases):
if not session_phrases:
return "目前還沒有紀錄喔!開始對話吧。"
return "\n\n".join([f"[{r['tribe']}]你:{r.get('user', '')}\n🧑‍🏫 [{r['tribe']}] {r['native']}\n💬 ({r['zh']})" for r in session_phrases[-10:]])
def export_csv(session_phrases):
session_phrases = session_phrases or []
now_str = datetime.datetime.now().strftime("%Y%m%d_%H%M")
out_path = str(STATIC_DIR / f"{now_str}_notes.csv")
with open(out_path, 'w', encoding='utf-8-sig', newline='') as f:
writer = csv.writer(f)
writer.writerow(["族別", "你的提問", "族語回覆", "中文回覆"])
for r in session_phrases:
writer.writerow([r['tribe'], r.get('user', ''), r['native'], r['zh']])
return out_path
def export_doc(session_phrases):
session_phrases = session_phrases or []
doc = Document()
doc.add_heading('原住民族語學習筆記', 0)
for r in session_phrases:
p = doc.add_paragraph()
p.add_run(f"[{r['tribe']}]你:{r.get('user', '')}\n")
p.add_run(f"[{r['tribe']}] {r['native']}\n").bold = True
p.add_run(f"({r['zh']})")
now_str = datetime.datetime.now().strftime("%Y%m%d_%H%M")
out_path = str(STATIC_DIR / f"{now_str}_notes.docx")
doc.save(out_path)
return out_path
# ══════════════════════════════════════════════════════════════
# Gradio UI 介面
# ══════════════════════════════════════════════════════════════
custom_css = """
body {
font-family: 'Noto Sans TC', sans-serif;
background: linear-gradient(135deg, #e0f7fa 0%, #e1f5fe 100%);
}
.gradio-container {
font-size: 18px !important;
background: rgba(255, 255, 255, 0.75) !important;
backdrop-filter: blur(15px);
border-radius: 20px !important;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.08) !important;
padding: 20px !important;
border: 2px solid #b2ebf2 !important;
}
.message.user {
background-color: #e3f2fd !important;
border: 1px solid #bbdefb !important;
border-radius: 15px !important;
box-shadow: 0 2px 10px rgba(0,0,0,0.05) !important;
}
.message.bot {
background-color: #ffffff !important;
border: 1px solid #eceff1 !important;
border-radius: 15px !important;
box-shadow: 0 2px 10px rgba(0,0,0,0.05) !important;
}
.message p { font-size: 20px !important; line-height: 1.5; font-family: 'Times New Roman', 'Noto Sans TC', sans-serif !important; }
textarea { font-family: 'Times New Roman', 'Noto Sans TC', sans-serif !important; background: rgba(255,255,255,0.9) !important; border: 1px solid #b0bec5 !important; }
span[data-testid="block-info"] { font-size: 18px !important; }
.prose p { font-size: 20px !important; }
.prose h1 { font-size: 32px !important; color: #006064 !important; }
.prose h3 { font-size: 24px !important; color: #00838f !important; }
.form-label, .block-label { font-size: 20px !important; font-weight: bold !important; color: #004d40 !important; }
#font-size-radio { display: flex; flex-direction: row; align-items: center; gap: 15px; }
#font-size-radio span[data-testid="block-info"] { margin-bottom: 0 !important; white-space: nowrap; }
button { transition: all 0.3s ease !important; }
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 131, 143, 0.2) !important;
}
/* 增強各區塊邊界與聊天視窗底色 */
.block {
border: 1px solid #b0bec5 !important;
box-shadow: 0 1px 4px rgba(0,0,0,0.05) !important;
}
#chat-window {
background-color: #f1f8e9 !important; /* 淺淡綠色 */
border: 2px solid #aed581 !important;
}
#chat-window > div {
background-color: transparent !important;
}
#header-row .block {
border: none !important;
box-shadow: none !important;
background: transparent !important;
}
"""
js_head = """<script>
window.addEventListener('beforeunload', function (e) { e.preventDefault(); e.returnValue = ''; });
document.addEventListener("DOMContentLoaded", function() {
new MutationObserver(function() {
document.querySelectorAll("button").forEach(b => {
b.childNodes.forEach(n => {
if (n.nodeType === 3) {
if (n.textContent.trim() === "Record") n.textContent = " 錄音";
if (n.textContent.trim() === "Stop") n.textContent = " 停止";
}
});
});
}).observe(document.body, {childList: true, subtree: true});
});
</script>"""
with gr.Blocks(title="原住民族語 AI 對話", css=custom_css, theme=gr.themes.Soft(primary_hue="cyan", neutral_hue="slate"), head=js_head) as app:
# 狀態儲存
session_phrases = gr.State([])
with gr.Row(elem_id="header-row"):
with gr.Column(scale=3):
gr.Markdown("# 🗣️ 原住民族語 AI 對話機器人\n選擇你的族別,按下麥克風開始對話!")
with gr.Column(scale=2):
font_size_radio = gr.Radio(choices=["正常", "中 (150%)", "大 (200%)"], value="正常", label="網頁文字大小", interactive=True, elem_id="font-size-radio")
font_size_radio.change(None, [font_size_radio], js="(v) => { document.body.style.zoom = v.includes('150') ? '150%' : v.includes('200') ? '200%' : '100%'; }")
with gr.Row():
with gr.Column(scale=4):
with gr.Row():
tribe_dropdown = gr.Dropdown(choices=TRIBE_NAMES, label="🌍 選擇族別", value="阿美" if "阿美" in TRIBE_NAMES else None)
speaker_dropdown = gr.Dropdown(label="🔊 選擇語音", choices=[], interactive=True)
chatbot = gr.Chatbot(label="對話視窗", height=400, elem_id="chat-window")
with gr.Row():
audio_input = gr.Audio(sources=["microphone"], type="filepath", label="🎙️ 按下錄音")
audio_output = gr.Audio(label="🔊 AI 語音回應", autoplay=True)
with gr.Column(scale=1):
gr.Markdown("### 📚 學習筆記 (最新 10 筆)")
history_box = gr.Textbox(label="你的專屬語料庫", lines=15, interactive=False, value="目前還沒有紀錄喔!開始對話吧。")
with gr.Row():
dl_csv_btn = gr.DownloadButton("📥 下載 CSV")
dl_doc_btn = gr.DownloadButton("📥 下載 Word")
dl_csv_btn.click(fn=export_csv, inputs=[session_phrases], outputs=[dl_csv_btn])
dl_doc_btn.click(fn=export_doc, inputs=[session_phrases], outputs=[dl_doc_btn])
def update_speakers(tribe):
choices = get_valid_speakers(tribe)
val = choices[0] if choices else None
return gr.update(choices=choices, value=val)
tribe_dropdown.change(fn=update_speakers, inputs=[tribe_dropdown], outputs=[speaker_dropdown])
app.load(fn=update_speakers, inputs=[tribe_dropdown], outputs=[speaker_dropdown])
# 事件綁定:錄音結束後自動送出處理
audio_input.stop_recording(
fn=process_voice,
inputs=[audio_input, tribe_dropdown, speaker_dropdown, chatbot, session_phrases],
outputs=[chatbot, audio_output, session_phrases]
).then(
fn=render_phrases,
inputs=[session_phrases],
outputs=[history_box]
).then(
# 清除麥克風狀態,方便下一次錄音
fn=lambda: None,
outputs=[audio_input]
)
gr.HTML("""
<div style="text-align: center; margin-top: 50px; padding-top: 20px; border-top: 1px solid #ccc; color: #666; font-size: 14px;">
<p>開發者:<b>LowkingNowbucyang</b> | 使用模型出自:<b><a href="https://ai-labs.ilrdf.org.tw/" target="_blank" style="color: #0066cc; text-decoration: none;">ILRDF (原語會)</a></b></p>
</div>
""")
if __name__ == "__main__":
port = int(os.getenv("PORT", 7860))
log.info(f"🚀 啟動網頁版 AI 對話 (Port: {port})")
app.launch(server_name="0.0.0.0", server_port=port, share=False)