File size: 16,039 Bytes
1035bf7 cc65c5b 1035bf7 cc65c5b d2699f3 1035bf7 57ead62 1035bf7 57ead62 1035bf7 57ead62 1035bf7 57ead62 cc65c5b 1035bf7 29b986f cc65c5b 1035bf7 29b986f cc65c5b 1035bf7 cc65c5b 1035bf7 d2699f3 cc65c5b d2699f3 cc65c5b d2699f3 1035bf7 d2699f3 1035bf7 cc65c5b 1035bf7 d2699f3 1035bf7 cc65c5b 1035bf7 d2699f3 1035bf7 6bae108 1035bf7 6bae108 cc65c5b 1035bf7 29b986f 1035bf7 0690c55 1035bf7 0690c55 1035bf7 0690c55 1035bf7 0690c55 1035bf7 0690c55 1035bf7 ad3f624 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 | #!/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)
|