CH-01 / app.py
Neil6387
Add matplotlib requirement and safe import fallback
ce21361
Raw
History Blame Contribute Delete
37.5 kB
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)