Update app.py
Browse files
app.py
CHANGED
|
@@ -4,7 +4,7 @@ import shutil
|
|
| 4 |
from pydub import AudioSegment
|
| 5 |
from openai import OpenAI
|
| 6 |
import gradio as gr
|
| 7 |
-
from fastapi import
|
| 8 |
|
| 9 |
# ======================================================
|
| 10 |
# 🔐 設定區
|
|
@@ -17,58 +17,6 @@ print("===== 🚀 啟動中 =====")
|
|
| 17 |
print(f"APP_PASSWORD: {'✅ 已載入' if PASSWORD else '❌ 未載入'}")
|
| 18 |
print(f"目前密碼內容:{PASSWORD}")
|
| 19 |
|
| 20 |
-
# ======================================================
|
| 21 |
-
# ⚔️ 防暴力破解
|
| 22 |
-
# ======================================================
|
| 23 |
-
MAX_FAILED_IN_WINDOW = 10
|
| 24 |
-
WINDOW_SECONDS = 24 * 3600
|
| 25 |
-
LOCK_DURATION_SECONDS = 24 * 3600
|
| 26 |
-
SHORT_BURST_LIMIT = 5
|
| 27 |
-
SHORT_BURST_SECONDS = 60
|
| 28 |
-
|
| 29 |
-
attempts = {}
|
| 30 |
-
locked = {}
|
| 31 |
-
|
| 32 |
-
def _now():
|
| 33 |
-
return int(time.time())
|
| 34 |
-
|
| 35 |
-
def prune_old_attempts(sid):
|
| 36 |
-
cutoff = _now() - WINDOW_SECONDS
|
| 37 |
-
if sid in attempts:
|
| 38 |
-
attempts[sid] = [t for t in attempts[sid] if t >= cutoff]
|
| 39 |
-
if not attempts[sid]:
|
| 40 |
-
del attempts[sid]
|
| 41 |
-
|
| 42 |
-
def check_lock(sid):
|
| 43 |
-
if sid in locked:
|
| 44 |
-
if _now() < locked[sid]:
|
| 45 |
-
remain = locked[sid] - _now()
|
| 46 |
-
return True, f"🔒 已被鎖定,請 {remain // 60} 分鐘後再試。"
|
| 47 |
-
else:
|
| 48 |
-
locked.pop(sid, None)
|
| 49 |
-
attempts.pop(sid, None)
|
| 50 |
-
prune_old_attempts(sid)
|
| 51 |
-
cnt = len(attempts.get(sid, []))
|
| 52 |
-
if cnt >= MAX_FAILED_IN_WINDOW:
|
| 53 |
-
locked[sid] = _now() + LOCK_DURATION_SECONDS
|
| 54 |
-
return True, f"🔒 嘗試過多,已鎖定 24 小時。"
|
| 55 |
-
return False, ""
|
| 56 |
-
|
| 57 |
-
def record_failed_attempt(sid):
|
| 58 |
-
now = _now()
|
| 59 |
-
attempts.setdefault(sid, []).append(now)
|
| 60 |
-
prune_old_attempts(sid)
|
| 61 |
-
recent_cutoff = now - SHORT_BURST_SECONDS
|
| 62 |
-
recent = [t for t in attempts[sid] if t >= recent_cutoff]
|
| 63 |
-
if len(recent) >= SHORT_BURST_LIMIT:
|
| 64 |
-
locked[sid] = now + 300
|
| 65 |
-
return len(attempts[sid]), "⚠️ 多次快速嘗試,暫時鎖定5分鐘。"
|
| 66 |
-
return len(attempts[sid]), ""
|
| 67 |
-
|
| 68 |
-
def clear_attempts(sid):
|
| 69 |
-
attempts.pop(sid, None)
|
| 70 |
-
locked.pop(sid, None)
|
| 71 |
-
|
| 72 |
# ======================================================
|
| 73 |
# 🎧 音訊轉錄核心
|
| 74 |
# ======================================================
|
|
@@ -88,8 +36,7 @@ def split_audio_if_needed(path):
|
|
| 88 |
files.append(fn)
|
| 89 |
return files
|
| 90 |
|
| 91 |
-
def transcribe_core(path, model):
|
| 92 |
-
# 1️⃣ 修正 LINE 語音 mp4 假副檔名
|
| 93 |
if path and path.lower().endswith(".mp4"):
|
| 94 |
fixed_path = path[:-4] + ".m4a"
|
| 95 |
try:
|
|
@@ -97,9 +44,8 @@ def transcribe_core(path, model):
|
|
| 97 |
path = fixed_path
|
| 98 |
print("🔧 已自動修正 mp4 → m4a")
|
| 99 |
except Exception as e:
|
| 100 |
-
print(f"⚠️ mp4→m4a
|
| 101 |
|
| 102 |
-
# 2️⃣ Whisper 轉錄
|
| 103 |
chunks = split_audio_if_needed(path)
|
| 104 |
raw_parts = []
|
| 105 |
for f in chunks:
|
|
@@ -112,7 +58,7 @@ def transcribe_core(path, model):
|
|
| 112 |
raw_parts.append(res)
|
| 113 |
full_raw = "\n".join(raw_parts)
|
| 114 |
|
| 115 |
-
#
|
| 116 |
conv_prompt = (
|
| 117 |
"請將以下內容完整轉換為「繁體中文(台灣用語)」:\n"
|
| 118 |
"規則:1) 僅做簡→繁字形轉換;2) 不要意譯或改寫;3) 不要添加任何前後綴。\n"
|
|
@@ -128,11 +74,10 @@ def transcribe_core(path, model):
|
|
| 128 |
)
|
| 129 |
full_trad = trad_resp.choices[0].message.content.strip()
|
| 130 |
|
| 131 |
-
#
|
| 132 |
sum_prompt = (
|
| 133 |
-
"
|
| 134 |
-
|
| 135 |
-
"請勿添加前綴或評論,僅輸出摘要。\n\n" + full_trad
|
| 136 |
)
|
| 137 |
sum_resp = client.chat.completions.create(
|
| 138 |
model="gpt-4o-mini",
|
|
@@ -143,126 +88,33 @@ def transcribe_core(path, model):
|
|
| 143 |
temperature=0.2,
|
| 144 |
)
|
| 145 |
summ = sum_resp.choices[0].message.content.strip()
|
| 146 |
-
|
| 147 |
return full_trad, summ
|
| 148 |
|
| 149 |
# ======================================================
|
| 150 |
-
#
|
| 151 |
# ======================================================
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
"""供捷徑上傳音訊並取得 JSON"""
|
| 160 |
-
if token != PASSWORD:
|
| 161 |
-
raise HTTPException(status_code=403, detail="Invalid token")
|
| 162 |
-
|
| 163 |
-
temp = file.filename
|
| 164 |
-
with open(temp, "wb") as f:
|
| 165 |
-
f.write(await file.read())
|
| 166 |
|
| 167 |
-
text, summary = transcribe_core(temp, "whisper-1")
|
| 168 |
-
os.remove(temp)
|
| 169 |
-
return {"text": text, "summary": summary}
|
| 170 |
-
|
| 171 |
-
@app.get("/health")
|
| 172 |
-
def health():
|
| 173 |
-
"""捷徑可 ping 這個確認服務運作中"""
|
| 174 |
-
return {"status": "ok", "time": int(time.time())}
|
| 175 |
-
|
| 176 |
-
# ======================================================
|
| 177 |
-
# 💬 Gradio 主介面
|
| 178 |
-
# ======================================================
|
| 179 |
-
def _normalize_upload_path(file_input):
|
| 180 |
-
if not file_input:
|
| 181 |
-
return None
|
| 182 |
-
if isinstance(file_input, str):
|
| 183 |
-
return file_input
|
| 184 |
-
if isinstance(file_input, list) and file_input:
|
| 185 |
-
return _normalize_upload_path(file_input[0])
|
| 186 |
-
path = getattr(file_input, "name", None)
|
| 187 |
-
if not path and isinstance(file_input, dict):
|
| 188 |
-
path = file_input.get("name") or file_input.get("path")
|
| 189 |
-
return path
|
| 190 |
-
|
| 191 |
-
def transcribe_with_password(session_id, password, file_input, model_choice):
|
| 192 |
-
password = password.strip().replace(" ", "").replace("\u200b", "")
|
| 193 |
-
locked_flag, msg = check_lock(session_id)
|
| 194 |
-
if locked_flag:
|
| 195 |
-
return msg, "", ""
|
| 196 |
-
if password != PASSWORD:
|
| 197 |
-
cnt, msg2 = record_failed_attempt(session_id)
|
| 198 |
-
return msg2 or f"密碼錯誤(第 {cnt} 次)", "", ""
|
| 199 |
-
path = _normalize_upload_path(file_input)
|
| 200 |
-
if not path or not os.path.exists(path):
|
| 201 |
-
return "找不到上傳檔案,請重新選擇。", "", ""
|
| 202 |
-
clear_attempts(session_id)
|
| 203 |
-
full, summ = transcribe_core(path, model_choice)
|
| 204 |
-
return "✅ 轉錄完成", full, summ
|
| 205 |
-
|
| 206 |
-
def ask_about_transcript(full_text, q):
|
| 207 |
-
if not full_text.strip():
|
| 208 |
-
return "⚠️ 尚未有轉錄內容"
|
| 209 |
-
if not q.strip():
|
| 210 |
-
return "請輸入問題"
|
| 211 |
-
prompt = f"以下是轉錄內容:\n{full_text}\n\n問題:{q}\n請用繁體中文回答。"
|
| 212 |
-
res = client.chat.completions.create(
|
| 213 |
-
model="gpt-4o-mini",
|
| 214 |
-
messages=[{"role":"user","content":prompt}],
|
| 215 |
-
temperature=0.6,
|
| 216 |
-
)
|
| 217 |
-
return res.choices[0].message.content.strip()
|
| 218 |
-
|
| 219 |
-
# ======================================================
|
| 220 |
-
# 🖥️ Gradio UI
|
| 221 |
-
# ======================================================
|
| 222 |
with gr.Blocks(theme=gr.themes.Soft()) as demo:
|
| 223 |
-
gr.Markdown("## 🎧
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
transcript_box = gr.Textbox(label="完整轉錄文字", lines=10)
|
| 232 |
-
copy_transcript = gr.Button("📋 複製轉錄文字")
|
| 233 |
-
summary_box = gr.Textbox(label="摘要結果", lines=10)
|
| 234 |
-
copy_summary = gr.Button("📋 複製摘要結果")
|
| 235 |
-
|
| 236 |
-
with gr.Accordion("💬 進一步問 AI", open=False):
|
| 237 |
-
user_q = gr.Textbox(label="輸入問題", lines=2)
|
| 238 |
-
ask_btn = gr.Button("詢問 AI 🤔")
|
| 239 |
-
ai_reply = gr.Textbox(label="AI 回覆", lines=6)
|
| 240 |
-
copy_reply = gr.Button("📋 複製 AI 回覆")
|
| 241 |
-
|
| 242 |
-
def init_session():
|
| 243 |
-
import uuid
|
| 244 |
-
return str(uuid.uuid4())
|
| 245 |
-
|
| 246 |
-
demo.load(init_session, None, session_state)
|
| 247 |
-
transcribe_btn.click(transcribe_with_password, [session_state, password_input, file_input, model_choice], [status_box, transcript_box, summary_box])
|
| 248 |
-
ask_btn.click(ask_about_transcript, [transcript_box, user_q], [ai_reply])
|
| 249 |
-
|
| 250 |
-
copy_js = """async (text) => {try {await navigator.clipboard.writeText(text); alert("✅ 已複製到剪貼簿!");} catch (e) {alert("❌ 複製失敗:" + e);}}"""
|
| 251 |
-
copy_transcript.click(fn=None, inputs=transcript_box, outputs=None, js=copy_js)
|
| 252 |
-
copy_summary.click(fn=None, inputs=summary_box, outputs=None, js=copy_js)
|
| 253 |
-
copy_reply.click(fn=None, inputs=ai_reply, outputs=None, js=copy_js)
|
| 254 |
|
| 255 |
# ======================================================
|
| 256 |
-
# 🚀
|
| 257 |
# ======================================================
|
| 258 |
-
|
| 259 |
-
# ✅ Hugging Face 會自動搜尋變數 `app` 作為入口
|
| 260 |
-
# 所以我們要在這裡重新指派回 FastAPI + Gradio 結合後的物件
|
| 261 |
-
app = gr.mount_gradio_app(app, demo, path="/")
|
| 262 |
-
|
| 263 |
-
# ✅ 不要在 Hugging Face 上手動啟動 uvicorn
|
| 264 |
-
# 若你要在本機測試,再用 python app.py 啟動即可
|
| 265 |
if __name__ == "__main__":
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
| 4 |
from pydub import AudioSegment
|
| 5 |
from openai import OpenAI
|
| 6 |
import gradio as gr
|
| 7 |
+
from fastapi import HTTPException
|
| 8 |
|
| 9 |
# ======================================================
|
| 10 |
# 🔐 設定區
|
|
|
|
| 17 |
print(f"APP_PASSWORD: {'✅ 已載入' if PASSWORD else '❌ 未載入'}")
|
| 18 |
print(f"目前密碼內容:{PASSWORD}")
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
# ======================================================
|
| 21 |
# 🎧 音訊轉錄核心
|
| 22 |
# ======================================================
|
|
|
|
| 36 |
files.append(fn)
|
| 37 |
return files
|
| 38 |
|
| 39 |
+
def transcribe_core(path, model="whisper-1"):
|
|
|
|
| 40 |
if path and path.lower().endswith(".mp4"):
|
| 41 |
fixed_path = path[:-4] + ".m4a"
|
| 42 |
try:
|
|
|
|
| 44 |
path = fixed_path
|
| 45 |
print("🔧 已自動修正 mp4 → m4a")
|
| 46 |
except Exception as e:
|
| 47 |
+
print(f"⚠️ mp4→m4a 轉檔失敗:{e}")
|
| 48 |
|
|
|
|
| 49 |
chunks = split_audio_if_needed(path)
|
| 50 |
raw_parts = []
|
| 51 |
for f in chunks:
|
|
|
|
| 58 |
raw_parts.append(res)
|
| 59 |
full_raw = "\n".join(raw_parts)
|
| 60 |
|
| 61 |
+
# 簡轉繁
|
| 62 |
conv_prompt = (
|
| 63 |
"請將以下內容完整轉換為「繁體中文(台灣用語)」:\n"
|
| 64 |
"規則:1) 僅做簡→繁字形轉換;2) 不要意譯或改寫;3) 不要添加任何前後綴。\n"
|
|
|
|
| 74 |
)
|
| 75 |
full_trad = trad_resp.choices[0].message.content.strip()
|
| 76 |
|
| 77 |
+
# 摘要
|
| 78 |
sum_prompt = (
|
| 79 |
+
"請用台灣繁體中文撰寫摘要。若內容資訊多,可條列出重點;若內容簡短,請用一句話概述即可。\n\n"
|
| 80 |
+
+ full_trad
|
|
|
|
| 81 |
)
|
| 82 |
sum_resp = client.chat.completions.create(
|
| 83 |
model="gpt-4o-mini",
|
|
|
|
| 88 |
temperature=0.2,
|
| 89 |
)
|
| 90 |
summ = sum_resp.choices[0].message.content.strip()
|
|
|
|
| 91 |
return full_trad, summ
|
| 92 |
|
| 93 |
# ======================================================
|
| 94 |
+
# 💬 Gradio 介面
|
| 95 |
# ======================================================
|
| 96 |
+
def transcribe_with_password(password, file):
|
| 97 |
+
if password.strip() != PASSWORD:
|
| 98 |
+
raise HTTPException(status_code=403, detail="密碼錯誤 ❌")
|
| 99 |
+
if not file:
|
| 100 |
+
return "⚠️ 未選擇��案", "", ""
|
| 101 |
+
text, summary = transcribe_core(file.name)
|
| 102 |
+
return "✅ 完成", text, summary
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
with gr.Blocks(theme=gr.themes.Soft()) as demo:
|
| 105 |
+
gr.Markdown("## 🎧 LINE 語音轉錄與摘要(支援 .m4a / .mp4)")
|
| 106 |
+
pw = gr.Textbox(label="輸入密碼", type="password")
|
| 107 |
+
f = gr.File(label="上傳音訊檔")
|
| 108 |
+
run = gr.Button("開始轉錄 🚀")
|
| 109 |
+
s = gr.Textbox(label="狀態", interactive=False)
|
| 110 |
+
t = gr.Textbox(label="轉錄結果", lines=10)
|
| 111 |
+
su = gr.Textbox(label="AI 摘要", lines=8)
|
| 112 |
+
run.click(transcribe_with_password, [pw, f], [s, t, su])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
# ======================================================
|
| 115 |
+
# 🚀 啟動
|
| 116 |
# ======================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
if __name__ == "__main__":
|
| 118 |
+
demo.launch(server_name="0.0.0.0", server_port=7860)
|
| 119 |
+
else:
|
| 120 |
+
demo.launch()
|