#!/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 = """""" 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("""

開發者:LowkingNowbucyang | 使用模型出自:ILRDF (原語會)

""") 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)