import gradio as gr import json import os import re from datetime import datetime import gspread from oauth2client.service_account import ServiceAccountCredentials from openai import OpenAI # === Google Sheet 初始化 === def init_gsheet(): scope = ["https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive"] service_account_info = json.loads(os.getenv("GCP_SERVICE_ACCOUNT_JSON")) creds = ServiceAccountCredentials.from_json_keyfile_dict(service_account_info, scope) client = gspread.authorize(creds) # ⚠️ 換成你的 Google Sheet 名稱 sheet = client.open("OneMinuteSummaryScores").sheet1 return sheet SHEET = init_gsheet() # === API === api_key = os.getenv("OPENAI_API_KEY") client = OpenAI(api_key=api_key) MODEL_NAME = "gpt-5-mini" INTRO_TEXT = """

國防醫學大學 醫學模擬實驗室 - 「One-Minute Summary」考核評分

National Defense Medical University Medical Simulation Lab - 「One-Minute Summary」

本系統根據 等第制 (A~F) 與七大題項 (總分100分) 對你的總結給出評分與評語。

The System is based on ranking A~F and 7 questions (totoal 100) to evaluate your summary

Builder: 鄭文隆(M115) Supervisor: 劉峰誠(M92)

""" # === 分數解析工具 === def parse_scores(text: str): total = None m_total = re.search(r"總分[::]\s*(\d+)\s*/\s*100", text) if m_total: try: total = int(m_total.group(1)) except: total = None return total def render_score_card(total, raw): parts = [] if total is not None: parts.append(f"### 成績重點\n- **總分**:**{total}/100**\n") parts.append("---") parts.append(raw.strip()) return "\n".join(parts) # === 呼叫 API === def call_model(summary: str) -> str: if not api_key: return "【設定錯誤】找不到 OPENAI_API_KEY,請在 Hugging Face Space Secrets 設定。" prompt = f"""你是臨床教師,學生主要用什麼語言回答你就用什麼語言給分打回饋。根據以下七大題項和等第制,請對實習醫學生的一分鐘總結進行評分與評論。學生主要用什麼語言回答你就用什麼語言給分打回饋。 評分規準: • 優異(A):90-100分 o 評語: 內容精確、邏輯嚴謹,能對病程變化做出全面且深入的分析。能將各項資訊融會貫通,並在鑑別診斷中展現清晰的臨床思維。 • 良好(B):80-89分 o 評語: 內容完整,能掌握核心問題,但部分細節有待加強。能提出合理的鑑別診斷,但推論過程可更為細膩。 • 普通(C):70-79分 o 評語: 內容大致正確,但邏輯性不強,部分關鍵資訊遺漏。對鑑別診斷的掌握度有待提升。 • 尚可(D):60-69分 o 評語: 內容有所缺失,對病程的理解有限。未能在鑑別診斷中展現足夠的臨床思考。 • 不及格(F):60分以下 o 評語: 無法正確理解病程,遺漏過多關鍵資訊,且鑑別診斷不合理。 ________________________________________ 各主題詳細評分 1. 年齡、性別、Chief Complaint (佔10分) • 優異(A): 簡潔扼要地說出患者姓名、年齡、性別,並精確總結主訴,能明確指出時間點、特徵及嚴重程度。 • 良好(B): 能夠說出患者基本資料與主訴,但主訴的描述可能較為籠統。 • 尚可(C): 資訊不夠完整,如遺漏年齡或性別,或主訴描述不清。 • 需加強(F): 提供的基本資訊有誤或與主訴無關。 2. 個人病史 (佔10分) • 優異(A): 完整且有條理地羅列與本次主訴相關的所有過去病史,並能說明其與當前病情的潛在關聯。 • 良好(B): 能夠提及主要相關病史,但可能遺漏部分重要細節。 • 尚可(C): 僅能提及部分或不相關的病史,未建立與當前病情的連結。 • 需加強(F): 無法回答或對患者個人病史的認知錯誤。 3. 家族史 (佔10分) • 優異(A): 能主動詢問並說明相關家族史對本次診斷的重要性,即使資訊缺乏,也能展現全面性的思考。 • 良好(B): 能夠提及是否有家族史,但沒有深入說明其意義。 • 尚可(C): 僅提及無家族史,但沒有展現主動詢問的意識。 • 需加強(F): 遺漏此部分,或將其他病史混淆為家族史。 ________________________________________ 4. 身體檢查陽性發現 (佔10分) • 優異(A): 準確報告關鍵的陽性體徵,並能解釋這些發現所代表的臨床意義。 • 良好(B): 能夠報告主要的陽性體徵,但未能完整連結其臨床意義。 • 尚可(C): 報告體徵不完整或包含太多不重要的陰性發現,導致重點模糊。 • 需加強(F): 無法正確報告任何陽性體徵,或與患者實際情況不符。 5. 實驗室與影像結果 (佔10分) • 優異(A): 精確總結關鍵的實驗室與影像異常數據,並能解釋這些結果如何支持或排除您的鑑別診斷。 • 良好(B): 能夠提及主要的異常檢驗結果,但可能遺漏部分數據或缺乏對其臨床意義的深入解釋。 • 尚可(C): 僅提及部分或不相關的檢驗結果,未能與病情建立有效連結。 • 需加強(F): 提供的檢驗或影像結果有誤,或完全無法回答。 6. 鑑別診斷 (佔10分) • 優異(A): 有條理地列出至少三個鑑別診斷,並從最致命的疾病開始,依據病史、體徵及檢驗結果,說明您的推理過程。 • 良好(B): 能夠列出幾個鑑別診斷,但排序或邏輯性不夠強,解釋不夠充分。 • 尚可(C): 僅能提出一個或少數幾個鑑別診斷,且缺乏支持論點。 • 需加強(F): 無法提出合理的鑑別診斷,或提出的診斷與病情明顯不符。 ________________________________________ 7. 目前處置與治療 (佔40分) • 優異(A): 能清楚說明目前已給予的處置或治療,並解釋其背後的目的或原因。能展現治療決策的邏輯,例如「先穩定生命徵象,再進行進一步檢查」。 • 良好(B): 能夠提及已採取的處置,但未能完整解釋其目的或背後的治療邏輯。 • 尚可(C): 僅能提及部分處置,且資訊不夠精確。 • 需加強(F): 提供的處置資訊有誤,或與病情不符,或完全無法回答此問題 全人照護是以個臨床工作場域的跨領域教學(InterprofessionalEducatio,IPE)為基礎,並利用跨領域團隊照護(Interprofessional Practice,IPP)的 實際運作方式,透過各職類團隊的相互合作,讓學員把知識、技能、態度(KAS)運用在病人身、心、靈、社會(四面向)照護,更以病人本身的全人照護為出發點, 擴展至全團隊共同提供全程、全家、全社區的五全照護。 5-五全: 全人、全家、全程、全隊、全社區 4-四面向: 生、心、靈、社會 3-三要素: 知識、態度、技能 (KAS) 全人評分分數分配: 5: 50分/4: 40 分/3: 10 分 共100分 學生主要用什麼語言回答你就用什麼語言給分打回饋。 ________________________________________ 請輸出格式: 1. 各項逐條給分與簡評 2. 總分 (x/100) 3. 等第 (A~F) 4. 總結性評論 5. 簡要全人照護面向評估與分數(5-五全: 全人、全家、全程、全隊、全社區 / 4-四面向: 生、心、靈、社會 /3-三要素: 知識、態度、技能 (KAS)) 學生主要用什麼語言回答你就用什麼語言給分打回饋。 學生的一分鐘總結如下: {summary} 學生主要用什麼語言回答你就用什麼語言給分打回饋。 """ resp = client.chat.completions.create( model=MODEL_NAME, messages=[{"role": "user", "content": prompt}], ) try: return resp.choices[0].message.content except Exception as e: return f"【解析回應失敗】{e}|原始:{str(resp)[:300]}" # === 紀錄到 Google Sheet === def log_to_sheet(level, name, p1, p2, p3, p4, p5, p6, p7, raw, total): now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") SHEET.append_row([now, level, name, p1, p2, p3, p4, p5, p6, p7, total, raw]) # === 整合流程 === def collect_and_score(level, name, p1, p2, p3, p4, p5, p6, p7): combined = f"""1. 年齡/性別/主訴: {p1} 2. 個人病史: {p2} 3. 家族史: {p3} 4. 身體檢查: {p4} 5. 實驗室與影像: {p5} 6. 鑑別診斷: {p6} 7. 處置與治療: {p7} """ raw = call_model(combined) total = parse_scores(raw) md = render_score_card(total, raw) log_to_sheet(level, name, p1, p2, p3, p4, p5, p6, p7, raw, total) return level, name, p1, p2, p3, p4, p5, p6, p7, md def do_clear(): return "Clerk1", "", "", "", "", "", "", "", "", "(已清空)" # === Gradio 介面 === with gr.Blocks(title="One-Minute Summary 評分") as demo: gr.HTML(INTRO_TEXT) level = gr.Dropdown( choices=["主治醫師Visiting Staff", "住院醫師Resident", "PGY2", "PGY1", "Clerk2", "Clerk1","其他Other"], value="Clerk1", label="請選擇職級Choose your role" ) name = gr.Textbox(label="姓名Name", lines=1, placeholder="請輸入姓名Your Name") p1 = gr.Textbox(label="1. 年齡Age、性別Sex、Chief Complaint (10分)", lines=2) p2 = gr.Textbox(label="2. 個人病史Personal History (10分)", lines=2) p3 = gr.Textbox(label="3. 家族史Family History (10分)", lines=2) p4 = gr.Textbox(label="4. 身體檢查陽性發現Physical Examination (10分)", lines=2) p5 = gr.Textbox(label="5. 實驗室與影像結果Lab and Image (10分)", lines=2) p6 = gr.Textbox(label="6. 鑑別診斷Differential Diagnosis (10分)", lines=2) p7 = gr.Textbox(label="7. 處置與治療Treatment (40分)", lines=2) with gr.Row(): submit_btn = gr.Button("送出評分 Submit", variant="primary") clear_btn = gr.Button("清空 Clear") result = gr.Markdown(label="評分結果Result ") submit_btn.click( collect_and_score, [level, name, p1, p2, p3, p4, p5, p6, p7], [level, name, p1, p2, p3, p4, p5, p6, p7, result] ) clear_btn.click( do_clear, outputs=[level, name, p1, p2, p3, p4, p5, p6, p7, result] ) demo.launch()