From 263eb1c39439d5fa5bc6a5a9d97392cc4c056a45 Mon Sep 17 00:00:00 2001 From: egg Date: Thu, 11 Dec 2025 21:00:27 +0800 Subject: [PATCH] feat: Add Dify audio transcription with VAD chunking and SSE progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add audio file upload transcription via Dify STT API - Implement VAD-based audio segmentation in sidecar (3-min chunks) - Add SSE endpoint for real-time transcription progress updates - Fix chunk size enforcement for reliable uploads - Add retry logic with exponential backoff for API calls - Support Python 3.13+ with audioop-lts package - Update frontend with Chinese progress messages and chunk display - Improve start.sh health check with retry loop 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/.env.example | 1 + backend/app/config.py | 1 + backend/app/routers/ai.py | 429 ++++++++++++++++++++++++- backend/requirements.txt | 1 + backend/template/meeting_template.xlsx | Bin 10477 -> 10567 bytes client/src/pages/meeting-detail.html | 127 +++++++- client/src/services/api.js | 225 +++++++++++++ sidecar/requirements.txt | 2 + sidecar/transcriber.py | 220 ++++++++++++- start.sh | 18 +- 10 files changed, 1008 insertions(+), 16 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 3d99e27..740e941 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,6 +9,7 @@ DB_NAME=db_A060 AUTH_API_URL=https://pj-auth-api.vercel.app/api/auth/login DIFY_API_URL=https://dify.theaken.com/v1 DIFY_API_KEY=app-xxxxxxxxxxx +DIFY_STT_API_KEY=app-xxxxxxxxxxx # Application Settings ADMIN_EMAIL=ymirliu@panjit.com.tw diff --git a/backend/app/config.py b/backend/app/config.py index a103e8c..3cda8e2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -16,6 +16,7 @@ class Settings: ) DIFY_API_URL: str = os.getenv("DIFY_API_URL", "https://dify.theaken.com/v1") DIFY_API_KEY: str = os.getenv("DIFY_API_KEY", "") + DIFY_STT_API_KEY: str = os.getenv("DIFY_STT_API_KEY", "") ADMIN_EMAIL: str = os.getenv("ADMIN_EMAIL", "ymirliu@panjit.com.tw") JWT_SECRET: str = os.getenv("JWT_SECRET", "meeting-assistant-secret") diff --git a/backend/app/routers/ai.py b/backend/app/routers/ai.py index 6613758..b749843 100644 --- a/backend/app/routers/ai.py +++ b/backend/app/routers/ai.py @@ -1,11 +1,22 @@ -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, UploadFile, File +from fastapi.responses import StreamingResponse import httpx import json +import os +import tempfile +import subprocess +import shutil +import asyncio +from typing import Optional, AsyncGenerator from ..config import settings from ..models import SummarizeRequest, SummarizeResponse, ActionItemCreate, TokenPayload from .auth import get_current_user +# Supported audio formats +SUPPORTED_AUDIO_FORMATS = {".mp3", ".wav", ".m4a", ".webm", ".ogg", ".flac", ".aac"} +MAX_FILE_SIZE = 500 * 1024 * 1024 # 500MB + router = APIRouter() @@ -74,6 +85,9 @@ def parse_dify_response(answer: str) -> dict: Parse Dify response to extract conclusions and action items. Attempts JSON parsing first, then falls back to text parsing. """ + print(f"[Dify Summarize] Raw answer length: {len(answer)} chars") + print(f"[Dify Summarize] Raw answer preview: {answer[:500]}...") + # Try to find JSON in the response try: # Look for JSON block @@ -90,13 +104,424 @@ def parse_dify_response(answer: str) -> dict: raise ValueError("No JSON found") data = json.loads(json_str) + print(f"[Dify Summarize] Parsed JSON keys: {list(data.keys())}") + print(f"[Dify Summarize] conclusions count: {len(data.get('conclusions', []))}") + print(f"[Dify Summarize] action_items count: {len(data.get('action_items', []))}") + return { "conclusions": data.get("conclusions", []), "action_items": data.get("action_items", []), } - except (ValueError, json.JSONDecodeError): + except (ValueError, json.JSONDecodeError) as e: + print(f"[Dify Summarize] JSON parse failed: {e}") # Fallback: return raw answer as single conclusion return { "conclusions": [answer] if answer else [], "action_items": [], } + + +@router.post("/ai/transcribe-audio") +async def transcribe_audio( + file: UploadFile = File(...), + current_user: TokenPayload = Depends(get_current_user) +): + """ + Transcribe an uploaded audio file using Dify STT service. + Large files are automatically chunked using VAD segmentation. + """ + if not settings.DIFY_STT_API_KEY: + raise HTTPException(status_code=503, detail="Dify STT API not configured") + + # Validate file extension + file_ext = os.path.splitext(file.filename or "")[1].lower() + if file_ext not in SUPPORTED_AUDIO_FORMATS: + raise HTTPException( + status_code=400, + detail=f"Unsupported audio format. Supported: {', '.join(SUPPORTED_AUDIO_FORMATS)}" + ) + + # Create temp directory for processing + temp_dir = tempfile.mkdtemp(prefix="transcribe_") + temp_file_path = os.path.join(temp_dir, f"upload{file_ext}") + + try: + # Save uploaded file + file_size = 0 + with open(temp_file_path, "wb") as f: + while chunk := await file.read(1024 * 1024): # 1MB chunks + file_size += len(chunk) + if file_size > MAX_FILE_SIZE: + raise HTTPException( + status_code=413, + detail=f"File too large. Maximum size: {MAX_FILE_SIZE // (1024*1024)}MB" + ) + f.write(chunk) + + print(f"[Transcribe] Saved uploaded file: {temp_file_path}, size: {file_size} bytes") + + # Call sidecar to segment audio + segments = await segment_audio_with_sidecar(temp_file_path, temp_dir) + + if "error" in segments: + raise HTTPException(status_code=500, detail=segments["error"]) + + segment_list = segments.get("segments", []) + total_segments = len(segment_list) + + print(f"[Transcribe] Segmentation complete: {total_segments} chunks created") + for seg in segment_list: + print(f" - Chunk {seg.get('index')}: {seg.get('path')} ({seg.get('duration', 0):.1f}s)") + + if total_segments == 0: + raise HTTPException(status_code=400, detail="No audio content detected") + + # Transcribe each chunk via Dify STT + transcriptions = [] + failed_chunks = [] + async with httpx.AsyncClient() as client: + for i, segment in enumerate(segment_list): + chunk_path = segment.get("path") + chunk_index = segment.get("index", i) + + print(f"[Transcribe] Processing chunk {chunk_index + 1}/{total_segments}: {chunk_path}") + + if not chunk_path: + print(f"[Transcribe] ERROR: Chunk {chunk_index} has no path!") + failed_chunks.append(chunk_index) + continue + + if not os.path.exists(chunk_path): + print(f"[Transcribe] ERROR: Chunk file does not exist: {chunk_path}") + failed_chunks.append(chunk_index) + continue + + chunk_size = os.path.getsize(chunk_path) + print(f"[Transcribe] Chunk {chunk_index} exists, size: {chunk_size} bytes") + + # Call Dify STT API with retry + text = await transcribe_chunk_with_dify( + client, chunk_path, current_user.email + ) + if text: + print(f"[Transcribe] Chunk {chunk_index} transcribed: {len(text)} chars") + transcriptions.append(text) + else: + print(f"[Transcribe] Chunk {chunk_index} transcription failed (no text returned)") + failed_chunks.append(chunk_index) + + # Concatenate all transcriptions + final_transcript = " ".join(transcriptions) + + print(f"[Transcribe] Complete: {len(transcriptions)}/{total_segments} chunks transcribed") + if failed_chunks: + print(f"[Transcribe] Failed chunks: {failed_chunks}") + + return { + "transcript": final_transcript, + "chunks_processed": len(transcriptions), + "chunks_total": total_segments, + "chunks_failed": len(failed_chunks), + "total_duration_seconds": segments.get("total_duration", 0), + "language": "zh" + } + + finally: + # Clean up temp files + shutil.rmtree(temp_dir, ignore_errors=True) + + +@router.post("/ai/transcribe-audio-stream") +async def transcribe_audio_stream( + file: UploadFile = File(...), + current_user: TokenPayload = Depends(get_current_user) +): + """ + Transcribe an uploaded audio file with real-time progress via SSE. + Returns Server-Sent Events for progress updates. + """ + if not settings.DIFY_STT_API_KEY: + raise HTTPException(status_code=503, detail="Dify STT API not configured") + + # Validate file extension + file_ext = os.path.splitext(file.filename or "")[1].lower() + if file_ext not in SUPPORTED_AUDIO_FORMATS: + raise HTTPException( + status_code=400, + detail=f"Unsupported audio format. Supported: {', '.join(SUPPORTED_AUDIO_FORMATS)}" + ) + + # Read file into memory for streaming + file_content = await file.read() + if len(file_content) > MAX_FILE_SIZE: + raise HTTPException( + status_code=413, + detail=f"File too large. Maximum size: {MAX_FILE_SIZE // (1024*1024)}MB" + ) + + async def generate_progress() -> AsyncGenerator[str, None]: + temp_dir = tempfile.mkdtemp(prefix="transcribe_") + temp_file_path = os.path.join(temp_dir, f"upload{file_ext}") + + try: + # Save file + with open(temp_file_path, "wb") as f: + f.write(file_content) + + yield f"data: {json.dumps({'event': 'start', 'message': '音訊檔案已接收,開始處理...'})}\n\n" + + # Segment audio + yield f"data: {json.dumps({'event': 'segmenting', 'message': '正在分析音訊並分割片段...'})}\n\n" + + segments = await segment_audio_with_sidecar(temp_file_path, temp_dir) + + if "error" in segments: + yield f"data: {json.dumps({'event': 'error', 'message': segments['error']})}\n\n" + return + + segment_list = segments.get("segments", []) + total_segments = len(segment_list) + total_duration = segments.get("total_duration", 0) + + if total_segments == 0: + yield f"data: {json.dumps({'event': 'error', 'message': '未檢測到音訊內容'})}\n\n" + return + + yield f"data: {json.dumps({'event': 'segments_ready', 'total': total_segments, 'duration': total_duration, 'message': f'分割完成,共 {total_segments} 個片段'})}\n\n" + + # Transcribe each chunk + transcriptions = [] + async with httpx.AsyncClient() as client: + for i, segment in enumerate(segment_list): + chunk_path = segment.get("path") + chunk_index = segment.get("index", i) + chunk_duration = segment.get("duration", 0) + + yield f"data: {json.dumps({'event': 'chunk_start', 'chunk': chunk_index + 1, 'total': total_segments, 'duration': chunk_duration, 'message': f'正在轉錄片段 {chunk_index + 1}/{total_segments}...'})}\n\n" + + if not chunk_path or not os.path.exists(chunk_path): + yield f"data: {json.dumps({'event': 'chunk_error', 'chunk': chunk_index + 1, 'message': f'片段 {chunk_index + 1} 檔案不存在'})}\n\n" + continue + + text = await transcribe_chunk_with_dify( + client, chunk_path, current_user.email + ) + + if text: + transcriptions.append(text) + yield f"data: {json.dumps({'event': 'chunk_done', 'chunk': chunk_index + 1, 'total': total_segments, 'text_length': len(text), 'message': f'片段 {chunk_index + 1} 完成'})}\n\n" + else: + yield f"data: {json.dumps({'event': 'chunk_error', 'chunk': chunk_index + 1, 'message': f'片段 {chunk_index + 1} 轉錄失敗'})}\n\n" + + # Final result + final_transcript = " ".join(transcriptions) + yield f"data: {json.dumps({'event': 'complete', 'transcript': final_transcript, 'chunks_processed': len(transcriptions), 'chunks_total': total_segments, 'duration': total_duration})}\n\n" + + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + return StreamingResponse( + generate_progress(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) + + +async def segment_audio_with_sidecar(audio_path: str, output_dir: str) -> dict: + """Call sidecar to segment audio file using VAD.""" + # Find sidecar script + sidecar_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "sidecar") + sidecar_script = os.path.join(sidecar_dir, "transcriber.py") + venv_python = os.path.join(sidecar_dir, "venv", "bin", "python") + + # Use venv python if available, otherwise system python + python_cmd = venv_python if os.path.exists(venv_python) else "python3" + + if not os.path.exists(sidecar_script): + return {"error": "Sidecar not found"} + + try: + # Prepare command + cmd_input = json.dumps({ + "action": "segment_audio", + "file_path": audio_path, + "max_chunk_seconds": 180, # 3 minutes (smaller chunks for reliable upload) + "min_silence_ms": 500, + "output_dir": output_dir + }) + + # Run sidecar process + process = await asyncio.create_subprocess_exec( + python_cmd, sidecar_script, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=sidecar_dir + ) + + # Send command and wait for response + stdout, stderr = await asyncio.wait_for( + process.communicate(input=f"{cmd_input}\n{{\"action\": \"quit\"}}\n".encode()), + timeout=600 # 10 minutes timeout for large files + ) + + # Parse response (skip status messages, find the segment result) + for line in stdout.decode().strip().split('\n'): + if line: + try: + data = json.loads(line) + if data.get("status") == "success" or "segments" in data: + return data + if "error" in data: + return data + except json.JSONDecodeError: + continue + + return {"error": "No valid response from sidecar"} + + except asyncio.TimeoutError: + return {"error": "Sidecar timeout during segmentation"} + except Exception as e: + return {"error": f"Sidecar error: {str(e)}"} + + +async def upload_file_to_dify( + client: httpx.AsyncClient, + file_path: str, + user_email: str +) -> Optional[str]: + """Upload a file to Dify and return the file ID.""" + try: + upload_url = f"{settings.DIFY_API_URL}/files/upload" + + file_size = os.path.getsize(file_path) + print(f"[Upload] File: {file_path}, size: {file_size / (1024*1024):.1f} MB") + + # Adjust timeout based on file size (minimum 60s, ~1MB per 5 seconds) + timeout_seconds = max(60.0, file_size / (1024 * 1024) * 5) + print(f"[Upload] Using timeout: {timeout_seconds:.0f}s") + + with open(file_path, "rb") as f: + files = {"file": (os.path.basename(file_path), f, "audio/wav")} + response = await client.post( + upload_url, + headers={ + "Authorization": f"Bearer {settings.DIFY_STT_API_KEY}", + }, + files=files, + data={"user": user_email}, + timeout=timeout_seconds, + ) + + print(f"[Upload] Response: {response.status_code}") + + if response.status_code == 201 or response.status_code == 200: + data = response.json() + file_id = data.get("id") + print(f"[Upload] Success, file_id: {file_id}") + return file_id + + print(f"[Upload] Error: {response.status_code} - {response.text[:500]}") + return None + + except httpx.ReadError as e: + print(f"[Upload] Network read error (connection reset): {e}") + return None + except httpx.TimeoutException as e: + print(f"[Upload] Timeout: {e}") + return None + except Exception as e: + import traceback + print(f"[Upload] Error: {e}") + print(traceback.format_exc()) + return None + + +async def transcribe_chunk_with_dify( + client: httpx.AsyncClient, + chunk_path: str, + user_email: str, + max_retries: int = 3 +) -> Optional[str]: + """Transcribe a single audio chunk via Dify chat API with file upload.""" + for attempt in range(max_retries): + try: + print(f"[Dify] Attempt {attempt + 1}/{max_retries} for chunk: {chunk_path}") + + # Step 1: Upload file to Dify (with retry inside this attempt) + file_id = None + for upload_attempt in range(2): # 2 upload attempts per main attempt + file_id = await upload_file_to_dify(client, chunk_path, user_email) + if file_id: + break + print(f"[Dify] Upload attempt {upload_attempt + 1} failed, retrying...") + await asyncio.sleep(1) + + if not file_id: + print(f"[Dify] Failed to upload file after retries: {chunk_path}") + if attempt < max_retries - 1: + await asyncio.sleep(2 ** attempt) + continue + return None + + print(f"[Dify] File uploaded, file_id: {file_id}") + + # Step 2: Send chat message with file to request transcription + response = await client.post( + f"{settings.DIFY_API_URL}/chat-messages", + headers={ + "Authorization": f"Bearer {settings.DIFY_STT_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "inputs": {}, + "query": "請將這段音檔轉錄成文字,只回傳轉錄的文字內容,不要加任何額外說明。", + "response_mode": "blocking", + "user": user_email, + "files": [ + { + "type": "audio", + "transfer_method": "local_file", + "upload_file_id": file_id + } + ] + }, + timeout=300.0, # 5 minutes per chunk (increased for longer segments) + ) + + print(f"[Dify] Chat response: {response.status_code}") + + if response.status_code == 200: + data = response.json() + answer = data.get("answer", "") + print(f"[Dify] Transcription success, length: {len(answer)} chars") + return answer + + # Retry on server errors or rate limits + if response.status_code >= 500 or response.status_code == 429: + print(f"[Dify] Server error {response.status_code}, will retry...") + if attempt < max_retries - 1: + wait_time = 2 ** attempt + if response.status_code == 429: + wait_time = 10 # Wait longer for rate limits + await asyncio.sleep(wait_time) + continue + + # Log error but don't fail entire transcription + print(f"[Dify] Chat error for chunk: {response.status_code} - {response.text[:500]}") + return None + + except httpx.TimeoutException: + if attempt < max_retries - 1: + await asyncio.sleep(2 ** attempt) + continue + return None + except Exception as e: + print(f"Chunk transcription error: {e}") + return None + + return None diff --git a/backend/requirements.txt b/backend/requirements.txt index 281f09a..39ca288 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,6 +4,7 @@ python-dotenv>=1.0.0 mysql-connector-python>=9.0.0 pydantic>=2.10.0 httpx>=0.27.0 +python-multipart>=0.0.9 python-jose[cryptography]>=3.3.0 openpyxl>=3.1.2 pytest>=8.0.0 diff --git a/backend/template/meeting_template.xlsx b/backend/template/meeting_template.xlsx index 765489fe2e177510385372ac21dc3a41cf8abbf8..c55f74cb362acf3531839fd2f8c21547b258bd4e 100644 GIT binary patch delta 4160 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 delta 4094 zcmYjUbyO5yuwJB**ri!=0qJg#?pl!U5Ee=4RA4DlIu=lxML=pLrMtToC0uDiknVau z-|rmX{o|fFXJ)=LGk4B>a|iYJ^r}a2u(8fT!HtMX96I>8`+EPGNzdz;=y?)Ob!Y4Rh*+n))@7wrCi?4EaymletFca9P#YwGQmrg`RbyFn!k7 zjGdX6(Nge6l;&Qzw_f#&$~EC4*Loc@*g4b2OZ0(7`JfimTQ*k4{On zKxijy*BXu)W*&cPIWiy)P1&qxUX2&6k4~ppqeu~0cQp{nN!4CY8z~pzjly}R5^Iiz zT-=ZoHkeazCYUZ5*JnHi>S!^CcMQd;o;Vr3Ah1-q2w9}&tN@3ZQaV5wA3w3G(7*S# z64uFh)YRVpv0>wY@JA9z#28M>(=Jucr9XjXF$@!nZ^z&7#1W?c7-P_GUy%@=iukE&UlGG&G(npWH~ z$zI0lSqXk5(QZmfWIrf8va5MDO+Tq7icXEkMdnmBXG~K#Yf0il&JS$g@X$gW@O$ty zHQ+%|*4%3I2;%e55Y(pBuEvD?%0m3z9A%E;)9{Wi6DN%wL~#?F(7d^I;%UDf%Sc^n zanV?2a$M4AL&hGnBZbw}>5FO4GZyHE9RW7(MlcngUa5XY+#`*B=UiS^6u6YWC9uTd zsKxUxGE1Xe5vCaLAG-U!GoGrc_tv^*9-iuY5K3dE@I^KmcL>7qXz}gEBUb#y0Kbut z;Pa^Wo}@&%U-1MRh}auQS*qzr-|+BL4>X8-wH=U2^x}q&sB<5;OOv0N2FO3G$|!*? zR@tsMs`wWjMxDtio!W@pF0gM@x8QjYw1CtgWypqh-ktd811;gao;!ydp0m8)DdCx8 z>7*%`Y^}}vPBjzZg8nj}NC;E~X@BYF(V87{bPvE_X3_S#$sj^Sm?;Z?tjuV`?$aqj zp?tlakCp{rPR*Cohx0pcj|TVi@2@UR2+vzvQd{MfoG*6hLNizRQf`f>ObLpSEfbzm zDWdw6zT8z&yDy$NV35~DJ|Ib;!-V%YxJvY`&bLy&2Xi?SYQCUFq+pWQ zp$LQ@5C{eSF7Cww09Hs4JA_PdbW))Ja1hPke2?6IoK{FGx;kJm{CjaDRCZoS-CnD? zp)`B+c3<4qSNyQw?E#FsB!546+xO5{EQ_u?$RRuDNCJrkcGmj5>vMIi27+=PDu@(M zHH7DMM10U4oy@0X|3%z^gNKEmFzHOM_}Fo56dZq0^BmdDpYErM73>FpxoLr0NJmLC zKEpcv4pfe$>RFQMsWvKRb*tPZ6j^!TRiM&!7lJZ}7t>tgP2dImTu?WkWw!fJPf&%K zM#{3qe?1^oGyC&;Y)~<^u9v(2rEfaTQGlkZhGRQQF&^f>efj+^e*9gs_)Po#Yk}Y~ zIy$4yaF5axd)~w0g!B%0w<=?lgShE^>r$J6gU-FXNw6Wg1_*T#NT1MHVI;Dw-KqQw z+Q7^9E$hR#T}CTD%~y|?TA7Kq3g_!JSQ28MXmt;F#%Tas_X`woDsU4A~hM`W3@WBB~`uX7nrqy({mz8bBWQr+ca|> z#2h*O2nHt9fJ-px`vXfPoMl3?zt>map;QpjXC{SI_Lnt1zD`|_JgYvF0{{T`_kXY8 zjOD}y0idc4_67!lPMCbUr@_rJ7!T>rzkm|+y}tS+Jdl?;xkK~@F}V(-PRh(^99#=( zm+^SJy><)V-?aaIR89o%4q(|jeBX~6-20YWB za*FNRN#92a8pW%(>6r-+iSogJA!DEEGxEb~W%Gb=84(q-+}5VjCEqG#PQ5sL{J#0x z^#WQ7NuQX#$5RWgQ_q9~g?7#<2%XrOq^C zzXs3Cx=nW4c|Fh2PAcFxzo6|hDapj3K7@6Bbb}r#uBgEn?R#=;{A+I#Lh3#t;V&Cy@DtUt~>#N9u$}%00gw>N7y0Es`b%SFU zF7YRwAIe!n7}hR|bt&`8WUeBA-S9Kre#w1+mse@ga8odM}>$FK(Ng@jL2oD zr;nS7I0=tlUp1kTN_*h^Z}Fx3I8vlhAHxwu&Iq59bwk`ooF*ed#jk zIL6?z;A~ud>;_q+A#&Wl%r2YVJO8V6Dwp}vWp4Thdo6Ht#f0iy(PiMcau;@Y6-ao> zFgdg!Q))(Q#Z)Yi3p%^4abh}&u31)Q(w?vXmF;}v1XxuZ6R9lgKZ<>(!7zq+GhfYf z!s?T4t~`MT{2gwbq_?;P5|=(mIcxynAqzqPOaa%5TT=yg;2tqX{brEi3EJ&GtIeb7 z?P0@u_Mo|;IAd8^Kil{vvj^V#I|C-5qPyh!JKUE;km_U`gtq^^N_ne;mU+^^k?Z^f zo%D`z#ZHCeTg~N=R8^$^YcoZsrCF4)!Mx|LUVh(N-re?I_4JodQSZO1K@z-l^VgK& zGxzX=NcWyCNJpmam?#s7T_&XG@}x(A)FXl=EoYiDaYaYoEJ}j#+gJ4xLKZm?i3;** z_OsD9z!*Q373QNIU<~j&SnPSANEWk>Dl2O+pYyi)x|KBTU>Z^n?}@>vLSBYB?=x3U z3xSk6?qKFs!K|sUd3uLwLa2Cwmrgl$QJ->W zcH7kNCwRVYHB0Xy4q#ja<|D1KK8_xj{#z3M``fjR+az?y<;F`dc6ku~)=u7wzzDeM z1LC5eq84^3zWMR;%3beB)9g^8YIa8W$`eQVouTmCoe$UO(9q+ZR%jDg1?S;vZUFOy zf(qyM2+wF8l@&_p34?+i;|J;`RJ%#Zj1GPiDSmHp_TwJ$r-kTO@m+#(uLY)Re=S~; zOAAM@7t?8cw5WSAHnmi{d_}@4!rB3s7wq5}h%~(5$hL4P+EDMot_1oKOoJ^w>daDo zS%;w#8qozqAO~64y~=xX@6COV&Psr?A&hd&VLX^7X z`B7GwVsG*$*pq)y1+6}($D^cqZT{;KC4TW2@31y`OV@T%_jWf26diV&fj$}@Ze_n+ z{PNRfNr6#cn+aN?7FekcHaiQmTwf7r1DUPW6&UsuDIkNx9~kX^E=&czO`fRJN)MhZ zK&wD#20@9)rmlE0T|*uQ!kTjS8NiHjO8%;~?NU9&_4$OERwNOV;dB5OYhA(F*iTm{ zDWWZD>^^w(_t4^ zrGUNTSGo(NOc0B&rvlPLE2pAe7>54EGYQg z8Tg=9qh#G+iWs58TkOeRwmX{@o0wZ~0R8*By~6v-!|yUbsIaR$pgV9>3X{uV-h#CM zA`gQ$(5ymX@5?J53w0s?e_U++pl$@y46$VTp_yer^*%K z;8(^^-$wC3xLXvTZ|oX(^EO(z>b54v035gUSZE5omSs>w=YlC zzOvWh?WUrxqL%ddG)_)#u9`1`pAG9SPHBeZb)EBFRE<+_Z`)XtOguK3S8AOW!`+LX z?Q?&>M*I={(MPhW1?;cA*mhIy>HYMz&%>hv>=O^(EoS@`U(C^{@hi2J=D+w7n(HqB)c<;tQyfMe3G}kk8&Y^S`HfZ37ym(YD}>VjWGqgWuTf zAYzTS>d!rj{bmBBgp8mbQ`4Ry-I|ko%)gU0lkp1%HZx^gkXD^9gbf3Z*6bA^AK1r6 zBiB2ivr;Kj{spz%xDjOit;sph``L&z>K9o`lYSiI%SLbKe@+O3tg~%hPw=B9S+m_? z6m1bT4k&NsLTc=SKwiyHBh$Bbl-31ZQYBawF4&R_5h}B*C#-8(M+;r3GEr)i2qI|> zvk||d)_Ay+y>JWTMVgy<6-TUZFno3E+(KWeQwFScHLlSn~oo zI-l_l%Vyt$<~w&IcjY@jpUyo8{8jIlKctOi>S&R;k$Axz3rW=m3^NtK`UxpCa|77VKnP}zM{NH%4getfXZS}NrU3u~ z-S~ZcgWMbtRU8zEOh(|te-^@sTn={HzYmE40607V06_nge
Transcript (逐字稿) -
+
+ +
@@ -155,6 +184,14 @@ Segments: 0
+ +
+ Uploading... +
+
+
+
+