Update app.py
Browse files
app.py
CHANGED
|
@@ -1,15 +1,77 @@
|
|
| 1 |
import os
|
|
|
|
|
|
|
|
|
|
| 2 |
from pydub import AudioSegment
|
| 3 |
from openai import OpenAI
|
| 4 |
import gradio as gr
|
| 5 |
|
| 6 |
# ========================
|
| 7 |
-
# 🔐
|
| 8 |
# ========================
|
| 9 |
PASSWORD = os.getenv("APP_PASSWORD", "defaultpass")
|
| 10 |
-
MAX_SIZE = 25 * 1024 * 1024
|
| 11 |
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
# ========================
|
| 14 |
# 🔊 音訊分割
|
| 15 |
# ========================
|
|
@@ -31,14 +93,9 @@ def split_audio_if_needed(input_path):
|
|
| 31 |
return chunk_files
|
| 32 |
|
| 33 |
# ========================
|
| 34 |
-
# 🎧
|
| 35 |
# ========================
|
| 36 |
-
def
|
| 37 |
-
if password != PASSWORD:
|
| 38 |
-
return "❌ 密碼錯誤,請重新輸入。", "", ""
|
| 39 |
-
if not file:
|
| 40 |
-
return "請上傳音訊檔。", "", ""
|
| 41 |
-
|
| 42 |
chunks = split_audio_if_needed(file)
|
| 43 |
transcripts = []
|
| 44 |
for idx, f in enumerate(chunks, 1):
|
|
@@ -50,27 +107,42 @@ def transcribe_and_summarize(password, file, model_choice):
|
|
| 50 |
)
|
| 51 |
transcripts.append(text)
|
| 52 |
full_text = "\n".join(transcripts)
|
| 53 |
-
|
| 54 |
response = client.chat.completions.create(
|
| 55 |
model="gpt-4o-mini",
|
| 56 |
messages=[
|
| 57 |
{"role": "system", "content": "你是一位精準且擅長摘要的助手。"},
|
| 58 |
-
{"role": "user", "content":
|
| 59 |
],
|
| 60 |
temperature=0.4,
|
| 61 |
)
|
| 62 |
summary = response.choices[0].message.content.strip()
|
| 63 |
-
return full_text, summary
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
# ========================
|
| 66 |
# 💬 進一步問 AI
|
| 67 |
# ========================
|
| 68 |
def ask_about_transcript(full_text, user_question):
|
| 69 |
if not full_text.strip():
|
| 70 |
-
return "⚠️
|
| 71 |
if not user_question.strip():
|
| 72 |
-
return "
|
| 73 |
-
prompt = f"
|
| 74 |
response = client.chat.completions.create(
|
| 75 |
model="gpt-4o-mini",
|
| 76 |
messages=[{"role": "user", "content": prompt}],
|
|
@@ -81,42 +153,53 @@ def ask_about_transcript(full_text, user_question):
|
|
| 81 |
# ========================
|
| 82 |
# 🌐 Gradio 介面
|
| 83 |
# ========================
|
| 84 |
-
with gr.Blocks(theme=gr.themes.Soft()
|
| 85 |
-
gr.Markdown("## 🎧
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
with gr.Row():
|
| 89 |
-
password_input = gr.Textbox(label="輸入密碼", type="password"
|
| 90 |
model_choice = gr.Dropdown(
|
| 91 |
choices=["whisper-1", "gpt-4o-mini-transcribe"],
|
| 92 |
value="whisper-1",
|
| 93 |
label="選擇轉錄模型"
|
| 94 |
)
|
| 95 |
|
| 96 |
-
audio_input = gr.Audio(type="filepath", label="
|
| 97 |
transcribe_btn = gr.Button("開始轉錄與摘要 🚀")
|
| 98 |
|
| 99 |
status_box = gr.Textbox(label="狀態", interactive=False)
|
| 100 |
-
|
| 101 |
with gr.Row():
|
| 102 |
transcript_box = gr.Textbox(label="完整轉錄文字", lines=10)
|
| 103 |
-
copy_transcript = gr.Button("📋 複製"
|
| 104 |
|
| 105 |
with gr.Row():
|
| 106 |
summary_box = gr.Textbox(label="摘要結果", lines=10)
|
| 107 |
-
copy_summary = gr.Button("📋 複製"
|
| 108 |
|
| 109 |
with gr.Accordion("💬 進一步問 AI", open=False):
|
| 110 |
-
user_question = gr.Textbox(label="
|
| 111 |
ask_btn = gr.Button("詢問 AI 🤔")
|
| 112 |
ai_reply = gr.Textbox(label="AI 回覆", lines=6)
|
| 113 |
-
copy_reply = gr.Button("📋 複製
|
| 114 |
|
| 115 |
-
# 事件綁定
|
| 116 |
transcribe_btn.click(
|
| 117 |
-
fn=
|
| 118 |
-
inputs=[password_input, audio_input, model_choice],
|
| 119 |
-
outputs=[transcript_box, summary_box,
|
| 120 |
)
|
| 121 |
|
| 122 |
ask_btn.click(
|
|
@@ -125,7 +208,6 @@ with gr.Blocks(theme=gr.themes.Soft(), css=".gr-button {font-size: 16px;} .copy-
|
|
| 125 |
outputs=[ai_reply]
|
| 126 |
)
|
| 127 |
|
| 128 |
-
# 前端 JS 複製功能
|
| 129 |
copy_js = """
|
| 130 |
async (text) => {
|
| 131 |
try {
|
|
|
|
| 1 |
import os
|
| 2 |
+
import time
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import timedelta
|
| 5 |
from pydub import AudioSegment
|
| 6 |
from openai import OpenAI
|
| 7 |
import gradio as gr
|
| 8 |
|
| 9 |
# ========================
|
| 10 |
+
# 🔐 環境變數設定
|
| 11 |
# ========================
|
| 12 |
PASSWORD = os.getenv("APP_PASSWORD", "defaultpass")
|
| 13 |
+
MAX_SIZE = 25 * 1024 * 1024 # 25 MB 限制
|
| 14 |
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
| 15 |
|
| 16 |
+
# ========================
|
| 17 |
+
# ⚔️ 防暴力破解機制
|
| 18 |
+
# ========================
|
| 19 |
+
MAX_FAILED_IN_WINDOW = 10 # 24小時內最多10次
|
| 20 |
+
WINDOW_SECONDS = 24 * 3600
|
| 21 |
+
LOCK_DURATION_SECONDS = 24 * 3600 # 鎖24小時
|
| 22 |
+
SHORT_BURST_LIMIT = 5 # 一分鐘內最多5次
|
| 23 |
+
SHORT_BURST_SECONDS = 60
|
| 24 |
+
attempts = {} # {session_id: [timestamps]}
|
| 25 |
+
locked = {} # {session_id: unlock_time}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _now():
|
| 29 |
+
return int(time.time())
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def prune_old_attempts(session_id):
|
| 33 |
+
if session_id not in attempts:
|
| 34 |
+
return
|
| 35 |
+
cutoff = _now() - WINDOW_SECONDS
|
| 36 |
+
attempts[session_id] = [t for t in attempts[session_id] if t >= cutoff]
|
| 37 |
+
if not attempts[session_id]:
|
| 38 |
+
del attempts[session_id]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def check_lock(session_id):
|
| 42 |
+
if session_id in locked:
|
| 43 |
+
unlock = locked[session_id]
|
| 44 |
+
if _now() < unlock:
|
| 45 |
+
remain = unlock - _now()
|
| 46 |
+
return True, f"🔒 已被鎖定,請 {remain // 60} 分鐘後再試。"
|
| 47 |
+
else:
|
| 48 |
+
del locked[session_id]
|
| 49 |
+
attempts.pop(session_id, None)
|
| 50 |
+
prune_old_attempts(session_id)
|
| 51 |
+
cnt = len(attempts.get(session_id, []))
|
| 52 |
+
if cnt >= MAX_FAILED_IN_WINDOW:
|
| 53 |
+
locked[session_id] = _now() + LOCK_DURATION_SECONDS
|
| 54 |
+
return True, f"🔒 嘗試過多,已鎖定 24 小時。"
|
| 55 |
+
return False, ""
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def record_failed_attempt(session_id):
|
| 59 |
+
now = _now()
|
| 60 |
+
attempts.setdefault(session_id, []).append(now)
|
| 61 |
+
prune_old_attempts(session_id)
|
| 62 |
+
cnt = len(attempts.get(session_id, []))
|
| 63 |
+
recent_cutoff = now - SHORT_BURST_SECONDS
|
| 64 |
+
recent = [t for t in attempts[session_id] if t >= recent_cutoff]
|
| 65 |
+
if len(recent) >= SHORT_BURST_LIMIT:
|
| 66 |
+
locked[session_id] = now + 300 # 鎖5分鐘
|
| 67 |
+
return cnt, "⚠️ 多次快速嘗試,暫時鎖定5分鐘。"
|
| 68 |
+
return cnt, ""
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def clear_attempts(session_id):
|
| 72 |
+
attempts.pop(session_id, None)
|
| 73 |
+
locked.pop(session_id, None)
|
| 74 |
+
|
| 75 |
# ========================
|
| 76 |
# 🔊 音訊分割
|
| 77 |
# ========================
|
|
|
|
| 93 |
return chunk_files
|
| 94 |
|
| 95 |
# ========================
|
| 96 |
+
# 🎧 轉錄與摘要
|
| 97 |
# ========================
|
| 98 |
+
def transcribe_core(file, model_choice):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
chunks = split_audio_if_needed(file)
|
| 100 |
transcripts = []
|
| 101 |
for idx, f in enumerate(chunks, 1):
|
|
|
|
| 107 |
)
|
| 108 |
transcripts.append(text)
|
| 109 |
full_text = "\n".join(transcripts)
|
|
|
|
| 110 |
response = client.chat.completions.create(
|
| 111 |
model="gpt-4o-mini",
|
| 112 |
messages=[
|
| 113 |
{"role": "system", "content": "你是一位精準且擅長摘要的助手。"},
|
| 114 |
+
{"role": "user", "content": "請用繁體中文摘要以下內容:\n" + full_text}
|
| 115 |
],
|
| 116 |
temperature=0.4,
|
| 117 |
)
|
| 118 |
summary = response.choices[0].message.content.strip()
|
| 119 |
+
return full_text, summary
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def transcribe_with_password(session_id, password, file, model_choice):
|
| 123 |
+
locked_flag, msg = check_lock(session_id)
|
| 124 |
+
if locked_flag:
|
| 125 |
+
return msg, "", "", ""
|
| 126 |
+
if password != PASSWORD:
|
| 127 |
+
cnt, msg2 = record_failed_attempt(session_id)
|
| 128 |
+
if msg2:
|
| 129 |
+
return msg2, "", "", ""
|
| 130 |
+
return f"密碼錯誤(第 {cnt} 次)", "", "", ""
|
| 131 |
+
if not file:
|
| 132 |
+
return "請上傳音訊檔。", "", "", ""
|
| 133 |
+
clear_attempts(session_id)
|
| 134 |
+
full_text, summary = transcribe_core(file, model_choice)
|
| 135 |
+
return "✅ 成功轉錄與摘要完成", full_text, summary, ""
|
| 136 |
|
| 137 |
# ========================
|
| 138 |
# 💬 進一步問 AI
|
| 139 |
# ========================
|
| 140 |
def ask_about_transcript(full_text, user_question):
|
| 141 |
if not full_text.strip():
|
| 142 |
+
return "⚠️ 尚未有轉錄內容。"
|
| 143 |
if not user_question.strip():
|
| 144 |
+
return "請輸入問題。"
|
| 145 |
+
prompt = f"以下是轉錄內容:\n{full_text}\n\n使用者問:{user_question}\n請用繁體中文回答。"
|
| 146 |
response = client.chat.completions.create(
|
| 147 |
model="gpt-4o-mini",
|
| 148 |
messages=[{"role": "user", "content": prompt}],
|
|
|
|
| 153 |
# ========================
|
| 154 |
# 🌐 Gradio 介面
|
| 155 |
# ========================
|
| 156 |
+
with gr.Blocks(theme=gr.themes.Soft()) as demo:
|
| 157 |
+
gr.Markdown("## 🎧 語音轉錄與摘要工具(含防暴力破解)")
|
| 158 |
+
|
| 159 |
+
session_id_box = gr.Textbox(value="", visible=False)
|
| 160 |
+
init_session_js = """
|
| 161 |
+
() => {
|
| 162 |
+
let sid = localStorage.getItem('my_session_id');
|
| 163 |
+
if (!sid) {
|
| 164 |
+
sid = crypto.randomUUID ? crypto.randomUUID() :
|
| 165 |
+
(Date.now().toString(36) + Math.random().toString(36).slice(2));
|
| 166 |
+
localStorage.setItem('my_session_id', sid);
|
| 167 |
+
}
|
| 168 |
+
return sid;
|
| 169 |
+
}
|
| 170 |
+
"""
|
| 171 |
+
session_id_box.load(_js=init_session_js)
|
| 172 |
|
| 173 |
with gr.Row():
|
| 174 |
+
password_input = gr.Textbox(label="輸入密碼", type="password")
|
| 175 |
model_choice = gr.Dropdown(
|
| 176 |
choices=["whisper-1", "gpt-4o-mini-transcribe"],
|
| 177 |
value="whisper-1",
|
| 178 |
label="選擇轉錄模型"
|
| 179 |
)
|
| 180 |
|
| 181 |
+
audio_input = gr.Audio(type="filepath", label="上傳音訊 (.m4a, .aac, .wav)")
|
| 182 |
transcribe_btn = gr.Button("開始轉錄與摘要 🚀")
|
| 183 |
|
| 184 |
status_box = gr.Textbox(label="狀態", interactive=False)
|
|
|
|
| 185 |
with gr.Row():
|
| 186 |
transcript_box = gr.Textbox(label="完整轉錄文字", lines=10)
|
| 187 |
+
copy_transcript = gr.Button("📋 複製")
|
| 188 |
|
| 189 |
with gr.Row():
|
| 190 |
summary_box = gr.Textbox(label="摘要結果", lines=10)
|
| 191 |
+
copy_summary = gr.Button("📋 複製")
|
| 192 |
|
| 193 |
with gr.Accordion("💬 進一步問 AI", open=False):
|
| 194 |
+
user_question = gr.Textbox(label="輸入你的問題", lines=2)
|
| 195 |
ask_btn = gr.Button("詢問 AI 🤔")
|
| 196 |
ai_reply = gr.Textbox(label="AI 回覆", lines=6)
|
| 197 |
+
copy_reply = gr.Button("📋 複製")
|
| 198 |
|
|
|
|
| 199 |
transcribe_btn.click(
|
| 200 |
+
fn=transcribe_with_password,
|
| 201 |
+
inputs=[session_id_box, password_input, audio_input, model_choice],
|
| 202 |
+
outputs=[status_box, transcript_box, summary_box, gr.Textbox(visible=False)]
|
| 203 |
)
|
| 204 |
|
| 205 |
ask_btn.click(
|
|
|
|
| 208 |
outputs=[ai_reply]
|
| 209 |
)
|
| 210 |
|
|
|
|
| 211 |
copy_js = """
|
| 212 |
async (text) => {
|
| 213 |
try {
|