| import os |
| import json |
| import requests |
| import uuid |
| import datetime |
| import tempfile |
| try: |
| import matplotlib.pyplot as plt |
| import matplotlib.dates as mdates |
| HAS_MATPLOTLIB = True |
| except ImportError: |
| plt = None |
| mdates = None |
| HAS_MATPLOTLIB = False |
| import pandas as pd |
| from fpdf import FPDF |
| import gradio as gr |
| from pathlib import Path |
|
|
| DATA_FILE = "cases.json" |
| HF_API_URL = "https://api-inference.huggingface.co/models" |
| TRANSCRIBE_MODEL = "openai/whisper-large" |
| IMAGE_CAPTION_MODEL = "nlpconnect/vit-gpt2-image-captioning" |
| OCR_MODEL = "microsoft/trocr-base-printed" |
| TEXT_MODEL = "gpt-3.5-mini" |
|
|
| LOGIN_USER = "Neil67" |
| LOGIN_PASSWORD = "AsexqweasD12" |
| CASE_CATEGORIES = ["太陽能", "儲能", "魚電", "再生能源", "停車場", "營建"] |
|
|
| HUGGINGFACEHUB_API_TOKEN = os.environ.get("HUGGINGFACEHUB_API_TOKEN") or os.environ.get("HF_TOKEN") |
| APP_ACCESS_KEY = os.environ.get("APP_ACCESS_KEY") |
| HEADERS = {"Authorization": f"Bearer {HUGGINGFACEHUB_API_TOKEN}"} if HUGGINGFACEHUB_API_TOKEN else None |
|
|
|
|
| def ensure_data_file(): |
| if not os.path.exists(DATA_FILE): |
| with open(DATA_FILE, "w", encoding="utf-8") as f: |
| json.dump([], f, ensure_ascii=False, indent=2) |
|
|
|
|
| def load_cases(): |
| ensure_data_file() |
| with open(DATA_FILE, "r", encoding="utf-8") as f: |
| return json.load(f) |
|
|
|
|
| def save_cases(cases): |
| with open(DATA_FILE, "w", encoding="utf-8") as f: |
| json.dump(cases, f, ensure_ascii=False, indent=2) |
|
|
|
|
| def create_case(site_name, location, client, stage, category, tags, bid_amount, manager_notes): |
| cases = load_cases() |
| new_case = { |
| "id": str(uuid.uuid4()), |
| "site_name": site_name, |
| "location": location, |
| "client": client, |
| "stage": stage, |
| "category": category, |
| "tags": [t.strip() for t in tags.split(",") if t.strip()], |
| "bid_amount": bid_amount, |
| "manager_notes": manager_notes, |
| "created_at": datetime.datetime.now().isoformat(), |
| "meetings": [], |
| "photo_history": [], |
| "ocr_documents": [], |
| "attachments": [] |
| } |
| cases.append(new_case) |
| save_cases(cases) |
| return cases |
|
|
|
|
| def list_site_options(): |
| cases = load_cases() |
| return [f"{case['site_name']} ({case['location']}) | {case['id']}" for case in cases] |
|
|
|
|
| def get_case_by_label(label): |
| if not label: |
| return None |
| cases = load_cases() |
| case_id = label.split("|")[-1].strip() |
| return next((case for case in cases if case["id"] == case_id), None) |
|
|
|
|
| def call_hf_inference(model, body=None, files=None): |
| if HEADERS is None: |
| raise RuntimeError("請先設定 HUGGINGFACEHUB_API_TOKEN 環境變數。") |
| endpoint = f"{HF_API_URL}/{model}" |
| if files: |
| response = requests.post(endpoint, headers=HEADERS, files=files) |
| else: |
| response = requests.post(endpoint, headers=HEADERS, json=body) |
| response.raise_for_status() |
| return response.json() |
|
|
|
|
| def transcribe_audio(audio_path): |
| if not audio_path: |
| return "" |
| with open(audio_path, "rb") as audio_file: |
| data = call_hf_inference(TRANSCRIBE_MODEL, files={"file": audio_file}) |
| return data.get("text", "") |
|
|
|
|
| def generate_meeting_minutes(transcript, meeting_title, participants, site_info, extra_focus): |
| prompt = ( |
| "你是專案經理助理。請根據以下會議內容產生:\n" |
| "1. 會議摘要\n" |
| "2. 決議事項與後續行動項目\n" |
| "3. 風險與注意事項\n" |
| "4. 案場分類與細分建議\n" |
| "5. 標案比對重點與專案管理須關注的責任項目\n\n" |
| f"會議標題:{meeting_title}\n" |
| f"參與者:{participants}\n" |
| f"案場:{site_info.get('site_name', '')} / {site_info.get('location', '')} / 客戶:{site_info.get('client', '')}\n" |
| f"案場階段:{site_info.get('stage', '')}\n" |
| f"案場標籤:{', '.join(site_info.get('tags', []))}\n" |
| f"標案金額:{site_info.get('bid_amount', '')}\n" |
| f"專案經理註記:{site_info.get('manager_notes', '')}\n" |
| f"額外重點:{extra_focus}\n\n" |
| f"會議內容:{transcript}\n\n" |
| "請用中文回覆,條列式整理,並用清楚的標題區分每個部分。" |
| ) |
| body = { |
| "inputs": prompt, |
| "parameters": {"max_new_tokens": 700, "temperature": 0.3} |
| } |
| data = call_hf_inference(TEXT_MODEL, body=body) |
| if isinstance(data, list) and data: |
| return data[0].get("generated_text", "") |
| return data.get("generated_text", "") if isinstance(data, dict) else str(data) |
|
|
|
|
| def add_meeting(site_label, meeting_title, participants, audio, extra_focus): |
| case = get_case_by_label(site_label) |
| if case is None: |
| return "請先選擇或新增案場。" |
| transcript = transcribe_audio(audio) |
| minutes = generate_meeting_minutes(transcript, meeting_title, participants, case, extra_focus) |
| meeting_record = { |
| "id": str(uuid.uuid4()), |
| "title": meeting_title, |
| "participants": participants, |
| "recorded_at": datetime.datetime.now().isoformat(), |
| "audio_path": audio, |
| "transcript": transcript, |
| "minutes": minutes, |
| "focus": extra_focus |
| } |
| cases = load_cases() |
| for stored_case in cases: |
| if stored_case["id"] == case["id"]: |
| stored_case["meetings"].append(meeting_record) |
| break |
| save_cases(cases) |
| return transcript, minutes |
|
|
|
|
| def build_case_summary(case): |
| meeting_count = len(case.get("meetings", [])) |
| lines = [ |
| f"**案場名稱**:{case['site_name']}", |
| f"**地點**:{case['location']}", |
| f"**案場類別**:{case.get('category', '')}", |
| f"**客戶**:{case['client']}", |
| f"**階段**:{case['stage']}", |
| f"**標案金額**:{case['bid_amount']}", |
| f"**標籤**:{', '.join(case['tags'])}", |
| f"**會議紀錄數**:{meeting_count}", |
| f"**專案管理備註**:{case['manager_notes']}", |
| "---" |
| ] |
| for meeting in case.get("meetings", []): |
| lines.append(f"- {meeting['title']} / {meeting['recorded_at']} / 參與者:{meeting['participants']}") |
| return "\n".join(lines) |
|
|
|
|
| def compare_sites(): |
| cases = load_cases() |
| rows = [] |
| for case in cases: |
| rows.append( |
| { |
| "案場": case["site_name"], |
| "地點": case["location"], |
| "類別": case.get("category", ""), |
| "客戶": case["client"], |
| "階段": case["stage"], |
| "標案金額": case["bid_amount"], |
| "標籤": ", ".join(case["tags"]), |
| "會議數": len(case.get("meetings", [])) |
| } |
| ) |
| return rows |
|
|
|
|
| def get_dashboard(case_label): |
| case = get_case_by_label(case_label) |
| if case is None: |
| return "請選擇一個案場以查看專案管理儀表板。" |
| summary = build_case_summary(case) |
| risk = ( |
| "- 風險 1:關鍵時程延誤可能影響標案交付。\n" |
| "- 風險 2:客戶需求變更需同步更新管理計畫。\n" |
| "- 風險 3:多案場資料需統一分類以避免跨場溝通斷層。\n" |
| ) |
| bid_focus = ( |
| "- 比對標案條件:標的範圍、交期、報價、驗收標準。\n" |
| "- 專案經理責任:控管成本、時程、品質、風險、變更管理。\n" |
| "- 下一步:檢視每個案場決策、行動項目與資源配置。\n" |
| ) |
| return f"### 專案管理儀表板\n\n{summary}\n\n### 風險與待辦\n{risk}\n\n### 標案比對與專案經理責任\n{bid_focus}\n" |
|
|
|
|
| def get_case_detail(case_label): |
| case = get_case_by_label(case_label) |
| if case is None: |
| return "請先選擇一個案場以查看詳細資料。" |
| lines = [ |
| f"### 案場詳細:{case['site_name']}", |
| f"**地點**:{case['location']}", |
| f"**客戶**:{case['client']}", |
| f"**類別**:{case.get('category', '')}", |
| f"**階段**:{case.get('stage', '')}", |
| f"**標案金額**:{case.get('bid_amount', '')}", |
| f"**標籤**:{', '.join(case.get('tags', []))}", |
| f"**專案經理備註**:{case.get('manager_notes', '')}", |
| f"**建立日期**:{case.get('created_at', '')}", |
| f"**會議數**:{len(case.get('meetings', []))}", |
| f"**照片建檔數**:{len(case.get('photo_history', []))}", |
| f"**OCR 文件數**:{len(case.get('ocr_documents', []))}", |
| f"**匯入文件數**:{len(case.get('attachments', []))}", |
| "---", |
| ] |
| if case.get('attachments'): |
| lines.append("#### 匯入文件清單") |
| for attachment in case.get('attachments', [])[:10]: |
| lines.append(f"- {attachment.get('name', '')}") |
| if len(case.get('attachments', [])) > 10: |
| lines.append(f"- ...還有 {len(case.get('attachments', [])) - 10} 個文件") |
| return "\n".join(lines) |
|
|
|
|
| def generate_case_gantt(case_label): |
| if not HAS_MATPLOTLIB: |
| return None |
| case = get_case_by_label(case_label) |
| if case is None: |
| return None |
| tasks = [] |
| if case.get('meetings'): |
| for meeting in case.get('meetings', []): |
| try: |
| start = datetime.datetime.fromisoformat(meeting['recorded_at']) |
| except Exception: |
| start = datetime.datetime.now() |
| end = start + datetime.timedelta(days=1) |
| tasks.append((meeting['title'] or '會議', start, end)) |
| else: |
| start = datetime.datetime.fromisoformat(case.get('created_at')) if case.get('created_at') else datetime.datetime.now() |
| tasks = [ |
| ("立項準備", start, start + datetime.timedelta(days=7)), |
| ("設計與規劃", start + datetime.timedelta(days=7), start + datetime.timedelta(days=21)), |
| ("採購與動工", start + datetime.timedelta(days=21), start + datetime.timedelta(days=45)), |
| ("施工驗收", start + datetime.timedelta(days=45), start + datetime.timedelta(days=60)), |
| ("交付與回饋", start + datetime.timedelta(days=60), start + datetime.timedelta(days=70)), |
| ] |
| fig, ax = plt.subplots(figsize=(10, max(4, len(tasks) * 0.8))) |
| for idx, (task_name, start, end) in enumerate(tasks): |
| ax.barh(idx, mdates.date2num(end) - mdates.date2num(start), left=mdates.date2num(start), height=0.5) |
| ax.set_yticks(range(len(tasks))) |
| ax.set_yticklabels([task[0] for task in tasks]) |
| ax.xaxis_date() |
| ax.xaxis.set_major_formatter(mdates.DateFormatter('%m-%d')) |
| ax.set_xlabel('日期') |
| ax.set_title(f"{case['site_name']} 專案進度甘特圖") |
| plt.tight_layout() |
| temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.png') |
| fig.savefig(temp_file.name, bbox_inches='tight') |
| plt.close(fig) |
| return temp_file.name |
|
|
|
|
| def sanitize_filename(name): |
| return "".join(ch for ch in name if ch.isalnum() or ch in (" ", "-", "_")).rstrip() |
|
|
|
|
| def get_case_photo_gallery(case_label): |
| case = get_case_by_label(case_label) |
| if case is None: |
| return [] |
| gallery = [] |
| for record in case.get("photo_history", []): |
| gallery.append([record["image_path"], record.get("caption", "")]) |
| return gallery |
|
|
|
|
| def search_ocr_text(search_text): |
| if not search_text: |
| return [] |
| query = search_text.lower() |
| rows = [] |
| for case in load_cases(): |
| for record in case.get("ocr_documents", []): |
| ocr_text = record.get("ocr_text", "") |
| summary = record.get("summary", "") |
| notes = record.get("notes", "") |
| if query in ocr_text.lower() or query in summary.lower() or query in notes.lower(): |
| excerpt = ocr_text.replace("\n", " ")[:120] |
| if len(ocr_text) > 120: |
| excerpt += "..." |
| rows.append({ |
| "案場": case["site_name"], |
| "時間": record["timestamp"], |
| "檔案": record["image_path"], |
| "OCR 摘要": summary, |
| "文字片段": excerpt |
| }) |
| return rows |
|
|
|
|
| def generate_case_report_files(case_label): |
| case = get_case_by_label(case_label) |
| if case is None: |
| raise ValueError("請先選擇一個案場。") |
| timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") |
| safe_name = sanitize_filename(case["site_name"]) |
| pdf_path = f"case_report_{safe_name}_{timestamp}.pdf" |
| excel_path = f"case_report_{safe_name}_{timestamp}.xlsx" |
|
|
| pdf = FPDF() |
| pdf.set_auto_page_break(auto=True, margin=15) |
| pdf.add_page() |
| font_path = None |
| for candidate in [ |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", |
| "/usr/share/fonts/truetype/noto/NotoSansCJKtc-Regular.otf", |
| "/usr/share/fonts/truetype/noto/NotoSansCJKsc-Regular.otf", |
| "C:\\Windows\\Fonts\\msjh.ttf", |
| "C:\\Windows\\Fonts\\msjh.ttf", |
| "C:\\Windows\\Fonts\\mingliu.ttc" |
| ]: |
| if os.path.exists(candidate): |
| font_path = candidate |
| break |
| if font_path: |
| pdf.add_font("Noto", "", font_path, uni=True) |
| pdf.set_font("Noto", "B", 16) |
| else: |
| pdf.set_font("Arial", "B", 16) |
| pdf.cell(0, 10, f"案場報告:{case['site_name']}", ln=True) |
| if font_path: |
| pdf.set_font("Noto", size=12) |
| else: |
| pdf.set_font("Arial", size=12) |
| pdf.multi_cell(0, 8, f"地點:{case['location']}\n客戶:{case['client']}\n類別:{case.get('category', '')}\n階段:{case['stage']}\n標案金額:{case['bid_amount']}\n標籤:{', '.join(case['tags'])}\n") |
| pdf.ln(4) |
| if font_path: |
| pdf.set_font("Noto", "B", 14) |
| else: |
| pdf.set_font("Arial", "B", 14) |
| pdf.cell(0, 8, "專案管理備註", ln=True) |
| if font_path: |
| pdf.set_font("Noto", size=12) |
| else: |
| pdf.set_font("Arial", size=12) |
| pdf.multi_cell(0, 8, case.get("manager_notes", "")) |
| pdf.ln(4) |
| pdf.set_font("Arial", "B", 14) |
| pdf.cell(0, 8, "會議紀錄", ln=True) |
| pdf.set_font("Arial", size=12) |
| for meeting in case.get("meetings", []): |
| pdf.multi_cell(0, 8, f"- {meeting['title']} ({meeting['recorded_at']})") |
| pdf.multi_cell(0, 8, f" 參與者:{meeting['participants']}") |
| pdf.multi_cell(0, 8, f" 重點:{meeting['focus']}") |
| pdf.ln(4) |
| pdf.set_font("Arial", "B", 14) |
| pdf.cell(0, 8, "照片歷史庫", ln=True) |
| pdf.set_font("Arial", size=12) |
| for record in case.get("photo_history", []): |
| pdf.multi_cell(0, 8, f"- {record['timestamp']}: {record.get('caption', '')}") |
| pdf.multi_cell(0, 8, f" 摘要:{record.get('summary', '')}") |
| pdf.ln(4) |
| pdf.set_font("Arial", "B", 14) |
| pdf.cell(0, 8, "OCR 文件歷史", ln=True) |
| pdf.set_font("Arial", size=12) |
| for record in case.get("ocr_documents", []): |
| pdf.multi_cell(0, 8, f"- {record['timestamp']}: {record.get('notes', '')}") |
| pdf.multi_cell(0, 8, f" 摘要:{record.get('summary', '')}") |
| pdf.output(pdf_path) |
|
|
| report_rows = [] |
| for meeting in case.get("meetings", []): |
| report_rows.append({ |
| "類型": "會議紀錄", |
| "時間": meeting["recorded_at"], |
| "內容": meeting["minutes"], |
| "說明": meeting["focus"] |
| }) |
| for record in case.get("photo_history", []): |
| report_rows.append({ |
| "類型": "照片建檔", |
| "時間": record["timestamp"], |
| "內容": record["summary"], |
| "說明": record.get("caption", "") |
| }) |
| for record in case.get("ocr_documents", []): |
| report_rows.append({ |
| "類型": "OCR 文件", |
| "時間": record["timestamp"], |
| "內容": record["summary"], |
| "說明": record.get("notes", "") |
| }) |
|
|
| df = pd.DataFrame(report_rows) |
| with pd.ExcelWriter(excel_path, engine="openpyxl") as writer: |
| df.to_excel(writer, index=False, sheet_name="報告摘要") |
| pd.DataFrame([{ |
| "案場": case['site_name'], |
| "地點": case['location'], |
| "類別": case.get('category', ''), |
| "客戶": case['client'], |
| "階段": case['stage'], |
| "標案金額": case['bid_amount'], |
| "標籤": ", ".join(case['tags']), |
| "備註": case.get('manager_notes', '') |
| }]).to_excel(writer, index=False, sheet_name="案場資訊") |
| return pdf_path, excel_path |
|
|
|
|
| def verify_login(user_id, password, access_key): |
| if user_id != LOGIN_USER or password != LOGIN_PASSWORD: |
| return "帳號或密碼錯誤,請重新輸入。", gr.update(visible=True), gr.update(visible=False) |
| if APP_ACCESS_KEY and access_key != APP_ACCESS_KEY: |
| return "安全存取金鑰錯誤,請檢查環境變數 APP_ACCESS_KEY。", gr.update(visible=True), gr.update(visible=False) |
| return "登入成功,歡迎 Neil。", gr.update(visible=False), gr.update(visible=True) |
|
|
|
|
| def scan_photo_and_generate_summary(site_label, image_path, photo_notes): |
| case = get_case_by_label(site_label) |
| if case is None: |
| return "請先選擇或新增案場。", "" |
| if not image_path: |
| return "請上傳照片後再掃描建檔。", "" |
| with open(image_path, "rb") as image_file: |
| caption_response = call_hf_inference(IMAGE_CAPTION_MODEL, files={"file": image_file}) |
| if isinstance(caption_response, list) and caption_response: |
| caption = caption_response[0].get("generated_text", "無法辨識照片內容。") |
| elif isinstance(caption_response, dict): |
| caption = caption_response.get("generated_text", "無法辨識照片內容。") |
| else: |
| caption = str(caption_response) |
|
|
| prompt = ( |
| "你是專案經理助理。請根據以下照片內容與備註建立建檔摘要,並指出重點:\n" |
| f"照片描述:{caption}\n" |
| f"備註:{photo_notes}\n" |
| "請以條列式回覆,並包含建檔重點與後續專案管理建議。" |
| ) |
| body = { |
| "inputs": prompt, |
| "parameters": {"max_new_tokens": 500, "temperature": 0.2} |
| } |
| data = call_hf_inference(TEXT_MODEL, body=body) |
| if isinstance(data, list) and data: |
| summary = data[0].get("generated_text", "") |
| else: |
| summary = data.get("generated_text", "") if isinstance(data, dict) else str(data) |
| photo_record = { |
| "id": str(uuid.uuid4()), |
| "timestamp": datetime.datetime.now().isoformat(), |
| "image_path": image_path, |
| "caption": caption, |
| "summary": summary, |
| "notes": photo_notes |
| } |
| cases = load_cases() |
| for stored_case in cases: |
| if stored_case["id"] == case["id"]: |
| stored_case["photo_history"].append(photo_record) |
| break |
| save_cases(cases) |
| return caption, summary |
|
|
|
|
| def perform_ocr(image_path): |
| if not image_path: |
| return "" |
| with open(image_path, "rb") as image_file: |
| data = call_hf_inference(OCR_MODEL, files={"file": image_file}) |
| if isinstance(data, dict): |
| return data.get("generated_text", "") |
| if isinstance(data, list) and data: |
| return data[0].get("generated_text", "") |
| return str(data) |
|
|
|
|
| def save_ocr_document(case, image_path, ocr_text, ocr_summary, notes): |
| doc_record = { |
| "id": str(uuid.uuid4()), |
| "timestamp": datetime.datetime.now().isoformat(), |
| "image_path": image_path, |
| "ocr_text": ocr_text, |
| "summary": ocr_summary, |
| "notes": notes |
| } |
| case["ocr_documents"].append(doc_record) |
| cases = load_cases() |
| for stored_case in cases: |
| if stored_case["id"] == case["id"]: |
| stored_case["ocr_documents"].append(doc_record) |
| break |
| save_cases(cases) |
|
|
|
|
| def scan_document_with_ocr(site_label, image_path, doc_notes): |
| case = get_case_by_label(site_label) |
| if case is None: |
| return "請先選擇或新增案場。", "", "" |
| if not image_path: |
| return "請上傳文件照片後再進行 OCR。", "", "" |
| ocr_text = perform_ocr(image_path) |
| prompt = ( |
| "你是專案經理助理。請根據以下文件 OCR 結果與備註建立完整建檔摘要,並指出管理重點:\n" |
| f"OCR 內容:{ocr_text}\n" |
| f"備註:{doc_notes}\n" |
| "請呈現為條列式摘要,並列出後續專案管理建議。" |
| ) |
| body = { |
| "inputs": prompt, |
| "parameters": {"max_new_tokens": 700, "temperature": 0.2} |
| } |
| data = call_hf_inference(TEXT_MODEL, body=body) |
| if isinstance(data, list) and data: |
| summary = data[0].get("generated_text", "") |
| else: |
| summary = data.get("generated_text", "") if isinstance(data, dict) else str(data) |
| save_ocr_document(case, image_path, ocr_text, summary, doc_notes) |
| return "OCR 完成並已存檔。", ocr_text, summary |
|
|
|
|
| def get_case_photo_history(case_label): |
| case = get_case_by_label(case_label) |
| if case is None: |
| return [] |
| rows = [] |
| for record in case.get("photo_history", []): |
| rows.append({ |
| "時間": record["timestamp"], |
| "照片檔案": record["image_path"], |
| "說明": record["caption"], |
| "摘要": record["summary"] |
| }) |
| return rows |
|
|
|
|
| def get_case_ocr_history(case_label): |
| case = get_case_by_label(case_label) |
| if case is None: |
| return [] |
| rows = [] |
| for record in case.get("ocr_documents", []): |
| rows.append({ |
| "時間": record["timestamp"], |
| "檔案": record["image_path"], |
| "OCR 內容": record["ocr_text"][:100] + "...", |
| "摘要": record["summary"] |
| }) |
| return rows |
|
|
|
|
| def refresh_site_dropdown(): |
| update = gr.Dropdown.update(choices=list_site_options()) |
| return update, update, update, update, update, update, update, update |
|
|
|
|
| def clear_site_inputs(): |
| return "", "", "", "", "", "", "" |
|
|
| with gr.Blocks(title="CH-01 錄音會議與案場管理系統") as demo: |
| gr.HTML(""" |
| <style> |
| body { |
| background: linear-gradient(135deg, #071327 0%, #0b1f3f 55%, #132b57 100%); |
| color: #eef4ff; |
| } |
| .gradio-container, .gradio-app { |
| background: transparent !important; |
| } |
| .glass-panel { |
| border-radius: 24px; |
| background: rgba(12, 25, 54, 0.72); |
| border: 1px solid rgba(255, 215, 0, 0.18); |
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.28); |
| backdrop-filter: blur(18px); |
| padding: 24px; |
| margin-bottom: 16px; |
| } |
| .glass-panel .gradio-container { |
| background: transparent; |
| } |
| .gradio-markdown, .gradio-textbox, .gradio-dropdown, .gradio-audio, .gradio-button, .gradio-dataframe, .gradio-image { |
| color: #eef4ff; |
| } |
| .gradio-markdown h1, .gradio-markdown h2, .gradio-markdown h3 { |
| color: #f5d06f; |
| } |
| .gradio-button { |
| background: linear-gradient(135deg, #2c74eb 0%, #1f53b3 100%); |
| border: 1px solid rgba(255, 215, 0, 0.22); |
| color: #ffffff; |
| } |
| .gradio-button:hover, .gr-button:hover { |
| background: linear-gradient(135deg, #4a8bf7 0%, #2d62d8 100%); |
| } |
| .gradio-textbox, .gradio-dropdown, .gradio-dataframe, .gradio-audio, .gradio-image { |
| background: rgba(255, 255, 255, 0.08); |
| border: 1px solid rgba(255, 255, 255, 0.14); |
| backdrop-filter: blur(12px); |
| } |
| .gradio-dataframe table { |
| color: #eef4ff; |
| } |
| .gradio-app, .gradio-container { |
| max-width: 100% !important; |
| padding-left: 8px !important; |
| padding-right: 8px !important; |
| } |
| .glass-panel { |
| padding: 18px; |
| } |
| @media (max-width: 900px) { |
| .gradio-row { |
| flex-direction: column !important; |
| } |
| .gradio-col { |
| width: 100% !important; |
| min-width: 100% !important; |
| } |
| .glass-panel { |
| margin-left: 0 !important; |
| margin-right: 0 !important; |
| padding: 14px !important; |
| } |
| .gradio-button, .gradio-textbox, .gradio-dropdown, .gradio-image, .gradio-dataframe { |
| width: 100% !important; |
| min-width: 0 !important; |
| } |
| } |
| </style> |
| """) |
|
|
| with gr.Column(elem_id="login_area", elem_classes="glass-panel") as login_area: |
| gr.Markdown("## 會員專屬登入") |
| gr.Markdown("請設定 Hugging Face Space Secrets:`HUGGINGFACEHUB_API_TOKEN` 與 `APP_ACCESS_KEY`(若啟用資安金鑰)。") |
| login_user = gr.Textbox(label="登入ID", placeholder="輸入你的帳號", lines=1) |
| login_pass = gr.Textbox(label="密碼", type="password", placeholder="輸入你的密碼", lines=1) |
| login_key = gr.Textbox(label="安全存取金鑰", type="password", placeholder="若已設定請輸入", lines=1) |
| login_button = gr.Button("登入") |
| login_message = gr.Textbox(label="登入狀態", interactive=False, lines=1) |
|
|
| with gr.Column(elem_id="main_area", elem_classes="glass-panel", visible=False) as main_area: |
| gr.Markdown("# CH-01 會議錄音與專案管理系統") |
| gr.Markdown("此系統支援:錄音轉文字、會議紀錄生成、案場管理、拍照掃描建檔、跨案場比較與專案管理儀表板。") |
|
|
| with gr.Tab("會議錄音與紀錄"): |
| with gr.Row(): |
| with gr.Column(): |
| site_selector = gr.Dropdown(label="選擇案場", choices=list_site_options(), interactive=True) |
| meeting_title = gr.Textbox(label="會議標題", placeholder="輸入會議名稱", lines=1) |
| participants = gr.Textbox(label="參與者", placeholder="輸入參與者名稱,用逗號分隔", lines=1) |
| extra_focus = gr.Textbox(label="重點/標案細分說明", placeholder="例如:標案對比、現場分類、驗收重點", lines=2) |
| audio_input = gr.Audio(sources=["microphone"], type="filepath", label="錄音/上傳音檔") |
| transcribe_button = gr.Button("轉錄並生成會議紀錄") |
| with gr.Column(): |
| transcript_output = gr.Textbox(label="會議逐字稿", interactive=False, lines=12) |
| minutes_output = gr.Textbox(label="會議紀錄與專案管理摘要", interactive=False, lines=18) |
|
|
| transcribe_button.click( |
| add_meeting, |
| inputs=[site_selector, meeting_title, participants, audio_input, extra_focus], |
| outputs=[transcript_output, minutes_output] |
| ) |
|
|
| with gr.Tab("案場管理"): |
| with gr.Row(): |
| with gr.Column(): |
| site_name = gr.Textbox(label="案場名稱", placeholder="輸入案場名稱") |
| location = gr.Textbox(label="地點", placeholder="輸入案場地址/區域") |
| client = gr.Textbox(label="客戶", placeholder="輸入客戶名稱") |
| stage = gr.Textbox(label="階段", placeholder="例如:招標/設計/施工/驗收") |
| category = gr.Dropdown(label="案場類別", choices=CASE_CATEGORIES, value=CASE_CATEGORIES[0], interactive=True) |
| new_category = gr.Textbox(label="新增案場類別", placeholder="若需要新增類別請輸入", lines=1) |
| tags = gr.Textbox(label="標籤", placeholder="輸入案場細分類,如:智慧照明, 工程招標", lines=1) |
| bid_amount = gr.Textbox(label="標案金額", placeholder="輸入預估金額") |
| manager_notes = gr.Textbox(label="專案經理備註", placeholder="輸入管理、品質、驗收、風險等注意事項", lines=3) |
| add_site_btn = gr.Button("新增/儲存案場") |
| with gr.Column(): |
| case_summary = gr.Markdown("### 案場列表與會議摘要") |
| case_list = gr.Dataframe(value=compare_sites(), headers=["案場", "地點", "類別", "客戶", "階段", "標案金額", "標籤", "會議數"], interactive=False) |
| site_message = gr.Textbox(label="案場建立狀態", interactive=False, lines=1) |
|
|
| def add_site_and_refresh(site_name, location, client, stage, category, new_category, tags, bid_amount, manager_notes): |
| chosen_category = category |
| if new_category and new_category.strip(): |
| chosen_category = new_category.strip() |
| if chosen_category not in CASE_CATEGORIES: |
| CASE_CATEGORIES.append(chosen_category) |
| create_case(site_name, location, client, stage, chosen_category, tags, bid_amount, manager_notes) |
| site_update = gr.Dropdown.update(choices=list_site_options()) |
| category_update = gr.Dropdown.update(choices=CASE_CATEGORIES, value=chosen_category) |
| return ( |
| compare_sites(), |
| site_update, |
| site_update, |
| site_update, |
| site_update, |
| site_update, |
| site_update, |
| site_update, |
| site_update, |
| category_update, |
| "新增案場已成功儲存。", |
| ) |
|
|
| with gr.Tab("拍照掃描建檔"): |
| with gr.Row(): |
| with gr.Column(): |
| photo_site = gr.Dropdown(label="選擇案場", choices=list_site_options(), interactive=True) |
| photo_input = gr.Image(type="filepath", label="拍照/上傳照片") |
| photo_notes = gr.Textbox(label="備註說明", placeholder="輸入現場或文件重點備註", lines=2) |
| scan_button = gr.Button("掃描並生成建檔摘要") |
| with gr.Column(): |
| scan_caption = gr.Textbox(label="照片內容辨識", interactive=False, lines=4) |
| scan_summary = gr.Textbox(label="建檔重點摘要", interactive=False, lines=12) |
|
|
| scan_button.click( |
| scan_photo_and_generate_summary, |
| inputs=[photo_site, photo_input, photo_notes], |
| outputs=[scan_caption, scan_summary] |
| ) |
|
|
| with gr.Tab("OCR 文件建檔"): |
| with gr.Row(): |
| with gr.Column(): |
| doc_site = gr.Dropdown(label="選擇案場", choices=list_site_options(), interactive=True) |
| doc_image = gr.Image(type="filepath", label="上傳文件照片") |
| doc_notes = gr.Textbox(label="文件備註", placeholder="輸入文件重點、版本或來源說明", lines=2) |
| ocr_button = gr.Button("OCR 並生成全文建檔") |
| with gr.Column(): |
| ocr_status = gr.Textbox(label="OCR 狀態", interactive=False, lines=1) |
| ocr_text = gr.Textbox(label="OCR 文字內容", interactive=False, lines=8) |
| ocr_summary = gr.Textbox(label="文件建檔摘要", interactive=False, lines=12) |
|
|
| ocr_button.click( |
| scan_document_with_ocr, |
| inputs=[doc_site, doc_image, doc_notes], |
| outputs=[ocr_status, ocr_text, ocr_summary] |
| ) |
|
|
| with gr.Tab("照片歷史庫"): |
| with gr.Row(): |
| with gr.Column(): |
| history_site = gr.Dropdown(label="選擇案場", choices=list_site_options(), interactive=True) |
| refresh_history = gr.Button("更新照片歷史") |
| with gr.Column(): |
| photo_history_table = gr.Dataframe(headers=["時間", "照片檔案", "說明", "摘要"], interactive=False) |
| ocr_history_table = gr.Dataframe(headers=["時間", "檔案", "OCR 內容", "摘要"], interactive=False) |
|
|
| refresh_history.click( |
| lambda label: (get_case_photo_history(label), get_case_ocr_history(label)), |
| inputs=[history_site], |
| outputs=[photo_history_table, ocr_history_table] |
| ) |
|
|
| with gr.Tab("手機快速掃描"): |
| with gr.Row(): |
| with gr.Column(): |
| mobile_site = gr.Dropdown(label="選擇案場", choices=list_site_options(), interactive=True) |
| mobile_photo = gr.Image(type="filepath", label="手機拍照/上傳照片") |
| mobile_notes = gr.Textbox(label="備註", placeholder="快速輸入現場或文件說明", lines=2) |
| mobile_scan_button = gr.Button("快速掃描建檔") |
| with gr.Column(): |
| mobile_caption = gr.Textbox(label="掃描結果", interactive=False, lines=4) |
| mobile_summary = gr.Textbox(label="快速建檔摘要", interactive=False, lines=10) |
|
|
| mobile_scan_button.click( |
| scan_photo_and_generate_summary, |
| inputs=[mobile_site, mobile_photo, mobile_notes], |
| outputs=[mobile_caption, mobile_summary] |
| ) |
|
|
| with gr.Tab("照片瀑布流畫廊"): |
| with gr.Row(): |
| with gr.Column(): |
| gallery_site = gr.Dropdown(label="選擇案場", choices=list_site_options(), interactive=True) |
| refresh_gallery = gr.Button("更新畫廊") |
| with gr.Column(): |
| photo_gallery = gr.Gallery(label="案場照片瀑布流", show_label=True) |
|
|
| refresh_gallery.click(get_case_photo_gallery, inputs=[gallery_site], outputs=[photo_gallery]) |
|
|
| with gr.Tab("OCR 文字全文搜尋"): |
| with gr.Row(): |
| with gr.Column(): |
| ocr_search = gr.Textbox(label="搜尋關鍵字", placeholder="輸入 OCR 文字搜尋字詞", lines=1) |
| search_button = gr.Button("搜尋 OCR") |
| with gr.Column(): |
| search_results = gr.Dataframe(headers=["案場", "時間", "檔案", "OCR 摘要", "文字片段"], interactive=False) |
|
|
| search_button.click(search_ocr_text, inputs=[ocr_search], outputs=[search_results]) |
|
|
| with gr.Tab("報告匯出"): |
| with gr.Row(): |
| with gr.Column(): |
| export_site = gr.Dropdown(label="選擇案場", choices=list_site_options(), interactive=True) |
| export_button = gr.Button("匯出 PDF/Excel 報告") |
| with gr.Column(): |
| pdf_report = gr.File(label="下載 PDF 報告") |
| excel_report = gr.File(label="下載 Excel 報告") |
|
|
| export_button.click(generate_case_report_files, inputs=[export_site], outputs=[pdf_report, excel_report]) |
|
|
| with gr.Tab("多案場比較"): |
| compare_button = gr.Button("更新比較表") |
| compare_result = gr.Dataframe(headers=["案場", "地點", "類別", "客戶", "階段", "標案金額", "標籤", "會議數"], interactive=False) |
| compare_button.click(compare_sites, outputs=[compare_result]) |
|
|
| with gr.Tab("案場詳細檢視"): |
| detail_site = gr.Dropdown(label="選擇案場查看詳細資料", choices=list_site_options(), interactive=True) |
| detail_button = gr.Button("查看案場詳細") |
| detail_display = gr.Markdown() |
| detail_button.click(get_case_detail, inputs=[detail_site], outputs=[detail_display]) |
|
|
| with gr.Tab("專案進度甘特圖"): |
| gantt_site = gr.Dropdown(label="選擇案場生成甘特圖", choices=list_site_options(), interactive=True) |
| gantt_button = gr.Button("生成專案甘特圖") |
| gantt_image = gr.Image(label="專案進度甘特圖", interactive=False) |
| gantt_button.click(generate_case_gantt, inputs=[gantt_site], outputs=[gantt_image]) |
|
|
| with gr.Tab("專案管理儀表板"): |
| dashboard_site = gr.Dropdown(label="選擇案場查看儀表板", choices=list_site_options(), interactive=True) |
| dashboard_display = gr.Markdown() |
| dashboard_button = gr.Button("刷新儀表板") |
| dashboard_button.click(get_dashboard, inputs=[dashboard_site], outputs=[dashboard_display]) |
|
|
| add_site_btn.click( |
| add_site_and_refresh, |
| inputs=[site_name, location, client, stage, category, new_category, tags, bid_amount, manager_notes], |
| outputs=[ |
| case_list, |
| site_selector, |
| dashboard_site, |
| photo_site, |
| doc_site, |
| mobile_site, |
| history_site, |
| gallery_site, |
| export_site, |
| category, |
| site_message, |
| ], |
| ) |
|
|
| with gr.Row(): |
| reset_button = gr.Button("重新整理案場列表") |
| reset_button.click(refresh_site_dropdown, outputs=[site_selector, dashboard_site, photo_site, doc_site, mobile_site, history_site, gallery_site, export_site]) |
|
|
| gr.Markdown("---\n請在 Hugging Face Space 中設定 `HUGGINGFACEHUB_API_TOKEN` 為你的 HF 金鑰,然後部署此應用。") |
|
|
| login_button.click( |
| verify_login, |
| inputs=[login_user, login_pass, login_key], |
| outputs=[login_message, login_area, main_area] |
| ) |
|
|
| if __name__ == "__main__": |
| demo.launch(server_name="0.0.0.0", server_port=7860) |
|
|