import os import re import tempfile from pathlib import Path from typing import Tuple import gradio as gr import pandas as pd from openai import OpenAI # reportlab for PDF from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.cidfonts import UnicodeCIDFont # -------------------- # Setup OpenAI client # -------------------- API_KEY = os.getenv("OPENAI_API_KEY") or os.getenv("OPENAIAPIKEY") client = OpenAI(api_key=API_KEY) if API_KEY else None FALLBACK_TEXT = """ ### 1. 可能相關疾病 - 胃食道逆流(GERD):胃酸回流到食道,可能引起燒灼感和胃痛。 - 胃潰瘍:胃內部的黏膜受損,形成潰瘍,可能導致疼痛和不適。 ### 2. 飲食營養建議 - 纖維素:有助於消化,減少便秘和腸道不適。 - 維生素C:幫助修復胃黏膜,增強免疫系統。 - Omega-3脂肪酸:具有抗炎特性,可能有助於減輕胃部炎症。 ### 3. 一日三餐具體菜單 #### 中式版本 - 早餐: 燕麥粥(牛奶)、水煮蛋 - 午餐: 清蒸魚、炒青菜、白飯 - 晚餐: 魚湯、燙青菜、糙米 #### 西式版本 - 早餐: 全麥吐司、水煮蛋、藍莓 - 午餐: 烤雞胸肉、藜麥沙拉、優格 - 晚餐: 煎三文魚、蒸花椰菜、藜麥 """.strip() # -------------------- # GPT 呼叫(加速版) # -------------------- def call_gpt_system(prompt: str) -> str: if client is None: return FALLBACK_TEXT try: resp = client.chat.completions.create( model="gpt-4o-mini", max_tokens=500, # 限制字數以加快速度 temperature=0.6, # 適度隨機 messages=[{"role": "user", "content": prompt}], ) text = resp.choices[0].message.content return text if text and text.strip() else FALLBACK_TEXT except Exception as e: print("GPT 呼叫失敗:", e) return FALLBACK_TEXT # -------------------- # 區塊解析 # -------------------- def extract_block(text: str, starts: list, ends: list = None) -> str: if not text: return "" start_pos = None for s in starts: m = re.search(s, text, flags=re.I) if m: start_pos = m.end() break if start_pos is None: return "" if not ends: return text[start_pos:].strip() end_pos = len(text) for e in ends: m = re.search(e, text[start_pos:], flags=re.I) if m: end_pos = start_pos + m.start() break return text[start_pos:end_pos].strip() def parse_bullets_to_pairs(block: str): items = [] if not block: return items lines = [ln.strip() for ln in block.splitlines() if ln.strip()] candidate = [ln for ln in lines if ln.lstrip().startswith("-")] or lines for ln in candidate: ln2 = re.sub(r"^-+\s*", "", ln).strip() if ":" in ln2: name, desc = ln2.split(":", 1) items.append((name.strip(), desc.strip())) elif ":" in ln2: name, desc = ln2.split(":", 1) items.append((name.strip(), desc.strip())) else: items.append((ln2.strip(), "")) return items def parse_menu_block(block: str): rows = [] if not block: return rows lines = [ln.strip() for ln in block.splitlines() if ln.strip()] for ln in lines: ln2 = re.sub(r"^-+\s*", "", ln).strip() if ":" in ln2: meal, food = ln2.split(":", 1) rows.append({"meal": meal.strip(), "food": food.strip()}) elif ":" in ln2: meal, food = ln2.split(":", 1) rows.append({"meal": meal.strip(), "food": food.strip()}) else: rows.append({"meal": "", "food": ln2}) return rows def robust_parse(text: str) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: disease_block = extract_block(text, [r"可能相關疾病", r"###\s*1", r"1\."], [r"飲食", r"###\s*2", r"2\."]) nutrition_block = extract_block(text, [r"飲食營養建議", r"###\s*2", r"2\."], [r"一日三餐", r"###\s*3", r"3\."]) menu_block = extract_block(text, [r"一日三餐", r"###\s*3", r"3\."], [r"參考來源", r"$"]) diseases = parse_bullets_to_pairs(disease_block) nutritions = parse_bullets_to_pairs(nutrition_block) ch_block = "" we_block = "" m_ch = re.search(r"(?:中式版本|中式)\s*(.*?)(?=(?:西式版本|西式|$))", menu_block, flags=re.S | re.I) if m_ch: ch_block = m_ch.group(1) m_we = re.search(r"(?:西式版本|西式)\s*(.*)", menu_block[m_ch.end():], flags=re.S | re.I) if m_we: we_block = m_we.group(1) else: ch_block = menu_block ch_rows = parse_menu_block(ch_block) we_rows = parse_menu_block(we_block) if not diseases: diseases = parse_bullets_to_pairs(extract_block(FALLBACK_TEXT, [r"可能相關疾病"], None)) if not nutritions: nutritions = parse_bullets_to_pairs(extract_block(FALLBACK_TEXT, [r"飲食營養建議"], None)) df_disease = pd.DataFrame([{"疾病": n, "說明": d} for n, d in diseases]) if diseases else pd.DataFrame(columns=["疾病", "說明"]) df_nutrition = pd.DataFrame([{"營養素": n, "建議": d} for n, d in nutritions]) if nutritions else pd.DataFrame(columns=["營養素", "建議"]) max_len = max(len(ch_rows), len(we_rows)) meals = [] for i in range(max_len): ch = ch_rows[i]["food"] if i < len(ch_rows) else "" we = we_rows[i]["food"] if i < len(we_rows) else "" meals.append({"中式餐點": ch, "西式餐點": we}) df_meals = pd.DataFrame(meals, columns=["中式餐點", "西式餐點"]) return df_disease, df_nutrition, df_meals # -------------------- # PDF 生成(修正版:文字換行) # -------------------- def has_chinese(s: str) -> bool: return bool(re.search(r"[\u4e00-\u9fff]", str(s))) def clean_dataframe(df: pd.DataFrame) -> pd.DataFrame: if df.empty: return df mask = df.apply(lambda row: any(has_chinese(x) for x in row), axis=1) return df[mask].reset_index(drop=True) def generate_pdf_file(df_disease, df_nutrition, df_meals, basic_info=None): df_disease = clean_dataframe(df_disease) df_nutrition = clean_dataframe(df_nutrition) df_meals = clean_dataframe(df_meals) pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light")) font_name = "STSong-Light" file_prefix = basic_info.get("name", "個人") if basic_info else "個人" safe_name = re.sub(r"[^\w\u4e00-\u9fff]", "_", file_prefix) filename = f"{safe_name}的專屬食療報告書.pdf" tmp_path = Path(tempfile.gettempdir()) / filename doc = SimpleDocTemplate(str(tmp_path), pagesize=A4, leftMargin=36, rightMargin=36, topMargin=36, bottomMargin=36) elements = [] styles = getSampleStyleSheet() title_style = styles["Title"].clone("title") heading_style = styles["Heading2"].clone("h2") normal_style = ParagraphStyle("normal", fontName=font_name, fontSize=10, leading=14) title_style.fontName = heading_style.fontName = font_name # 標題 elements.append(Paragraph(f"健康分析與飲食建議報告 — {file_prefix}", title_style)) elements.append(Spacer(1, 12)) # 基本資料摘要表格 if basic_info: elements.append(Paragraph("基本資料摘要", heading_style)) table_data = [ ["姓名", basic_info.get("name", "")], ["性別", basic_info.get("gender", "")], ["年齡", str(basic_info.get("age", ""))], ["身高", f"{basic_info.get('height', '')} cm"], ["體重", f"{basic_info.get('weight', '')} kg"], ] t = Table(table_data, colWidths=[80, 400]) style = TableStyle([ ("GRID", (0, 0), (-1, -1), 0.8, colors.black), ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey), ("FONTNAME", (0, 0), (-1, -1), font_name), ]) t.setStyle(style) elements.append(t) elements.append(Spacer(1, 16)) # 疾病表 if not df_disease.empty: elements.append(Paragraph("一、可能相關疾病", heading_style)) table_data = [df_disease.columns.tolist()] + [[Paragraph(str(c), normal_style) for c in row] for row in df_disease.values.tolist()] t = Table(table_data, colWidths=[150, 330]) t.setStyle(TableStyle([("GRID", (0, 0), (-1, -1), 0.8, colors.black), ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey), ("FONTNAME", (0, 0), (-1, -1), font_name)])) elements.append(t) elements.append(Spacer(1, 12)) # 營養建議表 if not df_nutrition.empty: elements.append(Paragraph("二、飲食營養建議", heading_style)) table_data = [df_nutrition.columns.tolist()] + [[Paragraph(str(c), normal_style) for c in row] for row in df_nutrition.values.tolist()] t = Table(table_data, colWidths=[150, 330]) t.setStyle(TableStyle([("GRID", (0, 0), (-1, -1), 0.8, colors.black), ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey), ("FONTNAME", (0, 0), (-1, -1), font_name)])) elements.append(t) elements.append(Spacer(1, 12)) # 餐點表(換行修正) if not df_meals.empty: elements.append(Paragraph("三、一日三餐(中式 / 西式)", heading_style)) table_data = [df_meals.columns.tolist()] + [[Paragraph(str(c), normal_style) for c in row] for row in df_meals.values.tolist()] t = Table(table_data, colWidths=[240, 240]) t.setStyle(TableStyle([("GRID", (0, 0), (-1, -1), 0.8, colors.black), ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey), ("FONTNAME", (0, 0), (-1, -1), font_name)])) elements.append(t) doc.build(elements) return str(tmp_path) # -------------------- # 主處理函式 # -------------------- def on_submit(name, height, weight, age, gender, symptom): prompt = f"""你是一位專業營養師。根據以下使用者基本資料與症狀,請給出: 1) 可能相關疾病(每行 - 疾病:說明) 2) 飲食營養建議(每行 - 營養素:建議) 3) 一日三餐具體菜單(分中式版本與西式版本,每項用 - 早餐: ... 格式) 使用者資料: - 姓名: {name} - 性別: {gender} - 年齡: {age} - 身高: {height} cm - 體重: {weight} kg 症狀: {symptom} 請用中文輸出,格式如上,方便解析。""" raw_text = call_gpt_system(prompt) df_disease, df_nutrition, df_meals = robust_parse(raw_text) basic_info = {"name": name, "gender": gender, "age": age, "height": height, "weight": weight} pdf_path = generate_pdf_file(df_disease, df_nutrition, df_meals, basic_info) return df_disease, df_nutrition, df_meals, gr.update(value=pdf_path, visible=True) # -------------------- # Gradio UI + CSS 樣式 # -------------------- css_code = """ #submit_btn {background-color: #007bff !important; color: white !important;} #download_btn {background-color: orange !important; color: white !important;} """ with gr.Blocks(css=css_code) as demo: gr.Markdown("## 🩺AI食療專家") with gr.Row(): with gr.Column(scale=1): gr.Markdown("### 請輸入基本資料") name = gr.Textbox(label="姓名", placeholder="請輸入姓名") gender = gr.Dropdown(["男", "女"], label="性別", value="男") age = gr.Number(label="年齡", value=30, precision=0) height = gr.Number(label="身高 (cm)", value=170, precision=0) weight = gr.Number(label="體重 (kg)", value=65, precision=0) symptom = gr.Textbox(label="症狀 / 主訴", placeholder="例如:胃痛、頭痛") submit_btn = gr.Button("分析並生成報告", elem_id="submit_btn") with gr.Column(scale=2): gr.Markdown("### 判讀結果表格") df_disease_out = gr.Dataframe(label="可能相關疾病", interactive=False) df_nutrition_out = gr.Dataframe(label="飲食營養建議", interactive=False) df_meals_out = gr.Dataframe(label="中式 / 西式 餐點對照", interactive=False) download_btn = gr.DownloadButton(label="一鍵下載 PDF", visible=False, elem_id="download_btn") submit_btn.click( fn=on_submit, inputs=[name, height, weight, age, gender, symptom], outputs=[df_disease_out, df_nutrition_out, df_meals_out, download_btn], ) if __name__ == "__main__": demo.launch()