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)