Spaces:
Sleeping
Sleeping
Download app_agent.py from minchun/II_SpectraEngine: direct link, hf CLI and curl.
- Browser
- Download file 340 kB
-
https://huggingface.co/spaces/minchun/II_SpectraEngine/resolve/main/app_agent.py
- Command line
-
hf download hf://spaces/minchun/II_SpectraEngine/app_agent.py
-
curl -L -o app_agent.py https://huggingface.co/spaces/minchun/II_SpectraEngine/resolve/main/app_agent.py
340 kB
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """ | |
| app_agent.py | |
| 重譯文本分析系統 AI Agent(雙 Agent 架構) | |
| ──────────────────────────────────────────── | |
| 主 Agent (LLM1): google/gemma-4-31b-it | |
| → 研究情境詢問 & 最終研究報告撰寫 | |
| 工作程序Agent(LLM2): google/gemma-4-26b-a4b-it | |
| → 文本上傳引導 / 格式驗證 / 指標計算 / 統計分析 | |
| """ | |
| # ============================================================================ | |
| # SECTION 1: Imports & Configuration | |
| # ============================================================================ | |
| import sys, io, os, re, json, uuid, shutil, warnings, functools, collections, time | |
| import threading, zipfile, traceback | |
| # 強制 UTF-8 輸出(避免 Windows cp950 編碼錯誤) | |
| if sys.platform.startswith("win"): | |
| try: | |
| sys.stdout.reconfigure(encoding="utf-8", errors="replace", line_buffering=True) | |
| sys.stderr.reconfigure(encoding="utf-8", errors="replace", line_buffering=True) | |
| except AttributeError: | |
| sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") | |
| sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") | |
| from pathlib import Path | |
| from datetime import datetime, timezone, timedelta | |
| # 台灣時區(UTC+8):確保雲端(UTC 伺服器)與本地時間一致 | |
| TW_TZ = timezone(timedelta(hours=8)) | |
| def now_tw(): | |
| """回傳台灣時間(UTC+8),不受伺服器時區影響。""" | |
| return datetime.now(TW_TZ) | |
| from collections import Counter | |
| import numpy as np | |
| import pandas as pd | |
| import scipy.stats as stats | |
| import matplotlib | |
| matplotlib.use('Agg') | |
| import matplotlib.pyplot as plt | |
| import matplotlib.font_manager as fm | |
| from sklearn.ensemble import RandomForestClassifier | |
| from sklearn.model_selection import train_test_split | |
| from sklearn.metrics import accuracy_score, classification_report | |
| from sklearn.preprocessing import LabelEncoder, StandardScaler | |
| from sklearn.cluster import KMeans, AgglomerativeClustering | |
| from sklearn.decomposition import PCA | |
| from scipy.cluster.hierarchy import dendrogram, linkage | |
| from docx import Document | |
| from docx.shared import Pt, Cm, RGBColor | |
| from docx.enum.table import WD_TABLE_ALIGNMENT | |
| from docx.enum.text import WD_ALIGN_PARAGRAPH | |
| import urllib.request, urllib.error | |
| import gradio as gr | |
| warnings.filterwarnings('ignore') | |
| print = functools.partial(print, flush=True) | |
| # ── 載入 .env 環境變數 ─────────────────────────────────────────────────────── | |
| try: | |
| from dotenv import load_dotenv | |
| load_dotenv(Path(__file__).parent / ".env") | |
| except ImportError: | |
| print("[警告] 未安裝 python-dotenv,略過 .env 載入(請執行 pip install python-dotenv)") | |
| # ── 路徑常數 ──────────────────────────────────────────────────────────────── | |
| BASE_DIR = Path(__file__).parent | |
| CORPUS_INFO = BASE_DIR / "01-Corpus" / "03-Info" | |
| SESSIONS_DIR = BASE_DIR / "sessions" | |
| SESSIONS_DIR.mkdir(exist_ok=True) | |
| # ── Session 目錄自動清理(2026-08-22,見 docs/開發優化建議.md 第四節)──────── | |
| # 每個 session 資料夾含使用者上傳的原始語料與全部分析產出,先前沒有任何過期 | |
| # 清理機制,會無限期留在磁碟上(正式機部署在 Hugging Face Spaces,公開對外 | |
| # 使用後這個問題會更明顯)。這裡加一支獨立的清理工具:只依資料夾的最後修改 | |
| # 時間(mtime)判斷是否過期,不讀取、不解析任何 session 內部狀態,因此不算 | |
| # 觸碰《開發優化建議.md》第五節界定的高風險核心運算區。 | |
| SESSION_RETENTION_DAYS_DEFAULT = 7 # 保留天數,可用環境變數 SESSION_RETENTION_DAYS 覆寫 | |
| def cleanup_old_sessions(max_age_days: int = None) -> int: | |
| """刪除 SESSIONS_DIR 底下超過保留天數的舊 session 資料夾。 | |
| 以資料夾本身的最後修改時間(mtime)判斷年齡:分析運算過程中會持續寫入 | |
| 該資料夾(上傳檔案、指標 CSV、圖表、報告……),mtime 會隨之更新,因此 | |
| 不會誤刪還在使用中的 session;只有久沒被寫入、代表使用者早已離開的舊 | |
| 資料夾才會被清掉。 | |
| 回傳實際刪除的資料夾數量(供啟動 log 顯示,非必要)。 | |
| """ | |
| if max_age_days is None: | |
| max_age_days = int(os.environ.get("SESSION_RETENTION_DAYS", str(SESSION_RETENTION_DAYS_DEFAULT))) | |
| if not SESSIONS_DIR.exists(): | |
| return 0 | |
| cutoff = datetime.now(timezone.utc).timestamp() - max_age_days * 86400 | |
| deleted = 0 | |
| for entry in SESSIONS_DIR.iterdir(): | |
| if not entry.is_dir(): | |
| continue | |
| try: | |
| if entry.stat().st_mtime < cutoff: | |
| shutil.rmtree(entry, ignore_errors=True) | |
| deleted += 1 | |
| except FileNotFoundError: | |
| continue # 極少數情況下清理過程中資料夾被同時刪除,略過即可 | |
| if deleted: | |
| print(f"【Session 清理】已刪除 {deleted} 個超過 {max_age_days} 天未使用的舊 session 資料夾。") | |
| return deleted | |
| def start_session_cleanup_scheduler() -> None: | |
| """啟動一支背景 daemon 執行緒:程式啟動時先清一次,之後每 24 小時重跑一次。 | |
| 只在程序啟動時呼叫一次即可。用背景執行緒(而非只在啟動時跑一次)的原因: | |
| Hugging Face Spaces 若持續有人使用,容器可能長時間不重啟,只在啟動時清 | |
| 一次的話,長期掛著不重啟的情況下舊資料夾一樣會無限期累積。 | |
| """ | |
| def _loop(): | |
| while True: | |
| try: | |
| cleanup_old_sessions() | |
| except Exception as e: | |
| print(f"【Session 清理】背景清理發生例外(不影響主程式運作):{e}") | |
| time.sleep(24 * 3600) | |
| threading.Thread(target=_loop, daemon=True, name="session-cleanup").start() | |
| # ── LLM 設定 ──────────────────────────────────────────────────────────────── | |
| # API 金鑰維持環境變數(本來就該是每個部署環境各自設定的機密資訊,不進版控)。 | |
| LLM1_API_KEY = os.environ.get("LLM1_API_KEY", "") | |
| LLM2_API_KEY = os.environ.get("LLM2_API_KEY", "") | |
| # 2026-08-22(業主要求:為研究結果的「可重複性」鎖定模型版本):模型代號 | |
| # 原本是 `os.environ.get("LLM1_MODEL", "google/gemma-4-31b-it")`——可以被 | |
| # 環境變數在執行期任意覆寫,且不會留下任何紀錄。docs/開發優化建議.md 第 | |
| # 3.3/3.4 節就記錄過一次真實案例:測試環境曾被覆寫成 `openrouter/free` | |
| # 這種自動路由別名,同一個代號兩次呼叫被導去兩個不同的實際模型,耗時與 | |
| # 輸出品質都不穩定——這正是「模型版本沒鎖定」會實際發生的風險,不是假設。 | |
| # 改成固定常數:只能透過修改這裡的程式碼(進而走 git commit 留下紀錄) | |
| # 才能更換模型,執行期無法覆寫,「這次分析用的是哪個模型」才會是一個 | |
| # 可以回頭稽核、不會被悄悄改掉的事實。 | |
| LLM1_MODEL = "google/gemma-4-31b-it" | |
| LLM2_MODEL = "google/gemma-4-26b-a4b-it" | |
| # 2026-08-22(同上,可重複性設計之二:提示詞版本控制):SYSTEM_PROMPT_BASE | |
| # 與各 SCENARIO_MODULE_* 本身已經隨這個檔案進 git 版控,但報告產出當下 | |
| # 沒有留下任何「這份報告是哪一版提示詞寫的」的紀錄——事後拿到一份舊報告, | |
| # 沒辦法回推當時的提示詞內容跟現在是否一致。這裡加一個顯式版本號,每次 | |
| # 異動 SYSTEM_PROMPT_BASE 或任一 SCENARIO_MODULE_* 的內容時手動遞增,並且 | |
| # 直接印在每份產出的報告上(見 run_report_writing()),讓版本資訊跟著 | |
| # 報告本身走,不用回頭 git blame 才查得到。 | |
| PROMPT_VERSION = "v1" | |
| # 2026-08-26(「關於本系統」面板用):跟 PROMPT_VERSION 是兩件不同的事—— | |
| # PROMPT_VERSION 只追蹤 LLM 提示詞內容,這個追蹤的是整個系統(畫面、流程、 | |
| # 各項修正)的版本,給使用者回報問題時對照用。用日期字串(而非 v1.0 這種 | |
| # 語意化版號)是刻意的:日期比較不會忘記更新,且跟 使用說明.md 文末的 | |
| # 「文件版本」日期同一套慣例。有實質功能異動時手動更新這個日期即可。 | |
| APP_VERSION = "2026-08-26" | |
| # 2026-08-26(業主指定內容):導覽列「關於」按鈕彈出的說明面板。版權聲明/ | |
| # 聯絡方式/Bug 回報/引用格式這四項業主明確要求「論文發表前不公開姓名或 | |
| # 信箱」,這裡不放任何真實聯絡資訊、也不留 [方括號] 這種看起來像沒填完的 | |
| # 佔位符,改寫成正式的「將於論文發表後公開」——對使用者來說是完整、有意義 | |
| # 的句子,之後論文發表了直接把這幾行換成真實資訊即可。 | |
| ABOUT_CONTENT_MD = f"""## 關於本系統 | |
| **譯彩紛呈 II 分析引擎 SpectraEngine**(TransPrism: An Analytical Framework for Retranslation II) | |
| 系統版本:{APP_VERSION} 回報問題時請附上此版本號 | |
| --- | |
| ### 免責聲明 | |
| 本系統分析結果僅供研究參考,不構成正式學術判斷;上傳內容將傳送至第三方 AI 服務進行分析,請勿上傳尚未公開發表之機密內容。 | |
| ### 姊妹工具 | |
| 文本須先經由「[語料淬鍊(CorpusRefinery)](https://github.com/minchun-git/I_CorpusRefinery)」完成斷詞後才能上傳本系統分析。 | |
| ### 開發紀錄 | |
| 系統功能與版本異動,請參見導覽列「使用說明」文末的文件版本紀錄。 | |
| ### 版權聲明 | |
| © 2026|作者姓名/單位將於論文發表後公開 | |
| ### 聯絡方式 | |
| 將於論文發表後公開 | |
| ### Bug 回報 | |
| 聯絡窗口將於論文發表後公開 | |
| ### 引用格式 | |
| 建議引用格式將於論文發表後一併提供 | |
| """ | |
| ALPHA = 0.05 | |
| # 2026-08-22(業主提出「數據完整性檢查」的必要性,追查後確認是真實存在的 | |
| # 問題):差異性檢定(t 檢定/ANOVA)在任一組樣本數 N<2 時,scipy 會算出 | |
| # p=NaN(沒有組內變異,這個檢定工具本來就不適用),但 `NaN < 0.05` 這類比較 | |
| # 在 Python 裡恆為 False,若不特別攔截,會一路 fallthrough 到「無顯著差異」 | |
| # 的判斷分支,跟「有算出結果、但真的沒有顯著差異」變成同一個值,讀者與 LLM | |
| # 都無法分辨「資料不足、根本沒辦法檢定」跟「檢定過、沒有差異」這兩種本質 | |
| # 不同的情況。「個體間比較」功能(見 _extract_individual_grouping)允許 | |
| # 把分組粒度細到每個檔案自己一組,N=1 per group 是實際會發生的情境,不是 | |
| # 假設的邊角案例。這裡定義一個獨立標籤,任何用到「顯著」欄位的地方都要用 | |
| # 這個常數判斷,不要漏掉、也不要各自硬寫字串。 | |
| INSUFFICIENT_DATA_LABEL = "資料不足" | |
| # 2026-08-24(業主回報:報告上出現「Cohen's d=-9028313184615308.0」這種 | |
| # 誇張數字):跟上面 INSUFFICIENT_DATA_LABEL 是兩種不同性質的問題,不要混用 | |
| # ——INSUFFICIENT_DATA_LABEL 代表「p=NaN,檢定本身無法執行」;這個標籤代表 | |
| # 「p 值有效、檢定確實跑出顯著結果,但 Cohen's d/η² 的公式分母(組內變異) | |
| # 趨近於零,除出來的數字在數值上不穩定、失真」。兩組數值幾乎沒有組內變異卻 | |
| # 彼此不同,是統計上最乾淨俐落的一種顯著差異,不該被當成「檢定失敗」處理, | |
| # 顯著性判斷(p 值)仍然有效、正常參與統計比例;只有效果量這個數字本身 | |
| # 換成這個描述性標籤,不強行印出失真的天文數字。 | |
| EFFECT_UNSTABLE_LABEL = "效果量不穩定(組內變異趨近於零)" | |
| # 2026-08-24(業主回報:AG_05 在 MT 組標準差為 0,Welch t 檢定仍算出 | |
| # p=.047 並被 LLM 列為顯著發現,但這個結果不可靠):這是跟上面兩種都不同 | |
| # 的第三種退化情況——Welch t 檢定公式是 t = (mean1-mean2) / sqrt(var1/n1 + | |
| # var2/n2),只要「其中一組」變異數不是零,分母就不會退化成 0 或 NaN, | |
| # 檢定照樣算得出一個數值上正常、但統計上不可靠的 p 值;INSUFFICIENT_DATA_ | |
| # LABEL(p=NaN)跟 EFFECT_UNSTABLE_LABEL(兩組都幾乎零變異、Cohen's d 分母 | |
| # 趨近於零)都攔不到這種「只有一組零變異」的情況。這裡不動指標計算方式 | |
| # 本身(AG_05 算出來是 0 就是事實),只在檢定這一層加警示標記——p 值與 | |
| # 效果量照常計算、正常參與顯著性統計,只是額外附註「這個結果的統計穩定性 | |
| # 存疑」,供人與 LLM 判讀時謹慎看待,不當成跟其他乾淨顯著結果同等份量 | |
| # 的證據。 | |
| ZERO_VARIANCE_WARNING = "⚠️ 其中一組標準差為 0,檢定結果之統計穩定性有限,建議謹慎解讀" | |
| # 2026-08-28(業主追問「零膨脹」偵測):上面 ZERO_VARIANCE_WARNING 只抓「整組標準差 | |
| # 趨近於 0」——通常代表該組幾乎全部樣本值相同(多半是全部掛零)。但還有一種較不極端、 | |
| # SD 判準抓不到的退化情況:某組樣本值大量集中在 0,只靠少數幾筆非零值撐出「看起來 | |
| # 正常」的變異數,平均數與檢定結果其實被那幾筆離群值主導,不能代表該組的整體特徵。 | |
| # 這裡另開一條獨立判準:任一組零值比例達到 ZERO_PROPORTION_THRESHOLD(預設 | |
| # 50%,過半數樣本為 0)就額外標記。門檻定 50% 而不是更低(如 20%、30%)的理由: | |
| # 許多語言指標(如成語、特定詞性頻率)本身就容易有不少檔案掛零,門檻太低會對 | |
| # 「只是資料偏稀疏但正常」的指標大量誤報、稀釋警示的可信度;50%(過半數)才是 | |
| # 「零值已經是這組的主流樣態」的合理起點。刻意只在該組「尚未被 SD 判準抓到」時 | |
| # 才檢查零值比例(見下方偵測邏輯),避免同一組資料被兩條規則重複描述。 | |
| ZERO_PROPORTION_THRESHOLD = 0.5 | |
| ZERO_PROPORTION_WARNING = "⚠️ 其中一組逾半數樣本值為 0,資料集中於零值,檢定結果建議謹慎解讀" | |
| # ── POS 集合(與 Prog02 相同)─────────────────────────────────────────────── | |
| Vset = ['VC','VH','VE','VJ','VA','V2','VK','VL','VD','VCL','VHC','VB','VF','VI','VAC','VG'] | |
| Nset = ['Na','Nb','Nc','Ncd','Nd','Nf','Nh','Nv'] | |
| Nhset = ['Nh'] | |
| ADVset = ['D','Da','Dfa','Dfb','Dk'] | |
| Cset = ['Caa','Cbb'] | |
| Caset = ['Caa'] | |
| Cbset = ['Cbb'] | |
| DETset = ['Neu','Nes','Nep','Neqa'] | |
| POSTset= ['Cab','Cba','Neqb','Ng'] | |
| Tset = ['I','T'] | |
| Aset = ['A'] | |
| # ── 研究情境定義 ───────────────────────────────────────────────────────────── | |
| SCENARIOS = { | |
| "1": { | |
| "title": "人工翻譯 vs 機器翻譯", | |
| "short": "人機對比", | |
| "upload_prompt": ( | |
| "1. 檔案格式:僅接受 **.txt** 純文字檔。\n" | |
| "2. 檔名規範:請以 **A_xx.txt**、**B_xx.txt** 等格式區分人工翻譯與機器翻譯。\n" | |
| "3. 斷詞要求:必須先使用 [語料淬鍊](https://github.com/minchun-git/I_CorpusRefinery) 工具完成斷詞。\n" | |
| "4. 內容格式:僅限 `詞_詞性` 或空白行。" | |
| ), | |
| "desc": "焦點在人機差異的語言特徵。", | |
| "scenario_module_key": "scenario_2", | |
| }, | |
| "2": { | |
| "title": "全譯 vs 編譯(節譯、改寫)", | |
| "short": "全譯vs編譯", | |
| "upload_prompt": ( | |
| "1. 檔案格式:僅接受 **.txt** 純文字檔。\n" | |
| "2. 檔名規範:請以 **A_xx.txt**、**B_xx.txt** 等格式區分全譯與編譯。\n" | |
| "3. 斷詞要求:必須先使用 [語料淬鍊](https://github.com/minchun-git/I_CorpusRefinery) 工具完成斷詞。\n" | |
| "4. 內容格式:僅限 `詞_詞性` 或空白行。" | |
| ), | |
| "desc": "焦點在文本完整性和改寫策略。", | |
| "scenario_module_key": "scenario_3", | |
| }, | |
| "3": { | |
| "title": "不同譯者的同期譯本", | |
| "short": "譯者風格", | |
| "upload_prompt": ( | |
| "1. 檔案格式:僅接受 **.txt** 純文字檔。\n" | |
| "2. 檔名規範:請以 **A_xx.txt**、**B_xx.txt**、**C_xx.txt** 等格式區分不同群組的譯者。\n" | |
| "3. 斷詞要求:必須先使用 [語料淬鍊](https://github.com/minchun-git/I_CorpusRefinery) 工具完成斷詞。\n" | |
| "4. 內容格式:僅限 `詞_詞性` 或空白行。" | |
| ), | |
| "desc": "焦點在個人風格差異。排除時代因素,討論譯者個人的語言偏好。", | |
| "scenario_module_key": "scenario_4", | |
| }, | |
| "4": { | |
| "title": "不同時期人工譯本", | |
| "short": "歷時重譯", | |
| "upload_prompt": ( | |
| "1. 檔案格式:僅接受 **.txt** 純文字檔。\n" | |
| "2. 檔名規範:請以 **A_xx.txt**、**B_xx.txt**、**C_xx.txt** 等格式區分不同時期的譯本。\n" | |
| "3. 斷詞要求:必須先使用 [語料淬鍊](https://github.com/minchun-git/I_CorpusRefinery) 工具完成斷詞。\n" | |
| "4. 內容格式:僅限 `詞_詞性` 或空白行。" | |
| ), | |
| "desc": "分析焦點在歷時變化、語言現代化、翻譯規範的演變。時間維度是核心變數。", | |
| "scenario_module_key": "scenario_1", | |
| }, | |
| } | |
| # ── 指標分類(同 prog03)──────────────────────────────────────────────────── | |
| INDEX_CATEGORIES = { | |
| "基礎統計": { | |
| "AA_01": "總字數", "AA_02": "相異字數", "AA_03": "總詞數", | |
| "AA_04": "相異詞數", "AA_05": "段落數", "AA_06": "總句數", | |
| }, | |
| "詞彙難易度": {"AC_01": "非常用詞比率", "AC_02": "難詞比率"}, | |
| "詞彙多樣性": {"AD_02": "MSTTR", "AD_03": "RTTR"}, | |
| "語意複雜度": { | |
| "AG_01": "詞彙密度1", "AG_02": "詞彙密度2", | |
| "AG_05": "每千詞成語數1", "AG_06": "每千詞成語數2", | |
| }, | |
| "語法複雜度": { | |
| "AH_01": "平均句長", "AH_03": "句長標準差", | |
| "AH_06": "平均小句長度", "AH_08": "小句標準差", | |
| }, | |
| "篇章連貫": { | |
| "AJ_01": "對等關聯連接詞密度", | |
| "AJ_02": "連接詞總密度", | |
| "AJ_03": "從屬連接詞密度", | |
| "AJ_04": "指示代詞密度", | |
| "AJ_05": "一般代名詞密度", | |
| "AJ_06": "指示照應代詞密度", | |
| "AJ_07": "第一人稱代詞密度", | |
| "AJ_08": "第二人稱代詞密度", | |
| "AJ_09": "第三人稱代詞密度", | |
| "AJ_10": "泛指與反身代詞密度", | |
| "AJ_11": "泛指與反身代詞密度2", | |
| }, | |
| "詞性標記": { | |
| "AF_01": "A非謂形容詞", "AF_02": "Caa對等連接詞", "AF_03": "Cab連接詞", | |
| "AF_04": "Cba連接詞", "AF_05": "Cbb關聯連接詞", "AF_06": "D副詞", | |
| "AF_07": "Da數量副詞", "AF_08": "DE的之得地", | |
| "AF_09": "Dfa動詞前程度副詞", "AF_10": "Dfb動詞後程度副詞", | |
| "AF_11": "Di時態標記", "AF_12": "Dk句副詞", "AF_14": "I感嘆詞", | |
| "AF_15": "Na普通名詞", "AF_16": "Nb專有名詞", "AF_17": "Nc地方詞", | |
| "AF_18": "Ncd位置詞", "AF_19": "Nd時間詞", "AF_20": "Nep指代定詞", | |
| "AF_21": "Neqa數量定詞", "AF_22": "Neqb後置數量定詞", | |
| "AF_23": "Nes特指定詞", "AF_24": "Neu數詞定詞", "AF_25": "Nf量詞", | |
| "AF_26": "Ng後置詞", "AF_27": "Nh代名詞", "AF_28": "Nv名物化動詞", | |
| "AF_29": "P介詞", "AF_30": "SHI是", "AF_31": "T語助詞", | |
| "AF_32": "V2有", "AF_33": "VA動作不及物動詞", "AF_34": "VAC動作使動動詞", | |
| "AF_35": "VB動作類及物動詞", "AF_36": "VC動作及物動詞", | |
| "AF_37": "VCL動作接地方賓語動詞", "AF_38": "VD雙賓動詞", | |
| "AF_39": "VE動作句賓動詞", "AF_40": "VF動作謂賓動詞", | |
| "AF_41": "VG分類動詞", "AF_42": "VH狀態不及物動詞", | |
| "AF_43": "VHC狀態使動動詞", "AF_44": "VI狀態類及物動詞", | |
| "AF_45": "VJ狀態及物動詞", "AF_46": "VK狀態句賓動詞", | |
| "AF_47": "VL狀態謂賓動詞", | |
| }, | |
| } | |
| INDEX_NAME_MAP = {} | |
| INDEX_CATEGORY_MAP = {} | |
| for _cat, _items in INDEX_CATEGORIES.items(): | |
| for _code, _name in _items.items(): | |
| INDEX_NAME_MAP[_code] = _name | |
| INDEX_CATEGORY_MAP[_code] = _cat | |
| # 2026-08-14(業主要求):相異字數(AA_02)+相異詞數(AA_04)+篇章連貫 | |
| # 全類(AJ_01~AJ_11,共 11 項),合計 13 項暫時整理掉,不刪除計算本身, | |
| # 只在「結果呈現」的地方隱藏——業主拍板範圍是全面隱藏:敘述統計、差異性 | |
| # 分析、隨機森林特徵重要性、分群分析都不再拿這 13 項當特徵,LLM 報告也 | |
| # 不會提到;語言指標分析分頁的表格畫面同理濾掉。 | |
| # textIndex_calculated.csv/.xlsx(指標計算原始輸出檔+其下載)刻意不動, | |
| # 仍保留完整 75 項,理由是業主明確說「無須刪除」——這兩個檔案是計算結果的 | |
| # 原始存檔,不算「結果呈現」,之後要復原顯示只要拿掉這個集合的套用即可, | |
| # 不用重跑指標計算。 | |
| HIDDEN_INDICATOR_CODES = {"AA_02", "AA_04"} | {f"AJ_{i:02d}" for i in range(1, 12)} | |
| def _drop_hidden_indicators(df): | |
| """回傳去掉 HIDDEN_INDICATOR_CODES 欄位後的 DataFrame(欄名為原始指標代碼)。""" | |
| cols = [c for c in HIDDEN_INDICATOR_CODES if c in df.columns] | |
| return df.drop(columns=cols) if cols else df | |
| # 2026-08-14:介面上凡是提到「系統共計算幾項語言指標」的地方,一律引用這個 | |
| # 常數,不要各自寫死數字——INDEX_NAME_MAP 是系統定義的指標總表(目前 75), | |
| # 扣掉 HIDDEN_INDICATOR_CODES 就是使用者實際會看到的指標數(目前 62)。 | |
| # 之後隱藏清單增減,這裡跟著自動變動,不用每次巡一遍程式碼改字面數字。 | |
| TOTAL_VISIBLE_INDICATORS = len(INDEX_NAME_MAP) - len(HIDDEN_INDICATOR_CODES) | |
| # 2026-08-24(業主提供 system_prompts_v1.3_0529.md 設計稿,要求補回原始設計 | |
| # 裡有、但現行實作缺少的內容):v1.3 在 system prompt 裡內嵌了一份完整的 | |
| # 指標代碼對照表,讓 LLM 有明確依據判斷每個代碼的意思,並規定「未列於 | |
| # 對照表的代碼視為未定義指標」(見嚴格禁令第 10 條)。這裡不手動謄寫一份 | |
| # 靜態文字(那樣容易跟 INDEX_CATEGORIES 各自走鐘、兩份對不起來),改成 | |
| # 直接從 INDEX_CATEGORIES 動態產生,且比照 HIDDEN_INDICATOR_CODES 的規則 | |
| # 排除隱藏指標——LLM 實際收到的 JSON 資料本來就已經濾掉這 13 項,對照表 | |
| # 若照樣列出它們,LLM 反而會誤以為資料裡漏了這些代碼、去猜測或杜撰。 | |
| def _build_indicator_reference_table() -> str: | |
| lines = [] | |
| for cat, items in INDEX_CATEGORIES.items(): | |
| visible = {c: n for c, n in items.items() if c not in HIDDEN_INDICATOR_CODES} | |
| if not visible: | |
| continue | |
| lines.append(f"## {cat}") | |
| for code, name in visible.items(): | |
| lines.append(f"- {code} {name}") | |
| return "\n".join(lines) | |
| INDICATOR_REFERENCE_TABLE = _build_indicator_reference_table() | |
| # ── LLM 報告用情境模組(同 prog04)───────────────────────────────────────── | |
| SCENARIO_MODULE_1 = """## 三、情境特定分析:歷時變化分析 | |
| 在陳述差異時,必須將「時間順序」作為組織軸線。 | |
| 請依照以下子項目分析: | |
| ### 3.1 語言現代化的跡象 | |
| - 詞彙難易度(AC_01, AC_02)是否隨時間呈現規律變化? | |
| - 成語比率(AG_06)在不同時期譯本中的變化方向? | |
| - 若出現非單調變化,明確標示此為反常模式,需進一步解釋。 | |
| ### 3.2 句式與篇章結構的演變 | |
| - 段落數、句數(AA_05, AA_06)的變化是否反映閱讀習慣的改變? | |
| - 詞性分布中,連接詞、代詞比例的變化是否呈現顯著化或隱化趨勢? | |
| ### 3.3 譯本間的親疏關係 | |
| - 根據分群結果,時序相鄰的譯本是否聚為一群? | |
| - 若出現時序相隔較遠的譯本反而更相似的情況,標記為需進一步探討的現象。 | |
| ### 禁令補充 | |
| - 不得將「較新」直接等同於「較現代」或「較好」。""" | |
| SCENARIO_MODULE_2 = """## 三、情境特定分析:人機差異分析 | |
| ### 3.1 機器翻譯典型特徵的檢視 | |
| 根據量化數據檢視機器翻譯是否呈現以下已知特徵: | |
| - 詞彙多樣性是否偏低?(MSTTR, RTTR) | |
| - 句長是否較為整齊(變異數較小)? | |
| ### 3.2 人工翻譯的差異化特徵 | |
| - 人工譯本在哪些指標上顯著偏離機器譯本? | |
| ### 禁令補充 | |
| - 不得預設「人工一定優於機器」或反之。""" | |
| SCENARIO_MODULE_3 = """## 三、情境特定分析:改寫策略分析 | |
| 【重要限制】:本系統目前未進行原文對齊,所有關於刪減/增補的陳述必須明確標示為「推測」。 | |
| ### 3.1 規模差異的量化 | |
| - 總字數、總詞數(AA_01, AA_03)的差距幅度 | |
| ### 3.2 改寫策略的語言訊號 | |
| - 編譯本的詞彙密度是否反而較高?(可能反映資訊壓縮策略) | |
| - 編譯本的平均句長是否較短?(可能反映簡化策略) | |
| ### 禁令補充 | |
| - 嚴禁使用「忠實度」、「完整性」等規範性詞彙。""" | |
| SCENARIO_MODULE_4 = """## 三、情境特定分析:個人風格差異分析 | |
| 【前提】:所有譯本產出於相近時期,時代因素已被控制。 | |
| ### 3.1 核心風格指標的差異 | |
| - 在哪些指標上,譯者間差異達到統計顯著? | |
| ### 3.2 風格穩定性的檢視 | |
| - 譯者內變異 vs 譯者間變異的比較""" | |
| SCENARIO_MODULES = { | |
| "scenario_1": SCENARIO_MODULE_1, | |
| "scenario_2": SCENARIO_MODULE_2, | |
| "scenario_3": SCENARIO_MODULE_3, | |
| "scenario_4": SCENARIO_MODULE_4, | |
| } | |
| SYSTEM_PROMPT_BASE = """# 角色定義 | |
| 你是一位翻譯研究的量化分析助理。你的工作是將統計數據轉化為結構化的觀察與假設,供研究者進一步驗證。 | |
| 你的角色邊界: | |
| - 你是數據的描述者與模式的辨識者,不是結論的下定者。 | |
| - 你產出的是「初步假設」,不是「研究發現」。 | |
| - 所有假設都需要研究者基於專業知識進行最終的詮釋、驗證與修正。 | |
| # 嚴格禁令 | |
| 以下禁令為絕對規則,違反任一項即為失敗輸出: | |
| 1. 僅根據本次提供的量化數據進行分析。不得引入外部知識,不得引用任何關於具體譯本、譯者、作品的已有評價、學術討論或文學史定位。 | |
| 2. 不得使用以下類型的評價性詞彙描述任何譯本:「優秀」、「忠實」、「準確」、「流暢」、「高品質」、「拙劣」、「粗糙」、「典雅」、「洗鍊」。 | |
| 3. 不得推測譯者的主觀意圖、情感、動機或翻譯理念,除非數據直接支持且標明為推論。 | |
| 4. 不得將個人風格差異與「翻譯能力」或「翻譯素養」掛鉤。語言特徵的差異僅反映選擇的不同,不代表能力的高低。 | |
| 5. 遇到數據中未涵蓋的問題,明確回答「本次數據無法回答此問題」,不得編造或外推。 | |
| 6. 所有關於統計顯著性的陳述必須基於提供的 p 值或效果量。未提供 p 值的指標差異,使用「數值上的差距」而非「顯著差異」來描述;不得自行使用「明顯」、「大幅」、「顯著」等程度副詞,除非有統計依據。 | |
| 7. 不得對翻譯品質做整體評價或排名。 | |
| 8. metadata 處理規則: | |
| a. 語料 metadata 中標示為「(未填)」或完全未提及的欄位:分析時不得對該欄位做任何推論或臆測。 | |
| b. 群組層資訊中,若某一組別完全未出現任何說明(該組別整段未列出):視同該組別背景未知,不得杜撰其翻譯類型、方向或譯者背景。 | |
| c. 個體層資訊中,若某一檔案完全未列出對應說明:視同該檔案背景未知,不得杜撰其譯者、出版年等資訊。 | |
| d. 群組層資訊中所有組別皆未填寫「群組標籤」:在「一、語料說明」中明確標記「群組標籤未提供,以下分析僅基於數值差異,不涉及群組身份的討論」。 | |
| e. 使用者於語料 Metadata 步驟選擇略過:僅陳述「使用者未提供語料補充說明」,不得臆測或杜撰任何背景內容。 | |
| 9. metadata 僅用於框定分析範圍與數據結構關係,不得用於構建敘事性的譯者畫像或歷史背景描述。 | |
| 10. 未定義指標處理規則:若量化分析結果中出現未列於下方「指標代碼對照表」的指標代碼,不得猜測其含義、不得將其納入任何分析步驟,須在「四、待驗證事項」中標記為「未定義指標:{{代碼}}」。 | |
| 11. 跨指標邏輯連結許可:允許連結多個指標的結果進行邏輯推論,但須滿足: | |
| a. 所連結的每個指標均有明確的數據支持(數值或統計檢定結果)。 | |
| b. 連結的邏輯基礎來自下方指標代碼對照表中的定義關係,不得憑空聯想。 | |
| c. 必須在陳述中明確標示「此為跨指標推論」,以區分直接的數據觀察。 | |
| d. 不得基於外部知識進行跨指標連結。 | |
| # 指標代碼對照表 | |
| 以下對照表僅供符號辨識用,不代表任何詮釋框架。未列於此對照表的指標代碼依嚴格禁令第 10 條處理。 | |
| {indicator_reference_table} | |
| # 輸入資料結構 | |
| 【研究情境】:{scenario_type} | |
| 【譯本 metadata】:{metadata} | |
| 【量化分析結果】:(見 user message 中的 JSON 資料) | |
| 【使用者補充關注點】:{user_focus} | |
| # 格式規範(務必嚴格遵守,直接影響報告的可讀性) | |
| 1. 以下「通用輸出結構」中 `## 一、`、`## 二、`、`## 四、`、`## 五、` 這幾個章節標題,輸出時必須原樣保留 `## ` 這個 Markdown 標題語法開頭,不可改寫成「**粗體**」或其他格式。 | |
| 2. 章節內若需要子標題或小節提示語(例如「總體事實」「觀察」「結論」之類的段落標籤),一律使用 `#### ` 開頭的四級標題語法,不可用「**粗體**」取代標題。 | |
| 3. `**粗體**` 只能用於一般敘述中的強調字詞,不可整行只放一個粗體片語來充當標題。 | |
| 4. 表格請使用標準 Markdown 表格語法(`| 欄位 | 欄位 |` 搭配 `|---|---|` 分隔列)。 | |
| 5. 量化分析結果 JSON 裡的英文鍵名(例如 rf_accuracy、rf_accuracy_is_meaningful、HC_Cluster、 | |
| KM_Cluster 等)是給程式讀取用的變數名稱,敘述時一律換成中文說法(例如「隨機森林分類準確率」 | |
| 「此準確率是否具備樣本外預測意義」「階層式分群」「K-means 分群」),不得原樣把英文鍵名寫進 | |
| 報告內文;指標代碼(如 AF_36、AC_01 這類)例外,這些代碼本身就是報告的一部分,要保留。 | |
| # 通用輸出結構 | |
| 輸出必須包含以下五個區塊,缺一不可: | |
| ## 一、語料說明 | |
| 用 2-4 句話摘要上方【譯本 metadata】提供的原文資訊、群組層與個體層背景(例如原文標題、各組別的翻譯類型/方向、個別譯本的譯者或系統名稱等,視實際提供的內容而定)。 | |
| 若 metadata 顯示使用者略過未填,僅陳述「使用者未提供語料補充說明」,不得臆測或杜撰內容。這個區塊只做摘要陳述,不做任何統計分析或推論。 | |
| 【metadata 一致性檢查】摘要時若發現群組層與個體層資訊彼此矛盾(例如某組別標示為「人工翻譯」,但該組底下某檔案的譯者/系統名稱明顯是 AI 模型或翻譯系統名稱;或研究情境選的是歷時比較,但個體層完全沒有任何出版年資訊),須在本區塊結尾明確標記該矛盾之處,供使用者確認,**不得自行判斷何者為準、不得擅自修正或忽略其中一方**。若沒有發現矛盾,不需要特別聲明「一致性檢查通過」,維持原本的摘要陳述即可。 | |
| ## 二、數據事實描述 | |
| 本區塊須包含以下三類數據事實,缺一不可(量化分析結果 JSON 裡都有對應資料,不得因為篇幅省略): | |
| 1. 差異性分析:每項陳述除了指標代碼、p 值與效果量之外,**必須明確指出各組別中平均數偏高與 | |
| 偏低的是哪一組,並附上實際平均數數值**(difference_analysis 資料裡每個組別都有一欄以該組 | |
| 名稱結尾 _M 的平均數欄位)——只寫 p 值與效果量、不寫是哪一組偏高偏低,讀者無法理解差異的 | |
| 方向與意義。 | |
| 三組以上時是用 ANOVA 檢定,效果量(η²)本身不帶方向性(不像兩組時的 Cohen's d 還有正負號 | |
| 可看出方向),更不能省略指出方向的那句話。 | |
| 【用詞規則,務必遵守】JSON 裡的 groups/difference_analysis 只會有 2 或 3 個以上的組別, | |
| 依組別數量決定用詞,不可混用: | |
| - **恰好兩組**:只能用「較高」「較低」,不可以用「最高」「最低」——只有兩個東西比較時, | |
| 沒有「最」這種在三者以上才成立的最高級語意,用「最高」「最低」邏輯上是錯的。 | |
| 範例:「總字數(AA_01):p=0.0461,Cohen's d=-1.5621(大);機器翻譯較高(M=60537.5), | |
| 人工翻譯較低(M=54313.6)。」 | |
| - **三組以上**:用「最高」「最低」(是在多個組別中挑出最大最小值,最高級語意才成立)。 | |
| 範例:「總句數(AA_06):p=0.0068,η²=0.9932(大);2024 年組別最高(M=32.5), | |
| 1989 年組別最低(M=18.2)。」 | |
| 區分:達統計顯著的差異(標明 p 值、效果量、方向與平均數)、未達顯著但數值差距較大的趨勢 | |
| (同樣要指出方向)、無明顯差異的指標。 | |
| 【資料完整性】若 JSON 附有「資料完整性提醒」欄位(表示部分指標因組別樣本數不足、根本無法 | |
| 進行統計檢定),這些指標一律只能陳述為「資料不足,無法檢定」,絕對不可以寫成「無顯著差異」, | |
| 也不可以把它們計入「達統計顯著」或「無明顯差異」的統計比例——這是兩種完全不同的結論,混為 | |
| 一談會誤導讀者以為系統已經檢定過、確認沒有差異。 | |
| 【效果量無法計算】若某指標的「效果大小」欄位顯示「效果量不穩定(組內變異趨近於零)」、 | |
| 「效果量」欄位為 null,代表 p 值仍然有效、該指標**確實達統計顯著**(顯著性判斷正常參與統計 | |
| 比例),只是組內變異趨近於零,Cohen's d/η² 這個公式本身除不出有意義的數字。陳述時正常 | |
| 引用 p 值與各組平均數(依上面【用詞規則】,兩組寫「較高/較低」、三組以上寫「最高/最低」), | |
| 效果量部分改寫成「效果量因組內變異趨近於零而無法計算」,不得省略、也不得自己編造一個數字 | |
| 或寫成「效果量極大」(那是失真的浮點數,不是真的統計結論)。這跟【資料完整性】的「資料 | |
| 不足」是兩種不同情況,不要混為一談:後者連檢定都做不了,前者是檢定做了、結果確實顯著, | |
| 只是效果量這個數字算不出來。 | |
| 【資料穩定性提醒】若 JSON 附有「資料穩定性提醒」欄位(表示部分指標至少有一組出現「樣本 | |
| 標準差趨近於 0」或「半數以上樣本值為 0」之一),這些指標的 p 值與效果量正常計算、正常算入 | |
| 「達統計顯著」的統計比例,**不要**排除或改標成「資料不足」;但陳述時必須額外加一句保留 | |
| 語氣,例如「此結果統計穩定性有限,建議謹慎解讀」,不可用跟其他變異正常的顯著指標完全相同 | |
| 的肯定語氣呈現,避免讀者把統計上脆弱的單組結果誤判為跟其他證據同等可信。 | |
| 2. 監督式學習(隨機森林):說明 feature_importance_top20 裡重要性最高的 3-5 項指標與其重要性數值。 | |
| 同時陳述「隨機森林分類準確率」的數字,並原樣附上 JSON 裡「隨機森林準確率之樣本外預測意義」 | |
| 這欄給的完整句子(那句話已經是可以直接使用的中文敘述,不必再改寫或省略)。 | |
| 3. 非監督式學習(分群):說明 cluster_results 裡階層式分群(HC_Cluster)與 K-means 分群 | |
| (KM_Cluster)的分群結果,是否與原始分組一致,若不一致明確指出差異所在。提及「原始分組」 | |
| 時只用這個中文說法即可,不要在後面加註英文欄名。 | |
| {scenario_module} | |
| ## 四、待驗證事項 | |
| - 需研究者回到原文比對的具體項目 | |
| - 數據中的異常值或可疑模式 | |
| - 本分析無法處理的面向 | |
| ## 五、本次分析的已知限制 | |
| 明確列出:分詞系統潛在誤差、樣本量對統計檢定的影響、指標本身的理論侷限。""" | |
| # ── matplotlib 中文字型 ────────────────────────────────────────────────────── | |
| def _find_cjk_font(): | |
| candidates = ["Microsoft JhengHei","Microsoft YaHei","SimHei", | |
| "Noto Sans CJK TC","Noto Sans CJK SC"] | |
| available = {f.name for f in fm.fontManager.ttflist} | |
| for c in candidates: | |
| if c in available: | |
| return c | |
| return None | |
| _cjk = _find_cjk_font() | |
| if _cjk: | |
| plt.rcParams['font.family'] = _cjk | |
| plt.rcParams['axes.unicode_minus'] = False | |
| # ============================================================================ | |
| # SECTION 2: Resource Loading (load once at startup) | |
| # ============================================================================ | |
| _RESOURCES = {} | |
| def _load_resources(): | |
| global _RESOURCES | |
| print("【系統啟動】載入參考語料資源...") | |
| def _read(fname): | |
| p = CORPUS_INFO / fname | |
| if not p.exists(): | |
| print(f" 警告:找不到 {fname}") | |
| return [] | |
| with open(p, 'r', encoding='utf-8') as f: | |
| return [l.replace('\n','') for l in f.readlines()] | |
| _RESOURCES['T01'] = _read('T01_成語5335.txt') | |
| _RESOURCES['T01_B'] = _read('T01_成語20389.txt') | |
| _RESOURCES['T02'] = _read('T02_國小常用詞.txt') | |
| _RESOURCES['T03'] = _read('T03_國小常用詞3000.txt') | |
| _RESOURCES['T04'] = _read('T04_現代漢語3000.txt') | |
| _RESOURCES['T05'] = _read('T05_現代漢語8000.txt') | |
| _RESOURCES['T06_V500'] = _read('T06_Sinica高頻動詞前500.txt') | |
| _RESOURCES['T06_N3000'] = _read('T06_Sinica高頻詞前3000詞_常用名詞.txt') | |
| _RESOURCES['T06_A3000'] = _read('T06_Sinica高頻詞前3000詞_常用形容詞.txt') | |
| _RESOURCES['T06_D3000'] = _read('T06_Sinica高頻詞前3000詞_常用副詞.txt') | |
| _RESOURCES['T06_N8000'] = _read('T06_Sinica高頻詞前8000詞_常用名詞.txt') | |
| _RESOURCES['T06_A8000'] = _read('T06_Sinica高頻詞前8000詞_常用形容詞.txt') | |
| _RESOURCES['T06_D8000'] = _read('T06_Sinica高頻詞前8000詞_常用副詞.txt') | |
| strokes_path = CORPUS_INFO / 'strokesDictionary.json' | |
| if strokes_path.exists(): | |
| with open(strokes_path, 'r', encoding='utf-8') as f: | |
| _RESOURCES['strokes'] = json.load(f) | |
| else: | |
| _RESOURCES['strokes'] = {} | |
| print(" 警告:找不到 strokesDictionary.json") | |
| print("【系統啟動】資源載入完成。") | |
| _load_resources() | |
| # ============================================================================ | |
| # SECTION 3: LLM API Wrappers | |
| # ============================================================================ | |
| def _call_openrouter(api_key, model, system_prompt, user_prompt, max_tokens=16000, temperature=0): | |
| """通用 OpenRouter API 呼叫(同步)。 | |
| 2026-08-22(可重複性設計):temperature 預設從 0.5 改成 0,降低同一組輸入 | |
| 在不同次呼叫間的用字差異。注意這不代表輸出保證逐字相同——OpenRouter | |
| 後端本身的浮點運算、批次處理等因素仍可能造成極小差異,temperature=0 | |
| 只是把「取樣造成的隨機性」這個變因降到最低,不是完全消除所有變因。 | |
| """ | |
| url = "https://openrouter.ai/api/v1/chat/completions" | |
| headers = { | |
| "Authorization": f"Bearer {api_key}", | |
| "Content-Type": "application/json", | |
| "HTTP-Referer": "https://retranslation-agent.local", | |
| "X-Title": "Retranslation Analysis Agent", | |
| } | |
| payload = { | |
| "model": model, | |
| "messages": [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ], | |
| "max_tokens": max_tokens, | |
| "temperature": temperature, | |
| } | |
| data = json.dumps(payload).encode("utf-8") | |
| req = urllib.request.Request(url, data=data, headers=headers, method="POST") | |
| try: | |
| with urllib.request.urlopen(req, timeout=180) as resp: | |
| result = json.loads(resp.read().decode("utf-8")) | |
| return result["choices"][0]["message"]["content"] | |
| except urllib.error.HTTPError as e: | |
| body = e.read().decode("utf-8") if e.fp else "" | |
| raise RuntimeError(f"OpenRouter API HTTP 錯誤 ({e.code}): {body}") | |
| except Exception as e: | |
| raise RuntimeError(f"OpenRouter API 呼叫失敗: {e}") | |
| def call_main_agent(system_prompt, user_prompt): | |
| """主 Agent (LLM1) 呼叫。""" | |
| return _call_openrouter(LLM1_API_KEY, LLM1_MODEL, system_prompt, user_prompt) | |
| def call_worker_agent(system_prompt, user_prompt): | |
| """工作程序 Agent (LLM2) 呼叫。""" | |
| return _call_openrouter(LLM2_API_KEY, LLM2_MODEL, system_prompt, user_prompt) | |
| # ============================================================================ | |
| # SECTION 4: Text Format Validation | |
| # ============================================================================ | |
| def validate_ckip_format(filepath: Path): | |
| """ | |
| 檢查檔案是否為 ckip_segmentation_sumin 工具產生的斷詞格式。 | |
| 判斷依據: | |
| 1. 非空行中,含底線(_)的 token 佔比 > 70% | |
| 2. 至少出現一個 CKIP 特徵標記(CATEGORY 系列或標準詞性碼) | |
| 返回 (is_valid: bool, reason: str) | |
| """ | |
| CKIP_SIGNATURES = re.compile( | |
| r'(PERIODCATEGORY|COMMACATEGORY|PAUSECATEGORY|SEMICOLONCATEGORY|' | |
| r'QUESTIONCATEGORY|EXCLAMATIONCATEGORY|PARENTHESISCATEGORY|' | |
| r'DASHCATEGORY|ETCCATEGORY|COLONCATEGORY|SPCHANGECATEGORY|' | |
| r'\b(?:Na|Nb|Nc|Ncd|Nd|Nf|Nh|Nv|VA|VB|VC|VD|VE|VF|VG|VH|VHC|' | |
| r'VI|VJ|VK|VL|VAC|VCL|DE|Di|SHI|FW|Caa|Cab|Cba|Cbb|' | |
| r'Neu|Nes|Nep|Neqa|Neqb|Ng|P|D|Da|Dfa|Dfb|Dk|I|T|A)\b)' | |
| ) | |
| try: | |
| with open(filepath, 'r', encoding='utf-8-sig') as f: | |
| lines = f.readlines() | |
| except Exception as e: | |
| return False, f"無法讀取檔案:{e}" | |
| total_tokens = 0 | |
| underscore_tokens = 0 | |
| has_ckip_signature = False | |
| sample_lines = [l.rstrip() for l in lines if l.strip()][:100] | |
| if not sample_lines: | |
| return False, "檔案為空。" | |
| for line in sample_lines: | |
| tokens = line.split() | |
| for tok in tokens: | |
| total_tokens += 1 | |
| if '_' in tok: | |
| underscore_tokens += 1 | |
| if not has_ckip_signature and CKIP_SIGNATURES.search(tok): | |
| has_ckip_signature = True | |
| if total_tokens == 0: | |
| return False, "檔案無有效內容。" | |
| ratio = underscore_tokens / total_tokens | |
| if ratio < 0.70: | |
| return False, ( | |
| f"token 中含底線(詞_詞性)的比例僅 {ratio:.0%}," | |
| "不符合語料淬鍊(CorpusRefinery)的輸出格式。" | |
| ) | |
| if not has_ckip_signature: | |
| return False, "未偵測到 CKIP 詞性標記,可能非語料淬鍊(CorpusRefinery)產生的檔案。" | |
| return True, "格式符合語料淬鍊(CorpusRefinery)輸出。" | |
| # ============================================================================ | |
| # SECTION 5: Indicator Calculation (adapted from Prog02) | |
| # ============================================================================ | |
| def _text2strokes(tokens, strokes_db): | |
| words, strokes, missing = [], [], [] | |
| for tok in tokens: | |
| for char in tok: | |
| if char in strokes_db: | |
| words.append(char) | |
| strokes.append(strokes_db[char]['strokes_total']) | |
| else: | |
| missing.append(char) | |
| return words, strokes, missing | |
| def calculate_text_index(corpus_file_path: Path, resources: dict) -> dict: | |
| """ | |
| 計算單一文本的所有語言學指標。 | |
| 改寫自 Prog02_indicators_multi-v3_1150509.py 的 calculateTextIndex()。 | |
| """ | |
| fname = corpus_file_path.name | |
| with open(corpus_file_path, 'r', encoding='utf-8-sig') as xf: | |
| text0 = xf.read() | |
| text0 = text0.replace('', '') | |
| text1 = text0.split('\n') | |
| text2 = [xx.split() for xx in text1] | |
| text3_1, text3_2 = [], [] | |
| for i, row in enumerate(text2): | |
| t1, t2 = [], [] | |
| for xxi in row: | |
| parts = xxi.split('_') | |
| t1.append(parts[0]) | |
| t2.append(parts[1] if len(parts) > 1 else '') | |
| text3_1.append(t1) | |
| text3_2.append(t2) | |
| # ── 標點 / 句子統計 ─────────────────────────────────────────────────────── | |
| com1 = sum(row.count("COMMACATEGORY") for row in text3_2) | |
| ss1 = sum(row.count("PERIODCATEGORY") for row in text3_2) | |
| ss2 = sum(row.count("SEMICOLONCATEGORY") for row in text3_2) | |
| ss3 = sum(row.count("QUESTIONCATEGORY") for row in text3_2) | |
| ss4 = sum(row.count("EXCLAMATIONCATEGORY")for row in text3_2) | |
| sentenceTotal = ss1 + ss2 + ss3 + ss4 | |
| if sentenceTotal == 0: | |
| sentenceTotal = 1 # 防止除以零 | |
| A_index1 = com1 / sentenceTotal | |
| # ── 展開成一維 ───────────────────────────────────────────────────────────── | |
| text3_1_long = [t for row in text3_1 for t in row] | |
| text3_2_long = [t for row in text3_2 for t in row] | |
| sen_pos_set = ["PERIODCATEGORY","SEMICOLONCATEGORY","QUESTIONCATEGORY","EXCLAMATIONCATEGORY"] | |
| sec_pos_set = sen_pos_set + ["COMMACATEGORY","COLONCATEGORY","DASHCATEGORY"] | |
| def _calc_lengths(boundary_set): | |
| lengths, xlen = [], 0 | |
| for sss in text3_2_long: | |
| if sss in boundary_set: | |
| lengths.append(xlen); xlen = 0 | |
| elif 'CATEGORY' not in sss: | |
| xlen += 1 | |
| return lengths | |
| sen_length = _calc_lengths(sen_pos_set) | |
| sec_length = _calc_lengths(sec_pos_set) | |
| if not sen_length: sen_length = [0] | |
| if not sec_length: sec_length = [0] | |
| mean_sentenceLength = np.mean(sen_length) | |
| var_sentenceLength = np.var(sen_length) | |
| mean_sectionLength = np.mean(sec_length) | |
| var_sectionLength = np.var(sec_length) | |
| sd_sectionLength = np.sqrt(var_sectionLength) | |
| # ── 標點符號總數 ─────────────────────────────────────────────────────────── | |
| categoryTotal = sum(1 for t in text3_2_long if 'CATEGORY' in t) | |
| # ── 詞 / 詞性統計 ─────────────────────────────────────────────────────────── | |
| tokens, tokens_pos, TokenAddPos, uniTokens, uni_TokenAddPos = [], [], [], [], [] | |
| for ii, row in enumerate(text3_1): | |
| for jj in range(len(row)): | |
| if 'CATEGORY' not in text3_2[ii][jj]: | |
| w = text3_1[ii][jj] | |
| p = text3_2[ii][jj] | |
| tokens.append(w) | |
| tokens_pos.append(p) | |
| TokenAddPos.append(f"{w}-{p}") | |
| if w not in uniTokens: uniTokens.append(w) | |
| if f"{w}-{p}" not in uni_TokenAddPos: uni_TokenAddPos.append(f"{w}-{p}") | |
| tokensTotal = len(tokens) | |
| uniTokensTotal = len(uni_TokenAddPos) | |
| if tokensTotal == 0: tokensTotal = 1 | |
| wordTotal = sum(len(w) for w in tokens) | |
| words_0 = ''.join(tokens) | |
| uni_words = set(words_0) | |
| uni_words_len = len(uni_words) | |
| # 詞長 | |
| tokens_len = [len(w) for w in tokens] | |
| mean_tokens_len = np.mean(tokens_len) if tokens_len else 0 | |
| # 段落數 | |
| lineTotal = sum(1 for row in text3_1 if row) | |
| # 基礎比值 | |
| A_index2 = wordTotal / sentenceTotal | |
| A_index3 = tokensTotal / sentenceTotal | |
| A_index4 = categoryTotal / sentenceTotal | |
| A_index5 = wordTotal / max(categoryTotal, 1) | |
| A_index6 = tokensTotal / max(categoryTotal, 1) | |
| A_index7 = wordTotal / max(lineTotal, 1) | |
| A_index8 = tokensTotal / max(lineTotal, 1) | |
| # ── 詞頻比對 ────────────────────────────────────────────────────────────── | |
| R = resources | |
| elementaryTotal = sum(1 for w in tokens if w in R.get('T02', [])) | |
| elementaryTotal3 = sum(1 for w in tokens if w in R.get('T03', [])) | |
| elementaryTotal4 = sum(1 for w in tokens if w in R.get('T04', [])) | |
| elementaryTotal5 = sum(1 for w in tokens if w in R.get('T05', [])) | |
| xx = Counter(tokens) | |
| idiomTotal = sum(v for k,v in xx.items() if k in R.get('T01', [])) | |
| idiomTotal2 = sum(v for k,v in xx.items() if k in R.get('T01_B',[])) | |
| tokens_V = [tokens[i] for i in range(len(tokens)) if tokens_pos[i] in Vset] | |
| tokens_N = [tokens[i] for i in range(len(tokens)) if tokens_pos[i] in Nset] | |
| tokens_A = [tokens[i] for i in range(len(tokens)) if tokens_pos[i] in Aset] or ['XX'] | |
| tokens_ADV = [tokens[i] for i in range(len(tokens)) if tokens_pos[i] in ADVset] | |
| VelementaryTotal500 = sum(1 for w in tokens_V if w in R.get('T06_V500', [])) | |
| NelementaryTotal3000 = sum(1 for w in tokens_N if w in R.get('T06_N3000',[])) | |
| AelementaryTotal3000 = sum(1 for w in tokens_A if w in R.get('T06_A3000',[])) | |
| ADVelementaryTotal3000= sum(1 for w in tokens_ADV if w in R.get('T06_D3000',[])) | |
| NelementaryTotal8000 = sum(1 for w in tokens_N if w in R.get('T06_N8000',[])) | |
| AelementaryTotal8000 = sum(1 for w in tokens_A if w in R.get('T06_A8000',[])) | |
| ADVelementaryTotal8000= sum(1 for w in tokens_ADV if w in R.get('T06_D8000',[])) | |
| # ── 詞性計數 ───────────────────────────────────────────────────────────── | |
| pos_A_fre = tokens_pos.count("A") | |
| pos_V_fre = sum(1 for p in tokens_pos if p in Vset) | |
| pos_N_fre = sum(1 for p in tokens_pos if p in Nset) | |
| pos_Nh_fre2 = sum(1 for p in tokens_pos if p in Nhset) | |
| pos_ADV_fre = sum(1 for p in tokens_pos if p in ADVset) | |
| pos_ASP_fre = tokens_pos.count("Di") | |
| pos_C_fre = sum(1 for p in tokens_pos if p in Cset) | |
| pos_Ca_fre = sum(1 for p in tokens_pos if p in Caset) | |
| pos_Cb_fre = sum(1 for p in tokens_pos if p in Cbset) | |
| pos_DET_fre = sum(1 for p in tokens_pos if p in DETset) | |
| pos_P_fre = tokens_pos.count("P") | |
| pos_POST_fre = sum(1 for p in tokens_pos if p in POSTset) | |
| pos_T_fre = sum(1 for p in tokens_pos if p in Tset) | |
| pos_DE_fre = tokens_pos.count("DE") | |
| pos_SHI_fre = tokens_pos.count("SHI") | |
| pos_V2_fre = tokens_pos.count("V2") | |
| # 個別詞性 | |
| def _pc(p): return tokens_pos.count(p) | |
| pos_Caa_fre = _pc("Caa"); pos_Cab_fre = _pc("Cab") | |
| pos_Cba_fre = _pc("Cba"); pos_Cbb_fre = _pc("Cbb") | |
| pos_D_fre = _pc("D"); pos_Da_fre = _pc("Da") | |
| pos_Dfa_fre = _pc("Dfa"); pos_Dfb_fre = _pc("Dfb") | |
| pos_Di_fre = _pc("Di"); pos_Dk_fre = _pc("Dk") | |
| pos_I_fre = _pc("I"); pos_Na_fre = _pc("Na") | |
| pos_Nb_fre = _pc("Nb"); pos_Nc_fre = _pc("Nc") | |
| pos_Ncd_fre = _pc("Ncd"); pos_Nd_fre = _pc("Nd") | |
| pos_Nep_fre = _pc("Nep"); pos_Neqa_fre= _pc("Neqa") | |
| pos_Neqb_fre= _pc("Neqb");pos_Nes_fre = _pc("Nes") | |
| pos_Neu_fre = _pc("Neu"); pos_Nf_fre = _pc("Nf") | |
| pos_Ng_fre = _pc("Ng"); pos_Nh_fre = _pc("Nh") | |
| pos_Nv_fre = _pc("Nv") | |
| pos_VA_fre = _pc("VA"); pos_VAC_fre = _pc("VAC") | |
| pos_VB_fre = _pc("VB"); pos_VC_fre = _pc("VC") | |
| pos_VCL_fre = _pc("VCL"); pos_VD_fre = _pc("VD") | |
| pos_VE_fre = _pc("VE"); pos_VF_fre = _pc("VF") | |
| pos_VG_fre = _pc("VG"); pos_VH_fre = _pc("VH") | |
| pos_VHC_fre = _pc("VHC"); pos_VI_fre = _pc("VI") | |
| pos_VJ_fre = _pc("VJ"); pos_VK_fre = _pc("VK") | |
| pos_VL_fre = _pc("VL") | |
| # 內容詞 | |
| cont1 = pos_N_fre + pos_A_fre + pos_V_fre | |
| cont2 = cont1 + pos_ADV_fre | |
| # AJ 指標 | |
| AJ04_set = ['什麼','哪','什','甚麼','啥'] | |
| AJ05_set = ['它','牠','它們','牠們','其','之','前者','後者','兩者','二者', | |
| '以上','以下','誰','誰人','何者','什麼','甚麼','啥'] | |
| AJ06_set = ['它','牠','它們','牠們','其','之','前者','後者','兩者','二者','以上','以下'] | |
| AJ07_set = ['我','我們','我方','我倆','咱們','咱','俺','儂','咱家','咱們','予','余', | |
| '老子','我等','吾等'] | |
| AJ08_set = ['你','妳','你們','你方','妳們','妳方','你倆','妳倆'] | |
| AJ09_set = ['他','她','他們','她們','他倆'] | |
| AJ10_set = ['對方','個人','人家','各位','各自','自己','自我','本人','自身', | |
| '別人','彼此','雙方','大家','大夥兒','大眾','他人','私人','彼方'] | |
| AJ02_set = ['Caa','Cbb','Cab','Cba'] | |
| AJ03_set = ['Cbb','Cba'] | |
| pos_Nep_AJ04_fre = sum(1 for i in range(len(tokens)) | |
| if tokens_pos[i]=="Nep" and tokens[i] not in AJ04_set) | |
| pos_Nh_AJ05_fre = sum(1 for i in range(len(tokens)) | |
| if tokens_pos[i]=="Nh" and tokens[i] not in AJ05_set) | |
| pos_Nh_AJ06_fre = sum(1 for i in range(len(tokens)) | |
| if tokens_pos[i]=="Nh" and tokens[i] in AJ06_set) | |
| pos_Nh_AJ07_fre = sum(1 for i in range(len(tokens)) | |
| if tokens_pos[i]=="Nh" and tokens[i] in AJ07_set) | |
| pos_Nh_AJ08_fre = sum(1 for i in range(len(tokens)) | |
| if tokens_pos[i]=="Nh" and tokens[i] in AJ08_set) | |
| pos_Nh_AJ09_fre = sum(1 for i in range(len(tokens)) | |
| if tokens_pos[i]=="Nh" and tokens[i] in AJ09_set) | |
| pos_Nh_AJ10_fre = sum(1 for i in range(len(tokens)) | |
| if tokens_pos[i]=="Nh" and tokens[i] in AJ10_set) | |
| AJ02_fre = sum(1 for p in tokens_pos if p in AJ02_set) | |
| AJ03_fre = sum(1 for p in tokens_pos if p in AJ03_set) | |
| # ── 豐富度指標 ───────────────────────────────────────────────────────────── | |
| TTR = uniTokensTotal / tokensTotal | |
| perK = 1000 | |
| kk = len(TokenAddPos) // perK | |
| kk_TTR = [] | |
| for ii in range(1, kk+1): | |
| seg = TokenAddPos[(ii-1)*1000 : ii*1000] | |
| kk_TTR.append(len(set(seg)) / perK) | |
| STD_TTR = sum(kk_TTR) / len(kk_TTR) if kk_TTR else 0 | |
| Guiraud_R = uniTokensTotal / pow(tokensTotal, 0.5) | |
| LexicalDensity1 = cont1 / tokensTotal | |
| LexicalDensity2 = cont2 / tokensTotal | |
| return { | |
| "Text": [fname], | |
| "AA_01": [wordTotal], | |
| "AA_02": [uni_words_len], | |
| "AA_03": [tokensTotal], | |
| "AA_04": [uniTokensTotal], | |
| "AA_05": [lineTotal], | |
| "AA_06": [sentenceTotal], | |
| "AC_01": [round((tokensTotal - elementaryTotal4) / tokensTotal * 1000, 4)], | |
| "AC_02": [round((tokensTotal - elementaryTotal5) / tokensTotal * 1000, 4)], | |
| "AD_02": [round(STD_TTR, 4)], | |
| "AD_03": [round(Guiraud_R, 4)], | |
| "AG_01": [round(LexicalDensity1, 4)], | |
| "AG_02": [round(LexicalDensity2, 4)], | |
| "AG_05": [round(idiomTotal / tokensTotal * 1000, 4)], | |
| "AG_06": [round(idiomTotal2 / tokensTotal * 1000, 4)], | |
| "AH_01": [round(mean_sentenceLength, 4)], | |
| "AH_03": [round(np.sqrt(var_sentenceLength), 4)], | |
| "AH_06": [round(mean_sectionLength, 4)], | |
| "AH_08": [round(sd_sectionLength, 4)], | |
| "AJ_01": [round(pos_C_fre / tokensTotal * 1000, 4)], | |
| "AJ_02": [round(AJ02_fre / tokensTotal * 1000, 4)], | |
| "AJ_03": [round(AJ03_fre / tokensTotal * 1000, 4)], | |
| "AJ_04": [round(pos_Nep_AJ04_fre / tokensTotal * 1000, 4)], | |
| "AJ_05": [round(pos_Nh_AJ05_fre / tokensTotal * 1000, 4)], | |
| "AJ_06": [round(pos_Nh_AJ06_fre / tokensTotal * 1000, 4)], | |
| "AJ_07": [round(pos_Nh_AJ07_fre / tokensTotal * 1000, 4)], | |
| "AJ_08": [round(pos_Nh_AJ08_fre / tokensTotal * 1000, 4)], | |
| "AJ_09": [round(pos_Nh_AJ09_fre / tokensTotal * 1000, 4)], | |
| "AJ_10": [round(pos_Nh_AJ10_fre / tokensTotal * 1000, 4)], | |
| "AJ_11": [round(pos_Nh_AJ10_fre / tokensTotal * 1000, 4)], | |
| "AF_01": [round(pos_A_fre / tokensTotal * 1000, 4)], | |
| "AF_02": [round(pos_Caa_fre / tokensTotal * 1000, 4)], | |
| "AF_03": [round(pos_Cab_fre / tokensTotal * 1000, 4)], | |
| "AF_04": [round(pos_Cba_fre / tokensTotal * 1000, 4)], | |
| "AF_05": [round(pos_Cbb_fre / tokensTotal * 1000, 4)], | |
| "AF_06": [round(pos_D_fre / tokensTotal * 1000, 4)], | |
| "AF_07": [round(pos_Da_fre / tokensTotal * 1000, 4)], | |
| "AF_08": [round(pos_DE_fre / tokensTotal * 1000, 4)], | |
| "AF_09": [round(pos_Dfa_fre / tokensTotal * 1000, 4)], | |
| "AF_10": [round(pos_Dfb_fre / tokensTotal * 1000, 4)], | |
| "AF_11": [round(pos_Di_fre / tokensTotal * 1000, 4)], | |
| "AF_12": [round(pos_Dk_fre / tokensTotal * 1000, 4)], | |
| "AF_14": [round(pos_I_fre / tokensTotal * 1000, 4)], | |
| "AF_15": [round(pos_Na_fre / tokensTotal * 1000, 4)], | |
| "AF_16": [round(pos_Nb_fre / tokensTotal * 1000, 4)], | |
| "AF_17": [round(pos_Nc_fre / tokensTotal * 1000, 4)], | |
| "AF_18": [round(pos_Ncd_fre / tokensTotal * 1000, 4)], | |
| "AF_19": [round(pos_Nd_fre / tokensTotal * 1000, 4)], | |
| "AF_20": [round(pos_Nep_fre / tokensTotal * 1000, 4)], | |
| "AF_21": [round(pos_Neqa_fre/ tokensTotal * 1000, 4)], | |
| "AF_22": [round(pos_Neqb_fre/ tokensTotal * 1000, 4)], | |
| "AF_23": [round(pos_Nes_fre / tokensTotal * 1000, 4)], | |
| "AF_24": [round(pos_Neu_fre / tokensTotal * 1000, 4)], | |
| "AF_25": [round(pos_Nf_fre / tokensTotal * 1000, 4)], | |
| "AF_26": [round(pos_Ng_fre / tokensTotal * 1000, 4)], | |
| "AF_27": [round(pos_Nh_fre / tokensTotal * 1000, 4)], | |
| "AF_28": [round(pos_Nv_fre / tokensTotal * 1000, 4)], | |
| "AF_29": [round(pos_P_fre / tokensTotal * 1000, 4)], | |
| "AF_30": [round(pos_SHI_fre / tokensTotal * 1000, 4)], | |
| "AF_31": [round(pos_T_fre / tokensTotal * 1000, 4)], | |
| "AF_32": [round(pos_V2_fre / tokensTotal * 1000, 4)], | |
| "AF_33": [round(pos_VA_fre / tokensTotal * 1000, 4)], | |
| "AF_34": [round(pos_VAC_fre / tokensTotal * 1000, 4)], | |
| "AF_35": [round(pos_VB_fre / tokensTotal * 1000, 4)], | |
| "AF_36": [round(pos_VC_fre / tokensTotal * 1000, 4)], | |
| "AF_37": [round(pos_VCL_fre / tokensTotal * 1000, 4)], | |
| "AF_38": [round(pos_VD_fre / tokensTotal * 1000, 4)], | |
| "AF_39": [round(pos_VE_fre / tokensTotal * 1000, 4)], | |
| "AF_40": [round(pos_VF_fre / tokensTotal * 1000, 4)], | |
| "AF_41": [round(pos_VG_fre / tokensTotal * 1000, 4)], | |
| "AF_42": [round(pos_VH_fre / tokensTotal * 1000, 4)], | |
| "AF_43": [round(pos_VHC_fre / tokensTotal * 1000, 4)], | |
| "AF_44": [round(pos_VI_fre / tokensTotal * 1000, 4)], | |
| "AF_45": [round(pos_VJ_fre / tokensTotal * 1000, 4)], | |
| "AF_46": [round(pos_VK_fre / tokensTotal * 1000, 4)], | |
| "AF_47": [round(pos_VL_fre / tokensTotal * 1000, 4)], | |
| } | |
| def run_indicator_calculation(file_paths: list, output_dir: Path, resources: dict): | |
| """ | |
| 對所有文本計算指標,儲存 xlsx + csv,回傳 (DataFrame, xlsx_path, csv_path)。 | |
| """ | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| result_df = pd.DataFrame() | |
| for fp in file_paths: | |
| try: | |
| row_dict = calculate_text_index(Path(fp), resources) | |
| result_df = pd.concat([result_df, pd.DataFrame(row_dict)], | |
| axis=0, ignore_index=True) | |
| except Exception as e: | |
| print(f" 計算 {fp} 時發生錯誤:{e}") | |
| traceback.print_exc() | |
| timestamp = now_tw().strftime("%Y%m%d%H%M%S") | |
| xlsx_path = output_dir / f"textIndex_calculated_1_{timestamp}.xlsx" | |
| csv_path = output_dir / "textIndex_calculated.csv" | |
| result_df.to_excel(str(xlsx_path), index=False) | |
| result_df.to_csv(str(csv_path), index=False, encoding='utf-8-sig') | |
| return result_df, xlsx_path, csv_path | |
| # ============================================================================ | |
| # SECTION 6: Statistical Analysis (adapted from prog03, no tkinter) | |
| # ============================================================================ | |
| def cohens_d(g1, g2): | |
| """回傳 Cohen's d;合併標準差相對於資料尺度趨近於零時回傳 None(而非硬除 | |
| 出失真的天文數字)。 | |
| 2026-08-24(業主回報:報告上出現「Cohen's d=-9028313184615308.0」這種 | |
| 誇張數字):原本只用 `pooled != 0` 擋分母為零,但當兩組內部數值幾乎(並非 | |
| 精確)相同時,浮點運算會讓 pooled 變成一個極小但非零的數(例如 1e-13), | |
| 這個檢查完全擋不住,除下去就爆出巨大到毫無意義的數字。改用「相對於資料 | |
| 尺度的容忍值」判斷:pooled 小於合併平均數絕對值的極小比例時,視為組內 | |
| 變異趨近於零、效果量在數值上不穩定,回傳 None。注意:這不代表兩組沒有 | |
| 差異——p 值與平均數本身仍然有效,只是 Cohen's d 這個統計量的公式在 | |
| 「組內幾乎零變異」的情況下本身就定義不良,硬算出一個數字反而失真。""" | |
| n1, n2 = len(g1), len(g2) | |
| if n1 < 2 or n2 < 2: | |
| return None | |
| var1, var2 = np.var(g1, ddof=1), np.var(g2, ddof=1) | |
| pooled = np.sqrt(((n1-1)*var1 + (n2-1)*var2) / (n1+n2-2)) | |
| scale = max(abs(np.mean(np.concatenate([g1, g2]))), 1e-12) | |
| if pooled < scale * 1e-8: | |
| return None | |
| return (np.mean(g1) - np.mean(g2)) / pooled | |
| def eta_squared(group_data): | |
| """回傳 η²;ss_total 相對於資料尺度趨近於零時回傳 None,理由同 cohens_d()。""" | |
| all_data = np.concatenate(group_data) | |
| grand_mean = all_data.mean() | |
| ss_between = sum(len(g) * (np.mean(g) - grand_mean)**2 for g in group_data) | |
| ss_total = np.sum((all_data - grand_mean)**2) | |
| scale = max(abs(grand_mean), 1e-12) | |
| if ss_total < (scale ** 2) * 1e-8: | |
| return None | |
| return ss_between / ss_total | |
| def effect_label(val, method): | |
| if method == "t-test": | |
| v = abs(val) | |
| return "大" if v>=0.8 else "中" if v>=0.5 else "小" if v>=0.2 else "極小" | |
| else: | |
| return "大" if val>=0.14 else "中" if val>=0.06 else "小" if val>=0.01 else "極小" | |
| def run_statistical_analysis(csv_path: Path, output_dir: Path, group_labels: dict = None, | |
| individual_grouping: dict = None): | |
| """ | |
| 執行完整統計分析(敘述統計、差異分析、隨機森林、分群)。 | |
| group_labels:{分組代碼: 語料說明填寫的實際標籤} 選填,只影響箱形圖的 | |
| X 軸刻度文字(見下方 boxplot 區塊),不影響任何計算結果。 | |
| individual_grouping:{檔名: 個體間比較的分組標籤} 選填,2026-08-15 | |
| 業主要求新增——「語料說明」選「個體間比較」時,用個體層資訊填的 | |
| 「譯者/系統名稱」取代預設的檔名字首分組,這個 dict 非空時會真的 | |
| 改變 Type1(見下面),因此會連動影響差異性分析、隨機森林、分群 | |
| 全部的分組依據,不像 group_labels 只是換個顯示用的名字。沒填的 | |
| 檔案自動退回用檔名字首分組(業主拍板的容錯方式)。 | |
| 回傳 dict 包含所有輸出檔路徑與摘要資訊。 | |
| """ | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| # ── 1. 讀入資料 ──────────────────────────────────────────────────────────── | |
| df = pd.read_csv(str(csv_path)) | |
| # 2026-08-14:業主要求隱藏的 13 項指標(見 HIDDEN_INDICATOR_CODES 定義處 | |
| # 註解)在這裡就濾掉,之後敘述統計/差異分析/隨機森林/分群全部從這份 | |
| # df 衍生,一次過濾即可讓下游全部不再看到這 13 項,不用每個子區塊各自擋。 | |
| df = _drop_hidden_indicators(df) | |
| df['Type1'] = df['Text'].str[0].str.upper() | |
| # 2026-08-15(業主要求「個體間比較」要有實際效果):individual_grouping | |
| # 非空時,用它取代上面那行檔名字首分組——每個檔案若在「個體層資訊」 | |
| # 填了「譯者/系統名稱」,Type1 就改用那個值;沒填的檔案維持原本的 | |
| # 檔名字首(.map 對應不到的位置是 NaN,用 .fillna 退回原值,不是整批 | |
| # 放棄)。這一改,後面的差異性分析、隨機森林、分群全部自動跟著用新 | |
| # 分組依據算,不用另外改下游程式碼。 | |
| if individual_grouping: | |
| custom_group = df['Text'].astype(str).str.strip().map(individual_grouping) | |
| df['Type1'] = custom_group.fillna(df['Type1']) | |
| df['Type2'] = df['Type1'] | |
| GROUP_COL = 'Type1' | |
| N_CLUSTERS = max(2, len(df['Type1'].unique())) | |
| # 重新命名欄位(加中文名稱) | |
| rename_map = {} | |
| for col in df.columns: | |
| if col in INDEX_NAME_MAP: | |
| rename_map[col] = f"{col}_{INDEX_NAME_MAP[col]}" | |
| df.rename(columns=rename_map, inplace=True) | |
| numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist() | |
| # ── 2. 敘述統計(涵蓋所有計算指標,與語言指標分析一致)──────────────────── | |
| def _desc_row(cat, code, cname, rcol): | |
| s = df[rcol].dropna() | |
| if len(s) == 0: | |
| return None | |
| return { | |
| "類別": cat, "指標代碼": code, "指標名稱": cname, | |
| "文本個數": int(s.count()), | |
| "平均數": round(float(s.mean()), 4), | |
| "中位數": round(float(s.median()), 4), | |
| "標準差": round(float(s.std()), 4), | |
| "最小值": round(float(s.min()), 4), | |
| "最大值": round(float(s.max()), 4), | |
| "變異係數(%)": round(float(s.std()/s.mean()*100), 2) if s.mean()!=0 else None, | |
| } | |
| all_desc_rows = [] | |
| covered = set() | |
| # (a) 已分類指標(依分類順序呈現) | |
| for cat, items in INDEX_CATEGORIES.items(): | |
| for code, cname in items.items(): | |
| rcol = f"{code}_{cname}" | |
| if rcol not in df.columns: | |
| continue | |
| r = _desc_row(cat, code, cname, rcol) | |
| if r: | |
| all_desc_rows.append(r) | |
| covered.add(rcol) | |
| # (b) 未分類指標(如 AJ 系列),補入確保與指標計算數量一致 | |
| for rcol in numeric_cols: | |
| if rcol in covered: | |
| continue | |
| parts = rcol.split("_") | |
| code = (parts[0] + "_" + parts[1]) if len(parts) >= 2 else rcol | |
| cname = INDEX_NAME_MAP.get(code, code) | |
| r = _desc_row("其他指標", code, cname, rcol) | |
| if r: | |
| all_desc_rows.append(r) | |
| desc_df = pd.DataFrame(all_desc_rows) | |
| desc_path = output_dir / "01_descriptive_stats.csv" | |
| desc_df.to_csv(str(desc_path), index=False, encoding='utf-8-sig') | |
| # ── 3. 差異性分析 ────────────────────────────────────────────────────────── | |
| groups = df[GROUP_COL].unique() | |
| n_groups = len(groups) | |
| method_name = "Independent t-test (Welch)" if n_groups == 2 else "One-way ANOVA" | |
| diff_results = [] | |
| for col in numeric_cols: | |
| gdata = [df.loc[df[GROUP_COL]==g, col].dropna().values for g in groups] | |
| if any(len(g)==0 for g in gdata): continue | |
| row = {"指標": col} | |
| for gi, g in enumerate(groups): | |
| row[f"{g}_N"] = len(gdata[gi]) | |
| row[f"{g}_M"] = round(float(np.mean(gdata[gi])), 4) | |
| row[f"{g}_SD"] = round(float(np.std(gdata[gi], ddof=1)), 4) if len(gdata[gi])>1 else 0.0 | |
| if n_groups == 2: | |
| stat, p = stats.ttest_ind(gdata[0], gdata[1], equal_var=False) | |
| es_name = "Cohen's d"; method = "t-test" | |
| else: | |
| stat, p = stats.f_oneway(*gdata) | |
| es_name = "η²"; method = "ANOVA" | |
| row["方法"] = method | |
| # 見上方 INSUFFICIENT_DATA_LABEL 定義處的說明:p=NaN 代表這個指標在 | |
| # 目前的分組下根本無法檢定(通常是某組 N<2),不是「檢定過、沒有 | |
| # 顯著差異」。這裡明確分流,不落入下面的顯著性判斷,效果量也不計算 | |
| # ——cohens_d()/eta_squared() 各自對 N<2 有 0.0 的保底值,那是給 | |
| # 「算得出來、真的沒差異」用的,跟「根本沒算出來」不該共用同一個 | |
| # 0.0,會誤導成「效果量趨近於零」。 | |
| is_untestable = bool(np.isnan(p)) | |
| row["統計量"] = round(float(stat), 4) if not np.isnan(stat) else None | |
| row["p值"] = round(float(p), 4) if not is_untestable else None | |
| row["效果量名稱"] = es_name | |
| if is_untestable: | |
| row["顯著"] = INSUFFICIENT_DATA_LABEL | |
| row["效果量"] = None | |
| row["效果大小"] = None | |
| row["資料品質警示"] = "" # 已經整列標成「資料不足」,不用再疊加這個警示 | |
| else: | |
| es = cohens_d(gdata[0], gdata[1]) if n_groups == 2 else eta_squared(gdata) | |
| row["顯著"] = ("***" if p<0.001 else "**" if p<0.01 else "*" if p<0.05 else "ns") | |
| if es is None: | |
| # 見 EFFECT_UNSTABLE_LABEL 定義處:p 值仍然有效、正常判斷顯著性 | |
| # (上面那行不受影響),只有效果量這個數字換成描述性標籤。 | |
| row["效果量"] = None | |
| row["效果大小"] = EFFECT_UNSTABLE_LABEL | |
| else: | |
| row["效果量"] = round(float(es), 4) | |
| row["效果大小"] = effect_label(es, method) | |
| # 見 ZERO_VARIANCE_WARNING/ZERO_PROPORTION_WARNING 定義處:兩條獨立 | |
| # 判準都只看「有真的算出變異數」的情況——N<2 已經在 is_untestable 那支 | |
| # 處理過。SD 判準抓「整組幾乎沒變異」;零值比例判準抓「SD 沒有趨近於 | |
| # 零,但組內樣本值仍大量集中在 0」——後者刻意排除已被 SD 判準抓到的 | |
| # 組別(`not (... < 1e-9)`),避免同一組資料被兩條規則重複疊加描述。 | |
| # 都不影響顯著性與效果量的計算,純附註警示;兩者可同時列出(不同組別 | |
| # 各自命中不同判準時)。 | |
| _dq_warnings = [] | |
| if any(len(g) >= 2 and np.std(g, ddof=1) < 1e-9 for g in gdata): | |
| _dq_warnings.append(ZERO_VARIANCE_WARNING) | |
| if any(len(g) >= 2 and np.std(g, ddof=1) >= 1e-9 | |
| and (np.sum(g == 0) / len(g)) >= ZERO_PROPORTION_THRESHOLD | |
| for g in gdata): | |
| _dq_warnings.append(ZERO_PROPORTION_WARNING) | |
| row["資料品質警示"] = ";".join(_dq_warnings) | |
| diff_results.append(row) | |
| diff_df = pd.DataFrame(diff_results) | |
| sig_df = diff_df[~diff_df['顯著'].isin(["ns", INSUFFICIENT_DATA_LABEL])] | |
| untestable_df = diff_df[diff_df['顯著'] == INSUFFICIENT_DATA_LABEL] | |
| diff_path = output_dir / "02_difference_analysis.csv" | |
| diff_df.to_csv(str(diff_path), index=False, encoding='utf-8-sig') | |
| # boxplot(2026-08-24 起:達顯著差異的指標全部畫出,不再只取前 12 項) | |
| # 2026-08-14(業主要求):原本取前 12 項是照指標在資料表裡的固定欄位 | |
| # 順序(基礎統計→...→詞性標記)往下數,不是「最顯著/效果量最大」的 | |
| # 12 項,改成依效果量排序。 | |
| # 2026-08-24(業主要求):取消「只取前 12 項」的上限,達顯著差異的指標 | |
| # 全部畫出來;排序改成「先依組別、組內再依效果量」——先看哪一組在這批 | |
| # 顯著指標裡整體偏高,同一組內再依效果量絕對值由大到小排,比單純照 | |
| # 效果量從大到小混排更容易看出「兩組的差異主要往哪個方向集中」。 | |
| box_path = None | |
| if len(sig_df) > 0: | |
| sig_df_sorted = sig_df.copy() | |
| _group_order = {g: i for i, g in enumerate(groups)} | |
| sig_df_sorted['_highest_group'] = sig_df_sorted.apply( | |
| lambda row: max(groups, key=lambda g: row.get(f"{g}_M", float('-inf'))), | |
| axis=1, | |
| ) | |
| sig_df_sorted['_group_rank'] = sig_df_sorted['_highest_group'].map(_group_order) | |
| # 2026-08-24:效果量為 None(EFFECT_UNSTABLE_LABEL 情況,見該常數定義 | |
| # 處)代表組內幾乎零變異卻仍達顯著——是最乾淨俐落的一種差異,排序時 | |
| # 視為效果量無限大、排在同一組別群組的最前面,不能因為是 None 就被 | |
| # sort_values 排到最後面去(NaN 預設排序行為)。 | |
| # 2026-08-24(實測崩潰:TypeError: bad operand type for abs(): 'NoneType'): | |
| # 當顯著指標裡有不少(甚至全部)是 EFFECT_UNSTABLE_LABEL 情況(效果量= | |
| # None),這欄在 pandas 眼裡可能整欄維持 object dtype(而不是像單一 | |
| # 摻雜幾個 None 的情況那樣被自動轉成 float64、None 變 NaN),直接對 | |
| # object dtype 呼叫 .abs() 會真的去對 Python None 物件呼叫內建 abs() | |
| # 而炸掉,不是 NaN 安全語意。先用 pd.to_numeric(errors='coerce') 強制 | |
| # 轉成數值(None、NaN 都會變成 NaN,非數值也不會拋例外),再 .abs() | |
| # 就穩了。 | |
| sig_df_sorted['_abs_es'] = pd.to_numeric(sig_df_sorted['效果量'], errors='coerce').abs().fillna(float('inf')) | |
| sig_df_sorted = sig_df_sorted.sort_values( | |
| by=['_group_rank', '_abs_es'], ascending=[True, False] | |
| ) | |
| sig_cols = sig_df_sorted['指標'].tolist() | |
| n_row = max(1, (len(sig_cols) + 2) // 3) | |
| fig, axes = plt.subplots(n_row, 3, figsize=(14, 4*n_row)) | |
| axes = np.array(axes).flatten() | |
| # 群組軸標籤:語料說明填過的實際標籤(如「人工翻譯」)優先於原始 | |
| # 分組代碼(A/B),group_labels 由呼叫端(run_computation_pipeline_ | |
| # streaming)從 state["group_labels"] 傳入,沒填就退回代碼本身。 | |
| group_labels = group_labels or {} | |
| group_tick_labels = [group_labels.get(g, g) for g in groups] | |
| for i, col in enumerate(sig_cols): | |
| ax = axes[i] | |
| pd_data = [df.loc[df[GROUP_COL]==g, col].dropna().values for g in groups] | |
| ax.boxplot(pd_data) | |
| ax.set_xticks(range(1, len(groups) + 1)) | |
| ax.set_xticklabels(group_tick_labels) | |
| # 2026-08-15(業主要求):小標題原本只有 p 值,加上效果量(連同 | |
| # 名稱,例如 Cohen's d 或 η²,兩種方法的效果量單位不一樣,只寫 | |
| # 數字沒名稱會誤讀)一起顯示,一眼就能看出「差多少」不只是 | |
| # 「有沒有差」。 | |
| row = diff_df.loc[diff_df['指標']==col] | |
| pv_str = f"p={row['p值'].values[0]:.4f}" if len(row)>0 else "" | |
| _es_val = row['效果量'].values[0] if len(row)>0 else None | |
| if len(row) > 0 and _es_val is not None and not pd.isna(_es_val): | |
| es_str = f",{row['效果量名稱'].values[0]}={_es_val:.3f}" | |
| elif len(row) > 0: | |
| # 2026-08-24:效果量為 None 時(見 EFFECT_UNSTABLE_LABEL), | |
| # 標題別再硬套 :.3f 格式化(會直接拋例外),改顯示描述性標籤。 | |
| es_str = f",{EFFECT_UNSTABLE_LABEL}" | |
| else: | |
| es_str = "" | |
| ax.set_title(f"{col}\n({pv_str}{es_str})", fontsize=8) | |
| for j in range(len(sig_cols), len(axes)): | |
| axes[j].set_visible(False) | |
| # 2026-08-14(業主要求):原本「Significant Variables by Type1」直接 | |
| # 洩漏內部欄位名稱 Type1,使用者看不懂在比較什麼,改成中文標題。 | |
| # 2026-08-24(業主要求):不再只取前 12 項,標題文字同步更新為 | |
| # 「依組別與效果量排序」,不再提「前 12 項」(已無此上限)。 | |
| plt.suptitle("顯著指標比較(依組別與效果量排序)", fontsize=11, y=1.01) | |
| plt.tight_layout() | |
| box_path = output_dir / "02_significant_boxplots.png" | |
| plt.savefig(str(box_path), dpi=150, bbox_inches='tight') | |
| plt.close() | |
| # ── 4. 隨機森林 ──────────────────────────────────────────────────────────── | |
| le = LabelEncoder() | |
| y = le.fit_transform(df[GROUP_COL]) | |
| X = df[numeric_cols].fillna(df[numeric_cols].median()) | |
| min_class = pd.Series(y).value_counts().min() | |
| test_sz = max(len(np.unique(y)), int(len(df) * 0.2)) | |
| if len(df) > 6 and min_class >= 2 and test_sz < len(df): | |
| X_train, X_test, y_train, y_test = train_test_split( | |
| X, y, test_size=test_sz, random_state=42, stratify=y) | |
| do_eval = True | |
| else: | |
| X_train, y_train = X, y | |
| X_test, y_test = X, y | |
| do_eval = False | |
| rf = RandomForestClassifier(n_estimators=500, random_state=42) | |
| rf.fit(X_train, y_train) | |
| y_pred = rf.predict(X_test) | |
| accuracy = accuracy_score(y_test, y_pred) | |
| imp_df = pd.DataFrame({"指標": numeric_cols, "重要性": rf.feature_importances_})\ | |
| .sort_values("重要性", ascending=False).reset_index(drop=True) | |
| imp_path = output_dir / "03_feature_importance.csv" | |
| imp_df.to_csv(str(imp_path), index=False, encoding='utf-8-sig') | |
| top_n = min(20, len(imp_df)) | |
| fig, ax = plt.subplots(figsize=(10, 6)) | |
| ax.barh(imp_df['指標'][:top_n][::-1], imp_df['重要性'][:top_n][::-1], color='steelblue') | |
| ax.set_xlabel("特徵重要性") | |
| ax.set_title(f"隨機森林 — 前 {top_n} 項重要特徵") | |
| plt.tight_layout() | |
| imp_fig_path = output_dir / "03_feature_importance.png" | |
| plt.savefig(str(imp_fig_path), dpi=150, bbox_inches='tight') | |
| plt.close() | |
| # ── 5. 非監督式分群 ───────────────────────────────────────────────────────── | |
| scaler = StandardScaler() | |
| X_scaled = scaler.fit_transform(X) | |
| pca = PCA(n_components=min(2, X_scaled.shape[1]), random_state=42) | |
| X_pca = pca.fit_transform(X_scaled) | |
| # 階層式分群 | |
| linkage_mat = linkage(X_scaled, method='ward') | |
| fig, ax = plt.subplots(figsize=(10, 5)) | |
| dendrogram(linkage_mat, labels=df['Text'].values, leaf_rotation=45, leaf_font_size=8, ax=ax) | |
| ax.set_title("階層式分群樹狀圖(Ward 法)") | |
| # 2026-08-15(業主問「Sample 是什麼意思」):橫軸的每一格代表一個被 | |
| # 分群的文本樣本(下方會列出各檔名),改成中文「樣本」,不用再解釋。 | |
| ax.set_xlabel("樣本"); ax.set_ylabel("距離") | |
| plt.tight_layout() | |
| dendro_path = output_dir / "04a_dendrogram.png" | |
| plt.savefig(str(dendro_path), dpi=150, bbox_inches='tight') | |
| plt.close() | |
| hc = AgglomerativeClustering(n_clusters=N_CLUSTERS, linkage='ward') | |
| df['HC_Cluster'] = hc.fit_predict(X_scaled) | |
| # K-means + Elbow | |
| max_k = min(8, max(2, len(df)-1)) | |
| inertia = [] | |
| for k in range(1, max_k+1): | |
| km = KMeans(n_clusters=k, random_state=42, n_init=10) | |
| km.fit(X_scaled) | |
| inertia.append(km.inertia_) | |
| fig, ax = plt.subplots(figsize=(7, 4)) | |
| ax.plot(list(range(1, max_k+1)), inertia, marker='o', color='coral') | |
| ax.set_xlabel("分群數量(K)"); ax.set_ylabel("群內誤差平方和(SSE)") | |
| ax.set_title("K-means 手肘圖"); ax.set_xticks(list(range(1, max_k+1))) | |
| plt.tight_layout() | |
| elbow_path = output_dir / "04b_kmeans_elbow.png" | |
| plt.savefig(str(elbow_path), dpi=150, bbox_inches='tight') | |
| plt.close() | |
| km_final = KMeans(n_clusters=N_CLUSTERS, random_state=42, n_init=10) | |
| df['KM_Cluster'] = km_final.fit_predict(X_scaled) | |
| # PCA 2D 視覺化 | |
| fig, axes = plt.subplots(1, 2, figsize=(14, 5)) | |
| for ax, lcol, title in zip(axes, | |
| ['HC_Cluster','KM_Cluster'], | |
| ['階層式分群', f'K-means 分群(K={N_CLUSTERS})']): | |
| ulabels = sorted(df[lcol].unique()) | |
| colors = plt.cm.tab10(np.linspace(0, 1, len(ulabels))) | |
| for idx, cl in enumerate(ulabels): | |
| mask = df[lcol] == cl | |
| ax.scatter(X_pca[mask,0], X_pca[mask,1], | |
| c=[colors[idx]], label=f"群組 {cl}", s=100, edgecolors='k') | |
| for i, txt in enumerate(df['Text']): | |
| ax.annotate(txt[:8], (X_pca[i,0], X_pca[i,1]), | |
| fontsize=7, textcoords='offset points', xytext=(5,3)) | |
| ax.set_xlabel(f"主成分一({pca.explained_variance_ratio_[0]:.1%})" if X_scaled.shape[1]>=2 else "主成分一") | |
| ax.set_ylabel(f"主成分二({pca.explained_variance_ratio_[1]:.1%})" if X_scaled.shape[1]>=2 else "主成分二") | |
| ax.set_title(title); ax.legend() | |
| plt.suptitle("分群結果(主成分分析二維投影)", fontsize=12) | |
| plt.tight_layout() | |
| cluster_fig_path = output_dir / "04c_clustering_pca2d.png" | |
| plt.savefig(str(cluster_fig_path), dpi=150, bbox_inches='tight') | |
| plt.close() | |
| # 分群結果儲存 | |
| # 2026-08-15(業主回報「分群結果表格有個看不懂的 Type1.1 欄位」,追查 | |
| # 後發現):GROUP_COL 這個變數的值就是字串 'Type1'(見上面第 887 行), | |
| # 這裡卻又把 'Type1' 跟 GROUP_COL 兩個都放進 result_cols,等於同一欄 | |
| # 選了兩次;df[result_cols] 選出來的 DataFrame 因此有兩欄同名 Type1, | |
| # 存成 CSV 後欄名衝突,pandas 存檔/讀回的慣例是把重複欄名的第二個 | |
| # 加上 ".1" 後綴避免衝突——「Type1.1」就是這樣長出來的,內容跟 Type1 | |
| # 一模一樣,不是新資訊,純粹是選重複欄位的 bug。拿掉重複的 GROUP_COL | |
| # 就好,Type1 本身(真正的分組欄)保留。 | |
| result_cols = ['Text','Type1','Type2','HC_Cluster','KM_Cluster'] | |
| result_cols = [c for c in result_cols if c in df.columns] | |
| cluster_csv_path = output_dir / "05_cluster_results.csv" | |
| # 2026-08-15(業主要求):使用者下載這份 CSV 直接看到 Type1/Type2 也 | |
| # 反映同樣的看不懂問題(先前只改了畫面顯示的複本,見 _populate_tabs | |
| # 裡的同名 rename 邏輯),這次直接在存檔時就處理,下載到的檔案本身 | |
| # 就是乾淨版本,不用開了 Excel 才發現欄名還是英文技術代碼。Type2 | |
| # 拿掉(一直是 Type1 的原樣複製,無額外資訊)、Type1 改標成「原始 | |
| # 分組(A/B)」。 | |
| cluster_export_df = ( | |
| df[result_cols] | |
| .drop(columns=["Type2"], errors="ignore") | |
| .rename(columns={"Type1": "原始分組(A/B)"}) | |
| ) | |
| cluster_export_df.to_csv(str(cluster_csv_path), index=False, encoding='utf-8-sig') | |
| # ── 6. 產出 docx 技術報告 ────────────────────────────────────────────────── | |
| timestamp = now_tw().strftime("%Y%m%d_%H%M%S") | |
| report_path = output_dir / f"Final_Report-{timestamp}.docx" | |
| doc = Document() | |
| style = doc.styles['Normal'] | |
| style.font.name = '微軟正黑體' | |
| style.font.size = Pt(10) | |
| title = doc.add_heading('統計結果技術報告', level=0) | |
| title.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| doc.add_paragraph(f'報告產出時間:{now_tw().strftime("%Y-%m-%d %H:%M:%S")}(台灣時間)', | |
| style='Normal').alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| doc.add_paragraph(f'分析文本數:{df.shape[0]} 筆', | |
| style='Normal').alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| # 2026-09-01(業主回報):這裡原本直接印 GROUP_COL(一律是字面上的 | |
| # "Type1",一個內部欄位名稱,不管實際用哪種分組方式都印一樣的字, | |
| # 對讀者毫無意義)。改成依 individual_grouping 是否非空,印出真正 | |
| # 描述本次實際採用哪種分組依據的文字——邏輯對應 run_statistical_ | |
| # analysis 開頭那段「individual_grouping 非空時取代檔名字首分組」 | |
| # 的判斷(見該處註解),確保這裡顯示的說明跟實際分組行為一致。 | |
| _group_method_desc = ( | |
| '個體間比較(依「譯者/系統名稱」分組;未填寫者退回檔名字首分組)' | |
| if individual_grouping else | |
| '依檔名字首分組(A/B/C…)' | |
| ) | |
| doc.add_paragraph(f'分組方式:{_group_method_desc}', | |
| style='Normal').alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| doc.add_paragraph('') | |
| # 敘述統計 | |
| doc.add_heading('一、敘述性統計', level=1) | |
| TABLE_HEADERS = ["指標代碼","指標名稱","文本個數","平均數","中位數","標準差","最小值","最大值","變異係數(%)"] | |
| for cat, items in INDEX_CATEGORIES.items(): | |
| cat_data = desc_df[desc_df['類別'] == cat] | |
| if cat_data.empty: continue | |
| doc.add_heading(f'({cat})', level=2) | |
| tbl = doc.add_table(rows=1+len(cat_data), cols=len(TABLE_HEADERS)) | |
| tbl.style = 'Light Shading Accent 1' | |
| for j, h in enumerate(TABLE_HEADERS): | |
| cell = tbl.rows[0].cells[j] | |
| cell.text = h | |
| for p in cell.paragraphs: | |
| p.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| for r in p.runs: r.bold = True; r.font.size = Pt(8) | |
| for i, (_, row) in enumerate(cat_data.iterrows(), start=1): | |
| vals = [str(row['指標代碼']), str(row['指標名稱']), | |
| str(int(row['文本個數'])), | |
| f"{row['平均數']:.4f}", f"{row['中位數']:.4f}", | |
| f"{row['標準差']:.4f}", f"{row['最小值']:.4f}", | |
| f"{row['最大值']:.4f}", | |
| # 同下方差異性分析表格:改用 pd.isna(),避免 None 被 | |
| # pandas 建表時轉型成 NaN 後,is not None 抓不到而印出「nan」。 | |
| f"{row['變異係數(%)']:.2f}" if not pd.isna(row['變異係數(%)']) else "-"] | |
| for j, val in enumerate(vals): | |
| cell = tbl.rows[i].cells[j] | |
| cell.text = val | |
| for p in cell.paragraphs: | |
| p.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| for r in p.runs: r.font.size = Pt(8) | |
| doc.add_paragraph('') | |
| # 差異性分析 | |
| doc.add_page_break() | |
| doc.add_heading('二、差異性分析', level=1) | |
| # 2026-08-15:{GROUP_COL} 原本會直接印出內部欄位名稱「Type1」,跟先前 | |
| # 修過的 Type1.1/rf_accuracy_is_meaningful 是同一種「技術變數名稱外 | |
| # 洩漏」問題,改成不點名欄位、只講語意。 | |
| doc.add_paragraph(f'分組依據:語料分組(共 {n_groups} 組)。採用 {method_name},顯著水準 α = {ALPHA}。') | |
| _untestable_note = (f"另有 {len(untestable_df)} 個指標因組別樣本數不足(N<2)," | |
| f"無法進行統計檢定,不計入上述比例。" if len(untestable_df) > 0 else "") | |
| doc.add_paragraph(f'共 {len(diff_df)} 個指標,其中 {len(sig_df)} 個達顯著差異(p < {ALPHA})。{_untestable_note}') | |
| # 2026-08-15(業主要求):表格欄位裡的 N/M/SD 是統計慣用縮寫,第一次 | |
| # 看到報告的人不一定認得,加一句說明放在表格正上方。 | |
| note_p = doc.add_paragraph('表格中 N=樣本數、M=平均數、SD=標準差。') | |
| note_p.runs[0].italic = True | |
| note_p.runs[0].font.size = Pt(8) | |
| group_sub_headers = [] | |
| for g in groups: | |
| group_sub_headers.extend([f"{g}\nN", f"{g}\nM", f"{g}\nSD"]) | |
| diff_headers = ["指標"] + group_sub_headers + ["方法","統計量","p值","顯著","效果量","效果大小","資料品質警示"] | |
| diff_tbl = doc.add_table(rows=1+len(diff_df), cols=len(diff_headers)) | |
| diff_tbl.style = 'Light Shading Accent 1' | |
| for j, h in enumerate(diff_headers): | |
| cell = diff_tbl.rows[0].cells[j] | |
| cell.text = h | |
| for p in cell.paragraphs: | |
| p.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| for r in p.runs: r.bold = True; r.font.size = Pt(7) | |
| for i, (_, row) in enumerate(diff_df.iterrows(), start=1): | |
| vals = [str(row['指標'])] | |
| for g in groups: | |
| vals.extend([str(int(row[f"{g}_N"])), | |
| f"{row[f'{g}_M']:.4f}", | |
| f"{row[f'{g}_SD']:.4f}"]) | |
| # 2026-08-24(業主回報表格出現「nan」字樣):diff_results 是一份 | |
| # dict list,其中某些列的「統計量」/「p值」/「效果量」是 Python | |
| # None(見 INSUFFICIENT_DATA_LABEL/EFFECT_UNSTABLE_LABEL 兩處), | |
| # 但 `pd.DataFrame(diff_results)` 建表時,只要同一欄裡其餘列是數字, | |
| # pandas 會把整欄轉成 float64、None 也跟著被轉型成 NaN——`is not | |
| # None` 檢查完全抓不到轉型後的 NaN(NaN 不是 None 物件),因此原本 | |
| # 該顯示「-」的欄位印出了「nan」這個字面字串。改用 pd.isna(),能 | |
| # 同時抓到 None 與轉型後的 NaN 兩種情況。 | |
| _warn_text = row.get('資料品質警示', '') | |
| _warn_text = '' if pd.isna(_warn_text) else str(_warn_text) | |
| vals.extend([str(row['方法']), | |
| f"{row['統計量']:.4f}" if not pd.isna(row['統計量']) else "-", | |
| f"{row['p值']:.4f}" if not pd.isna(row['p值']) else "-", | |
| str(row['顯著']), | |
| f"{row['效果量']:.4f}" if not pd.isna(row['效果量']) else "-", | |
| str(row['效果大小']) if not pd.isna(row['效果大小']) else "-", | |
| _warn_text if _warn_text else "-"]) | |
| _is_untestable_row = row['顯著'] == INSUFFICIENT_DATA_LABEL | |
| # 2026-08-24:改用欄名查表格索引,不再用 len(vals) 往回數固定格數 | |
| # ——原本 len(vals)-4 在只有 6 個尾端欄位時其實已經對不準「顯著」 | |
| # 那一格(差一格,實際指到 p值),這次多加一欄「資料品質警示」後 | |
| # 用位置數只會更容易錯位,藉這次一起修成用欄名查,之後再加欄也不怕。 | |
| _sig_col_idx = diff_headers.index('顯著') | |
| _warn_col_idx = diff_headers.index('資料品質警示') | |
| for j, val in enumerate(vals): | |
| cell = diff_tbl.rows[i].cells[j] | |
| cell.text = val | |
| for p in cell.paragraphs: | |
| p.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| for r in p.runs: | |
| r.font.size = Pt(7) | |
| # 顯著(紅字)跟資料不足(灰字)是兩種不同性質的標記, | |
| # 不能共用紅字——紅字在這份報告的視覺語言裡代表「這是 | |
| # 值得注意的發現」,「資料不足」剛好相反,是「這項不能 | |
| # 拿來當發現看」,用紅字反而會讓人誤以為它也是顯著結果。 | |
| if row['顯著'] not in ("ns", INSUFFICIENT_DATA_LABEL) and j == _sig_col_idx: | |
| r.font.color.rgb = RGBColor(0xCC, 0x00, 0x00) | |
| elif _is_untestable_row: | |
| r.font.color.rgb = RGBColor(0x88, 0x88, 0x88) | |
| r.italic = True | |
| # 見 ZERO_VARIANCE_WARNING:琥珀色,跟「顯著」的紅、 | |
| # 「資料不足」的灰都不同——這不是「無法使用」(灰)也不是 | |
| # 「重點發現」(紅),是「可用但要謹慎看待」的第三種性質。 | |
| elif j == _warn_col_idx and _warn_text: | |
| r.font.color.rgb = RGBColor(0xB4, 0x7A, 0x00) | |
| doc.add_paragraph('') | |
| if box_path and box_path.exists(): | |
| doc.add_heading('顯著指標箱形圖', level=2) | |
| doc.add_picture(str(box_path), width=Cm(16)) | |
| doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| # 隨機森林 | |
| doc.add_page_break() | |
| doc.add_heading('三、監督式學習 — 隨機森林特徵重要性', level=1) | |
| doc.add_paragraph(f'模型準確率(Accuracy):{accuracy:.4f}') | |
| if imp_fig_path.exists(): | |
| doc.add_picture(str(imp_fig_path), width=Cm(15)) | |
| doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| # 分群 | |
| doc.add_page_break() | |
| doc.add_heading('四、非監督式學習 — 集群分析', level=1) | |
| if dendro_path.exists(): | |
| doc.add_heading('樹狀圖(階層式分群)', level=2) | |
| doc.add_picture(str(dendro_path), width=Cm(15)) | |
| doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| if elbow_path.exists(): | |
| doc.add_heading('Elbow Curve(K-means)', level=2) | |
| doc.add_picture(str(elbow_path), width=Cm(12)) | |
| doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| if cluster_fig_path.exists(): | |
| doc.add_heading('分群視覺化(PCA 2D)', level=2) | |
| doc.add_picture(str(cluster_fig_path), width=Cm(16)) | |
| doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| doc.save(str(report_path)) | |
| return { | |
| "df": df, | |
| "desc_path": desc_path, | |
| "diff_path": diff_path, | |
| "imp_path": imp_path, | |
| "imp_fig_path": imp_fig_path, | |
| "box_path": box_path, | |
| "dendro_path": dendro_path, | |
| "elbow_path": elbow_path, | |
| "cluster_fig_path": cluster_fig_path, | |
| "cluster_csv_path": cluster_csv_path, | |
| "report_path": report_path, | |
| "sig_count": len(sig_df), | |
| "untestable_count": len(untestable_df), | |
| "total_indicators": len(diff_df), | |
| "accuracy": accuracy, | |
| "do_eval": do_eval, | |
| "groups": list(groups), | |
| "n_groups": n_groups, | |
| "method_name": method_name, | |
| "GROUP_COL": GROUP_COL, | |
| "pca_ev": pca.explained_variance_ratio_.tolist(), | |
| } | |
| # ============================================================================ | |
| # SECTION 7: LLM Report Writing (adapted from prog04, uses LLM1) | |
| # ============================================================================ | |
| def load_analysis_data_for_llm(csv_path: Path, output_dir: Path, ml_summary: dict = None, | |
| group_labels: dict = None) -> dict: | |
| """讀取分析資料組成 JSON 結構。 | |
| ml_summary:{"accuracy": float, "do_eval": bool} 選填,來自 | |
| run_statistical_analysis() 的回傳值。之前這裡完全沒有把隨機森林的 | |
| 準確率數字(accuracy)跟「這次是不是真的切了測試集」(do_eval) | |
| 交給 LLM,LLM 因此連準確率是多少都不知道,等於沒有材料可以討論 | |
| 監督式學習結果——03_feature_importance.csv 只有各指標的重要性排名, | |
| 沒有整體準確率這個數字。 | |
| group_labels:{分組代碼: 語料說明填的實際標籤},選填。2026-08-24(業主 | |
| 回報):畫面上的差異性分析標題/箱形圖標籤早就會用語料說明填的實際 | |
| 標籤(如「人工翻譯」),但 LLM 報告內文卻一路只看得到原始代碼 A/B, | |
| 寫成「群組 A 最高」——追查後發現 difference_analysis 送給 LLM 的 JSON | |
| 欄名({組別}_N/{組別}_M/{組別}_SD)從頭到尾都是原始代碼,從沒套用過 | |
| 這組標籤,等於畫面跟報告各自套用了不同版本的分組名稱。這裡補上同一套 | |
| 轉換,讓兩邊使用同一個資料來源。""" | |
| data = {} | |
| if ml_summary: | |
| # 2026-08-15(業主回報):原本這裡是英文鍵名 rf_accuracy/ | |
| # rf_accuracy_is_meaningful,靠 system prompt 那條「英文鍵名要 | |
| # 換成中文說法」的規則要求 LLM 自己翻譯,結果 LLM 沒穩定照辦, | |
| # 報告裡直接印出「由於 rf_accuracy_is_meaningful 為 true...」這種 | |
| # 變數名稱。改成在這裡把「是否具樣本外預測意義」直接寫成完整的 | |
| # 中文句子存進 JSON——LLM 就算照抄 JSON 內容,抄出來的也已經是 | |
| # 人看得懂的句子,不用依賴它額外做翻譯這個不保證會做到的步驟。 | |
| acc = ml_summary.get("accuracy") | |
| data["隨機森林分類準確率"] = round(acc, 4) if isinstance(acc, (int, float)) else acc | |
| data["隨機森林準確率之樣本外預測意義"] = ( | |
| "具備樣本外預測意義(本次有切分獨立的測試集)" | |
| if ml_summary.get("do_eval") | |
| else "不具備樣本外預測意義(本次樣本數過小,訓練集與測試集為同一批資料," | |
| "此準確率僅反映模型對訓練資料本身的擬合程度)" | |
| ) | |
| if csv_path.exists(): | |
| raw = pd.read_csv(str(csv_path)) | |
| aj_cols = [c for c in raw.columns if c.startswith('AJ_')] | |
| raw.drop(columns=aj_cols, inplace=True, errors='ignore') | |
| data["texts"] = raw['Text'].tolist() | |
| data["text_count"] = len(raw) | |
| else: | |
| data["texts"] = []; data["text_count"] = 0 | |
| for fname, key in [ | |
| ("01_descriptive_stats.csv", "descriptive_stats"), | |
| ("02_difference_analysis.csv", "difference_analysis"), | |
| ("03_feature_importance.csv", "feature_importance_top20"), | |
| ("05_cluster_results.csv", "cluster_results"), | |
| ]: | |
| p = output_dir / fname | |
| if p.exists(): | |
| d = pd.read_csv(str(p)) | |
| if key == "difference_analysis" and group_labels: | |
| group_codes = [c[:-2] for c in d.columns if c.endswith('_N')] | |
| rename_map = {} | |
| for g in group_codes: | |
| label = group_labels.get(g) | |
| if label and label != g: | |
| rename_map[f"{g}_N"] = f"{label}_N" | |
| rename_map[f"{g}_M"] = f"{label}_M" | |
| rename_map[f"{g}_SD"] = f"{label}_SD" | |
| if rename_map: | |
| d = d.rename(columns=rename_map) | |
| if key == "cluster_results": | |
| # 2026-08-15(業主要求):跟 rf_accuracy_is_meaningful 同一個 | |
| # 教訓——原本這裡的欄名是 Type1,LLM 直接照抄,報告寫成 | |
| # 「原始分組(Type1)」。改成在資料層就把 Type1 換成「原始 | |
| # 分組」這個中文欄名,Type2 拿掉(一直是 Type1 的原樣複製, | |
| # 沒有額外資訊,見 run_statistical_analysis 那行 df['Type2'] | |
| # = df['Type1'] 的註解),LLM 照抄 JSON 內容時自然就只會 | |
| # 寫「原始分組」,不會再冒出 Type1 這個技術欄名。 | |
| d = d.drop(columns=["Type2"], errors="ignore") \ | |
| .rename(columns={"Type1": "原始分組"}) | |
| if key == "feature_importance_top20": | |
| data[key] = d.head(20).to_dict(orient='records') | |
| else: | |
| data[key] = d.to_dict(orient='records') | |
| if key == "difference_analysis": | |
| if '顯著' in d.columns: | |
| sig = d[~d['顯著'].isin(['ns', INSUFFICIENT_DATA_LABEL])] | |
| untestable = d[d['顯著'] == INSUFFICIENT_DATA_LABEL] | |
| else: | |
| sig = pd.DataFrame(); untestable = pd.DataFrame() | |
| data["significant_indicators"] = sig.to_dict(orient='records') | |
| data["significant_count"] = len(sig) | |
| data["total_indicators"] = len(d) | |
| # 2026-08-22:跟資料層預先中文化同一個原則(見 | |
| # rf_accuracy_is_meaningful/cluster Type1 的前例)——與其在 | |
| # system prompt 裡要求 LLM「自己判斷哪些指標資料不足」, | |
| # 這裡直接把清單跟現成的中文說明準備好,LLM 只要照抄,不用 | |
| # 自己從一堆 JSON 欄位裡推斷「顯著=資料不足」代表什麼。 | |
| data["untestable_indicators"] = untestable.to_dict(orient='records') | |
| data["untestable_count"] = len(untestable) | |
| data["資料完整性提醒"] = ( | |
| f"以上 difference_analysis 中有 {len(untestable)} 項指標的「顯著」欄位" | |
| f"標示為「{INSUFFICIENT_DATA_LABEL}」,代表該指標在某一組別的樣本數少於 2" | |
| "(無法計算組內變異),統計檢定本身就無法執行,p 值與效果量均為 null。" | |
| "這些指標必須明確陳述為「資料不足,無法檢定」,絕對不可以寫成「無顯著差異」" | |
| "或納入「達統計顯著」/「無明顯差異」的分類統計,兩者是完全不同的結論。" | |
| if len(untestable) > 0 else "" | |
| ) | |
| # 2026-08-24(業主回報 AG_05 案例,見 ZERO_VARIANCE_WARNING 定義 | |
| # 處):「資料品質警示」欄位非空的指標,p 值與效果量都是有效 | |
| # 計算結果、正常參與顯著性統計,跟上面的「資料不足」是不同 | |
| # 性質,不能用同一句話說明,另外準備一段中文提醒。 | |
| if '資料品質警示' in d.columns: | |
| zero_var = d[d['資料品質警示'].fillna('') != ''] | |
| else: | |
| zero_var = pd.DataFrame() | |
| data["zero_variance_warning_indicators"] = zero_var['指標'].tolist() if len(zero_var) > 0 else [] | |
| data["zero_variance_warning_count"] = len(zero_var) | |
| data["資料穩定性提醒"] = ( | |
| f"以上 difference_analysis 中有 {len(zero_var)} 項指標的「資料品質警示」欄位非空" | |
| "(列於 zero_variance_warning_indicators),代表該指標至少有一組出現以下情況之一:" | |
| "①該組所有文本在此指標上數值完全相同,標準差趨近於 0;或 ②該組半數以上文本在" | |
| "此指標上數值為 0,樣本集中於零值,平均數可能被少數非零離群值主導。這兩種情況下 " | |
| "p 值與效果量仍是正常計算、正常算入「達統計顯著」的統計比例,不像「資料不足」那樣" | |
| "要整項排除;但陳述這些指標時,必須額外註明「此結果統計穩定性有限,建議謹慎解讀」" | |
| "這類保留語氣,不可跟其他變異正常的顯著指標用完全相同的肯定語氣陳述,避免讀者把" | |
| "統計上脆弱的結果當成跟其他乾淨顯著結果同等份量的證據。" | |
| if len(zero_var) > 0 else "" | |
| ) | |
| else: | |
| data[key] = [] | |
| if "significant_count" not in data: | |
| data["significant_count"] = 0 | |
| if "total_indicators" not in data: | |
| data["total_indicators"] = 0 | |
| return data | |
| # 2026-08-14(待辦 A1,業主拍板):LLM 報告存成 docx 時,原本的迴圈只認 | |
| # 區塊層級語法(# 標題/- 項目符號),一行裡的 **粗體**、$LaTeX$ 這類行內 | |
| # 語法會原封不動貼成文字。這裡補一個行內語法解析: | |
| # **文字** → 粗體 run | |
| # $文字$ → 斜體 run(拆掉 $ 符號;docx 沒有內建 LaTeX 公式排版, | |
| # 真要轉成 Word 公式物件(OMML)得另外導入 latex2mathml | |
| # 之類的套件,複雜度與風險都高,業主拍板先用「斜體純文字」 | |
| # 這個較簡單、較不會转換失敗的做法) | |
| # 非貪婪 regex 一次切出 **...** 或 $...$ 區段,其餘原樣輸出;兩種語法 | |
| # 不處理巢狀(例如 **$x$** 或 $**x**$),這份報告的 LLM 輸出目前沒有 | |
| # 這種寫法,不特別處理以免過度工程化。 | |
| _INLINE_MD_RE = re.compile(r'(\*\*.+?\*\*|\$.+?\$)') | |
| def _add_inline_markdown_runs(paragraph, text: str) -> None: | |
| """把一行文字依 **粗體** / $LaTeX$ 語法拆成多個 run,加進既有段落。""" | |
| for piece in _INLINE_MD_RE.split(text): | |
| if not piece: | |
| continue | |
| if piece.startswith('**') and piece.endswith('**') and len(piece) >= 4: | |
| paragraph.add_run(piece[2:-2]).bold = True | |
| elif piece.startswith('$') and piece.endswith('$') and len(piece) >= 2: | |
| paragraph.add_run(piece[1:-1]).italic = True | |
| else: | |
| paragraph.add_run(piece) | |
| def run_report_writing(scenario_key: str, user_metadata: str, user_focus: str, | |
| csv_path: Path, output_dir: Path, ml_summary: dict = None, | |
| group_labels: dict = None) -> dict: | |
| """ | |
| 呼叫主 Agent (LLM1) 撰寫研究報告,儲存 docx + txt,回傳路徑 dict。 | |
| ml_summary:見 load_analysis_data_for_llm() 的說明。 | |
| group_labels:見 load_analysis_data_for_llm() 的說明。 | |
| """ | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| analysis_data = load_analysis_data_for_llm(csv_path, output_dir, ml_summary=ml_summary, | |
| group_labels=group_labels) | |
| scenario_info = SCENARIOS.get(scenario_key, SCENARIOS["1"]) | |
| scenario_mod_key = scenario_info.get("scenario_module_key", "scenario_1") | |
| scenario_module = SCENARIO_MODULES.get(scenario_mod_key, "") | |
| metadata_str = f"共 {analysis_data['text_count']} 個譯本\n" | |
| for i, t in enumerate(analysis_data["texts"], 1): | |
| metadata_str += f" 譯本{i}:{t}\n" | |
| if user_metadata: | |
| metadata_str += f"\n【使用者補充】:\n{user_metadata}" | |
| system_prompt = SYSTEM_PROMPT_BASE.format( | |
| scenario_type = scenario_info["title"], | |
| metadata = metadata_str, | |
| user_focus = user_focus if user_focus else "無", | |
| scenario_module = scenario_module, | |
| indicator_reference_table = INDICATOR_REFERENCE_TABLE, | |
| ) | |
| # 2026-08-26(業主要求:可重複性設計的最後一塊——目前報告只印「提示詞 | |
| # 版本 v1」這個版本號,使用者/論文口委無從查證這個版本實際規定了 | |
| # 什麼,只能回頭翻原始碼):把這次分析實際套用(.format() 展開後、 | |
| # 已代入研究情境/metadata/指標對照表的完整版本)的 system prompt | |
| # 原文存成一份檔案,跟其他分析產出放在同一個 output_dir,隨「下載 | |
| # 全部資料」一起打包。直接從程式實際使用的字串存出來,不是另外維護 | |
| # 一份文件,未來 SYSTEM_PROMPT_BASE 異動也不會有兩邊對不起來的風險。 | |
| system_prompt_path = output_dir / "06_system_prompt.txt" | |
| with open(str(system_prompt_path), 'w', encoding='utf-8') as f: | |
| f.write(f"研究情境:{scenario_info['title']}\n") | |
| f.write(f"提示詞版本:{PROMPT_VERSION}\n") | |
| f.write(f"模型:主 Agent(LLM1){LLM1_MODEL}\n") | |
| f.write( | |
| "\n本檔案為這次分析實際送給 LLM 的完整 system prompt 原文\n" | |
| "(已代入本次的研究情境、語料 metadata 與指標代碼對照表)。\n" | |
| ) | |
| f.write("\n---\n\n") | |
| f.write(system_prompt) | |
| user_prompt = ( | |
| f"請根據以下量化分析結果,按照 system prompt 中的輸出結構進行深度分析。\n\n" | |
| f"【量化分析資料(JSON)】:\n```json\n" | |
| f"{json.dumps(analysis_data, ensure_ascii=False, indent=2)}\n```\n\n" | |
| "請嚴格按照五個區塊(一、語料說明 / 二、數據事實描述 / " | |
| "三、情境特定分析 / 四、待驗證事項 / 五、已知限制)輸出分析報告。" | |
| ) | |
| result_text = call_main_agent(system_prompt, user_prompt) | |
| timestamp = now_tw().strftime("%Y%m%d_%H%M%S") | |
| docx_path = output_dir / f"LLM_Analysis-{timestamp}.docx" | |
| txt_path = output_dir / f"LLM_Analysis-{timestamp}.txt" | |
| # 存 docx | |
| doc = Document() | |
| style = doc.styles['Normal'] | |
| style.font.name = '微軟正黑體' | |
| style.font.size = Pt(11) | |
| heading = doc.add_heading('LLM 輔助重譯研究報告', level=0) | |
| heading.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| # 標題後標註使用的 LLM 模型(藍色) | |
| model_p = doc.add_paragraph() | |
| model_p.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| # 2026-08-22(可重複性設計):模型名稱後面補上提示詞版本號,讓每份報告 | |
| # 都留下「當時是哪一版 system prompt 產出的」這個事後可稽核的紀錄,不用 | |
| # 回頭 git blame 才查得到——PROMPT_VERSION 定義處、修改時機見該常數註解。 | |
| mr = model_p.add_run( | |
| f'本報告由主 Agent(LLM1)模型 {LLM1_MODEL} 生成(提示詞版本 {PROMPT_VERSION})' | |
| ) | |
| mr.font.size = Pt(10) | |
| mr.font.color.rgb = RGBColor(0x0E, 0x73, 0xB8) | |
| doc.add_paragraph(f'研究情境:{scenario_info["title"]}', | |
| style='Normal').alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| doc.add_paragraph(f'報告時間:{now_tw().strftime("%Y-%m-%d %H:%M:%S")}(台灣時間)', | |
| style='Normal').alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| # 2026-08-24(業主回報:之前加過的 N/M/SD 圖例只在 Final_Report.docx | |
| # 那份技術報告的表格上方,這份 LLM 敘述報告是完全獨立的檔案,內文一樣 | |
| # 大量使用「M=...」但從沒解釋過,使用者只拿到這份報告的話完全看不懂。 | |
| # 兩份報告各自獨立、讀者不一定兩份都會看,圖例需要在各自檔案裡都出現, | |
| # 不能假設讀者一定也看過另一份。 | |
| legend_p = doc.add_paragraph( | |
| '本報告中 N=樣本數、M=平均數、SD=標準差;p 為統計顯著性之機率值,' | |
| '效果量以 Cohen\'s d(二群比較)或 η²(三群以上)表示。' | |
| ) | |
| legend_p.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| legend_p.runs[0].italic = True | |
| legend_p.runs[0].font.size = Pt(9) | |
| # 2026-08-26:提示詞版本號旁邊只有一個代號(v1),讀者查不到內容本身 | |
| # 具體規定了什麼——這裡指向同一次下載包裡的完整原文檔案。 | |
| sp_note = doc.add_paragraph( | |
| f'本報告據以撰寫的完整 system prompt 原文,請見同一份下載包中的 06_system_prompt.txt。' | |
| ) | |
| sp_note.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| sp_note.runs[0].italic = True | |
| sp_note.runs[0].font.size = Pt(9) | |
| doc.add_page_break() | |
| # 2026-08-14(待辦 A1):區塊層級語法(標題/項目符號)沿用原本判斷, | |
| # 差別是段落文字改用 _add_inline_markdown_runs() 逐段解析 **粗體**/ | |
| # $LaTeX$,不再是整行原樣塞給 add_heading()/add_paragraph()。 | |
| for line in result_text.split('\n'): | |
| stripped = line.strip() | |
| if stripped.startswith('#### '): _add_inline_markdown_runs(doc.add_heading('', level=4), stripped[5:]) | |
| elif stripped.startswith('### '): _add_inline_markdown_runs(doc.add_heading('', level=3), stripped[4:]) | |
| elif stripped.startswith('## '): _add_inline_markdown_runs(doc.add_heading('', level=2), stripped[3:]) | |
| elif stripped.startswith('# '): _add_inline_markdown_runs(doc.add_heading('', level=1), stripped[2:]) | |
| elif stripped.startswith('- '): _add_inline_markdown_runs(doc.add_paragraph(style='List Bullet'), stripped[2:]) | |
| elif stripped.startswith('* '): _add_inline_markdown_runs(doc.add_paragraph(style='List Bullet'), stripped[2:]) | |
| elif stripped: _add_inline_markdown_runs(doc.add_paragraph(), stripped) | |
| doc.save(str(docx_path)) | |
| # 存 txt(模型標註置於標題下方) | |
| with open(str(txt_path), 'w', encoding='utf-8') as f: | |
| f.write(f"研究情境:{scenario_info['title']}\n") | |
| f.write(f"時間:{now_tw().strftime('%Y-%m-%d %H:%M:%S')}(台灣時間)\n") | |
| # 2026-08-22(可重複性設計):docx 版標題下方本來就有模型名稱標註, | |
| # txt 版之前完全沒寫,這裡補上、順便帶提示詞版本號,兩種格式的 | |
| # 稽核資訊要一致,不能只有 docx 有。 | |
| f.write(f"模型:主 Agent(LLM1){LLM1_MODEL}(提示詞版本 {PROMPT_VERSION})\n") | |
| # 2026-08-24(業主回報 N/M/SD 圖例只加在 Final_Report.docx,這份 LLM | |
| # 敘述報告內文一樣大量出現「M=...」卻沒有解釋,兩份報告是獨立檔案、 | |
| # 讀者不一定都看過,圖例要各自都有): | |
| f.write( | |
| "本報告中 N=樣本數、M=平均數、SD=標準差;p 為統計顯著性之機率值," | |
| "效果量以 Cohen's d(二群比較)或 η²(三群以上)表示。\n" | |
| ) | |
| # 2026-08-26:同 docx 版,指向同一份下載包裡的完整 system prompt 原文。 | |
| f.write("本報告據以撰寫的完整 system prompt 原文,請見同一份下載包中的 06_system_prompt.txt。\n") | |
| # 2026-07-27:原本用 "="*60 當純文字分隔線,但緊接在上兩行文字正下方, | |
| # 符合 Markdown Setext 標題語法(文字正上方接一整行 = 會被解讀成 h1 | |
| # 底線),導致這兩行 metadata 被渲染成巨大無樣式標題。改成前面留空行 | |
| # 再接 "---",變成正規的 <hr> 分隔線(已有 CSS 樣式),純文字閱讀也 | |
| # 不受影響。 | |
| f.write("\n---\n\n") | |
| f.write(result_text) | |
| return { | |
| "docx_path": docx_path, | |
| "txt_path": txt_path, | |
| "result_text": result_text, | |
| "system_prompt_path": system_prompt_path, | |
| } | |
| # ============================================================================ | |
| # SECTION 8: (Reserved) | |
| # ============================================================================ | |
| # ============================================================================ | |
| # SECTION 9: Conversation Flow & State Machine | |
| # ============================================================================ | |
| STATES = { | |
| "SCENARIO_SELECT": "研究情境選擇", | |
| "UPLOAD_GUIDE": "文本上傳說明", | |
| "UPLOAD_WAIT": "等待文本上傳", | |
| "FORMAT_CHECK": "格式檢查中", | |
| "CALCULATING": "指標計算中", | |
| "ANALYZING": "統計分析中", | |
| "REPORT_WRITING": "研究報告撰寫中", | |
| "COMPLETE": "分析完成", | |
| } | |
| def _assistant_msg(text: str): | |
| return {"role": "assistant", "content": text} | |
| def _user_msg(text: str): | |
| return {"role": "user", "content": text} | |
| def make_initial_state(session_id: str) -> dict: | |
| sess_dir = SESSIONS_DIR / session_id | |
| sess_dir.mkdir(parents=True, exist_ok=True) | |
| (sess_dir / "uploads").mkdir(exist_ok=True) | |
| (sess_dir / "output").mkdir(exist_ok=True) | |
| return { | |
| "session_id": session_id, | |
| "session_dir": str(sess_dir), | |
| "state": "SCENARIO_SELECT", | |
| "scenario": None, | |
| "uploaded_files": [], | |
| "invalid_info": {}, | |
| "cleaned_files": [], | |
| "output_files": [], | |
| # 2026-07-24:原本是 zip_path(單數),從專案第一天就只被初始化成 None、 | |
| # 從沒被賦值過。改成 dict 存四包 zip 的路徑,key 見 _ZIP_GROUPS。 | |
| "zip_paths": {}, | |
| "user_metadata": "", | |
| "user_focus": "", | |
| "group_labels": {}, # {分組代碼: 語料說明填的實際標籤},略過或未填時維持空 dict | |
| "individual_grouping": {}, # {檔名: 個體間比較的分組標籤},非「個體間比較」模式時維持空 dict | |
| "analysis_result": None, | |
| "done_steps": [], # 已完成步驟(1-based) | |
| "current_step": None, # 目前執行步驟 | |
| "failed_step": None, # 執行失敗時卡在哪一步(1-based),成功時維持 None | |
| # 「分析運算」步驟底下三個子階段的即時狀態,供 Canvas 內容區塊分段呈現 | |
| # (見 _build_analysis_html)。status: pending / running / done / failed; | |
| # line: 給人看的一句結果或失敗原因。由 run_computation_pipeline_streaming | |
| # 邊跑邊寫入。 | |
| "analysis": { | |
| "idx": {"status": "pending", "line": ""}, | |
| "stat": {"status": "pending", "line": ""}, | |
| "report": {"status": "pending", "line": ""}, | |
| }, | |
| # 「文本上傳」步驟底下兩個子階段的即時狀態,結構比照上面的 analysis | |
| # (3-C,2026-07-30):供 Canvas 內容區塊分段呈現上傳進度(見 | |
| # _build_upload_html),status: pending / running / done / failed; | |
| # line: 給人看的一句結果或失敗原因。由 on_upload_confirm 邊跑邊寫入。 | |
| "upload": { | |
| "receive": {"status": "pending", "line": ""}, | |
| "validate": {"status": "pending", "line": ""}, | |
| }, | |
| # 2026-07-31(Batch E 第 7 項):原本這裡有 "tabs_toast_shown": False, | |
| # 是 Bug 4-A 修復時為了擋「分析結果已就緒」toast 重複跳出而加的旗標 | |
| # (_populate_tabs 現在會在每個子階段剛完成時都被呼叫,不再只呼叫 | |
| # 一次)。業主要求拿掉那顆 toast 本身(見 _populate_tabs),旗標 | |
| # 跟著失去用途,一併移除,不留無作用的殘留狀態。 | |
| } | |
| # 2026-07-23 步驟列收斂:原本 8 步裡有 4 步不帶資訊——①情境選擇在 Phase 8-A | |
| # 搬去首頁後,使用者在工作區看到的是別的畫面做完的事;①② 在同一個 generator | |
| # 內連續完成,中間沒有任何使用者動作;⑦⑧ 本來就寫死在同一行一起標記(下載 | |
| # 成果不是獨立運算階段)。同時補上原本完全沒有步驟的「語料說明」表單——先前 | |
| # 格式驗證通過就直接把 current_step 設成「指標計算」,導致使用者在填表的整段 | |
| # 期間,步驟列都亮著「指標計算 進行中」的呼吸動畫,畫面在說電腦正在算,實際 | |
| # 上是在等人填表。收斂後每一步都對應一件使用者感知得到的事。 | |
| # 2026-07-24 再收斂:8→6 之後又拿掉兩步。 | |
| # ① 移除「格式驗證」:它在任何靜止狀態下都不會停留——通過時跟前一步在同一個 | |
| # generator 內連續完成、中間沒有使用者動作;失敗時步驟列退回「文本上傳 | |
| # 進行中」(要求重新上傳),也不停在格式驗證。它從來不對應一個使用者能看到 | |
| # 的畫面狀態,逐行細節仍在技術執行紀錄裡。 | |
| # ② 合併「指標計算/統計分析/報告產出」成單一「分析運算」:三段是同一個 | |
| # generator 依序跑完的運算,中間沒有任何使用者操作關卡。三段各自有失敗 | |
| # 可能,但「壞在哪一段、其他段有沒有救」這種細節,藥丸大小的步驟列講不 | |
| # 清楚,改由 Canvas 的分析運算內容區塊分段呈現(見 _build_analysis_html)。 | |
| # 2026-07-30:業主要求把情境選擇從首頁搬回工作區,成為正式的第 1 步「研究 | |
| # 選擇」(見 create_app() 的 scenario_select_row、on_start_analysis_show/ | |
| # on_scenario_pick)。原本①提到「情境選擇搬去首頁後不算步驟列一步」在此 | |
| # 已不成立,_STEPS 從 3 步變 4 步,後面三步的編號全部 +1。 | |
| _STEPS = [ | |
| (1, "情境選擇"), | |
| (2, "文本上傳"), | |
| (3, "語料說明"), | |
| (4, "分析運算"), | |
| ] | |
| # 分析運算底下的三個子階段(key 用於 state["analysis"]、內容區塊分段、入口連結) | |
| _ANALYSIS_STAGES = [ | |
| ("idx", "指標計算", "idx"), | |
| ("stat", "統計分析", "stat"), | |
| ("report", "報告撰寫", "report"), | |
| ] | |
| def _build_header_html(done_steps: list = None, current_step: int = None, | |
| failed_step: int = None) -> str: | |
| """ | |
| 產生完整標題區塊 HTML(精簡單行產品名+動態步驟列),確定性、不依賴 LLM。 | |
| 2026-07-23 千卉回報原本三行大標題+額外進度條佔比過高,改成單行精簡 | |
| 產品名、拿掉進度條(進度資訊本來就已經完整表達在步驟列的已完成/進行中 | |
| /失敗三種狀態上,進度條是重複資訊)。 | |
| done_steps: 已完成步驟(1-based list) | |
| current_step: 目前執行步驟(1-based int) | |
| failed_step: 執行失敗時卡住的步驟(1-based int),失敗時該步驟改用失敗樣式, | |
| 不再顯示「進行中」的呼吸動畫(原本失敗時圓點會永遠卡在動畫, | |
| 使用者容易誤以為系統還在運算而手滑刷新頁面) | |
| """ | |
| done_steps = done_steps or [] | |
| # 2026-07-24:原本全部完成後整條步驟列收起、只留產品名,理由是「把高度讓給 | |
| # 結果內容」。但那是舊的「結果堆疊往下捲」模型下的決定;7-23 改成互斥頁面 | |
| # 切換後,完成時若停在 AI 對話頁,Canvas 根本沒有結果內容,讓出來的是一片 | |
| # 空白——步驟列一消失,畫面就整片空掉(千卉實測回報「連步驟列都消失了」)。 | |
| # 改成完成後步驟列留在原地、全部標 ✓,收尾感與畫面穩定都靠這排 ✓ 維持, | |
| # Canvas 的分析運算內容區塊則負責承載完成摘要。 | |
| pills = [] | |
| for num, name in _STEPS: | |
| if num == failed_step: | |
| pills.append( | |
| f'<span class="step-pill step-pill--failed">' | |
| f'<span class="step-num">!</span>{name}</span>' | |
| ) | |
| elif num in done_steps: | |
| pills.append( | |
| f'<span class="step-pill step-pill--done">' | |
| f'<span class="step-num">✓</span>{name}</span>' | |
| ) | |
| elif num == current_step: | |
| pills.append( | |
| f'<span class="step-pill step-pill--current">' | |
| f'<span class="step-num">{num}</span>{name}</span>' | |
| ) | |
| else: | |
| pills.append( | |
| f'<span class="step-pill">' | |
| f'<span class="step-num">{num}</span>{name}</span>' | |
| ) | |
| # 2026-07-31(Batch E 第 4-b 項):業主指定頂部列不要標題文字, | |
| # 原本的 <p class="title-line"> 整行移除,只留步驟列(四顆藥丸)。 | |
| # .title-line 這個 class 的 CSS 規則(含深色模式那份漸層文字修正) | |
| # 留著不刪:跟前四批同名 class 的踩坑記錄綁在一起,且沒有其他地方 | |
| # 在用會自動失效,不影響畫面;真的要清理屬於「順手清理死碼」, | |
| # 這批任務範圍不包含。 | |
| return ( | |
| '<div class="app-header">' | |
| f' <div class="steps">{"".join(pills)}</div>' | |
| '</div>' | |
| ) | |
| def handle_scenario_select(user_input: str, state: dict): | |
| """處理研究情境選擇輸入,回傳 (reply_text, new_state)。""" | |
| choice = user_input.strip() | |
| m = re.search(r'[1-4]', choice) | |
| if not m: | |
| return ("請輸入數字 **1、2、3 或 4** 選擇研究情境。", state) | |
| sc_num = m.group() | |
| sc_info = SCENARIOS[sc_num] | |
| state["scenario"] = sc_num | |
| # 2026-07-30:研究選擇(3-A)搬進工作區成為正式第 1 步後,選定情境= | |
| # 完成第 1 步,直接站上第 2 步「文本上傳」(原本情境選擇在首頁完成、 | |
| # 不算步驟列一步,_STEPS 只有 3 步;現在 _STEPS 變 4 步,步驟編號全部 +1) | |
| state["done_steps"] = [1] | |
| state["current_step"] = 2 | |
| # 固定文字,不經 LLM:確保每次措辭一致,並直接引導使用者去看上傳卡片裡的 | |
| # 格式要求,不需要另外由工作程序 Agent 生成一則重複格式細節的引導訊息 | |
| confirm_text = ( | |
| f"已確認您的研究情境為「{sc_num}. {sc_info['title']}」。" | |
| "接下來請您上傳文本檔案。" | |
| ) | |
| state["state"] = "UPLOAD_GUIDE" | |
| return (confirm_text, state) | |
| def handle_uploaded_files(files, state: dict): | |
| """ | |
| 接收 Gradio 上傳的檔案,複製到 session 目錄,回傳狀態更新與訊息。 | |
| """ | |
| if not files: | |
| return "尚未偵測到上傳檔案,請重新上傳。", state | |
| uploads_dir = Path(state["session_dir"]) / "uploads" | |
| uploads_dir.mkdir(parents=True, exist_ok=True) | |
| saved_paths = [] | |
| for f in files: | |
| src = Path(f.name) if hasattr(f, 'name') else Path(str(f)) | |
| dst = uploads_dir / src.name | |
| shutil.copy2(str(src), str(dst)) | |
| saved_paths.append(str(dst)) | |
| # 2026-08-15(業主回報「上傳5個刪除2個,語料說明跟計算卻還是用5個」, | |
| # 追查後發現):這裡原本是跟 state["uploaded_files"] 合併,不是直接 | |
| # 覆蓋——原意是想支援「格式驗證失敗時只需補傳有問題的檔案」,但 | |
| # Gradio 的 file_input 元件送過來的 files 只反映「使用者目前選取的 | |
| # 檔案是哪些」,沒辦法分辨「這次沒選這個檔案」到底是「使用者刻意刪除 | |
| # 不要了」還是「使用者沒有要動它、單純沒重新選」——合併邏輯把兩種 | |
| # 都當成後者處理,使用者刪除的檔案因此被「復活」跟這次選的檔案合併, | |
| # 送進下游計算的數量比使用者實際想要的多。 | |
| # 改成直接覆蓋,不合併:file_input 元件本身在「格式驗證失敗、退回 | |
| # 上傳畫面」之後不會被清空(見 on_upload_confirm 的 has_errors 分支, | |
| # outputs 沒有動 file_input),使用者原本選的檔案(含通過驗證的) | |
| # 仍留在畫面上,只要把修正後的檔案加回同一次選取、不用動已經沒問題 | |
| # 的檔案,「只補傳壞掉的那幾個」這個使用情境本來就能透過 Gradio 元件 | |
| # 自身的多檔選取狀態達成,不需要在後端另外合併——後端合併反而是這次 | |
| # bug 的成因,拿掉之後「使用者當下選了什麼,就是什麼」,語意才不會 | |
| # 跟畫面上看到的檔案清單兜不起來。 | |
| state["uploaded_files"] = saved_paths | |
| state["state"] = "FORMAT_CHECK" | |
| # 工作程序 Agent 說明開始檢查 | |
| file_list_str = "\n".join([Path(p).name for p in saved_paths]) | |
| sys_p = ( | |
| "你是重譯文本分析系統的工作程序 Agent," | |
| "請用繁體中文簡短告知使用者已收到檔案並正在進行格式檢查,50 字以內。" | |
| ) | |
| user_p = f"已接收以下 {len(saved_paths)} 個檔案:\n{file_list_str}" | |
| try: | |
| msg = call_worker_agent(sys_p, user_p) | |
| except: | |
| msg = f"已接收 **{len(saved_paths)}** 個檔案,正在進行格式檢查……" | |
| return msg, state | |
| def run_format_check(state: dict): | |
| """ | |
| 檢查所有上傳檔案是否為 ckip_segmentation_sumin 產生的格式、檔名字首是否 | |
| 合法、以及每組是否至少各有1個檔案。 | |
| 通過 → CALCULATING;不通過 → UPLOAD_WAIT(要求重新上傳)。 | |
| 返回 (reply_text, new_state, has_errors) | |
| """ | |
| scenario = state.get("scenario", "") | |
| # 情境1/2固定二群(A/B)、情境3/4可二群或三群(A/B/C),C是否用到要等 | |
| # Metadata表單才知道,所以這裡的「允許字首」比「必要字首」寬鬆一點 | |
| allowed_prefixes = {"A", "B", "C"} if scenario in ("3", "4") else {"A", "B"} | |
| required_prefixes = {"A", "B"} | |
| failed = {} | |
| for fp in state["uploaded_files"]: | |
| name = Path(fp).name | |
| prefix = name[0].upper() if name else "" | |
| if prefix not in allowed_prefixes: | |
| allowed_str = "、".join(f"{p}_xx.txt" for p in sorted(allowed_prefixes)) | |
| failed[name] = f"檔名字首不符——本情境僅接受 {allowed_str} 格式" | |
| continue | |
| ok, reason = validate_ckip_format(Path(fp)) | |
| if not ok: | |
| failed[name] = reason | |
| if not failed: | |
| present_prefixes = {Path(fp).name[0].upper() for fp in state["uploaded_files"]} | |
| missing = required_prefixes - present_prefixes | |
| if missing: | |
| present_str = "、".join(sorted(present_prefixes)) or "無" | |
| missing_str = "、".join(sorted(missing)) | |
| msg = ( | |
| f"⚠️ 請確認 {'、'.join(sorted(required_prefixes))} 兩組皆已上傳," | |
| f"目前偵測到:{present_str},缺少:{missing_str}。" | |
| ) | |
| state["state"] = "UPLOAD_WAIT" | |
| return msg, state, True | |
| state["state"] = "CALCULATING" | |
| state["cleaned_files"] = state["uploaded_files"] | |
| sys_p = ( | |
| "你是重譯文本分析系統的工作程序 Agent," | |
| "請用繁體中文告知使用者所有檔案已確認為語料淬鍊(CorpusRefinery)斷詞格式," | |
| "即將開始計算指標,30 字以內。" | |
| ) | |
| try: | |
| msg = call_worker_agent(sys_p, "所有檔案格式驗證通過。") | |
| except: | |
| msg = "[OK] 所有檔案均符合語料淬鍊(CorpusRefinery)斷詞格式,即將開始計算文本指標……" | |
| return msg, state, False | |
| # 有檔案不符(格式或字首) | |
| fail_list = "\n".join([f"- **{fn}**:{reason}" for fn, reason in failed.items()]) | |
| sys_p = ( | |
| "你是重譯文本分析系統的工作程序 Agent,請用繁體中文告知使用者:" | |
| "部分檔案不符合語料淬鍊(CorpusRefinery)斷詞工具的輸出格式," | |
| "必須重新使用該工具斷詞後再上傳,語氣清晰,80 字以內。" | |
| "請附上工具連結:https://github.com/minchun-git/I_CorpusRefinery" | |
| ) | |
| try: | |
| msg = call_worker_agent(sys_p, f"格式不符的檔案:\n{fail_list}") | |
| msg = msg + f"\n\n{fail_list}" | |
| except: | |
| msg = ( | |
| f"⚠️ 以下檔案不符合語料淬鍊(CorpusRefinery)斷詞格式,請重新處理後再上傳:\n\n" | |
| f"{fail_list}\n\n" | |
| "請使用 [語料淬鍊(CorpusRefinery)](https://github.com/minchun-git/I_CorpusRefinery) " | |
| "工具完成斷詞後,重新上傳檔案。" | |
| ) | |
| failed_names = set(failed.keys()) | |
| state["uploaded_files"] = [fp for fp in state["uploaded_files"] if Path(fp).name not in failed_names] | |
| state["state"] = "UPLOAD_WAIT" | |
| return msg, state, True | |
| # ── 產出打包(zip)──────────────────────────────────────────────────────────── | |
| # 使用者要把成果全部帶走,原本得點進三個結果頁各按各的下載鈕(共 15 個檔案)。 | |
| # 這裡把產出分成四包,接到左側導覽列底部的下載捷徑。 | |
| # key 同時是 zip 檔名(使用者下載後看到的名字),順序即導覽列上的顯示順序。 | |
| # | |
| # 呼叫點刻意放在 _populate_tabs(結果整理)而非運算流程:結果頁提供下載的是 | |
| # 改過名的複本(TransPrism_LLM Report_*.docx)且刻意排除原始 .txt,這些重新 | |
| # 命名就發生在 _populate_tabs 裡。在那裡打包才能保證 zip 內容跟使用者在頁面上 | |
| # 看到的檔案完全一致,也不用把「哪些檔案是給使用者的」這件事寫兩遍。 | |
| _ZIP_GROUPS = [ | |
| ("idx", "指標數據"), | |
| ("stat", "統計圖表"), | |
| ("report", "研究報告"), | |
| ("all", "全部資料"), | |
| ] | |
| def _make_result_zips(output_dir: Path, grouped: dict, allow_all: bool = True) -> dict: | |
| """把各組產出打包成 zip,回傳 {group_key: zip 路徑字串}。 | |
| grouped:{group_key: [檔案路徑, ...]},不含 "all"(由本函式自己合併)。 | |
| 只打包真實存在的檔案;某組完全沒有檔案就不產生該包(對應的下載捷徑 | |
| 也就不會出現),例如 LLM 報告失敗時「研究報告」那包不該憑空生出來。 | |
| allow_all:呼叫端還有「必然會出現、但目前尚未跑完」的組別時傳 | |
| False,即使目前已有 ≥2 組非空也先不合併「整包」——避免「完整分析 | |
| 資料」在報告撰寫階段還沒結束前,就用 idx+stat 兩組拼出一包名不副實 | |
| 的「完整」資料(2026-08-06 踩坑,見呼叫端 report_stage_done 註解)。 | |
| """ | |
| targets = {k: [f for f in v if Path(f).exists()] for k, v in grouped.items()} | |
| # 整包 = 各組去重合併。只有一組有東西時,整包跟那一組內容完全一樣, | |
| # 多給一顆按鈕只會讓人猶豫該點哪個,這種情況不另外產生整包。 | |
| if allow_all and sum(1 for v in targets.values() if v) >= 2: | |
| seen = set() | |
| targets["all"] = [f for v in targets.values() for f in v | |
| if not (f in seen or seen.add(f))] | |
| zips = {} | |
| for key, label in _ZIP_GROUPS: | |
| files = targets.get(key) or [] | |
| if not files: | |
| continue | |
| zip_path = output_dir / f"{label}.zip" | |
| try: | |
| with zipfile.ZipFile(str(zip_path), "w", zipfile.ZIP_DEFLATED) as zf: | |
| for f in files: | |
| zf.write(str(f), arcname=Path(f).name) | |
| zips[key] = str(zip_path) | |
| except Exception: | |
| # 打包失敗不該拖垮整個分析流程——各結果頁的單項下載仍然可用 | |
| traceback.print_exc() | |
| return zips | |
| def run_computation_pipeline_streaming(state: dict): | |
| """ | |
| 執行完整計算流程(指標計算 → 統計分析 → LLM 報告), | |
| 使用 generator 逐步 yield (progress_msg, is_final, state)。 | |
| """ | |
| output_dir = Path(state["session_dir"]) / "output" | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| results_paths = [] | |
| anal = None | |
| A = state["analysis"] # 三個子階段的即時狀態,邊跑邊寫,Canvas 內容區塊據此渲染 | |
| # ── 指標計算 ───────────────────────────────────────────────────────────── | |
| A["idx"]["status"] = "running" | |
| yield "【指標計算】正在計算文本指標……", False, state | |
| try: | |
| idx_df, xlsx_path, csv_path = run_indicator_calculation( | |
| state["cleaned_files"], output_dir, _RESOURCES | |
| ) | |
| results_paths.extend([str(xlsx_path), str(csv_path)]) | |
| # 2026-08-14:這裡顯示的「N 項語言指標」是進度列上的即時字樣,跟畫面 | |
| # 其他地方一樣要扣掉隱藏指標,不能直接數 idx_df(指標計算的原始輸出, | |
| # 仍含完整 75 欄)的欄位數,否則這裡會跟其他分頁顯示的數字對不上。 | |
| _idx_display_cols = _drop_hidden_indicators(idx_df).columns | |
| n_ind = max(len(_idx_display_cols) - sum(c in _idx_display_cols for c in ("Text","Type1","Type2")), 0) | |
| A["idx"]["status"] = "done" | |
| A["idx"]["line"] = f"{len(idx_df)} 個文本 × {n_ind} 項語言指標" | |
| yield f" [OK] 指標計算完成({len(idx_df)} 個文本)", False, state | |
| except Exception as e: | |
| A["idx"]["status"] = "failed" | |
| A["idx"]["line"] = f"計算失敗:{e}" | |
| state["state"] = "COMPLETE" | |
| state["output_files"] = results_paths | |
| yield f"[ERROR] 指標計算失敗:{e}", True, state | |
| return | |
| # ── 統計分析 ───────────────────────────────────────────────────────────── | |
| A["stat"]["status"] = "running" | |
| yield "【統計分析】正在執行統計分析……", False, state | |
| try: | |
| anal = run_statistical_analysis( | |
| csv_path, output_dir, | |
| group_labels=state.get("group_labels", {}), | |
| individual_grouping=state.get("individual_grouping", {}), | |
| ) | |
| for key in ["desc_path","diff_path","imp_path","imp_fig_path", | |
| "box_path","dendro_path","elbow_path","cluster_fig_path", | |
| "cluster_csv_path","report_path"]: | |
| p = anal.get(key) | |
| if p and Path(str(p)).exists(): | |
| results_paths.append(str(p)) | |
| state["analysis_result"] = { | |
| k: str(v) if isinstance(v, Path) else v | |
| for k, v in anal.items() if k != "df" | |
| } | |
| A["stat"]["status"] = "done" | |
| A["stat"]["line"] = ( | |
| f"{anal['sig_count']}/{anal['total_indicators']} 個顯著指標" | |
| f"(p < {ALPHA})、分類準確率 {anal['accuracy']:.4f}" | |
| ) | |
| yield ( | |
| f" [OK] 統計分析完成({anal['sig_count']}/{anal['total_indicators']} 個顯著指標," | |
| f"隨機森林準確率 {anal['accuracy']:.4f})" | |
| ), False, state | |
| except Exception as e: | |
| A["stat"]["status"] = "failed" | |
| A["stat"]["line"] = f"分析失敗:{e}" | |
| state["state"] = "COMPLETE" | |
| state["output_files"] = results_paths | |
| yield f"[ERROR] 統計分析失敗:{e}", True, state | |
| return | |
| # ── 報告撰寫 ───────────────────────────────────────────────────────────── | |
| A["report"]["status"] = "running" | |
| yield "【報告撰寫】主 Agent 正在撰寫研究報告(約需 30-90 秒)……", False, state | |
| try: | |
| rpt = run_report_writing( | |
| scenario_key = state["scenario"], | |
| user_metadata = state.get("user_metadata", ""), | |
| user_focus = state.get("user_focus", ""), | |
| csv_path = csv_path, | |
| output_dir = output_dir, | |
| ml_summary = {"accuracy": anal.get("accuracy"), "do_eval": anal.get("do_eval")}, | |
| group_labels = state.get("group_labels", {}), | |
| ) | |
| results_paths.extend([str(rpt["docx_path"]), str(rpt["txt_path"]), str(rpt["system_prompt_path"])]) | |
| A["report"]["status"] = "done" | |
| A["report"]["line"] = "研究報告已產出" | |
| yield " [OK] 研究報告撰寫完成", False, state | |
| except Exception as e: | |
| # A1(畫面說謊):報告失敗以前只 yield 一則 WARNING 進技術執行紀錄,然後 | |
| # 照常往下走到「全部分析完成」,步驟列還標 ✓——使用者拿到一份少了報告卻 | |
| # 顯示全部完成的結果。改成明確標記 failed,讓內容區塊與頂部步驟列都據實 | |
| # 呈現;指標與統計已完成、可下載,這點也講清楚不讓人以為全毀。 | |
| A["report"]["status"] = "failed" | |
| # 千卉 2026-07-24:加一句人話但保留原始技術訊息(診斷用,不刪) | |
| A["report"]["line"] = f"報告產出失敗,請稍後再試或聯繫管理員。(技術訊息:{e})" | |
| yield f" [WARNING] 研究報告撰寫失敗({e}),指標與統計結果不受影響,仍可查看與下載。", False, state | |
| # ── 完成 ───────────────────────────────────────────────────────────────── | |
| state["output_files"] = results_paths | |
| state["state"] = "COMPLETE" | |
| report_ok = A["report"]["status"] == "done" | |
| sig_info = "" | |
| if anal: | |
| sig_info = ( | |
| f"顯著指標:**{anal['sig_count']}/{anal['total_indicators']}** 個(p < {ALPHA})。\n" | |
| f"隨機森林分類準確率:**{anal['accuracy']:.4f}**。\n" | |
| ) | |
| if report_ok: | |
| summary = ( | |
| f"**分析完成!**\n\n" | |
| f"共分析 **{len(state['cleaned_files'])}** 個文本。\n" | |
| f"{sig_info}\n" | |
| f"請點選左側導覽列查看各項結果,或使用下載捷徑取回檔案。" | |
| ) | |
| else: | |
| summary = ( | |
| f"**分析完成(研究報告未產出)。**\n\n" | |
| f"共分析 **{len(state['cleaned_files'])}** 個文本。\n" | |
| f"{sig_info}\n" | |
| f"LLM 研究報告因故未能產出,但**指標與統計結果均已完成**," | |
| f"請點選左側導覽列查看,或使用下載捷徑取回檔案。" | |
| ) | |
| yield summary, True, state | |
| # ============================================================================ | |
| # SECTION 10: Gradio Interface (Tabbed) | |
| # ============================================================================ | |
| _EMPTY_HINT = ( | |
| '<div style="text-align:center;padding:4rem 1rem 3rem;">' | |
| '<div style="width:64px;height:64px;margin:0 auto 1.25rem;border-radius:16px;' | |
| 'background:rgba(var(--c-accent-rgb),.07);border:1px solid rgba(var(--c-accent-rgb),.20);' | |
| 'display:flex;align-items:center;justify-content:center;">' | |
| '<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="none" ' | |
| 'viewBox="0 0 24 24" stroke="var(--c-accent)" stroke-width="1.5">' | |
| '<path stroke-linecap="round" stroke-linejoin="round" ' | |
| 'd="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z"/>' | |
| '</svg></div>' | |
| '<p style="font-size:1rem;font-weight:600;color:var(--c-text-muted);margin:0 0 .4rem;">尚未產生資料</p>' | |
| '<p style="font-size:var(--font-size-base);color:var(--c-text-subtle);margin:0;">請先於「AI對話」完成分析流程</p>' | |
| '</div>' | |
| ) | |
| # ── SVG 圖示(Phase 8:導覽/操作圖示改用線條 icon,不再用 emoji)─────────── | |
| # 手繪 Heroicons 風格(24x24 viewBox、stroke=currentColor、圓角線頭), | |
| # 不是逐點對照官方套件,純線條輪廓、視覺重量統一,供 _icon() 組裝使用。 | |
| _ICON_PATHS = { | |
| "folder": '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>', | |
| "chevron-down": '<polyline points="6,9 12,15 18,9"/>', | |
| "chevron-double-left": '<polyline points="17,6 11,12 17,18"/><polyline points="11,6 5,12 11,18"/>', | |
| "bars-3": '<line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="17" x2="20" y2="17"/>', | |
| "x": '<line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/>', | |
| "home": '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5"/>', | |
| "chart-bar": '<line x1="4" y1="20" x2="20" y2="20"/><rect x="5" y="12" width="3" height="8"/><rect x="10.5" y="7" width="3" height="13"/><rect x="16" y="14" width="3" height="6"/>', | |
| "trending-up": '<polyline points="4,17 9,11 13,14 20,6"/><polyline points="14,6 20,6 20,12"/>', | |
| "document": '<path d="M7 3h7l5 5v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><line x1="8.5" y1="12" x2="15.5" y2="12"/><line x1="8.5" y1="15.5" x2="15.5" y2="15.5"/>', | |
| "plus": '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>', | |
| "download": '<path d="M12 3v12"/><polyline points="7,10 12,15 17,10"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/>', | |
| "moon": '<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3a6.6 6.6 0 0 0 9.8 9.8z"/>', | |
| "sun": '<circle cx="12" cy="12" r="4.2"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>', | |
| # 2026-07-30(2-B 第 3 項):「AI對話」改用對話氣泡圖示(跟 home.svg | |
| # 同一套 Heroicons 風格手繪,path 需與 assets/icons/chat-bubble.svg | |
| # 內容保持一致,這裡是給 _icon() 行內用法的對應版本,見該函式 docstring)。 | |
| "chat-bubble": '<path d="M4 5.5h16a1 1 0 0 1 1 1V16a1 1 0 0 1-1 1H9.5L5 21v-4H4a1 1 0 0 1-1-1V6.5a1 1 0 0 1 1-1z"/><line x1="7.5" y1="10" x2="16.5" y2="10"/><line x1="7.5" y1="13.5" x2="13" y2="13.5"/>', | |
| # 2026-08-12:導覽列最底的「使用說明」下載項目(assets/icons/book-open.svg | |
| # 的行內對應版,path 內容需與該檔一致)。 | |
| # 2026-08-12 二版:初版畫得比其他導覽圖示小一圈(實際上色像素 16×15, | |
| # 其他多是 18×16——用截圖逐一量非背景像素才看得出來,肉眼只覺得「怪怪的」)。 | |
| # 這版把 path 撐到 x 3.2–20.8、y 4.5–20,上色後約 18×16,跟 folder/ | |
| # chat-bubble/table-cells 同一個視覺量體。**新增導覽圖示時記得量一次**, | |
| # 光看 viewBox 都是 24×24 不代表畫出來一樣大。 | |
| "book-open": '<path d="M12 6.6C10.3 5.2 8.1 4.5 5.3 4.5c-.8 0-1.5.06-2.1.17v13.2c.6-.11 1.3-.17 2.1-.17 2.8 0 5 .75 6.7 2.13"/><path d="M12 6.6c1.7-1.4 3.9-2.1 6.7-2.1.8 0 1.5.06 2.1.17v13.2c-.6-.11-1.3-.17-2.1-.17-2.8 0-5 .75-6.7 2.13"/><line x1="12" y1="6.6" x2="12" y2="19.83"/>', | |
| # 2026-08-12:AI 對話氣泡頭像的白色四角星(assets/icons/ai-avatar.svg 的 | |
| # 行內對應版)。頭像的紫→藍漸層圓底畫在 CSS 的 .avatar-container 上, | |
| # 不畫在 SVG 裡——avatar_images 是 Python 端參數,同一個檔案沒辦法隨 | |
| # 明暗主題換色,交給 CSS 才吃得到 --c-accent-strong/2/3 這組 token。 | |
| # 二版(業主:兩顆都改實心、再大一點):檔案版改成 fill 實心、不描邊, | |
| # path 一併放大(大星 x3.9–17.1/y2.2–17.2、小星 x15.3–21.5/y13.6–20.7, | |
| # 幾乎撐滿 24 的框)。這裡的行內版是 _icon() 用的,走 stroke=currentColor | |
| # 的通用寫法,path 資料與檔案保持一致即可。 | |
| "sparkle": '<path d="M10.5 2.2c.75 4.3 2.3 6.6 6.6 7.5-4.3.9-5.85 3.2-6.6 7.5-.75-4.3-2.3-6.6-6.6-7.5 4.3-.9 5.85-3.2 6.6-7.5z"/><path d="M18.4 13.6c.34 2 1.08 3.1 3.1 3.55-2.02.45-2.76 1.55-3.1 3.55-.34-2-1.08-3.1-3.1-3.55 2.02-.45 2.76-1.55 3.1-3.55z"/>', | |
| } | |
| def _icon(name: str, size: int = 20) -> str: | |
| """回傳單一 SVG 圖示字串,供 gr.HTML() 內容與 gr.Button(icon=...) 檔案外的行內用法共用。""" | |
| path = _ICON_PATHS.get(name, "") | |
| return ( | |
| f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" ' | |
| f'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" ' | |
| f'stroke-linecap="round" stroke-linejoin="round">{path}</svg>' | |
| ) | |
| # E4(2026-08-05):全站樣式(原 _CUSTOM_CSS 常數,約 2165 行)已抽成獨立 | |
| # 檔案 assets/app.css,改用 app.launch(css_paths=...) 載入(Gradio 6 原生 | |
| # 支援,語意與原本 gr.Blocks(css=...) 相同,Gradio 一樣會自動幫每條選擇器 | |
| # 加上 .gradio-container .contain 前綴)。要改樣式直接改那份 .css 檔。 | |
| # Gradio 會將 gr.Blocks(css=...) 內的所有選擇器自動加上 .gradio-container .contain 前綴, | |
| # 這讓 @media 區塊內的 :root 永遠無法命中(:root 不可能是任何元素的子孫)。 | |
| # 因此系統深色模式的 token 覆寫改用 launch(head=...) 注入純 <style>,繞過該轉換。 | |
| # | |
| # 這個常數後來也收留了另一個踩到的漸層文字問題(2026-07-22 千卉實機回報, | |
| # 首頁與這裡的標題都會發生,要反白選取才看得到):這條規則原本寫成 | |
| # 「background: linear-gradient(角度, var(--x) 0%, var(--y) 55%, var(--z) | |
| # 100%) !important;」(background 簡寫),結果瀏覽器把這個簡寫展開成多個 | |
| # 子屬性時,background-image 這個子屬性解析成空值(用瀏覽器 devtools 直接 | |
| # 讀該 CSS 規則的 CSSStyleDeclaration 證實:getPropertyValue | |
| # ('background-image') 回傳空字串)——文字本身是 transparent,底下又沒有 | |
| # 漸層可畫,視覺上等於「標題消失」。這不是 Gradio 的 css= 轉換管線造成的 | |
| # (這裡本來就是繞過該管線的 head 注入 <style>,一樣會踩到),也不是 | |
| # repaint/合成層問題(試過 transform:translateZ(0) 無效)。修法:不要用 | |
| # background 簡寫,改直接寫 background-image 這個子屬性本身,跳過簡寫展開 | |
| # 的解析路徑——已用瀏覽器 devtools 直接改這條規則驗證過,改長寫法後漸層 | |
| # 立即正常顯示。首頁 .homepage-hero h1 用的是同一種 background 簡寫寫法, | |
| # 同一批修掉(見 assets/app.css 對應區塊)。 | |
| _DARK_MODE_HEAD_CSS = """ | |
| <style> | |
| .app-header .title-line { | |
| background-image: linear-gradient(130deg, var(--c-accent-strong) 0%, var(--c-accent2) 55%, var(--c-accent3) 100%) !important; | |
| -webkit-background-clip: text !important; | |
| background-clip: text !important; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root:not(.light) { | |
| --c-bg: #0b1424; | |
| --c-surface: #172136; | |
| --c-surface2: #1b2740; | |
| --c-surface3: #1e293b; | |
| --c-surface3-border: #334155; | |
| --c-border: rgba(14,165,233,.11); | |
| --c-border-hover: rgba(14,165,233,.30); | |
| /* 2026-08-12:新增的線條/底色 token(說明見 assets/app.css 的 :root | |
| 區塊)。這裡是「系統深色模式」那條觸發路徑,跟 app.css 的 :root | |
| 與 .dark 合計三個宣告點,任何 token 新增都要三處一起補,漏一處 | |
| 就會在某一種深色觸發方式下退回淺色值。 */ | |
| --c-border-strong: rgba(148,163,184,.28); | |
| --c-divider: rgba(148,163,184,.18); | |
| --c-bubble-border: rgba(148,163,184,.26); | |
| --c-thead-bg: #1e2a42; | |
| --c-bubble-bg: #172136; | |
| --c-accent: #0ea5e9; | |
| --c-accent-strong: #38bdf8; | |
| --c-accent2: #818cf8; | |
| --c-accent2-strong: #a5b4fc; | |
| --c-accent3: #c084fc; | |
| --c-accent-rgb: 14,165,233; | |
| --c-accent2-rgb: 129,140,248; | |
| --c-accent3-rgb: 192,132,252; | |
| --c-bg-rgb: 9,14,26; | |
| --c-text: #e2e8f0; | |
| --c-text-soft: #cbd5e1; | |
| --c-text-muted: #94a3b8; | |
| --c-text-subtle: #475569; | |
| --c-success: #34d399; | |
| --c-success-rgb: 52,211,153; | |
| --c-warn: #fbbf24; | |
| --c-user-bg: rgba(34,197,94,.18); | |
| --c-user-border: rgba(34,197,94,.42); | |
| --c-user-text: #bbf7d0; | |
| --c-code-bg: rgba(14,165,233,.12); | |
| --c-code-text: #fbbf24; | |
| --c-input-bg: #1a2540; | |
| --c-input-border: rgba(14,165,233,.15); | |
| --c-link-hover: #7dd3fc; | |
| --c-scrollbar: #1e3a5f; | |
| --c-scrollbar-hover: #334d6e; | |
| --body-background-fill: var(--c-bg) !important; | |
| --background-fill-primary: var(--c-surface) !important; | |
| --background-fill-secondary: #080f1c !important; | |
| --block-background-fill: var(--c-surface) !important; | |
| --block-border-color: var(--c-border) !important; | |
| --border-color-primary: var(--c-border) !important; | |
| --color-accent: var(--c-accent) !important; | |
| --color-accent-soft: rgba(14,165,233,.08) !important; | |
| --body-text-color: var(--c-text) !important; | |
| --block-label-text-color: var(--c-text-muted) !important; | |
| --block-title-text-color: var(--c-text-soft) !important; | |
| --input-background-fill: var(--c-input-bg) !important; | |
| --input-border-color: var(--c-input-border) !important; | |
| --button-primary-background-fill: var(--c-accent) !important; | |
| --button-primary-text-color: #fff !important; | |
| --shadow-drop: 0 1px 3px rgba(0,0,0,.5) !important; | |
| } | |
| } | |
| </style> | |
| """ | |
| # gr.Blocks(js=...) 在此版 Gradio 實測不會執行,所有需要在頁面載入後跑的 JS | |
| # 一律改用 launch(head=...) 注入 <script>,並用 readyState 判斷是否要等 | |
| # DOMContentLoaded(head 注入時機可能早於或晚於該事件)。 | |
| _APP_HEAD_SCRIPT = """ | |
| <script> | |
| (function () { | |
| function start() { | |
| document.title = '譯彩紛呈 II 分析引擎 SpectraEngine'; | |
| function fixBubbleWrap() { | |
| /* 直接鎖定 .bubble-wrap,強制 column + flex-start */ | |
| document.querySelectorAll('.main-chat .bubble-wrap, .main-chat div[role="log"]').forEach(function(el) { | |
| el.style.setProperty('flex-direction', 'column', 'important'); | |
| el.style.setProperty('justify-content', 'flex-start', 'important'); | |
| el.style.setProperty('align-items', 'flex-start', 'important'); | |
| el.style.setProperty('align-content', 'flex-start', 'important'); | |
| /* 注意:這裡原本有 el.scrollTop = 0,是舊版「最新訊息在上」設計 | |
| 遺留下來的重設。現在訊息改成新的在下(跟一般聊天介面同方向) | |
| 後,這行會在每次訊息更新(MutationObserver 觸發)時把捲動 | |
| 位置強制拉回最上面,導致使用者永遠沒辦法捲到對話視窗最下面 | |
| 看到最新內容——已移除,讓瀏覽器保留使用者原本的捲動位置。 */ | |
| }); | |
| } | |
| /* 多次觸發確保 Svelte hydration 後生效 */ | |
| [300, 800, 1500, 3000].forEach(function(t) { setTimeout(fixBubbleWrap, t); }); | |
| /* 首頁情境卡片(Phase 8):Gradio 沒有原生卡片元件,卡片本身是 | |
| gr.HTML() 刻的純展示內容,點擊時模擬點擊對應的隱藏版原生按鈕 | |
| (sc_btn_1..4,見 create_app() 的 .sc-bridge-btn),Python 端 | |
| on_scenario_pick 的 .click() 綁定完全不用改 */ | |
| /* 2026-07-24 修正:原本是把 listener 掛在 #homepage_scenario_grid | |
| 上、用 grid._bound 防重複,並在載入後 300/800/1500/3000ms 各綁 | |
| 一次。實測發現按「開始新分析」回到首頁後,情境卡片點了完全沒 | |
| 反應,必須重新整理頁面才能再選——因為 homepage_col 被設成 | |
| visible=False 時,Svelte 是把整個子樹「銷毀」而不是加 | |
| display:none(已用 devtools 確認:進工作區後 grid 節點直接從 | |
| DOM 消失),重新顯示時是全新節點,_bound 留在已被丟棄的舊節點 | |
| 上,而綁定只在開頁後那 3 秒內跑過,之後不會再重試。這讓 Bug1 | |
| 「選錯情境無法變更」的修復等於只有一半。 | |
| 改用事件代理掛在 document 上,跟 bindResultNav() 同一套解法: | |
| 不管卡片節點被銷毀重建幾次都不用重綁。 */ | |
| document.addEventListener('click', function (e) { | |
| var card = e.target.closest && e.target.closest('.scenario-card'); | |
| if (!card) return; | |
| var btn = document.getElementById('sc_btn_' + card.getAttribute('data-scenario')); | |
| if (btn) btn.click(); | |
| }); | |
| /* 分析運算內容區塊的「查看…」入口(2026-07-24):純畫面操作,點了 | |
| 等同點左側對應的結果導覽項目,沿用同一套 document 事件代理——內容 | |
| 區塊是 gr.HTML,會隨 gr.update 重建,掛在 document 上不用重綁。 */ | |
| document.addEventListener('click', function (e) { | |
| var entry = e.target.closest && e.target.closest('.analysis-entry'); | |
| if (!entry) return; | |
| var navBtn = document.getElementById(entry.getAttribute('data-nav')); | |
| if (navBtn) navBtn.click(); | |
| }); | |
| /* ── 明暗切換(5-F,2026-07-24)───────────────────────────────── | |
| 點了在 <html> 加 .dark / .light class 覆蓋系統預設並記進 localStorage。 | |
| CSS token 機制::root=淺色、.dark=深色、@media(prefers dark):root:not(.light) | |
| =系統深色。所以 html.dark→強制深、html.light→強制淺(贏過系統深色的 | |
| 媒體規則)、都沒有→跟隨系統。切換鈕在 nav-col 裡(工作區才有),但 | |
| _loadTheme 在頁面一載入就套用存好的偏好,首頁也吃得到。 */ | |
| /* 圖示 SVG 直接寫在按鈕裡(見 create_app 的 theme_toggle),顯示切換 | |
| 交給 CSS 的 #theme_toggle[data-theme-icon],這裡不再持有 SVG 字串。 */ | |
| /* 踩坑(2026-07-24,千卉回報「我自己開的 Chrome 按了沒用,你開的可以」): | |
| 原本只在 <html> 加 .dark/.light。但**當使用者的系統是深色時,Gradio 自己 | |
| 會偵測並在它的容器(.gradio-container/body)上加 dark class**,而 CSS 自訂 | |
| 屬性是「離內容最近的祖先勝出」——html.light 蓋不過 .gradio-container.dark, | |
| 所以點了畫面完全沒變化。我的 Playwright Chrome 是淺色系統、Gradio 不會加 | |
| 那個 class,因此自動測試一直測不出來。 | |
| 修法:偏好要同時套到 html/body/.gradio-container 三處,並在輪詢裡持續 | |
| 自我修正(Gradio 重繪時會把自己的 class 加回來)。 */ | |
| function _themeTargets() { | |
| return [document.documentElement, document.body, | |
| document.querySelector('.gradio-container')].filter(Boolean); | |
| } | |
| function _savedTheme() { | |
| try { return localStorage.getItem('spectra_theme'); } catch (err) { return null; } | |
| } | |
| /* 判斷「目前畫面實際是深是淺」。踩坑:原本沒有偏好時直接問 | |
| matchMedia(prefers-color-scheme),但 Gradio 可能因為自己的主題設定或 | |
| 系統偵測,已經在容器上掛了 dark class——此時畫面明明是深色,matchMedia | |
| 卻可能回報淺色,於是第一次點擊算出「要轉深」,使用者看到的是「按了 | |
| 沒變化」。改成先看畫面上真的有沒有 dark class,再退回問系統。 */ | |
| function _effectiveDark() { | |
| var saved = _savedTheme(); | |
| if (saved === 'dark') return true; | |
| if (saved === 'light') return false; | |
| var applied = _themeTargets().some(function (el) { | |
| return el.classList.contains('dark'); | |
| }); | |
| if (applied) return true; | |
| return window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| } | |
| /* 把使用者選的偏好壓在三個層級上;沒有偏好(跟隨系統)就完全不干預, | |
| 讓 Gradio 與媒體查詢自己決定。 */ | |
| function _applyThemeClasses() { | |
| var saved = _savedTheme(); | |
| if (!saved) return; | |
| var dark = saved === 'dark'; | |
| _themeTargets().forEach(function (el) { | |
| if (el.classList.contains('dark') !== dark) el.classList.toggle('dark', dark); | |
| if (el.classList.contains('light') === dark) el.classList.toggle('light', !dark); | |
| }); | |
| } | |
| function _applyThemeIcon() { | |
| var btn = document.getElementById('theme_toggle'); | |
| if (!btn) return; | |
| var dark = _effectiveDark(); | |
| /* 踩坑(2026-07-24,千卉回報「切換鈕不能用」):這個函式掛在 400ms | |
| 輪詢裡,原本每次 tick 都無條件重寫 innerHTML,等於每 0.4 秒把裡面 | |
| 的 <svg> 銷毀重建一次。真人點擊是 mousedown→mouseup 兩個事件, | |
| 只要中間跨到一次重建,瀏覽器就找不到「同一個元素上按下並放開」, | |
| click 事件根本不會產生——按鈕看起來完全沒反應。程式化 .click() | |
| 不經過 mousedown/mouseup,所以自動測試一直測不出來。 | |
| 修法:狀態沒變就不要動 DOM。 */ | |
| var want = dark ? 'dark' : 'light'; | |
| if (btn.dataset.themeIcon === want) return; | |
| /* 只改屬性,圖示顯示交給 CSS(#theme_toggle[data-theme-icon])—— | |
| DOM 結構完全不動,click 不會因為子元素被重建而消失。 */ | |
| btn.dataset.themeIcon = want; | |
| btn.setAttribute('aria-label', dark ? '切換為淺色模式' : '切換為深色模式'); | |
| } | |
| function _loadTheme() { | |
| _applyThemeClasses(); | |
| _applyThemeIcon(); | |
| } | |
| document.addEventListener('click', function (e) { | |
| if (!(e.target.closest && e.target.closest('#theme_toggle'))) return; | |
| var toDark = !_effectiveDark(); | |
| try { localStorage.setItem('spectra_theme', toDark ? 'dark' : 'light'); } catch (err) {} | |
| _applyThemeClasses(); | |
| _applyThemeIcon(); | |
| }); | |
| _loadTheme(); | |
| /* 2026-07-23:頁面切換模型。nav-col 的每個項目現在是真正的 | |
| 「頁面」——結果類項目(語言指標分析/統計比較分析/LLM輔助分析/ | |
| 技術執行紀錄)點進去時互斥顯示、收起對話欄、Canvas 佔滿寬度; | |
| 「AI對話」(原「回到上傳/表單」)點進去對話欄彈回、Canvas 顯示 | |
| 上傳/表單/運算中內容。跟 8-C/8-D 的舊行為(有資料就全部堆疊、 | |
| 使用者自己往下捲)完全不同:現在同一時間只顯示使用者當前選的 | |
| 那一頁,不管其他頁有沒有資料。 | |
| 技術做法:canvas-scroll 底下 7 個直屬區塊分成 5 組頁面歸屬 | |
| (upload_zone/metadata_zone/computing_zone 屬於 chat 頁; | |
| canvas_idx/canvas_stat/canvas_report/canvas_exec 各自獨立頁), | |
| JS 只清除或強制 inline display:none,絕不強制顯示——Python 那層 | |
| `visible=False`(資料是否存在的判斷,見 _populate_tabs)永遠 | |
| 優先,這裡只負責「同一頁的東西藏起來,不動 Python 已經決定隱藏 | |
| 的東西」(沿用 8-E 的 style.display='' / 'none' !important | |
| 技巧)。 */ | |
| var _PAGE_OWNED_IDS = { | |
| /* 3-A/3-C(2026-07-30):新增的研究選擇區塊(scenario_select_zone) | |
| 與上傳進度說明(upload_progress_zone)都屬於「chat」頁—— | |
| 跟 upload_zone/metadata_zone/computing_zone 一樣,是進工作區後 | |
| 對話欄仍顯示、Canvas 走流程內容的那個頁面,切到結果類頁面時要 | |
| 一併藏起來,切回 AI 對話才要出現。 */ | |
| chat: ['scenario_select_zone', 'upload_zone', 'metadata_zone', | |
| 'upload_progress_zone', 'computing_zone'], | |
| idx: ['canvas_idx'], | |
| stat: ['canvas_stat'], | |
| report: ['canvas_report'], | |
| exec: ['canvas_exec'] | |
| }; | |
| /* stat 頁沒有自己的導覽列項目——2026-07-24 拿掉「統計比較分析」 | |
| 那層 Accordion 後,四個子頁是平列的獨立項目,active 要落在使用者 | |
| 實際點的那一個,不能靠 page 名稱反查。因此 applyActivePage() 多收 | |
| 一個 navId 參數,並把結果記在 .main-layout-row 的 data-nav 上: | |
| 每 400ms 跑一次的 _reapplyActivePage() 不帶 navId,靠這個記錄才 | |
| 不會每個 tick 把 active 清掉。 */ | |
| var _PAGE_NAV_IDS = { | |
| chat: 'nav_top_btn', idx: 'nav_idx_btn', | |
| report: 'nav_report_btn', exec: 'nav_exec_btn' | |
| }; | |
| var _ALL_NAV_IDS = ['nav_top_btn', 'nav_idx_btn', 'nav_report_btn', 'nav_exec_btn', | |
| 'nav_stat_sub_0', 'nav_stat_sub_1', 'nav_stat_sub_2', 'nav_stat_sub_3']; | |
| function applyActivePage(page, navId) { | |
| var mainRow = document.querySelector('.main-layout-row'); | |
| if (!mainRow) return; | |
| mainRow.setAttribute('data-page', page); | |
| mainRow.classList.toggle('chat-collapsed', page !== 'chat'); | |
| if (navId) mainRow.setAttribute('data-nav', navId); | |
| else if (_PAGE_NAV_IDS[page]) mainRow.setAttribute('data-nav', _PAGE_NAV_IDS[page]); | |
| var activeId = mainRow.getAttribute('data-nav'); | |
| Object.keys(_PAGE_OWNED_IDS).forEach(function (key) { | |
| _PAGE_OWNED_IDS[key].forEach(function (id) { | |
| var el = document.getElementById(id); | |
| if (!el) return; | |
| if (key === page) { | |
| el.style.removeProperty('display'); | |
| } else { | |
| el.style.setProperty('display', 'none', 'important'); | |
| } | |
| }); | |
| }); | |
| _ALL_NAV_IDS.forEach(function (id) { | |
| var btn = document.getElementById(id); | |
| if (btn) btn.classList.toggle('active', id === activeId); | |
| }); | |
| } | |
| function _initActivePageIfNeeded() { | |
| var mainRow = document.querySelector('.main-layout-row'); | |
| if (!mainRow || mainRow.dataset.pageInit) return; | |
| mainRow.dataset.pageInit = '1'; | |
| applyActivePage('chat'); | |
| } | |
| /* 左側結果導覽(result_nav_col)——純粹是「切換頁面+捲動+切換 | |
| 巢狀子分頁」的畫面操作,不牽動任何後端狀態,所以這幾顆按鈕在 | |
| Python 端完全沒有掛 .click(),改用這裡的事件代理直接處理,不用 | |
| 走 Gradio queue 來回。 | |
| 注意:result_nav_col 建構時是 visible=False(跟 sc_btn_1..4 | |
| 一度踩過的坑同一類),如果比照 bindScenarioCards() 用 | |
| setTimeout 抓固定時間點的按鈕,情境選定前按鈕還沒掛載,之後 | |
| 也不會再重試。改用事件代理綁在 document 這個從頁面一開始就 | |
| 存在的祖先節點上,不管 result_nav_col 什麼時候才真正掛載進 | |
| DOM,點擊都能正確代理處理,不用重試機制。 */ | |
| function bindResultNav() { | |
| if (window._resultNavBound) return; | |
| window._resultNavBound = true; | |
| function scrollToId(id) { | |
| var el = document.getElementById(id); | |
| if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } | |
| document.addEventListener('click', function (e) { | |
| if (e.target.closest('#nav_top_btn')) { | |
| closeMobileDrawerIfOpen(); | |
| applyActivePage('chat'); | |
| var scrollBox = document.querySelector('.canvas-scroll'); | |
| if (scrollBox) scrollBox.scrollTo({ top: 0, behavior: 'smooth' }); | |
| return; | |
| } | |
| if (e.target.closest('#nav_idx_btn')) { | |
| closeMobileDrawerIfOpen(); applyActivePage('idx'); scrollToId('canvas_idx'); return; | |
| } | |
| if (e.target.closest('#nav_report_btn')) { | |
| closeMobileDrawerIfOpen(); applyActivePage('report'); scrollToId('canvas_report'); return; | |
| } | |
| if (e.target.closest('#nav_exec_btn')) { | |
| closeMobileDrawerIfOpen(); applyActivePage('exec'); scrollToId('canvas_exec'); return; | |
| } | |
| /* 2026-07-24:原本這裡還有一支處理「統計比較分析」Accordion | |
| 標題列點擊的分支,該層已移除,四個子項目自己就是入口。 */ | |
| for (var i = 0; i < 4; i++) { | |
| if (e.target.closest('#nav_stat_sub_' + i)) { | |
| closeMobileDrawerIfOpen(); | |
| applyActivePage('stat', 'nav_stat_sub_' + i); | |
| scrollToId('canvas_stat'); | |
| /* 注意:這版 Gradio 的巢狀分頁 DOM 結構是 | |
| .tabs > .tab-wrapper > .tab-container(兩個, | |
| 一個 .visually-hidden 是窄螢幕/溢出選單用的隱藏 | |
| 版本,另一個才是實際看得到、要點的),不是常見 | |
| 印象中的 .tab-nav——原本沿用 .tab-nav 的選擇器 | |
| (包含 CSS 那條,見上面 #canvas_stat 註解)在 | |
| 這版完全不會命中,只是沒人發現因為 Gradio 自己的 | |
| 預設樣式已經看起來夠好。這裡用 devtools 逐層 | |
| 確認過實際 class 後才改對。 */ | |
| var tabButtons = document.querySelectorAll( | |
| '#canvas_stat .tabs > .tab-wrapper > .tab-container:not(.visually-hidden) > button' | |
| ); | |
| if (tabButtons[i]) tabButtons[i].click(); | |
| return; | |
| } | |
| } | |
| }); | |
| } | |
| bindResultNav(); | |
| /* Phase 8-E:桌面收合 icon bar/手機漢堡抽屜。跟 bindResultNav() | |
| 同一套「事件代理綁在 document」手法,理由相同——nav-col 建構時 | |
| 在 visible=False 的 workspace_row 裡,情境選定前不存在,不能用 | |
| setTimeout 抓固定時間點的元素。 */ | |
| function setNavCollapsed(collapsed) { | |
| var navCol = document.querySelector('.nav-col'); | |
| if (!navCol) return; | |
| navCol.classList.toggle('collapsed', collapsed); | |
| var toggleBtn = document.getElementById('nav_collapse_toggle'); | |
| if (toggleBtn) { | |
| toggleBtn.setAttribute('aria-label', collapsed ? '展開導覽列' : '收合導覽列'); | |
| toggleBtn.setAttribute('aria-expanded', String(!collapsed)); | |
| } | |
| /* 收合後按鈕文字用 font-size:0 隱藏(見 CSS),accessible name | |
| 會跟著消失,改用 aria-label 補回原始文字,展開時移除即可 | |
| 讓瀏覽器繼續用按鈕原生文字內容當 accessible name。 */ | |
| ['reset_btn', 'nav_top_btn', 'nav_idx_btn', 'nav_report_btn', | |
| 'nav_guide_btn'].forEach(function (id) { | |
| /* elem_id 在 gr.Button 上這版 Gradio 是直接寫在 <button> | |
| 標籤本身,不是外層 wrapper div(跟 result_nav_col 那組 | |
| 純 gr.HTML() 按鈕、或 nav_top_btn 用 elem_id 做 CSS | |
| 選擇器點擊代理時假設的「wrapper 帶 id」不是同一種 | |
| 結構)——用 devtools 量過 outerHTML 才發現,兩者都要 | |
| 兼容。 */ | |
| var el = document.getElementById(id); | |
| var btn = el && (el.tagName === 'BUTTON' ? el : el.querySelector('button')); | |
| if (!btn) return; | |
| if (collapsed) { | |
| if (!btn.dataset.navLabel) btn.dataset.navLabel = btn.textContent.trim(); | |
| btn.setAttribute('aria-label', btn.dataset.navLabel); | |
| } else { | |
| btn.removeAttribute('aria-label'); | |
| } | |
| }); | |
| try { localStorage.setItem('spectra_nav_collapsed', collapsed ? '1' : '0'); } catch (err) {} | |
| } | |
| function setMobileDrawer(open) { | |
| var navCol = document.querySelector('.nav-col'); | |
| var backdrop = document.getElementById('nav_backdrop'); | |
| var toggle = document.getElementById('mobile_nav_toggle'); | |
| if (navCol) navCol.classList.toggle('mobile-open', open); | |
| if (backdrop) backdrop.classList.toggle('visible', open); | |
| if (toggle) toggle.setAttribute('aria-expanded', String(open)); | |
| /* 2026-07-30(2-B 驗收時發現,跟 3 行品牌字標同一批修): | |
| mobile_nav_toggle 是 position:fixed 浮動在 top:14/left:14 | |
| 的獨立按鈕,跟 nav-col 沒有 DOM 父子關係(見上方註解:它是 | |
| workspace_row 外層的常駐元件)。抽屜展開後 nav-col 也在同一 | |
| 個左上角(position:fixed,padding 1.1rem),z-index 45 的 | |
| 漢堡鈕疊在 z-index 40 的抽屜上面,正好蓋住品牌字標。品牌字標 | |
| 還是單行「SpectraEngine」時,這顆鈕只蓋到文字最前面一小角, | |
| 不明顯;這輪改 3 行之後蓋住的範圍變成兩整行,肉眼就看得出來。 | |
| 既然抽屜已經有自己的關閉鈕(nav_drawer_close),展開時這顆 | |
| 浮動漢堡鈕本來就沒有作用,直接藏起來最單純。 */ | |
| if (toggle) toggle.classList.toggle('mobile-nav-toggle--hidden', open); | |
| } | |
| function closeMobileDrawerIfOpen() { | |
| var navCol = document.querySelector('.nav-col'); | |
| if (navCol && navCol.classList.contains('mobile-open')) setMobileDrawer(false); | |
| } | |
| function bindNavControls() { | |
| if (window._navControlsBound) return; | |
| window._navControlsBound = true; | |
| document.addEventListener('click', function (e) { | |
| if (e.target.closest('#nav_collapse_toggle')) { | |
| var navCol = document.querySelector('.nav-col'); | |
| setNavCollapsed(!(navCol && navCol.classList.contains('collapsed'))); | |
| return; | |
| } | |
| if (e.target.closest('#mobile_nav_toggle')) { | |
| var navColEl = document.querySelector('.nav-col'); | |
| setMobileDrawer(!(navColEl && navColEl.classList.contains('mobile-open'))); | |
| return; | |
| } | |
| if (e.target.closest('#nav_drawer_close') || e.target.closest('#nav_backdrop')) { | |
| setMobileDrawer(false); | |
| return; | |
| } | |
| if (e.target.closest('#reset_btn')) { | |
| /* 2026-07-24:確認框改成全螢幕置中 modal 後,原本 | |
| 「收合狀態下先自動展開 nav-col,免得彈窗擠在 60px | |
| 窄欄裡」的變通已無必要,移除;手機抽屜仍要收起, | |
| 否則對話框會被抽屜遮住一半。 | |
| 這裡不 preventDefault/stopPropagation,事件照常 | |
| 冒泡給 Gradio 原生 .click()。 */ | |
| closeMobileDrawerIfOpen(); | |
| } | |
| /* 點遮罩空白處=取消(面板本身的點擊不算),沿用抽屜遮罩 | |
| 同一套互動慣例 */ | |
| if (e.target.id === 'reset_confirm_overlay') { | |
| var cancelBtn = document.getElementById('reset_cancel_btn'); | |
| if (cancelBtn) cancelBtn.click(); | |
| return; | |
| } | |
| if (e.target.closest('#reset_confirm_btn')) { | |
| /* 2026-07-23:真的按下「確定重新開始」才重置頁面切換 | |
| 狀態回 chat(不能在 #reset_btn 那支就做,那個只是 | |
| 打開確認彈窗,使用者還可能按取消)。.main-layout-row | |
| 這個 DOM 節點在 Gradio 重置流程中不會被銷毀重建, | |
| data-page/chat-collapsed 這些 attribute 不會自動 | |
| 清掉,若不主動重置,下一次選新情境時畫面會直接卡在 | |
| 上一次分析結束前所在的那一頁。 */ | |
| applyActivePage('chat'); | |
| } | |
| }); | |
| document.addEventListener('keydown', function (e) { | |
| if (e.key !== 'Escape') return; | |
| /* 對話框開著時 Esc 優先關對話框(modal 慣例:只關最上層的 | |
| 那一個),沒開著才輪到手機抽屜 */ | |
| /* 不能用 offsetParent !== null 判斷可見:position:fixed 的 | |
| 元素 offsetParent 永遠是 null,會判成「沒開」,Esc 就永遠 | |
| 關不到對話框(改 modal 前這個寫法是對的,改成 fixed 之後 | |
| 就不成立了)。改讀 computed display。 */ | |
| var overlay = document.getElementById('reset_confirm_overlay'); | |
| if (overlay && getComputedStyle(overlay).display !== 'none') { | |
| var cancelBtn = document.getElementById('reset_cancel_btn'); | |
| if (cancelBtn) cancelBtn.click(); | |
| return; | |
| } | |
| closeMobileDrawerIfOpen(); | |
| }); | |
| } | |
| bindNavControls(); | |
| /* 持續監聽 DOM 變化(每次訊息更新後重設) */ | |
| var mo = new MutationObserver(function(mutations) { | |
| var needsFix = mutations.some(function(m) { | |
| return m.target.closest && m.target.closest('.main-chat'); | |
| }); | |
| if (needsFix) { fixBubbleWrap(); } | |
| }); | |
| setTimeout(function() { | |
| var root = document.querySelector('.main-chat'); | |
| if (root) mo.observe(root, { childList: true, subtree: true, attributes: true }); | |
| }, 500); | |
| /* 新區塊(上傳卡片、metadata 表單)出現時自動捲入視野;消失時捲回頂部, | |
| 避免使用者看不到畫面下方新出現的操作元件(如上傳按鈕)而以為系統卡住。 | |
| 長時間運算期間 Gradio 會頻繁重新渲染畫面,元件的可見性有時會出現 | |
| 一瞬間的閃爍(不是真正的顯示/隱藏切換),若照單全收會反覆觸發 | |
| scrollIntoView,跟使用者手動往下捲動互相打架,感覺畫面卡住好久—— | |
| 改成要連續兩次(800ms)偵測到同一個新狀態才真的觸發捲動 */ | |
| var _zoneWasVisible = {}; | |
| var _zonePendingState = {}; | |
| var _zonePendingCount = {}; | |
| function _watchZone(id) { | |
| var zone = document.getElementById(id); | |
| var visible = !!(zone && zone.offsetParent !== null); | |
| var was = _zoneWasVisible[id] || false; | |
| if (visible === was) { _zonePendingCount[id] = 0; return; } | |
| if (_zonePendingState[id] === visible) { | |
| _zonePendingCount[id] = (_zonePendingCount[id] || 0) + 1; | |
| } else { | |
| _zonePendingState[id] = visible; | |
| _zonePendingCount[id] = 1; | |
| } | |
| if (_zonePendingCount[id] < 2) return; | |
| if (visible) { | |
| // 2026-08-15(業主回報「格式驗證通過切到語料說明時,畫面 | |
| // 會往下移動一下」):behavior:'smooth' 會讓捲動變成一段 | |
| // 看得見的滑動過程,使用者感受到的正是這段移動動畫本身, | |
| // 不是位置算錯。改成 'auto'(瀏覽器原生瞬間跳轉),新區塊 | |
| // 一出現畫面就直接定在該處,不再有可感知的移動過程。 | |
| zone.scrollIntoView({ behavior: 'auto', block: 'center' }); | |
| } else { | |
| window.scrollTo({ top: 0, behavior: 'smooth' }); | |
| } | |
| _zoneWasVisible[id] = visible; | |
| _zonePendingCount[id] = 0; | |
| } | |
| /* exec-log 已改成不設內部 max-height/overflow,只靠頁面本身捲動 | |
| (見陷阱:內部捲動 + 頁面捲動疊在一起看起來像兩條捲軸),所以不再 | |
| 需要 el.scrollTop 這種內部捲到底部的邏輯,避免又跟使用者手動捲動 | |
| 搶主導權(同樣的教訓見對話視窗捲不到底那次的修正)。 */ | |
| /* Phase 8-E:mobile_nav_toggle(手機漢堡鈕)是放在 workspace_row | |
| 外層、頁面一開始就存在的元件,但它只有在 nav-col 真正掛載 | |
| (情境已選定)時才該顯示;同理,桌面收合狀態要在 nav-col 剛 | |
| 掛載那一刻套用 localStorage 記住的值,早一步做(例如 DOMContentLoaded | |
| 當下)nav-col 根本還不存在,套不上。都改成搭這裡既有的 | |
| setInterval 一起輪詢,不額外開新計時器。 */ | |
| function _syncMobileHamburger() { | |
| /* 2026-07-23:改成切換 wrapper(#mobile_nav_toggle_wrap/ | |
| #nav_backdrop_wrap)本身的 display,不是內層按鈕/遮罩自己 | |
| 的 style——wrapper 預設 display:none 才能真正零高度(見 | |
| CSS 對應註解),內層元素桌面/手機斷點的顯示邏輯完全不變, | |
| 這裡只負責「nav-col 存不存在」這個外層開關。 */ | |
| var navColExists = !!document.querySelector('.nav-col'); | |
| var toggleWrap = document.getElementById('mobile_nav_toggle_wrap'); | |
| var backdropWrap = document.getElementById('nav_backdrop_wrap'); | |
| if (toggleWrap) toggleWrap.style.display = navColExists ? 'block' : 'none'; | |
| if (backdropWrap) backdropWrap.style.display = navColExists ? 'block' : 'none'; | |
| } | |
| function _initNavCollapseIfNeeded() { | |
| var navCol = document.querySelector('.nav-col'); | |
| if (!navCol || navCol.dataset.navInit) return; | |
| navCol.dataset.navInit = '1'; | |
| var saved; | |
| try { saved = localStorage.getItem('spectra_nav_collapsed'); } catch (err) { saved = null; } | |
| if (saved === '1') setNavCollapsed(true); | |
| } | |
| function _reapplyActivePage() { | |
| /* 踩坑(2026-07-23,全新踩坑類別):applyActivePage() 用 | |
| el.style.setProperty('display','none','important') 隱藏 | |
| 非當前頁區塊,理論上一次設定就該一直有效。但實測發現運算 | |
| 完成後(_populate_tabs 把 canvas_idx_col 等設 visible=True) | |
| canvas_idx/canvas_stat 又跑出來混進 chat 頁——代表 Gradio | |
| 的 Svelte reactivity 在元件的 visible prop 改變時,會自己 | |
| 整個重寫該元素的 style 屬性(不是「疊加」而是「覆蓋」), | |
| 把我手動加的 inline !important 一併洗掉。這跟先前踩過的 | |
| 「Python 的 gr.update 值改變時整個重新渲染」是同一個大類, | |
| 只是這次連 inline style 都被連帶清空,不只是 min-width 那種 | |
| 單一屬性優先權的問題。修法沿用這個檔案一貫的解法:不奢求 | |
| 一次設定永久有效,改成跟 _watchZone/_syncMobileHamburger | |
| 一樣搭這裡的輪詢,每 400ms 重新套用一次目前頁面對應的 | |
| 隱藏規則,Gradio 重新渲染蓋掉的話下一個 tick 就修回來。 */ | |
| var mainRow = document.querySelector('.main-layout-row'); | |
| var page = mainRow && mainRow.getAttribute('data-page'); | |
| if (page) applyActivePage(page); | |
| } | |
| var _unloadGuardOn = false; | |
| function _onBeforeUnload(e) { | |
| e.preventDefault(); | |
| e.returnValue = ''; /* 舊版瀏覽器需要,值本身不會被顯示 */ | |
| return ''; | |
| } | |
| function _syncUnloadGuard() { | |
| /* 防手滑:目前完全沒有存檔機制(.spectra 存檔/讀檔是後續工作), | |
| 分析跑到一半或跑完還沒下載時關掉分頁/按重新整理,整段工作直接 | |
| 歸零。這裡在工作區顯示期間掛上 beforeunload,讓瀏覽器跳原生 | |
| 確認框。判斷依據是工作區有沒有真的顯示在畫面上——還停在首頁 | |
| 沒選情境時不攔,避免只是進來看看的人也被問一次;按「開始新 | |
| 分析」重置回首頁後也會自動解除。 | |
| 兩個瀏覽器層面的限制(不是實作沒做好):①使用者必須先與頁面 | |
| 互動過,這個確認框才會出現,這是各家瀏覽器防濫用的共同規範 | |
| ②提示文字一律由瀏覽器決定,無法自訂成「您的分析尚未下載」。 */ | |
| var row = document.querySelector('.main-layout-row'); | |
| var active = !!(row && row.offsetParent !== null); | |
| if (active === _unloadGuardOn) return; | |
| _unloadGuardOn = active; | |
| if (active) window.addEventListener('beforeunload', _onBeforeUnload); | |
| else window.removeEventListener('beforeunload', _onBeforeUnload); | |
| } | |
| setInterval(function () { | |
| _watchZone('upload_zone'); | |
| _watchZone('metadata_zone'); | |
| _syncMobileHamburger(); | |
| _initNavCollapseIfNeeded(); | |
| _initActivePageIfNeeded(); | |
| _reapplyActivePage(); | |
| _syncUnloadGuard(); | |
| /* Gradio 重繪時會把自己偵測系統主題加的 dark class 加回容器上, | |
| 這裡持續把使用者選的偏好壓回去(跟本檔其他「輪詢自我修正」同一套 | |
| 解法)。theme_toggle 在工作區才掛載,圖示也一併補正。 */ | |
| _applyThemeClasses(); | |
| _applyThemeIcon(); | |
| }, 400); | |
| } | |
| if (document.readyState === 'loading') { | |
| window.addEventListener('DOMContentLoaded', start); | |
| } else { | |
| start(); | |
| } | |
| })(); | |
| </script> | |
| """ | |
| _APP_HEAD = _DARK_MODE_HEAD_CSS + _APP_HEAD_SCRIPT | |
| def _build_scenario_cards_html(grid_id: str) -> str: | |
| """4 張研究情境卡片,資料來源 SCENARIOS 字典。 | |
| 2026-07-30(3-A):從 _build_homepage_html() 抽出成獨立函式——情境選擇 | |
| 從首頁搬進工作區的「研究選擇」步驟後,卡片只剩工作區這一處在用,但 | |
| 仍抽成函式而不是內嵌寫死在呼叫處,避免將來又要多處共用時複製兩份 | |
| SCENARIOS 的渲染邏輯。grid_id 由呼叫端指定(原本首頁用的是 | |
| homepage_scenario_grid,這個 id 目前沒有任何 CSS/JS 依賴它,純粹方便 | |
| 肉眼辨識用途),卡片點擊事件是 document 層級的事件代理(見 | |
| _APP_HEAD_SCRIPT 的 .scenario-card 監聽器,認的是 class 不是這個 id), | |
| grid_id 換了完全不影響既有綁定。 """ | |
| cards = [] | |
| for n in ("1", "2", "3", "4"): | |
| sc = SCENARIOS[n] | |
| cards.append( | |
| '<div class="scenario-card" role="listitem" tabindex="0" ' | |
| f'data-scenario="{n}">' | |
| f'<span class="sc-index en">{n.zfill(2)}</span>' | |
| '<span class="sc-body">' | |
| f'<span class="sc-title">{sc["title"]}</span>' | |
| f'<span class="sc-desc">{sc["desc"]}</span>' | |
| '</span></div>' | |
| ) | |
| return f'<div class="scenario-grid" id="{grid_id}" role="list">{"".join(cards)}</div>' | |
| def _build_homepage_hero_html() -> str: | |
| """首頁介紹區塊上半部(品牌名稱+標題+描述)。 | |
| 2026-07-30(Phase 9):業主指定情境卡片搬去工作區的「研究選擇」步驟 | |
| (見 3-A、_build_scenario_cards_html),首頁只留介紹文字與一顆 | |
| 「開始研究分析」按鈕(1-B,見 create_app() 的 start_analysis_btn)。 | |
| 按鈕是真正的 gr.Button(需要接 Python 事件,不能用 gr.HTML() 刻), | |
| 因此原本一整塊首頁 HTML 拆成 hero/footer 兩段(見下面 | |
| _build_homepage_footer_html),讓 create_app() 能把 gr.Button 插在 | |
| 兩段中間;兩段共用同一個 .homepage-col 外層,改由 create_app() 用 | |
| gr.Column(elem_classes=["homepage-col"]) 包起來,不是這裡自己吐一個 | |
| 大 <div>。 | |
| """ | |
| return ( | |
| '<div class="homepage-hero">' | |
| # 1-C:業主指定移除第一行「重譯文本量化分析平台」(原本的 | |
| # <span class="kicker">),下方直接從系列名稱開始。 | |
| '<p class="series-name">譯彩紛呈:重譯文本分析系統</p>' | |
| '<p class="subtitle en">TransPrism: An Analytical Framework for Retranslation</p>' | |
| '<h1><span class="en">II</span> 分析引擎 <span class="en">SpectraEngine</span></h1>' | |
| # 1-F:描述文字砍掉原本結尾「請先選擇最符合您研究設計的比較情境:」 | |
| # ——情境選擇已搬去工作區,首頁不用再提示選情境。 | |
| f'<p class="lede">上傳已斷詞的譯本文本,系統將自動計算 <span class="en">{TOTAL_VISIBLE_INDICATORS}</span> 項語言指標並進行統計分析,' | |
| '協助您完成重譯研究的量化論證。</p>' | |
| '</div>' | |
| ) | |
| def _build_homepage_footer_html() -> str: | |
| """首頁介紹區塊下半部(讀取進度佔位連結+底部說明),接在 1-B 的 | |
| 「開始研究分析」按鈕下方。見 _build_homepage_hero_html 上方說明。 | |
| """ | |
| return ( | |
| '<div class="homepage-secondary">' | |
| f'<span class="placeholder-link">{_icon("folder", 16)}讀取進度(功能開發中)</span>' | |
| '</div>' | |
| # 1-G:底部說明文字改版,原文「選擇任一情境即開始分析」也不再適用 | |
| # (首頁不選情境了)。 | |
| '<p class="homepage-footnote">分析過程需要運算等待時間,建議一次執行一項分析,以確保運算速度。</p>' | |
| ) | |
| def create_app(): | |
| with gr.Blocks( | |
| title="譯彩紛呈 II 分析引擎 SpectraEngine", | |
| theme=gr.themes.Base( | |
| primary_hue=gr.themes.colors.sky, | |
| secondary_hue=gr.themes.colors.indigo, | |
| neutral_hue=gr.themes.colors.slate, | |
| font=[gr.themes.GoogleFont("Inter"), "Noto Sans TC", "system-ui", "sans-serif"], | |
| font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "monospace"], | |
| ), | |
| # E4:css= 移到 app.launch(css_paths=...)(見該處),Blocks 建構子不再帶樣式。 | |
| # 注意:gr.Blocks(js=...) 在目前這版 Gradio 實測不會執行(已用 | |
| # document.querySelectorAll('.main-chat .bubble-wrap')[0].getAttribute('style') | |
| # 驗證從未被寫入過 inline style),因此所有互動用 JS 都改放到 | |
| # launch(head=...) 注入的 <script>(見 _APP_HEAD_SCRIPT)。 | |
| ) as demo: | |
| session_state = gr.State(None) | |
| # ══════════════════════════════════════════════════════════════ | |
| # 主頁(Phase 8-C:不再包在 gr.Tabs()/gr.Tab() 裡,外層分頁列已拿掉) | |
| # ══════════════════════════════════════════════════════════════ | |
| # Phase 9(2026-07-30):首頁只留品牌介紹+一顆「開始研究分析」入口 | |
| # 按鈕(1-A/1-B)。原本住這裡的 4 張情境卡片搬去工作區的「研究 | |
| # 選擇」步驟(見下方 scenario_select_row、3-A),首頁不再展示情境 | |
| # 內容,也不需要在首頁解析使用者選了哪個情境。 | |
| # .homepage-col 這個外層改由這裡的 gr.Column 承接(elem_classes), | |
| # 不是 _build_homepage_hero_html()/_build_homepage_footer_html() | |
| # 自己吐一個 <div class="homepage-col">——因為「開始研究分析」是 | |
| # 真正的 gr.Button(需要接 Python 事件),必須插在 hero/footer | |
| # 兩段 HTML 中間,同一個 Column 才能讓三者視覺上仍是同一塊。 | |
| with gr.Column(visible=True) as homepage_col: | |
| with gr.Column(elem_classes=["homepage-col"]): | |
| gr.HTML(_build_homepage_hero_html()) | |
| # 1-B:真正的 gr.Button,不是首頁卡片那種「HTML+隱藏按鈕 | |
| # JS 橋接」手法——那個手法存在的理由是 Gradio 沒有原生卡片 | |
| # 元件,單顆按鈕不需要繞這一圈,直接掛 .click() 即可(見下方 | |
| # start_analysis_btn.click() wiring)。 | |
| start_analysis_btn = gr.Button( | |
| "開始研究分析", elem_id="start_analysis_btn", variant="primary", | |
| ) | |
| gr.HTML(_build_homepage_footer_html()) | |
| # 3-A:研究選擇用的 4 顆隱藏版情境按鈕(.sc-bridge-btn,JS 橋接 | |
| # 目標,見 _APP_HEAD_SCRIPT 的 document click 代理)。原本住在 | |
| # homepage_col 裡,首頁改版後 homepage_col 進工作區時會被設成 | |
| # visible=False——Gradio 對「visible=False 的容器」是整段從 DOM | |
| # 卸載(不是只用 CSS 隱藏),這幾顆按鈕會跟著被卸載,JS 就再也點 | |
| # 不到卡片。搬到這裡(跟 mobile_nav_toggle_html/nav_backdrop_html | |
| # 同一層級:最外層、一開始就 visible=True、建構時就掛進 DOM 的 | |
| # 位置),不管 homepage_col 或 workspace_row 怎麼切換可見性,這 | |
| # 幾顆按鈕永遠掛在 DOM 上,JS 永遠找得到可點的對象。.click() 綁定 | |
| # 邏輯(on_scenario_pick)完全不用改。 | |
| sc_btn_1 = gr.Button("1 人工翻譯 vs 機器翻譯", variant="secondary", | |
| elem_id="sc_btn_1", elem_classes=["sc-bridge-btn"]) | |
| sc_btn_2 = gr.Button("2 全譯 vs 編譯(節譯、改寫)", variant="secondary", | |
| elem_id="sc_btn_2", elem_classes=["sc-bridge-btn"]) | |
| sc_btn_3 = gr.Button("3 不同譯者的同期譯本", variant="secondary", | |
| elem_id="sc_btn_3", elem_classes=["sc-bridge-btn"]) | |
| sc_btn_4 = gr.Button("4 不同時期人工譯本", variant="secondary", | |
| elem_id="sc_btn_4", elem_classes=["sc-bridge-btn"]) | |
| # Phase 8-E:手機漢堡開關+抽屜背景遮罩。跟 sc_btn_1..4 同一類坑: | |
| # 建構時 visible=False 的容器整段不掛載進 DOM,而這兩個元件要在 | |
| # nav-col(在 workspace_row 裡,情境選定前不存在)尚未掛載前就能 | |
| # 被點到,所以放在最外層、一開始就 visible=True 的位置;CSS 預設 | |
| # display:none,只在手機寬度且 workspace 啟用時由 JS 顯示(見 | |
| # _APP_HEAD_SCRIPT 的 _syncMobileHamburger()),桌面寬度/首頁 | |
| # 狀態下完全不佔版面也不可點。 | |
| # 2026-07-23 踩坑(分兩輪才修對):這兩個元件的實際內容 | |
| # (#mobile_nav_toggle/#nav_backdrop)本身雖然預設 display:none, | |
| # 但那只是隱藏「內層」元素——Gradio 自動包在 gr.HTML() 外面的 | |
| # wrapper(.block)沒有被一併處理,仍然佔用版面高度+跟相鄰元件的 | |
| # flex gap,變成看得到但摸不著原因的空白區塊(千卉截圖回報頂部/ | |
| # 底部有大片空白才發現)。 | |
| # 第一輪嘗試:elem_classes 掛 nav-html-bridge,CSS 設 | |
| # `display:contents`,理論上該讓 wrapper 不產生盒模型——實測 | |
| # 沒用,逐一 display:none 排查後才發現:這版 Gradio 的 | |
| # elem_classes 會同時蓋在外層 .block wrapper 跟內層 .prose | |
| # wrapper 兩層 div 上(這個踩坑本檔 8-B 就記過一次「.block.<class> | |
| # 跟.prose.<class>」的雙重疊加,這次加新元件時忘了套用同一個 | |
| # 教訓),兩層 display:contents 疊加後不知為何沒有真的清零 | |
| # flex gap 佔用的空間。 | |
| # 第二輪改用最直接可靠的做法:wrapper 用 elem_id 直接鎖定, | |
| # 預設寫死 display:none(一般 block 元素包一個 display:none 的 | |
| # 子元素,天生就是零高度,不用依賴 display:contents 這種比較 | |
| # 少人踩過的屬性),JS 改成切換這個 wrapper 本身的 display, | |
| # 內層 #mobile_nav_toggle/#nav_backdrop 自己的 CSS(桌面/手機 | |
| # 斷點判斷)完全不用動,兩層互不干擾。 | |
| mobile_nav_toggle_html = gr.HTML( | |
| f'<button type="button" id="mobile_nav_toggle" class="mobile-nav-toggle" ' | |
| f'aria-label="開啟導覽選單" aria-expanded="false">{_icon("bars-3", 22)}</button>', | |
| elem_id="mobile_nav_toggle_wrap", | |
| ) | |
| nav_backdrop_html = gr.HTML( | |
| '<div id="nav_backdrop" class="nav-backdrop"></div>', | |
| elem_id="nav_backdrop_wrap", | |
| ) | |
| # 「回首頁」確認對話框(2026-07-24 改成置中 modal,2026-07-30 | |
| # 隨按鈕改名一併調整文案):原本是 | |
| # nav-col 裡的一張內嵌卡片,擠在 220px 窄欄裡,收合成 60px icon bar | |
| # 時更不成人形(8-E 當時還為此加了「收合狀態下點重置先自動展開 | |
| # nav-col」的變通,改成 modal 後那個理由消失,該變通已一併移除)。 | |
| # 放在 workspace_row 外層是必要的,不是順手:nav-col 在手機寬度是 | |
| # position:fixed+transform 的抽屜,而被 transform 的祖先會讓子孫的 | |
| # position:fixed 改以該祖先為定位基準,對話框會被關在抽屜裡而不是 | |
| # 覆蓋整個視窗。 | |
| # 2026-07-30(2-B 第 4 項):按鈕改名「回首頁」後,文案也要跟著改, | |
| # 但行為完全沒變——千卉已確認回首頁一樣是整個重置(進度會遺失), | |
| # 不是單純切換畫面,所以文案刻意保留「遺失進度」這句,避免使用者 | |
| # 誤以為只是導覽切換。elem_id(reset_confirm_overlay/panel/ | |
| # reset_cancel_btn/reset_confirm_btn)與 on_reset_clear_workspace/ | |
| # on_reset_confirm 的 wiring 完全不動,純文案異動。 | |
| with gr.Column(visible=False, elem_id="reset_confirm_overlay", | |
| elem_classes=["reset-confirm-overlay"]) as reset_confirm_row: | |
| with gr.Column(elem_id="reset_confirm_panel", elem_classes=["reset-confirm-panel"]): | |
| gr.Markdown("確定要回首頁嗎?目前進度將會遺失,需要重新開始整個分析流程。", | |
| elem_classes=["reset-confirm-text"]) | |
| with gr.Row(elem_classes=["reset-confirm-actions"]): | |
| reset_cancel_btn = gr.Button("取消", elem_id="reset_cancel_btn", | |
| scale=1, variant="secondary") | |
| reset_confirm_btn = gr.Button("確定回首頁", elem_id="reset_confirm_btn", | |
| scale=1, variant="primary") | |
| # 2026-08-26:「關於」面板,跟上面 reset_confirm_row 同一個理由放在 | |
| # workspace_row 外層(避免被手機版 nav-col 的 transform 抽屜關住), | |
| # 沿用同一套 overlay 機制(elem_classes 掛 reset-confirm-overlay 觸發 | |
| # 既有的置中 + display:none 控制邏輯),面板本身另開較寬的 | |
| # about-panel class(見 assets/app.css 註解)。 | |
| with gr.Column(visible=False, elem_id="about_overlay", | |
| elem_classes=["reset-confirm-overlay"]) as about_overlay: | |
| with gr.Column(elem_id="about_panel", elem_classes=["about-panel"]): | |
| gr.Markdown(ABOUT_CONTENT_MD) | |
| with gr.Row(elem_classes=["about-close-row"]): | |
| about_close_btn = gr.Button("關閉", elem_id="about_close_btn", | |
| scale=0, variant="secondary") | |
| # Phase 8-B:三欄式工作區——nav-col(窄,導覽/重置)+chat-col | |
| # (對話視窗+輸入框,獨立捲動)+canvas-col(步驟列+上傳/表單/ | |
| # 執行紀錄,獨立捲動)。手機寬度見 CSS 的 media query 改上下堆疊。 | |
| with gr.Row(elem_classes=["main-layout-row"], visible=False) as workspace_row: | |
| with gr.Column(scale=2, min_width=200, elem_classes=["nav-col"]) as nav_col: | |
| # 2026-07-30(2-B 第 1/2 項全面改版):nav-col 這輪拆成三段式 | |
| # 結構——①這裡(頂端品牌列,固定不捲動)②下面 | |
| # nav_items_scroll(中段,flex:1 自己 overflow-y:auto)③欄位 | |
| # 收尾處的 nav_bottom_toolbar(底部工具列,固定不捲動)。原因 | |
| # 見 .nav-col CSS 規則本身的長註解:明暗切換/收合鈕這輪要移到 | |
| # 「置底+置右」,如果整欄還是共用同一個捲動容器,內容一多, | |
| # 底部工具列就會被捲出視窗——跟 Bug 4-B(下載捷徑那組被摺行 | |
| # 裁掉)是同一類「flex 子項可以無限長大、把重要項目擠出可視 | |
| # 範圍」的問題,只是換了個受害者,這次拆結構從根本避免。 | |
| # 2026-07-31(Batch E 第 2 項):品牌字標從 3 行改 2 行(業主 | |
| # 改主意,見 .nav-brand CSS 的同批註解),字級統一改 1.1rem; | |
| # 手機抽屜關閉鈕(nav_drawer_close)跟品牌字標一起留在這個 | |
| # 固定頂端列,抽屜開關本來就该在使用者一打開就摸得到的 | |
| # 地方,不用跟著挪。 | |
| gr.HTML( | |
| '<div class="nav-top-row">' | |
| '<div class="nav-brand">' | |
| '<span class="nav-brand-line1">譯彩紛呈 <span class="en">II</span> 分析引擎</span>' | |
| '<span class="nav-brand-line2 en">SpectraEngine</span>' | |
| '</div>' | |
| '</div>' | |
| '<div class="nav-drawer-close-row">' | |
| f'<button type="button" id="nav_drawer_close" class="nav-drawer-close" ' | |
| f'aria-label="關閉導覽選單">{_icon("x", 18)}</button>' | |
| '</div>', | |
| elem_id="nav_top_html", | |
| ) | |
| # 中段可捲動區:回首頁連結、結果導覽全部包進來,只有這個 | |
| # Column 自己 overflow-y:auto(見 .nav-items-scroll CSS), | |
| # 不影響上下兩段固定區塊。 | |
| with gr.Column(elem_classes=["nav-items-scroll"]) as nav_items_scroll: | |
| # 「回首頁」連結與其確認彈窗:換情境/回首頁都走這一套共用 | |
| # 機制。情境是封閉式按鈕選擇,不透過對話框打字,所以「換 | |
| # 情境」也統一走這顆按鈕,不恢復文字解析路徑。 | |
| # 2026-07-30(2-B 第 3 項):原本是置中的「開始新分析」大按鈕, | |
| # 千卉這輪改成置左的「回首頁」文字連結,圖示也跟著換成 home | |
| # (對話氣泡讓給下面的「AI對話」,語意上 home 更適合「回首頁」)。 | |
| # elem_id="reset_btn" 不動,後面所有 .click() wiring/收合狀態 | |
| # 選擇器都靠這個 id,行為完全不變(見 on_reset_confirm 附近 | |
| # 註解:仍然是整個重置,不是單純換頁面)。 | |
| reset_btn = gr.Button( | |
| "回首頁", icon=str(BASE_DIR / "assets" / "icons" / "home.svg"), | |
| elem_id="reset_btn", elem_classes=["reset-btn"], variant="secondary", | |
| ) | |
| # Phase 8-D:結果導覽——情境選定後顯示,讓使用者不用手動 | |
| # 往下捲一大段就能跳到 Canvas 對應區塊。這幾顆按鈕刻意不接 | |
| # Python .click():純粹是「捲動+切換巢狀子分頁」的畫面 | |
| # 操作,不牽動任何後端狀態,改用跟首頁情境卡片同一套手法 | |
| # (_APP_HEAD_SCRIPT 的 JS 監聽),完全不用走 Gradio queue | |
| # 來回,反應更快。(下載捷徑當初規劃留到下一輪補,已於 | |
| # 2026-07-24 補齊,見下方 nav_dl_* 那四顆。) | |
| with gr.Column(visible=False, elem_id="result_nav_col", elem_classes=["result-nav"]) as result_nav_col: | |
| # 2026-07-23 改名「AI對話」+改變功能定位:原本純粹是 | |
| # 「捲回 Canvas 最上面」的畫面操作,現在身兼「切回流程頁」 | |
| # 的角色(見 applyActivePage() JS)——流程進行中(上傳/ | |
| # 表單/運算中)對話欄常駐顯示,點結果類項目才收起;點 | |
| # 這顆按鈕會讓對話欄彈回來。 | |
| # 2026-07-30(2-B 第 3 項):圖示改用對話氣泡(chat-bubble. | |
| # svg),語意上比原本借用的 home 圖示更準確——home 這次讓給 | |
| # 上面新改名的「回首頁」連結,兩顆圖示不再共用同一個。 | |
| nav_top_btn = gr.Button( | |
| "AI對話", icon=str(BASE_DIR / "assets" / "icons" / "chat-bubble.svg"), | |
| elem_id="nav_top_btn", elem_classes=["result-nav-btn"], variant="secondary", | |
| ) | |
| # 2026-07-24(A2):結果類導覽項目預設 interactive=False。 | |
| # 運算完成前它們就已經隨 result_nav_col 顯示,但對應的 | |
| # canvas_*_col 還是 Gradio visible=False,點下去只會切到一片 | |
| # 空白(JS 只能清 display,蓋不過 Python 的 visible=False)。 | |
| # 改成先 disable(灰階),由 _populate_tabs 依各結果是否真的 | |
| # 產出來逐一啟用——LLM 輔助分析尤其:報告失敗時它維持灰的, | |
| # 剛好是失敗訊號的第二道確認。AI 對話與技術執行紀錄不在此列 | |
| # (前者永遠可回、後者從第一秒就有內容)。 | |
| nav_idx_btn = gr.Button( | |
| "語言指標分析", icon=str(BASE_DIR / "assets" / "icons" / "trending-up.svg"), | |
| elem_id="nav_idx_btn", elem_classes=["result-nav-btn"], variant="secondary", | |
| interactive=False, | |
| ) | |
| # 2026-07-24 千卉決定:拿掉「統計比較分析」這層 Accordion | |
| # (標題文字與收合效果都不要),四個子頁直接跟其他導覽項目 | |
| # 平列。它原本存在的理由(2026-07-23 改 open=True 時的說法) | |
| # 是「讓人看得出底下還有四個子頁面」,但四頁攤開後這層標題 | |
| # 只剩下多一列文字跟一個不會有人去點的箭頭。 | |
| # 副作用:原本「統計比較分析」那一列兼任 stat 頁的 active | |
| # 指示器,拿掉後改由使用者實際點的那個子項目自己標 active, | |
| # 見 _APP_HEAD_SCRIPT 的 applyActivePage()。 | |
| nav_stat_sub_0 = gr.Button( | |
| "敘述統計", icon=str(BASE_DIR / "assets" / "icons" / "table-cells.svg"), | |
| elem_id="nav_stat_sub_0", elem_classes=["result-nav-btn"], variant="secondary", | |
| interactive=False) | |
| nav_stat_sub_1 = gr.Button( | |
| "差異性分析", icon=str(BASE_DIR / "assets" / "icons" / "scale.svg"), | |
| elem_id="nav_stat_sub_1", elem_classes=["result-nav-btn"], variant="secondary", | |
| interactive=False) | |
| nav_stat_sub_2 = gr.Button( | |
| "監督式學習", icon=str(BASE_DIR / "assets" / "icons" / "academic-cap.svg"), | |
| elem_id="nav_stat_sub_2", elem_classes=["result-nav-btn"], variant="secondary", | |
| interactive=False) | |
| nav_stat_sub_3 = gr.Button( | |
| "非監督式學習", icon=str(BASE_DIR / "assets" / "icons" / "squares-2x2.svg"), | |
| elem_id="nav_stat_sub_3", elem_classes=["result-nav-btn"], variant="secondary", | |
| interactive=False) | |
| nav_report_btn = gr.Button( | |
| "LLM輔助分析", icon=str(BASE_DIR / "assets" / "icons" / "document.svg"), | |
| elem_id="nav_report_btn", elem_classes=["result-nav-btn"], variant="secondary", | |
| interactive=False, | |
| ) | |
| # ── 下載捷徑(2026-07-24)──────────────────────────── | |
| # 上面四項是「去看結果」,這四顆是「把結果帶走」,性質 | |
| # 不同所以用分隔線隔開。原本使用者要拿到全部產出得點進 | |
| # 三個結果頁各按各的下載鈕(共 15 個檔案),這裡各打成 | |
| # 一包 zip。內容為空的那幾包不會產生,對應按鈕就不出現 | |
| # (例如 LLM 報告失敗時「下載研究報告」不會亮出來)。 | |
| # 分隔線與四顆按鈕整組包進 nav_dl_group:沒有任何一包 zip | |
| # 時整組隱藏(否則分隔線會孤零零留在畫面上,還跟下面技術 | |
| # 執行紀錄的分隔線貼在一起變兩條);有 zip 時整組顯示, | |
| # 個別按鈕再依自己那包是否存在決定。群組與個別可見性都由 | |
| # _populate_tabs 設定。 | |
| with gr.Column(visible=False, elem_id="nav_dl_group", | |
| elem_classes=["result-nav-dl-group"]) as nav_dl_group: | |
| gr.HTML('<div class="result-nav-divider"></div>') | |
| nav_dl_idx_btn = gr.DownloadButton( | |
| "下載指標數據", icon=str(BASE_DIR / "assets" / "icons" / "download.svg"), | |
| visible=False, elem_id="nav_dl_idx_btn", | |
| elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary", | |
| ) | |
| nav_dl_stat_btn = gr.DownloadButton( | |
| "下載統計圖表", icon=str(BASE_DIR / "assets" / "icons" / "download.svg"), | |
| visible=False, elem_id="nav_dl_stat_btn", | |
| elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary", | |
| ) | |
| nav_dl_report_btn = gr.DownloadButton( | |
| "下載研究報告", icon=str(BASE_DIR / "assets" / "icons" / "download.svg"), | |
| visible=False, elem_id="nav_dl_report_btn", | |
| elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary", | |
| ) | |
| nav_dl_all_btn = gr.DownloadButton( | |
| "下載全部資料", icon=str(BASE_DIR / "assets" / "icons" / "download.svg"), | |
| visible=False, elem_id="nav_dl_all_btn", | |
| elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary", | |
| ) | |
| # 2026-07-23:技術執行紀錄從手風琴轉正成獨立頁面(原本 | |
| # exec_log_accordion 在 canvas-col 底部,現在跟其他三個 | |
| # 結果頁同一種待遇)。用分隔線隔開,視覺上表達「低優先度 | |
| # 診斷工具,不是主要分析結果」,避免跟上面三項混為一談。 | |
| gr.HTML('<div class="result-nav-divider"></div>') | |
| nav_exec_btn = gr.Button( | |
| "技術執行紀錄", icon=str(BASE_DIR / "assets" / "icons" / "folder.svg"), | |
| elem_id="nav_exec_btn", elem_classes=["result-nav-btn", "result-nav-btn--muted"], | |
| variant="secondary", | |
| ) | |
| # ── 使用說明(2026-08-12,業主要求)────────────────── | |
| # 刻意放在 result_nav_col **外面**、nav_items_scroll 的最後 | |
| # 一項:說明文件跟分析進度無關,選研究情境前也該拿得到。選情境 | |
| # 後它自然落在「技術執行紀錄」下方,就是業主要的「最後一個 | |
| # 項目下方」。 | |
| # 它是下載動作不是頁面,所以跟 nav_dl_* 四顆一樣,不接 | |
| # applyActivePage()、不進 _APP_HEAD_SCRIPT 的 _ALL_NAV_IDS, | |
| # 否則點下去會把 active 狀態從當前結果頁搶走。 | |
| # 樣式提醒:導覽項目的視覺掛在 `#result_nav_col | |
| # .result-nav-btn` 或 `#reset_btn` 這種帶 id 的選擇器上 | |
| # (純 class 贏不過 Gradio 的 button.svelte-xxx.secondary), | |
| # 這顆住在 result_nav_col 外面,已比照 #reset_btn 把 | |
| # #nav_guide_btn 加進 app.css 那幾組選擇器清單。 | |
| gr.HTML('<div class="result-nav-divider"></div>') | |
| nav_guide_btn = gr.DownloadButton( | |
| "使用說明", value=str(BASE_DIR / "使用說明.docx"), | |
| icon=str(BASE_DIR / "assets" / "icons" / "book-open.svg"), | |
| elem_id="nav_guide_btn", | |
| elem_classes=["result-nav-btn", "result-nav-btn--muted"], | |
| variant="secondary", | |
| ) | |
| # ── 關於(2026-08-26,業主要求)─────────────────────── | |
| # 跟使用說明同一顆待遇:住在 result_nav_col 外面,同一份 | |
| # elem_classes(沿用 #nav_guide_btn 那組樣式選擇器,見 | |
| # assets/app.css)。這顆是彈窗不是下載,用一般 gr.Button | |
| # 搭配下面的 about_overlay 面板,toggle 顯示/隱藏。 | |
| about_btn = gr.Button( | |
| "關於", icon=str(BASE_DIR / "assets" / "icons" / "info-circle.svg"), | |
| elem_id="nav_about_btn", | |
| elem_classes=["result-nav-btn", "result-nav-btn--muted"], | |
| variant="secondary", | |
| ) | |
| # ↑ nav_items_scroll 到此結束(中段可捲動區)。 | |
| # 底部工具列(固定,不隨中段捲動):明暗切換/收合鈕原本在頂端 | |
| # 品牌列右側,這輪千卉指定移到「置底+置右」,上方用一條水平線 | |
| # (border-top,見 .nav-bottom-toolbar CSS)分隔。兩顆按鈕本身 | |
| # 的 id/aria-label/內部 SVG 完全沿用,只是搬了位置,_APP_HEAD_ | |
| # SCRIPT 裡靠 #theme_toggle/#nav_collapse_toggle 這兩個 id 找 | |
| # 元件的事件代理(document 層級)完全不受影響,不用動 JS。 | |
| gr.HTML( | |
| '<div class="nav-bottom-toolbar">' | |
| f'<button type="button" id="theme_toggle" class="nav-icon-btn" ' | |
| f'aria-label="切換為深色模式">' | |
| f'<span class="ico-moon">{_icon("moon", 18)}</span>' | |
| f'<span class="ico-sun">{_icon("sun", 18)}</span>' | |
| f'</button>' | |
| f'<button type="button" id="nav_collapse_toggle" class="nav-collapse-toggle" ' | |
| f'aria-label="收合導覽列" aria-expanded="true">{_icon("chevron-double-left", 18)}</button>' | |
| '</div>', | |
| elem_id="nav_bottom_toolbar", | |
| ) | |
| with gr.Column(scale=3, min_width=280, elem_classes=["chat-col"]): | |
| # main-chat 用 flex:1 1 auto 吃掉 chat-col 的全部高度(見該 | |
| # CSS 規則),下面 chat_upload_col 這輪起改成 visible=False | |
| # 常駐隱藏,對話視窗因此自然吃滿整欄高度,不用另外調 flex 比例。 | |
| # 2026-08-12(業主要求):AI 訊息左邊加頭像,讓「這句是 AI 在 | |
| # 講話」一眼可辨。用 Gradio 原生 avatar_images,不自己造 JS | |
| # 橋接;tuple 第一格是使用者頭像,給 None 代表只有 AI 這一側 | |
| # 顯示。SVG 檔只有白色四角星,紫→藍漸層圓底畫在 CSS 的 | |
| # .avatar-container(見 app.css「AI 頭像」區塊的理由說明)。 | |
| chatbot = gr.Chatbot( | |
| show_label=False, | |
| elem_classes=["main-chat"], | |
| avatar_images=(None, str(BASE_DIR / "assets" / "icons" / "ai-avatar.svg")), | |
| ) | |
| # 2026-07-30(2-C 第 5 項):對話輸入框這輪改成常駐隱藏 | |
| # (visible=False,不刪元件——降低風險,也保留 Phase 8 這段 | |
| # 沿革紀錄)。它本來就是「沒有真正作用的佔位元件」(見下方 | |
| # 沿革說明),千卉這輪決定乾脆藏起來,讓對話視窗吃滿整欄。 | |
| # 用 visible=False 而不是拿掉整個 gr.Column:component 仍在 | |
| # DOM 樹裡建構好,之後若要復活只要把這裡改回 True,不用重寫 | |
| # wiring;而且這是「建構當下就設定好、從未被事件動態切換過」 | |
| # 的靜態可見性,不吃本檔開頭記錄的 Gradio 6 容器卸載地雷 | |
| # (那個地雷只在「同一批訊息裡跟祖先可見性一起翻轉」時才會 | |
| # 咬人,這裡從頭到尾都是 False,沒有翻轉可言)。 | |
| # ── 以下是 Phase 8 沿革(千卉實測後回頭調整):對話輸入框當時 | |
| # 改回「常駐顯示、不負責上傳」——業主原本設計就是用拖曳/點擊 | |
| # 上傳的卡片(見下方 upload_row),對話框目前也沒有其他打字 | |
| # 用途(全站掃過沒有任何地方會解析使用者自由輸入文字),當時 | |
| # 只留著當畫面元素,不接 sources=["upload"],避免顯示附加 | |
| # 檔案的迴紋針卻沒有作用。(原本的 chat_upload.submit() 轉接 | |
| # 函式 on_chat_upload_submit 已於 2026-07-24 當死碼移除,之後 | |
| # 要復活直接重接 .submit() 即可,見該處註解。) | |
| with gr.Column(visible=False, elem_id="chat_upload_zone", elem_classes=["upload-zone-plain"]) as chat_upload_col: | |
| chat_upload = gr.MultimodalTextbox( | |
| file_types=[".txt"], | |
| sources=[], | |
| placeholder="對話輸入框(功能開發中)", | |
| show_label=False, | |
| lines=3, | |
| submit_btn=False, # 2026-07-24 千卉:拿掉送出鈕 | |
| interactive=True, # 2026-07-24 千卉:要可打字(雖然目前不接任何作用) | |
| ) | |
| with gr.Column(scale=7, min_width=380, elem_classes=["canvas-col"]): | |
| # 頂部步驟列+情境標籤:原本在全頁最上方(首頁也會看到), | |
| # Phase 8-B 搬進 Canvas 內部,只在 workspace_row 顯示後 | |
| # 才出現,首頁不再看到。 | |
| # 注意:這裡不能靠 app_header_html 自己 position:sticky | |
| # 達到「頂部固定」——它的直接 wrapper(Gradio 自動包的 | |
| # .block)高度只比它自己的內容高一點點,sticky 的可移動 | |
| # 範圍被那層 wrapper 框住,捲動一小段(wrapper 多出來的 | |
| # 高度)後就整個跟著捲走,不會真的貼在 Canvas 頂端不動 | |
| # (已實測踩坑)。正確做法:步驟列留在捲動區外面(跟著 | |
| # canvas-col 本身固定不動),下面另外包一層 | |
| # canvas-scroll 才是真正會捲動的區域,兩者高度各自獨立。 | |
| # 2-A(2026-07-30):elem_id 給頂部列的 wrapper,讓 CSS 能鎖定它 | |
| # 只在「AI 對話」頁顯示(見 5d 區塊 .main-layout-row:not(...) | |
| # 那條規則),其他結果頁(語言指標分析/敘述統計/…/技術 | |
| # 執行紀錄)都不顯示。用 elem_id 而不是 elem_classes: | |
| # elem_classes 會同時蓋在外層 .block 與內層 .prose 兩層 div | |
| # 上(本檔多處踩過這坑),id 選擇器鎖 wrapper 比較可靠。 | |
| app_header_html = gr.HTML(_build_header_html(), elem_id="app_header_wrap") | |
| with gr.Column(elem_classes=["canvas-scroll"]): | |
| # 3-A(2026-07-30):研究選擇區塊,工作區的第 1 步。點選 | |
| # 首頁「開始研究分析」進工作區後預設顯示(見 | |
| # on_start_analysis_show),使用者選定情境後隱藏、換 | |
| # upload_row 出現(見 on_scenario_pick 的延伸,同一份 | |
| # yield 裡把這裡設 visible=False)。卡片內容沿用 | |
| # _build_scenario_cards_html(),跟原本首頁那 4 張卡片 | |
| # 是同一份 SCENARIOS 資料,不重複刻兩份。視覺樣式沿用 | |
| # upload-zone/metadata-zone 同一批扁平卡片語彙(見 | |
| # .scenario-select-zone CSS)。 | |
| with gr.Column(visible=True, elem_id="scenario_select_zone", | |
| elem_classes=["scenario-select-zone"]) as scenario_select_row: | |
| # 標題+說明文字沿用 metadata_row 那則 Markdown 的寫法 | |
| # (純 "### 標題\n說明文字",不套 elem_classes),維持 | |
| # 同一批「Canvas 操作區塊」卡片的既有排版慣例。 | |
| gr.Markdown("### 情境選擇\n請選擇最符合您針對同一原文的重譯研究設計的比較情境,選定後將進入下一步「文本上傳」。") | |
| gr.HTML(_build_scenario_cards_html("workspace_scenario_grid")) | |
| # Phase 8(千卉實測後回頭調整):這張卡片改回正式使用 | |
| # 中的上傳入口(拖曳/點擊上傳),符合業主原本設計; | |
| # Phase 7 曾經把它永久隱藏、改用對話框上傳,這次反 | |
| # 過來。程式邏輯完全沒動,只是把可見性開關從 | |
| # chat_upload_col 換回這裡。 | |
| with gr.Column(visible=False, elem_id="upload_zone", elem_classes=["upload-zone"]) as upload_row: | |
| upload_hint_md = gr.Markdown(value="", elem_classes=["upload-hint"]) | |
| file_input = gr.File( | |
| show_label=False, | |
| file_types=[".txt"], file_count="multiple", | |
| ) | |
| # 2026-07-24 千卉:拿掉「取消」。它的行為是把整個上傳區 | |
| # 隱藏+清空檔案,使用者按了之後畫面沒有任何上傳入口、 | |
| # 等於把自己鎖在流程外,沒有實際用途。要換檔案直接在檔案 | |
| # 清單上逐一按 ×,要重來走「開始新分析」。 | |
| # 2026-07-31(Batch G 第 1 項):業主要求在「確認上傳」 | |
| # 左邊加一顆「重新選擇研究情境」返回鈕,讓使用者選錯情境 | |
| # 後不必整個「開始新分析」重來一次。次要樣式 | |
| # (variant="secondary"),視覺權重刻意低於「確認上傳」, | |
| # 不跟主要動作搶注意力。wiring 見下方 back_to_scenario_btn | |
| # .click()。 | |
| with gr.Row(elem_classes=["upload-actions"]): | |
| back_to_scenario_btn = gr.Button("重新選擇研究情境", scale=1, variant="secondary") | |
| upload_btn = gr.Button("確認上傳", scale=1, variant="primary") | |
| # 3-C(2026-07-30):文本上傳工作進度說明,比照「分析運算」 | |
| # 內容區塊(_build_analysis_html/computing_html)的做法, | |
| # 業主回報「上傳過程都一片白,只在對話視窗出現文字氣泡, | |
| # 用戶會不知道發生什麼事」。獨立於 upload_row 之外(不是 | |
| # 巢狀在裡面):on_upload_confirm 的第一步就會把 upload_row | |
| # 設 visible=False(避免遮罩擋住操作),若進度說明巢狀在 | |
| # upload_row 裡會跟著一起被藏起來,等於沒有解決業主回報的 | |
| # 問題。空字串=上傳尚未開始、不顯示,跟 computing_html | |
| # 同一種語意,見 _build_upload_html。 | |
| upload_progress_html = gr.HTML(value="", elem_id="upload_progress_zone") | |
| # ── 語料 Metadata 說明表單(上傳後、分析前)─────── | |
| with gr.Column(visible=False, elem_id="metadata_zone", elem_classes=["metadata-zone"]) as metadata_row: | |
| gr.Markdown("### 📑 語料說明\n請於正式分析前說明語料來源、範圍與比較結構,將寫入研究報告。") | |
| gr.Markdown("**原文資訊**") | |
| with gr.Row(): | |
| meta_title = gr.Textbox(label="原文標題", scale=2) | |
| meta_author = gr.Textbox(label="作者", scale=2) | |
| meta_lang = gr.Textbox(label="語言", scale=1) | |
| with gr.Row(): | |
| meta_pubyear = gr.Textbox(label="出版年", scale=1) | |
| meta_version = gr.Textbox(label="版本", scale=2) | |
| # 比較結構(僅情境 3、4 需要;情境 1、2 固定二群) | |
| # 2026-08-12:原本用 gr.Group,Gradio 會給它一條藍色 | |
| # 標題橫幅+外框,跟旁邊「原文資訊」「群組層資訊」那種 | |
| # 純文字小標題長得完全不同(業主截圖標了要拿掉)。改用 | |
| # gr.Column:一來沒有那層裝飾,二來本專案早有定論—— | |
| # 這版 Gradio 的 gr.Group 設 visible=False 只清空內容、 | |
| # 容器本身仍是 display:flex,會留下一塊空色塊,所有 | |
| # 「要切換 visible 的容器」一律用 gr.Column(Phase 6 踩過)。 | |
| with gr.Column(visible=False, elem_id="meta_compare_group") as meta_compare_group: | |
| gr.Markdown("**比較結構**") | |
| with gr.Row(): | |
| meta_ngroups = gr.Radio( | |
| ["二群", "三群"], label="群組數量", value="二群", scale=1, | |
| ) | |
| meta_method = gr.Radio( | |
| ["群組間比較", "個體間比較"], label="比較方式", | |
| value="群組間比較", scale=1, | |
| ) | |
| # 2026-08-15(業主回報「填了語料說明但沒生效」,追查後 | |
| # 發現根因):原本這裡是 gr.Dataframe(row_count= | |
| # (1,"dynamic")),實測編輯過一格之後,其他格子會完全 | |
| # 點不進去、無法再編輯(這版 Gradio 的 Dataframe 儲存格 | |
| # 編輯器有這個問題,換 row_count 寫法/col_count 寫法都 | |
| # 一樣)。改成三組獨立的 Textbox(最多支援三群,對應 | |
| # meta_ngroups 的「二群/三群」上限),完全避開有問題的 | |
| # Dataframe 儲存格編輯機制。三組固定宣告、依實際偵測到 | |
| # 的分組數量切換顯示(見 _show_metadata_form),標題會 | |
| # 動態顯示該組實際的分組代碼(例如「組別 A」),讓使用者 | |
| # 清楚知道自己在填哪一組——這點也順便修掉另一個問題: | |
| # 原本 Dataframe 版本的資料送進 LLM 時沒有明確標示 | |
| # 「這行屬於哪個分組代碼」,LLM 曾經因此把 A/B 的標籤 | |
| # 講反。 | |
| gr.Markdown("**群組層資訊**(共通屬性)") | |
| _META_GROUP_FIELD_LABELS = ["群組標籤", "翻譯類型", "翻譯方向", "譯者背景", "目標讀者"] | |
| meta_group_cols = [] | |
| meta_group_heads = [] | |
| meta_group_fields = [] # [[label,type,dir,bg,aud], ...] 每組一份 | |
| for _gi in range(3): | |
| with gr.Column(visible=(_gi < 2)) as _gcol: | |
| _ghead = gr.Markdown(f"組別 {_gi + 1}") | |
| with gr.Row(): | |
| _f_label = gr.Textbox(label="群組標籤", scale=1) | |
| _f_type = gr.Textbox(label="翻譯類型", scale=1) | |
| _f_dir = gr.Textbox(label="翻譯方向", scale=1) | |
| with gr.Row(): | |
| _f_bg = gr.Textbox(label="譯者背景", scale=1) | |
| _f_aud = gr.Textbox(label="目標讀者", scale=1) | |
| meta_group_cols.append(_gcol) | |
| meta_group_heads.append(_ghead) | |
| meta_group_fields.append([_f_label, _f_type, _f_dir, _f_bg, _f_aud]) | |
| # 2026-08-15(業主要求):拿掉「後編輯程度」欄位,業主 | |
| # 認為用不到。欄位索引 0(檔案)/1(譯者/系統名稱) | |
| # 沒有移動,_extract_individual_grouping() 讀的就是 | |
| # 這兩欄,拿掉的是原本索引 4 的欄位,不用連動改那邊。 | |
| gr.Markdown("**個體層資訊**(每個譯本/檔案)") | |
| meta_indiv_df = gr.Dataframe( | |
| headers=["檔案", "譯者/系統名稱", "出版年", "譯者母語", "重譯序位"], | |
| datatype=["str"] * 5, | |
| col_count=(5, "fixed"), | |
| row_count=(1, "dynamic"), | |
| interactive=True, | |
| label="個體層", | |
| ) | |
| gr.Markdown( | |
| "以下資訊會寫入報告開頭,您也可以先略過(略過後這次分析將不會用到這些補充說明)", | |
| elem_classes=["upload-hint"], | |
| ) | |
| with gr.Row(elem_classes=["upload-actions"]): | |
| meta_cancel_btn = gr.Button("略過", scale=1, variant="secondary") | |
| meta_confirm_btn = gr.Button("確認並開始分析", scale=2, variant="primary") | |
| # 「分析運算」內容區塊(2026-07-24 由 Phase 8-D 的單行運算中 | |
| # 提示擴充而來):運算中顯示三個子階段進度+轉圈動畫,運算後 | |
| # 變成分段摘要(標題+狀態+一句結論+查看入口)。空字串=運算 | |
| # 尚未開始、不顯示,見 _build_analysis_html/_computing_update。 | |
| computing_html = gr.HTML(value="", elem_id="computing_zone") | |
| # Phase 8-C:結果內容從 tab2/tab3/tab6(原本 gr.Tabs() 分頁頁面)搬進 | |
| # Canvas,改成三個互斥可見性的 gr.Column,取代整頁分頁切換。三塊 | |
| # 目前皆用同一組 visible=True/False 語意(見 _populate_tabs 的 | |
| # index 20-22),分析完成後會同時顯示、垂直堆疊;哪個結果先出現在 | |
| # 畫面上由使用者往下捲決定,nav-col 的「結果導覽」切換是 8-D 待辦。 | |
| with gr.Column(visible=False, elem_id="canvas_idx", elem_classes=["canvas-result"]) as canvas_idx_col: | |
| tab_idx_md = gr.Markdown(_EMPTY_HINT) | |
| tab_idx_df = gr.Dataframe( | |
| label="指標計算結果", | |
| interactive=False, visible=False, | |
| ) | |
| # 2026-08-12(業主要求):下載區塊移回表格下方。Phase 5 | |
| # 當初刻意把它移到頁面頂部,但其他五個結果頁(敘述統計/ | |
| # 差異性分析/監督式/非監督式/LLM 輔助分析)的下載都在 | |
| # 最下面,只有這一頁不同調。這是純宣告順序調整, | |
| # _TAB_IDX(見下方)本來就是 md/df/file 的位置對應, | |
| # 跟畫面順序無關,不用動。 | |
| tab_idx_file = gr.File( | |
| label="下載語言指標分析", | |
| file_count="multiple", interactive=False, visible=False, | |
| ) | |
| with gr.Column(visible=False, elem_id="canvas_stat", elem_classes=["canvas-result"]) as canvas_stat_col: | |
| with gr.Tabs(): | |
| # 子分頁 1:敘述統計 | |
| with gr.Tab("1. 敘述統計"): | |
| tab_desc_md = gr.Markdown(_EMPTY_HINT) | |
| tab_desc_df = gr.Dataframe( | |
| label="敘述性統計", | |
| interactive=False, visible=False, | |
| ) | |
| tab_desc_file = gr.File( | |
| label="下載敘述統計", | |
| interactive=False, visible=False, | |
| ) | |
| # 子分頁 2:差異性分析 | |
| # 2026-08-15(業主要求):排列順序改成「表格→下載 | |
| # →箱形圖」,原本是「箱形圖→表格→下載」——把讀者 | |
| # 最先要看的原始數據放最上面,下載按鈕緊接在表格 | |
| # 後面,圖表當作視覺化補充放最後。元件本身(identity) | |
| # 沒變,只是宣告順序(等於畫面上下順序)調整,不影響 | |
| # 任何其他 wiring。 | |
| with gr.Tab("2. 差異性分析"): | |
| tab_diff_head = gr.Markdown(_EMPTY_HINT) | |
| tab_diff_df = gr.Dataframe( | |
| label="差異性分析", | |
| interactive=False, visible=False, | |
| ) | |
| tab_diff_file = gr.File( | |
| label="下載差異性分析", | |
| interactive=False, visible=False, | |
| ) | |
| tab_box_img = gr.Image(label="顯著指標箱形圖", visible=False) | |
| # 子分頁 3:監督式學習 | |
| with gr.Tab("3. 監督式學習"): | |
| tab_rf_md = gr.Markdown(_EMPTY_HINT) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| tab_imp_df = gr.Dataframe( | |
| label="特徵重要性 Top 20", | |
| interactive=False, visible=False, | |
| ) | |
| with gr.Column(scale=2): | |
| tab_imp_img = gr.Image(label="特徵重要性圖", visible=False) | |
| tab_imp_file = gr.File( | |
| label="下載監督式學習", | |
| interactive=False, visible=False, | |
| ) | |
| # 子分頁 4:非監督式學習 | |
| with gr.Tab("4. 非監督式學習"): | |
| tab_clust_md = gr.Markdown(_EMPTY_HINT) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| tab_dendro_img = gr.Image(label="層次分群樹狀圖", visible=False) | |
| with gr.Column(scale=1): | |
| tab_elbow_img = gr.Image(label="K-means Elbow Curve", visible=False) | |
| tab_pca_img = gr.Image(label="分群視覺化(PCA 2D)", visible=False) | |
| tab_cluster_df = gr.Dataframe( | |
| label="分群結果", | |
| interactive=False, visible=False, | |
| ) | |
| tab_cluster_file = gr.File( | |
| label="下載非監督式學習", | |
| interactive=False, visible=False, | |
| ) | |
| with gr.Column(visible=False, elem_id="canvas_report", elem_classes=["canvas-result"]) as canvas_report_col: | |
| # 2026-08-14(待辦 A1):gr.Markdown 預設 latex_delimiters 只認 | |
| # $$...$$(區塊數學),LLM 報告裡常見的 $p=0.0223$ 這種行內數學 | |
| # 不會被 KaTeX 接手,原樣顯示成文字。這裡補上單一 $...$ 規則; | |
| # $$ 排在 $ 前面,避免 $$ 被拆成兩個各自不完整的單 $ 配對。 | |
| tab_rpt_md = gr.Markdown( | |
| _EMPTY_HINT, | |
| latex_delimiters=[ | |
| {"left": "$$", "right": "$$", "display": True}, | |
| {"left": "$", "right": "$", "display": False}, | |
| ], | |
| ) | |
| tab_rpt_file = gr.File( | |
| label="下載LLM輔助分析", | |
| file_count="multiple", interactive=False, visible=False, | |
| ) | |
| # 2026-07-23:技術執行紀錄從手風琴(exec_log_accordion) | |
| # 轉正成獨立頁面,跟 canvas_idx_col/canvas_stat_col/ | |
| # canvas_report_col 同一種待遇——不再需要 open/label | |
| # 收合狀態邏輯(原本靠 _accordion_update 控制),改用 | |
| # 跟其他三個結果頁一樣的 visible=True/False 語意,交給 | |
| # applyActivePage() JS 決定要不要顯示。 | |
| with gr.Column(visible=False, elem_id="canvas_exec", elem_classes=["canvas-result"]) as canvas_exec_col: | |
| # 千卉回報(2026-08-06):這頁一直缺大標題文字。原因是 | |
| # 另外三個結果頁(語言指標分析/敘述統計/研究報告)的 | |
| # 「### 標題」都是烤進 _populate_tabs 每次重新賦值的內容 | |
| # 字串裡(一次性結果,整段換新);但這頁是逐行往下累加 | |
| # 的執行 log(見 _log_append),不會整段重寫,所以從沒 | |
| # 機會把標題烤進 exec_log 的值裡。改成跟內容分開、獨立 | |
| # 一顆靜態 gr.Markdown,不受 log 累加邏輯影響。 | |
| gr.Markdown("### 技術執行紀錄") | |
| exec_log = gr.Markdown( | |
| value="", | |
| elem_id="exec_log", | |
| elem_classes=["exec-log"], | |
| ) | |
| # ── 結果內容輸出元件 + 3 個 Canvas 結果區塊可見性 ───────────────── | |
| # Phase 8-C:tab2/tab3/tab6(原 gr.Tab())已改成 canvas_idx_col/ | |
| # canvas_stat_col/canvas_report_col(gr.Column()),可見性語意不變。 | |
| # | |
| # E5(2026-08-05):這份清單是純位置對應——13 處呼叫端(_populate_tabs | |
| # 的回傳、_run_computation/on_metadata_confirm/on_metadata_skip 等 | |
| # generator 的每個 yield)都要交出跟 _TAB_OUTPUTS 完全等長、順序完全 | |
| # 一致的一份值,Gradio 只認位置、不認名字。拆成下面這些具名子清單只是 | |
| # 讓「這個 index 屬於哪個功能區塊」肉眼看得出來,**串接後的最終順序與 | |
| # 內容跟原本的單一扁平清單逐一相同,行為零改變**。真正的規則沒有變: | |
| # 子清單內部就代表最終 index 順序,新增元件只能加在對應子清單的尾端, | |
| # 不可插入中間、也不可調整子清單彼此的串接順序,否則所有呼叫端的 | |
| # index 對應會全部錯位。 | |
| _TAB_IDX = [tab_idx_md, tab_idx_df, tab_idx_file] # 語言指標分析 | |
| _TAB_DESC = [tab_desc_md, tab_desc_df, tab_desc_file] # 敘述統計(含下載) | |
| _TAB_DIFF = [tab_diff_head, tab_diff_df, tab_box_img, tab_diff_file] # 差異性分析(含下載) | |
| _TAB_SUPERVISED = [tab_rf_md, tab_imp_df, tab_imp_img] # 監督式學習 | |
| _TAB_UNSUPERVISED = [tab_clust_md, tab_dendro_img, tab_elbow_img, | |
| tab_pca_img, tab_cluster_df] # 非監督式學習 | |
| _TAB_REPORT = [tab_rpt_md, tab_rpt_file] # 研究報告 | |
| _TAB_CANVAS_VIS = [canvas_idx_col, canvas_stat_col, canvas_report_col] # 3 個 Canvas 結果區塊可見性 | |
| _TAB_EXTRA_DL = [tab_imp_file, tab_cluster_file] # 補的下載按鈕(監督式/非監督式學習) | |
| _TAB_EXEC_LOG = [canvas_exec_col] # 技術執行紀錄(2026-07-23 轉正成獨立頁面) | |
| _TAB_NAV_DL = [nav_dl_idx_btn, nav_dl_stat_btn, # 導覽列下載捷徑(2026-07-24) | |
| nav_dl_report_btn, nav_dl_all_btn, | |
| nav_dl_group] # 下載捷徑整組(分隔線+四顆)可見性 | |
| _TAB_NAV_GATES = [nav_idx_btn, nav_stat_sub_0, nav_stat_sub_1, # 結果導覽 interactive 閘門(A2) | |
| nav_stat_sub_2, nav_stat_sub_3, nav_report_btn] | |
| _TAB_OUTPUTS = ( | |
| _TAB_IDX + _TAB_DESC + _TAB_DIFF + _TAB_SUPERVISED + _TAB_UNSUPERVISED + | |
| _TAB_REPORT + _TAB_CANVAS_VIS + _TAB_EXTRA_DL + _TAB_EXEC_LOG + | |
| _TAB_NAV_DL + _TAB_NAV_GATES | |
| ) | |
| _N_TAB = len(_TAB_OUTPUTS) | |
| # ── helper ────────────────────────────────────────────────────────── | |
| def _ts(): | |
| # 台北時間 [HH:MM:SS]。2026-07-24:技術執行紀錄行首原本是 `›` 代碼 | |
| # 方框,渲染出來像可展開的按鈕但沒功能(千卉回報);改成時間戳, | |
| # 像真的執行 log、明顯不可點,還多了「每步幾點跑的」診斷資訊。 | |
| return datetime.now(timezone(timedelta(hours=8))).strftime("[%H:%M:%S]") | |
| def _log_append(log_history, msg): | |
| # 最新訊息放最下面,跟對話視窗(新訊息在下)方向一致; | |
| # 畫面捲動由 head 注入的 JS 自動處理(見 _APP_HEAD_SCRIPT 的 exec-log 監看) | |
| prev = log_history or "" | |
| line = f"{_ts()} {msg}" | |
| return f"{prev}\n\n{line}" if prev else line | |
| def _populate_tabs(state): | |
| """streaming 結束後呼叫,讀取 output 檔案並填入各分頁。""" | |
| R = [gr.update()] * _N_TAB # 預設全部不動 | |
| if state is None: | |
| return R | |
| # 下載捷徑(26-29)與整組容器(30)是少數必須「明確設回隱藏」的 | |
| # 輸出:其他結果元件都住在 canvas_*_col 裡,重置時整個 Column 被藏 | |
| # 起來、殘值看不到;這幾顆住在 result_nav_col,而 result_nav_col 會 | |
| # 在下次選定情境時重新顯示,不主動歸零的話上一輪的 zip 會跟著冒出 | |
| # 來。結果導覽閘門(31-36)同理,每次都先設回 disable,有結果再開。 | |
| for _i in (26, 27, 28, 29): | |
| R[_i] = gr.update(visible=False, value=None) | |
| R[30] = gr.update(visible=False) # nav_dl_group(Column,無 value) | |
| for _i in (31, 32, 33, 34, 35, 36): | |
| R[_i] = gr.update(interactive=False) | |
| output_dir = Path(state["session_dir"]) / "output" | |
| uploaded = state.get("uploaded_files", []) | |
| # ── 需要 output 目錄存在 ────────────────────────────────────── | |
| if not output_dir.exists(): | |
| return R | |
| # ── 語言指標分析(index 0-2)────────────────────────────────── | |
| csv_path = output_dir / "textIndex_calculated.csv" | |
| xlsx_list = sorted(output_dir.glob("textIndex_calculated_1_*.xlsx")) | |
| if csv_path.exists(): | |
| df = pd.read_csv(str(csv_path)) | |
| # 2026-08-14:畫面上的表格與指標數統計要濾掉業主要求隱藏的 13 項 | |
| # (見 HIDDEN_INDICATOR_CODES),但下載的 CSV/XLSX 刻意用未濾過 | |
| # 的 df(textIndex_calculated 原始檔)——業主說這 13 項「無須 | |
| # 刪除」,下載檔案代表的是完整原始計算結果,只有畫面呈現要隱藏。 | |
| df_display = _drop_hidden_indicators(df) | |
| # 指標數 = 總欄位數扣除中介欄(Text/Type1/Type2) | |
| meta_cols = [c for c in ["Text", "Type1", "Type2"] if c in df_display.columns] | |
| n_indicators = len(df_display.columns) - len(meta_cols) | |
| R[0] = f"### 語言指標分析\n共 **{len(df_display)}** 個文本,**{n_indicators}** 個語言指標。" | |
| R[1] = gr.update(value=df_display, visible=True) | |
| dl = [str(csv_path)] | |
| if xlsx_list: | |
| dl.append(str(xlsx_list[-1])) | |
| R[2] = gr.update(value=dl, visible=True) | |
| # ── 統計比較分析:敘述統計 + 差異分析(index 3-7)───────────── | |
| desc_path = output_dir / "01_descriptive_stats.csv" | |
| diff_path = output_dir / "02_difference_analysis.csv" | |
| box_path = output_dir / "02_significant_boxplots.png" | |
| if desc_path.exists(): | |
| desc = pd.read_csv(str(desc_path)) | |
| R[3] = f"### 敘述統計\n共 **{len(desc)}** 個指標的敘述統計。" | |
| R[4] = gr.update(value=desc, visible=True) | |
| R[5] = gr.update(value=str(desc_path), visible=True) | |
| if diff_path.exists(): | |
| diff = pd.read_csv(str(diff_path)) | |
| if '顯著' in diff.columns: | |
| sig = diff[~diff['顯著'].isin(['ns', INSUFFICIENT_DATA_LABEL])] | |
| untestable_n = int((diff['顯著'] == INSUFFICIENT_DATA_LABEL).sum()) | |
| else: | |
| sig = pd.DataFrame(); untestable_n = 0 | |
| # sig=0 時不會產生箱形圖(見下方 box_path 判斷),若只留白使用者 | |
| # 容易誤以為系統壞掉,這裡改在標題文字附加空狀態說明 | |
| empty_note = "" if len(sig) > 0 else "\n\n本次分析未發現統計顯著的指標差異,因此未產生箱形圖。" | |
| untestable_note = ( | |
| f"\n\n⚠️ 另有 **{untestable_n}** 個指標因組別樣本數不足(N<2)," | |
| "無法進行統計檢定,不計入上述比例。" | |
| if untestable_n > 0 else "" | |
| ) | |
| # 見 ZERO_VARIANCE_WARNING/ZERO_PROPORTION_WARNING 定義處:這些 | |
| # 指標仍計入顯著/不顯著比例(p 值照常有效),只是額外提醒統計 | |
| # 穩定性存疑,跟上面「資料不足」(完全無法檢定、不計入比例)是 | |
| # 不同性質,故用不同語氣、且不寫「不計入上述比例」。這裡的句子 | |
| # 刻意不細分是哪一條判準命中——個別是哪一種,下方表格會逐列標註。 | |
| warn_n = int((diff.get('資料品質警示', pd.Series(dtype=str)).fillna('') != '').sum()) | |
| zero_var_note = ( | |
| f"\n\n⚠️ 其中 **{warn_n}** 個顯著指標的資料品質有疑慮" | |
| "(某組標準差趨近於 0,或某組半數以上樣本為 0)," | |
| "統計穩定性有限,建議謹慎解讀(詳見下方表格個別標註)。" | |
| if warn_n > 0 else "" | |
| ) | |
| # 2026-08-14(業主要求):標題句前面加一句「以[檢定方法]比較 | |
| # [分組]文本」,方法與分組名稱都從 diff 本身反推——分組代碼 | |
| # 是「{組別}_N」這組欄名的前綴(run_statistical_analysis 產出 | |
| # 時就是這樣命名,見該處 diff_results.append 那段),不是另外 | |
| # 寫死 A/B,之後研究情境換了分組代號也會自動跟著對。 | |
| group_codes = [c[:-2] for c in diff.columns if c.endswith('_N')] | |
| # 2026-08-14(業主要求):如果「語料說明」有把分組代碼改寫成 | |
| # 實際標籤(例如把 A 改成「人工翻譯」),這裡要據實呈現那個 | |
| # 標籤,不是原始代碼——group_labels 沒填的組別維持顯示代碼。 | |
| group_labels_map = state.get("group_labels", {}) or {} | |
| group_names = [group_labels_map.get(g, g) for g in group_codes] | |
| if len(group_names) >= 2: | |
| group_desc = "、".join(group_names[:-1]) + f" 與 {group_names[-1]}" | |
| elif group_names: | |
| group_desc = group_names[0] | |
| else: | |
| group_desc = "" | |
| method_raw = diff['方法'].iloc[0] if len(diff) > 0 and '方法' in diff.columns else None | |
| method_zh = {"t-test": "獨立樣本 t 檢定(Welch)", | |
| "ANOVA": "單因子變異數分析(ANOVA)"}.get(method_raw, method_raw) | |
| prefix = f"以{method_zh}比較 {group_desc} 文本," if (method_zh and group_desc) else "" | |
| # 2026-08-15(業主要求):下面表格的欄名有 N/M/SD 這種統計 | |
| # 縮寫,加一句白話說明放在表格正上方,跟 docx 技術報告那份 | |
| # 的做法一致。 | |
| R[6] = gr.update( | |
| value=(f"### 差異性分析\n{prefix}共 **{len(diff)}** 個指標," | |
| f"其中 **{len(sig)}** 個達顯著差異(p < {ALPHA})。{empty_note}{untestable_note}{zero_var_note}\n\n" | |
| f"下方表格中 N=樣本數、M=平均數、SD=標準差。"), | |
| visible=True, | |
| ) | |
| R[7] = gr.update(value=diff, visible=True) | |
| R[9] = gr.update(value=str(diff_path), visible=True) | |
| if box_path.exists(): | |
| R[8] = gr.update(value=str(box_path), visible=True) | |
| # ── 統計比較分析:監督式學習(index 10-12)───────────────────── | |
| imp_path = output_dir / "03_feature_importance.csv" | |
| imp_fig = output_dir / "03_feature_importance.png" | |
| anal = state.get("analysis_result") or {} | |
| if anal.get("accuracy") is not None: | |
| acc = anal["accuracy"] | |
| acc_str = f"{acc:.4f}" if isinstance(acc, float) else str(acc) | |
| R[10] = f"### 監督式學習(隨機森林分類)\n模型準確率(Accuracy):**{acc_str}**" | |
| if imp_path.exists(): | |
| imp = pd.read_csv(str(imp_path)).head(20) | |
| R[11] = gr.update(value=imp, visible=True) | |
| if imp_fig.exists(): | |
| R[12] = gr.update(value=str(imp_fig), visible=True) | |
| imp_dl = [str(p) for p in [imp_path, imp_fig] if p.exists()] | |
| if imp_dl: | |
| R[23] = gr.update(value=imp_dl, visible=True) | |
| # ── 統計比較分析:非監督式學習(index 13-17)────────────────── | |
| dendro_path = output_dir / "04a_dendrogram.png" | |
| elbow_path = output_dir / "04b_kmeans_elbow.png" | |
| pca_fig_path = output_dir / "04c_clustering_pca2d.png" | |
| cluster_path = output_dir / "05_cluster_results.csv" | |
| pca_ev = anal.get("pca_ev", []) | |
| ev_str = "" | |
| if len(pca_ev) >= 2: | |
| ev_str = f"\n\nPCA 解釋變異量:PC1 = {pca_ev[0]:.1%},PC2 = {pca_ev[1]:.1%}" | |
| if any(p.exists() for p in [dendro_path, elbow_path, pca_fig_path]): | |
| R[13] = f"### 非監督式學習(集群分析){ev_str}" | |
| if dendro_path.exists(): | |
| R[14] = gr.update(value=str(dendro_path), visible=True) | |
| if elbow_path.exists(): | |
| R[15] = gr.update(value=str(elbow_path), visible=True) | |
| if pca_fig_path.exists(): | |
| R[16] = gr.update(value=str(pca_fig_path), visible=True) | |
| if cluster_path.exists(): | |
| cdf = pd.read_csv(str(cluster_path)) | |
| # 2026-08-15(業主要求):畫面呈現時 Type1 改標成看得懂的 | |
| # 「原始分組(A/B)」,Type2 不顯示(它一直是 Type1 的原樣 | |
| # 複製,沒有額外資訊,見 run_statistical_analysis 那行 | |
| # df['Type2'] = df['Type1'] 的註解)——下載的 CSV 檔案 | |
| # 刻意不動,仍保留原始欄名/欄位,跟語言指標分析那張表同一個 | |
| # 「下載檔案給完整原始資料、畫面只呈現整理過的版本」的原則。 | |
| cdf_display = cdf.drop(columns=["Type2"], errors="ignore") \ | |
| .rename(columns={"Type1": "原始分組(A/B)"}) | |
| R[17] = gr.update(value=cdf_display, visible=True) | |
| cluster_dl = [str(p) for p in [dendro_path, elbow_path, pca_fig_path, cluster_path] if p.exists()] | |
| if cluster_dl: | |
| R[24] = gr.update(value=cluster_dl, visible=True) | |
| # ── 研究報告(index 18-19)──────────────────────────────────── | |
| txt_files = sorted(output_dir.glob("LLM_Analysis-*.txt"), reverse=True) | |
| docx_files = sorted(output_dir.glob("LLM_Analysis-*.docx"), reverse=True) | |
| final_rpts = sorted(output_dir.glob("Final_Report-*.docx"), reverse=True) | |
| if txt_files: | |
| try: | |
| with open(str(txt_files[0]), 'r', encoding='utf-8') as f: | |
| rpt_text = f.read() | |
| # 2026-08-24(業主回報畫面上模型資訊重複顯示兩次):txt 檔本身 | |
| # 從 2026-08-22 起(可重複性設計)就會把「模型:...」這行寫進 | |
| # 檔頭,是給下載後獨立開啟 txt 檔的人看的;但畫面這裡另外又疊了 | |
| # 一行同樣內容的 model_note(有顏色樣式、給畫面用),兩者一起 | |
| # 顯示就變成重複。畫面上只保留有樣式的 model_note,rpt_text | |
| # 裡那行純文字的「模型:」拿掉,txt 檔本身仍完整保留不受影響。 | |
| rpt_text = re.sub(r'^模型:.*\n', '', rpt_text, count=1, flags=re.MULTILINE) | |
| model_note = (f"<span style='color:var(--c-accent-strong);font-weight:600'>" | |
| f"本報告由主 Agent(LLM1)模型 {LLM1_MODEL} 生成" | |
| f"(提示詞版本 {PROMPT_VERSION})</span>") | |
| R[18] = f"### LLM 輔助分析 {model_note}\n\n{rpt_text}" | |
| except Exception: | |
| R[18] = "### LLM 輔助分析\n\n(讀取報告時發生錯誤)" | |
| # 下載清單只留敘述統計技術報告跟 LLM 輔助報告兩份,複製成使用者 | |
| # 指定的檔名(保留原始檔案不動,避免影響其他邏輯依賴原檔名); | |
| # 原始 .txt 不提供下載,僅用於上方 R[18] 的文字顯示 | |
| # | |
| # 千卉發現的踩坑(2026-08-06):final_rpts(敘述統計技術報告) | |
| # 是統計階段一完成就存在,早於 LLM 報告撰寫階段。若不設防, | |
| # 「報告撰寫」子階段還在跑的當下,這裡就會把只有統計報告、缺 | |
| # LLM 報告的清單當成「研究報告」打包出去——使用者這時點下載, | |
| # 拿到的檔案內容不完整。改成整段等 report 子階段真正跑到終態 | |
| # (done 或 failed,不再是 running/pending)才組清單;報告若 | |
| # 失敗,final_rpts 仍在,讓使用者至少能拿到統計報告,不會被 | |
| # 卡死拿不到任何東西。 | |
| report_stage_done = state["analysis"].get("report", {}).get("status") in ("done", "failed") | |
| dl_rpt = [] | |
| if report_stage_done: | |
| if final_rpts: | |
| ts = final_rpts[0].stem.split("Final_Report-")[-1] | |
| # 2026-09-01(業主回報):下載檔名跟著同一輪一起從 | |
| # "TransPrism_stats_" 改成跟文件內標題(見上方 | |
| # run_statistical_analysis 的 doc.add_heading)一致的 | |
| # 「統計結果技術報告」,時間戳保留,避免多次下載互相 | |
| # 覆蓋。 | |
| stats_dst = output_dir / f"統計結果技術報告_{ts}.docx" | |
| if not stats_dst.exists(): | |
| shutil.copy(str(final_rpts[0]), str(stats_dst)) | |
| dl_rpt.append(str(stats_dst)) | |
| if docx_files: | |
| ts = docx_files[0].stem.split("LLM_Analysis-")[-1] | |
| llm_dst = output_dir / f"TransPrism_LLM Report_{ts}.docx" | |
| if not llm_dst.exists(): | |
| shutil.copy(str(docx_files[0]), str(llm_dst)) | |
| dl_rpt.append(str(llm_dst)) | |
| # 2026-08-26:這次分析實際套用的 system prompt 原文,跟報告 | |
| # 一起提供下載——固定檔名(不像上面兩份加時間戳),本來就 | |
| # 只會有一份,不需要另外複製改名。 | |
| sp_path = output_dir / "06_system_prompt.txt" | |
| if sp_path.exists(): | |
| dl_rpt.append(str(sp_path)) | |
| if dl_rpt: | |
| R[19] = gr.update(value=dl_rpt, visible=True) | |
| # ── 導覽列下載捷徑:把上面各頁的下載清單打成四包(index 26-29)─ | |
| # 刻意沿用上面已經整理好的清單變數,不另外去 output_dir 掃檔案: | |
| # 掃出來的會含 LLM_Analysis-*.txt 這種只供畫面顯示、不提供下載的 | |
| # 中間產物,也含前一次打包留下的 .zip 自己。 | |
| # | |
| # allow_all=report_stage_done:「完整分析資料」整包同理,report | |
| # 子階段還在跑時,即使 idx/stat 兩組都已完成(湊到 ≥2 組的舊 | |
| # 門檻),也不該急著把「完整」兩字掛出來卻漏掉報告——見上面 | |
| # report_stage_done 的踩坑說明。 | |
| zips = _make_result_zips(output_dir, { | |
| "idx": [str(csv_path)] + [str(p) for p in xlsx_list[-1:]], | |
| "stat": [str(p) for p in [desc_path, diff_path, box_path]] + imp_dl + cluster_dl, | |
| "report": dl_rpt, | |
| }, allow_all=report_stage_done) | |
| state["zip_paths"] = zips | |
| # 2026-08-15(業主要求):導覽列下載捷徑只留「下載全部資料」一顆, | |
| # 「下載指標數據/統計圖表/研究報告」三顆不再顯示。刻意不刪 | |
| # nav_dl_idx_btn/nav_dl_stat_btn/nav_dl_report_btn 這三個元件 | |
| # 的宣告、也不動 _TAB_NAV_DL 清單結構——這批 _TAB_OUTPUTS 的 index | |
| # 是全站共用的固定順序(見上方「子清單內部就代表最終 index 順序」 | |
| # 的警語),砍元件會讓後面所有 index 全部錯位,風險太高。改成 | |
| # 這裡的迴圈只留 "all" 這一組,前三個按鈕沒有任何地方會把它們 | |
| # 設回 visible=True,永遠維持宣告時的初始隱藏狀態,畫面上就只 | |
| # 剩第四顆——效果一樣,但完全不影響其他 index。 | |
| for _i, _key in ((29, "all"),): | |
| if zips.get(_key): | |
| R[_i] = gr.update(value=zips[_key], visible=True) | |
| # 2026-08-15:畫面上只剩「下載全部資料」一顆按鈕,容器可見性 | |
| # 改成只看 "all" 這包在不在——原本「只要有任一包 zip 就顯示」 | |
| # 在 idx/stat/report 任一包先做出來、但 all 還沒湊齊 | |
| # (report 階段尚未跑完)時,會出現分隔線卻沒有任何按鈕的空殼 | |
| # (因為那三顆已經被拿掉),剛好是這段容器邏輯當初想避免的 | |
| # 「分隔線孤零零留在畫面上」(見上方 B3 註解)。 | |
| if zips.get("all"): | |
| R[30] = gr.update(visible=True) | |
| # ── 顯示各分頁 Tab(index 20-22)+結果導覽閘門(31-36,A2)────── | |
| if csv_path.exists(): | |
| R[20] = gr.update(visible=True) # 語言指標分析 | |
| R[31] = gr.update(interactive=True) # nav_idx_btn | |
| stat_ready = desc_path.exists() or imp_path.exists() \ | |
| or any(p.exists() for p in [dendro_path, elbow_path, pca_fig_path]) | |
| if stat_ready: | |
| R[21] = gr.update(visible=True) # 統計比較分析 | |
| # 統計四個子頁共用同一頁,統計一產出就四個一起開 | |
| for _i in (32, 33, 34, 35): | |
| R[_i] = gr.update(interactive=True) | |
| if txt_files or docx_files: | |
| R[22] = gr.update(visible=True) # 研究報告 | |
| R[36] = gr.update(interactive=True) # nav_report_btn(報告失敗時維持 disable) | |
| # 2026-07-31(Batch E 第 7 項):業主要求拿掉統計分析跑完後跳出的 | |
| # gr.Info toast(理由:其他階段都沒有通知,只有這裡有,不一致又 | |
| # 干擾)。原本用來擋重複 toast 的 state["tabs_toast_shown"] 旗標 | |
| # (Bug 4-A 修復時加的,因為 _populate_tabs 現在會在 idx/stat/ | |
| # report 三個子階段各自完成時+結尾各呼叫一次,不再只呼叫一次) | |
| # 連同 any_tab_ready 這個只為了算 toast 條件而存在的區域變數 | |
| # 一併移除——拿掉 toast 之後兩者都沒有其他用途,留著是無作用的 | |
| # 殘留狀態,不是這批任務要避免的「順手重構」,是移除本項本身的 | |
| # 必要連帶清理。state["tabs_toast_shown"] 的初始化也一併從 | |
| # make_initial_state 拿掉(見該處)。 | |
| return R | |
| # ── 啟動:先顯示「啟動中」,再載入 LLM 歡迎訊息 ─────────────────── | |
| def on_load_init(): | |
| """第一步:立即顯示啟動中提示。""" | |
| sid = str(uuid.uuid4())[:8] | |
| state = make_initial_state(sid) | |
| return ( | |
| [_assistant_msg("系統啟動中...")], | |
| f"{_ts()} 系統啟動中…", | |
| state, | |
| gr.update(visible=False), | |
| gr.update(value=_build_header_html()), | |
| ) | |
| def on_load_ready(chat_history, log_history, state): | |
| """第二步:顯示固定歡迎訊息。""" | |
| # 2026-07-31(Batch E 第 4-d 項):業主拿掉「(或下方)」——情境 | |
| # 選擇已搬進工作區右側 Canvas,對話框不再需要提示「或下方」 | |
| # 這個舊版單欄置中版面才有意義的說法。 | |
| intro = ( | |
| "歡迎使用 TransPrism II 分析引擎 SpectraEngine。\n\n" | |
| "請依照您的研究設計,點選右方最符合的研究情境開始分析:" | |
| ) | |
| return ( | |
| [_assistant_msg(intro)], | |
| f"{_ts()} 系統已就緒,等待使用者操作。", | |
| ) | |
| demo.load( | |
| fn=on_load_init, | |
| outputs=[chatbot, exec_log, session_state, upload_row, app_header_html], | |
| ).then( | |
| fn=on_load_ready, | |
| inputs=[chatbot, exec_log, session_state], | |
| outputs=[chatbot, exec_log], | |
| ) | |
| # ── 標題步驟列即時更新 helper(原本更新 todo_md,現在改更新頂部標題)─── | |
| def _todo_update(state): | |
| """根據 state 的 done_steps / current_step 回傳 app_header_html gr.update。""" | |
| if not state or not state.get("scenario"): | |
| return gr.update(value=_build_header_html()) | |
| return gr.update( | |
| value=_build_header_html( | |
| state.get("done_steps", []), | |
| state.get("current_step"), | |
| state.get("failed_step"), | |
| ) | |
| ) | |
| # ── Phase 8-D:Canvas 運算中提示(粗粒度階段文字+轉圈動畫)───── | |
| # 只在三段運算階段顯示(2026-07-23 步驟列收斂後 key 從 5/6/7 改成 | |
| # 4/5/6,這段是當時還用數字 key 時的歷史紀錄;現在 _ANALYSIS_ENTRY | |
| # 的 key 已經是 "idx"/"stat"/"report" 字串,不是 _STEPS 的步驟編號, | |
| # 2026-07-30 新增「研究選擇」步驟、_STEPS 從 3 步變 4 步、純步驟編號 | |
| # 全部 +1,跟這個字典無關,不用一起改), | |
| # 不新增運算邏輯;用 gr.HTML() 單一元件承載,空字串=不顯示、不用另外 | |
| # 接一個 visibility 輸出,wiring 只多一個輸出點。 | |
| # 2026-07-24:原本這裡是「運算中…(步驟 N/3)」單行提示。它跟步驟列 | |
| # 講同一件事(哪一步)而且那個 N/3 會給錯期待(讓人以為要熬三段,實際 | |
| # 上前兩段一閃而過、時間幾乎全在報告撰寫)。改成 _build_analysis_html: | |
| # 一張分段的「分析運算」內容區塊,運算中顯示每段進度、運算後變成分段 | |
| # 摘要(標題+狀態+一句結論+查看入口),空間比藥丸大得多,報告失敗時 | |
| # 也能據實說明「壞的只是報告、其他不受影響」。同一塊 HTML 從頭用到尾, | |
| # 取代舊的 computing_html 顯示內容(元件與 elem_id 沿用不變)。 | |
| # 每段「查看…」入口是純 JS 橋接(見 _APP_HEAD_SCRIPT 的 analysis-entry | |
| # 事件代理),點了等同點左側對應導覽項目,不走 Gradio queue。 | |
| # 入口標籤一律跟導覽列名稱一致(item 2)。統計分析這段的產出對應四個 | |
| # 導覽子頁,2026-07-24 千卉決定:不用「統計比較分析」這個傘狀詞(導覽列 | |
| # 裡已無此項),直接列四個查看連結(item 4)。 | |
| _ANALYSIS_ENTRY = { | |
| "idx": [("nav_idx_btn", "查看語言指標分析")], | |
| "stat": [("nav_stat_sub_0", "查看敘述統計"), | |
| ("nav_stat_sub_1", "查看差異性分析"), | |
| ("nav_stat_sub_2", "查看監督式學習"), | |
| ("nav_stat_sub_3", "查看非監督式學習")], | |
| "report": [("nav_report_btn", "查看 LLM 輔助分析")], | |
| } | |
| def _build_analysis_html(state): | |
| if not state: | |
| return "" | |
| A = state.get("analysis") or {} | |
| if not A or all(A.get(k, {}).get("status", "pending") == "pending" | |
| for k, _, _ in _ANALYSIS_STAGES): | |
| return "" # 運算還沒開始,不顯示 | |
| rows = [] | |
| for key, title, _ in _ANALYSIS_STAGES: | |
| info = A.get(key, {}) | |
| status = info.get("status", "pending") | |
| line = info.get("line", "") | |
| if status == "running": | |
| icon = '<span class="analysis-spinner" aria-hidden="true"></span>' | |
| line = line or "處理中……" | |
| elif status == "done": | |
| icon = '<span class="analysis-mark analysis-mark--done">✓</span>' | |
| elif status == "failed": | |
| icon = '<span class="analysis-mark analysis-mark--failed">!</span>' | |
| else: # pending | |
| icon = '<span class="analysis-mark analysis-mark--pending">•</span>' | |
| line = line or "等待中" | |
| entry = "" | |
| if status == "done" and key in _ANALYSIS_ENTRY: | |
| entry = "".join( | |
| f'<a class="analysis-entry" role="button" tabindex="0" ' | |
| f'data-nav="{nav_id}">{label} →</a>' | |
| for nav_id, label in _ANALYSIS_ENTRY[key] | |
| ) | |
| rows.append( | |
| '<div class="analysis-row analysis-row--' + status + '">' | |
| f'<div class="analysis-row-head">{icon}' | |
| f'<span class="analysis-row-title">{title}</span></div>' | |
| f'<div class="analysis-row-body">{line}{entry}</div>' | |
| '</div>' | |
| ) | |
| # 2026-08-05:連線一斷、後端還是會照跑完,但前端畫面收不到任何 | |
| # 更新(見「開發優化建議.md」第一節選項C討論)。系統目前沒有 | |
| # 連線中斷後恢復進度的機制,只能先用文案提醒使用者在還沒看到 | |
| # 「分析完成」前,不要把分頁切到背景或讓電腦休眠。只在還有子 | |
| # 階段「進行中」時顯示,全部 done/failed 定案後就不需要了。 | |
| any_running = any( | |
| A.get(k, {}).get("status") == "running" for k, _, _ in _ANALYSIS_STAGES | |
| ) | |
| hint = ( | |
| '<p class="analysis-panel-hint">' | |
| '⚠️ 分析運算期間請保持此分頁在前景、電腦勿休眠,直到看到「分析完成」為止,' | |
| '避免連線中斷導致畫面卡住。</p>' | |
| if any_running else "" | |
| ) | |
| return ( | |
| '<div class="analysis-panel">' | |
| '<h2 class="analysis-panel-title">分析運算</h2>' | |
| + hint + "".join(rows) + | |
| '</div>' | |
| ) | |
| def _computing_update(state): | |
| return gr.update(value=_build_analysis_html(state)) | |
| # ── 3-C(2026-07-30):文本上傳工作進度說明 ───────────────────── | |
| # 業主回報「上傳過程都一片白,只在對話視窗出現文字氣泡,用戶會不 | |
| # 知道發生什麼事」。比照上面「分析運算」內容區塊的做法:同一組 | |
| # .analysis-panel CSS 視覺語彙(標題/狀態圖示/spinner),不另創 | |
| # 一套樣式;子階段切成「檔案接收」與「格式驗證」兩段,對應 | |
| # on_upload_confirm 現有的處理流程(接收檔案→跑 run_format_check)。 | |
| _UPLOAD_STAGES = [ | |
| ("receive", "檔案接收"), | |
| ("validate", "格式驗證"), | |
| ] | |
| def _build_upload_html(state): | |
| if not state: | |
| return "" | |
| U = state.get("upload") or {} | |
| if not U or all(U.get(k, {}).get("status", "pending") == "pending" | |
| for k, _ in _UPLOAD_STAGES): | |
| return "" # 上傳還沒開始,不顯示(同 _build_analysis_html 的語意) | |
| rows = [] | |
| for key, title in _UPLOAD_STAGES: | |
| info = U.get(key, {}) | |
| status = info.get("status", "pending") | |
| line = info.get("line", "") | |
| if status == "running": | |
| icon = '<span class="analysis-spinner" aria-hidden="true"></span>' | |
| line = line or "處理中……" | |
| elif status == "done": | |
| icon = '<span class="analysis-mark analysis-mark--done">✓</span>' | |
| elif status == "failed": | |
| icon = '<span class="analysis-mark analysis-mark--failed">!</span>' | |
| else: # pending | |
| icon = '<span class="analysis-mark analysis-mark--pending">•</span>' | |
| line = line or "等待中" | |
| rows.append( | |
| '<div class="analysis-row analysis-row--' + status + '">' | |
| f'<div class="analysis-row-head">{icon}' | |
| f'<span class="analysis-row-title">{title}</span></div>' | |
| f'<div class="analysis-row-body">{line}</div>' | |
| '</div>' | |
| ) | |
| return ( | |
| '<div class="analysis-panel">' | |
| '<h2 class="analysis-panel-title">文本上傳</h2>' | |
| + "".join(rows) + | |
| '</div>' | |
| ) | |
| def _upload_update(state): | |
| return gr.update(value=_build_upload_html(state)) | |
| # ── 1-B/3-A(2026-07-30):「開始研究分析」進入工作區 ────────────── | |
| # 首頁改版後,homepage_col → workspace_row 的翻轉搬到這裡(原本 | |
| # 「進工作區」跟「選定情境」是同一個事件 on_scenario_pick 的第一個 | |
| # yield;現在拆成兩個獨立事件:這裡只負責「進工作區、顯示研究選擇 | |
| # 卡片」,on_scenario_pick 改成只負責「選定情境後的下一步」。 | |
| # on_scenario_pick 仍保留對 homepage_col/workspace_row 的 gr.update() | |
| # 呼叫——此時它們已經是目標值,重複賦值是 no-op,拿掉徒增對「已修好 | |
| # 的三個 bug」邏輯的改動風險,不如保留不動。 | |
| # | |
| # 4-C 回歸修復(2026-07-31,Batch G 第 0 項):這裡曾經被拆成三個用 | |
| # .then() 串接的獨立事件(on_start_analysis_show → 補償子元件 → | |
| # 補償 nav_dl_group)。實測(DOM 輪詢量測+螢幕截圖)證實:拆成 | |
| # 「三個獨立 Blocks 事件」跟拆成「同一個 generator 裡的三個 yield」 | |
| # 不是等價寫法——.then() 串接的每一段都是獨立送出的請求,前端要收到 | |
| # 上一段回應、畫面重繪完,才會送出下一段請求;這中間有一段肉眼可見 | |
| # 的時間差(本機量測約 100ms,正式環境網路延遲更大時會更久)。 | |
| # workspace_row 從 visible=False 翻回 True 那一刻(第一段事件), | |
| # 只要 metadata_row/canvas_idx_col/canvas_stat_col/canvas_report_col | |
| # 曾經因為走過一次上傳確認/分析完成而被設成過 visible=True,Gradio | |
| # 6 就會在這一批訊息把它們的舊 True 值還原回來(本檔開頭記錄的地雷); | |
| # 這個還原值會先被畫出來,直到「補償子元件」那段事件送達才會被蓋掉。 | |
| # 兩段之間的那段真空時間,使用者看到的就是「上一輪的語料說明表單 | |
| # 疊在新一輪研究選擇卡片下方」——也就是業主回報的 4-C 復發。 | |
| # | |
| # 修法:改回跟 on_scenario_pick 完全一致的做法——單一個 generator | |
| # 函式,用多個 yield 取代多個 .then() 事件。同一個 generator 的多次 | |
| # yield 走的是同一條已建立的連線(stream),前端不需要再送一次新 | |
| # 請求才能收到下一段更新,兩個 yield 之間的間隔是伺服器端逐行執行 | |
| # 的時間,量測結果從「~100ms 的網路來回」降到「同一 tick 內」,不會 | |
| # 再讓使用者看到中間的還原值。 | |
| # | |
| # 【給後續改動者】:這個「祖先容器可見性翻轉」跟「子元件補償重置」 | |
| # 的先後順序,永遠只能靠「同一個 generator 的下一個 yield」保證, | |
| # 不能靠「另一個用 .then() 串接的獨立事件」——後者在 Gradio 6 裡是 | |
| # 兩個獨立的請求/回應循環,即使邏輯上「馬上接著執行」,使用者仍然 | |
| # 會先看到一次錯誤畫面。往後如果「進工作區」的入口又搬家(就像這次 | |
| # 從 on_scenario_pick 搬到 start_analysis_btn 一樣),這三個 yield | |
| # 要整組一起搬,不要只搬第一個 yield、把後面兩個留在原地或拆成 | |
| # .then()。 | |
| def on_start_analysis_show(state): | |
| if state is None: | |
| yield ( | |
| gr.update(visible=False), gr.update(visible=True), | |
| gr.update(visible=True), gr.update(), state, | |
| gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), | |
| gr.update(), gr.update(), gr.update(), | |
| ) | |
| return | |
| state["current_step"] = 1 | |
| # yield 1:workspace_row 從 visible=False 翻回 True 的那一批(祖先 | |
| # 翻轉),scenario_select_row 本來就要在這時候顯示 True。子元件的 | |
| # 補償刻意留到下一個 yield,不跟這批同時處理(理由見上方大段註解)。 | |
| # | |
| # result_nav_col/canvas_exec_col 例外:2026-08-06 千卉實測發現, | |
| # 「元件第一次從 visible=False 翻成 True(此生第一次掛進 DOM)」 | |
| # 這件事,只有在觸發它的 generator 的第一個 yield 裡設定才會真的 | |
| # 生效——on_scenario_pick 的 result_nav_col/canvas_exec_col 就是 | |
| # 這樣,在第一個 yield 就設 visible=True(見該函式),所以按鈕跟 | |
| # 內容區塊真的會出現。這裡原本學 4-C 的做法把兩者延到下一個 yield | |
| # 才設,結果變成「Python 端送出了 visible=True,前端卻從頭到尾 | |
| # 沒把這批按鈕跟內容掛進 DOM」——使用者點「開始研究分析」進入 | |
| # 研究選擇畫面後,左側導覽列只剩「回首頁」,AI對話/技術執行紀錄 | |
| # 等全部消失;就算硬點技術執行紀錄,右邊內容區塊也是一片空白 | |
| # (canvas_exec_col 同樣沒掛進 DOM,不是只缺標題文字)。4-C 的 | |
| # 「延一個 yield」技巧解的是另一種問題(曾經 True 過的元件被自動 | |
| # 還原),不適用在「這輩子第一次要 True」的元件上,兩者不能套 | |
| # 同一招。canvas_exec_col 跟著 result_nav_col 一起搬進第一個 | |
| # yield,也符合 nav_exec_btn 建構時刻意不設 interactive=False | |
| # 的原始設計意圖(見該按鈕附近註解:「技術執行紀錄……從第一秒 | |
| # 就有內容」,代表它從一開始就該是可點、有內容可看的狀態)。 | |
| yield ( | |
| gr.update(visible=False), # homepage_col | |
| gr.update(visible=True), # workspace_row | |
| gr.update(visible=True), # scenario_select_row | |
| gr.update(value=_build_header_html(current_step=1)), # app_header_html:站上第1步「研究選擇」 | |
| state, | |
| gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), # upload_row/metadata_row/canvas_idx/stat/report 留到下一個 yield | |
| gr.update(visible=True), # result_nav_col:第一次掛載必須跟祖先同一個 yield,見上方說明 | |
| gr.update(visible=True), # canvas_exec_col:同上,且從第一秒就該有內容可看 | |
| gr.update(), # nav_dl_group 留到第三個 yield | |
| ) | |
| # yield 2(4-C 補償,緊接著上一個 yield,同一個 generator/連線): | |
| # 4-a:result_nav_col 這幾顆結果類項目本來就都是 disabled 狀態 | |
| # (要等對應結果產出才 enable,見 create_app 的 interactive=False | |
| # 與 _populate_tabs 的閘門,這批不動那個閘門邏輯),改成「進工作區 | |
| # 就顯示」只是讓使用者一開始就看得到完整的流程地圖,不是讓他們能 | |
| # 提前點。result_nav_col/canvas_exec_col 本身已在上一個 yield | |
| # 掛載完成,這裡不再重複設定(重複設 visible=True 無害,但沒必要)。 | |
| yield ( | |
| gr.update(), gr.update(), gr.update(), gr.update(), state, | |
| gr.update(visible=False), # upload_row | |
| gr.update(visible=False), # metadata_row | |
| gr.update(visible=False), # canvas_idx_col | |
| gr.update(visible=False), # canvas_stat_col | |
| gr.update(visible=False), # canvas_report_col | |
| gr.update(), # result_nav_col:已在第一個 yield 掛載完成,這裡不再重複設定 | |
| gr.update(), # canvas_exec_col:同上,這裡不再重複設定 | |
| gr.update(), # nav_dl_group 留到下一個 yield(見下方說明) | |
| ) | |
| # yield 3:nav_dl_group(下載捷徑那組分隔線+四顆按鈕)住在 | |
| # result_nav_col 裡面,是比 result_nav_col 更深一層的子元件。 | |
| # result_nav_col 已在第一個 yield 翻成 visible=True,若 nav_dl_group | |
| # 太早跟著壓 False,會被同一種 still-mounted-snapback 蓋回舊值 | |
| # (跟 metadata_row 那組是同一條地雷,只是換了一層祖先/子元件); | |
| # 這裡隔了兩個 yield 才動 nav_dl_group,比原本的一個 yield 間隔 | |
| # 更保守安全,之後有 zip 產出時 _populate_tabs 會自己把它翻回 | |
| # True,不受影響。 | |
| yield ( | |
| gr.update(), gr.update(), gr.update(), gr.update(), state, | |
| gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), | |
| gr.update(), gr.update(), | |
| gr.update(visible=False), # nav_dl_group | |
| ) | |
| start_analysis_btn.click( | |
| fn=on_start_analysis_show, | |
| inputs=[session_state], | |
| outputs=[homepage_col, workspace_row, scenario_select_row, app_header_html, session_state, | |
| upload_row, metadata_row, canvas_idx_col, canvas_stat_col, canvas_report_col, | |
| result_nav_col, canvas_exec_col, nav_dl_group], | |
| ) | |
| # ── 文字送出(streaming generator)────────────────────────────────── | |
| # ── 研究情境選擇(封閉式固定四選一,按鈕直接觸發,不再解析打字輸入)── | |
| # Bug 4-C 補強(2026-07-30,驗證時發現):Gradio 6 對「visible 曾經被 | |
| # 設過 True 的元件」有個比原本認知更棘手的行為——不是只有「祖先隱藏時 | |
| # 子元件的 update 被丟棄」,實測(掛 MutationObserver 追蹤 class 變化, | |
| # 確認是同一個 DOM 節點、不是重新掛載)發現:workspace_row 這個祖先 | |
| # 一旦從 visible=False 翻回 True(也就是這裡、使用者選情境的當下), | |
| # 底下曾經被設過 True 的子元件(metadata_row/canvas_idx_col/ | |
| # canvas_stat_col/canvas_report_col——只要曾經因為真的走過一次上傳 | |
| # 確認/分析完成而被設成 visible=True)會被 Gradio 自動翻回 True, | |
| # 蓋掉 on_reset_clear_workspace 稍早設的 False,跟這次選情境的 | |
| # on_scenario_pick 本身完全無關。canvas_exec_col/result_nav_col | |
| # 不受影響,是因為下面第二個 yield 本來就會把它們重新設一次 True, | |
| # 剛好跟 Gradio 的還原值一致,看不出差異;file_input/computing_html | |
| # 也不受影響,因為它們動的是 value 不是 visible,不吃這個坑。 | |
| # 純靠 on_reset_confirm 那邊「藏起來」不夠可靠,唯一穩定的做法是在 | |
| # workspace_row 每次重新變 True 的這個時間點,主動把這四個「應該要 | |
| # 從乾淨狀態開始」的結果/表單容器重新設回 False,蓋過 Gradio 的還原 | |
| # 值,不管它是怎麼還原回來的。 | |
| def on_scenario_pick(sc_num, chat_history, log_history, state): | |
| if state is None: | |
| yield (chat_history, log_history, state, gr.update(), gr.update(), gr.update(), | |
| gr.update(), gr.update(), gr.update(), gr.update(), | |
| gr.update(), gr.update(), gr.update(), gr.update(), | |
| gr.update()) | |
| return | |
| sc_info = SCENARIOS[sc_num] | |
| chat_history = chat_history + [_user_msg(sc_info["title"])] | |
| log_history = _log_append(log_history, "使用者選擇研究情境中……") | |
| # 首頁一旦選定情境即隱藏,換成對話+操作區(workspace_row); | |
| # 結果導覽(result_nav_col)也在這時候一併顯示(雖然還沒有結果 | |
| # 可跳轉,但導覽列本身跟開始新分析按鈕一樣是常駐的操作區塊)。 | |
| # 技術執行紀錄(canvas_exec_col)跟著同一時間顯示——它從一開始 | |
| # 就有內容(這段 log 本身),不像其他三個結果頁要等運算完成才有 | |
| # 東西可看,不用透過 _populate_tabs 的檔案存在判斷來決定可見性。 | |
| # 這幾個 visibility 只需要在第一個 yield 就切換一次,後續 yield | |
| # 沿用 gr.update() 不動即可,不用每次都重複賦值。 | |
| yield (chat_history, log_history, state, gr.update(), gr.update(), gr.update(), | |
| gr.update(visible=False), gr.update(visible=True), gr.update(visible=True), | |
| gr.update(visible=True), | |
| gr.update(), gr.update(), gr.update(), gr.update(), | |
| gr.update()) | |
| reply, state = handle_scenario_select(sc_num, state) | |
| log_history = _log_append(log_history, "✅ 情境選擇完成") | |
| chat_history = chat_history + [_assistant_msg(reply)] | |
| # 4-C 補強:metadata_row/canvas_idx_col/canvas_stat_col/ | |
| # canvas_report_col 的重新隱藏刻意放在「跟 workspace_row 翻成 | |
| # visible=True 不同的一個 yield」,不是圖方便才分開寫。實測 | |
| # (MutationObserver 追蹤同一個 DOM 節點)發現:這幾個子元件若 | |
| # 曾經因為真的走過一次上傳確認/分析完成而被設成 visible=True, | |
| # Gradio 的前端會在 workspace_row 這個祖先「同一批訊息」翻回 | |
| # visible=True 的當下,把它們的舊 True 值蓋回來,跟這批訊息裡我們 | |
| # 自己對它們下的 visible=False 更新互相打架、我們的更新輸了。 | |
| # 等 workspace_row 的翻轉已經在前一個 yield 處理完、沉澱下來, | |
| # 這一個 yield 才不會再觸發同一種還原,我們的 False 才是最後贏家。 | |
| yield (chat_history, log_history, state, gr.update(), _todo_update(state), gr.update(), | |
| gr.update(), gr.update(), gr.update(), gr.update(), | |
| gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update()) | |
| # STEP 2 文本上傳(3-A:研究選擇搬進工作區成為正式第 1 步後, | |
| # 這裡的步驟編號全部 +1):確認訊息已經導向上傳卡片的「格式要求」 | |
| # 區塊,不再另外用 LLM 生成一則重複格式細節的引導訊息,避免對話 | |
| # 視窗出現冗餘內容。這裡原本是獨立的「上傳引導」步驟,但它跟前一步 | |
| # 在同一個 generator 內連續完成、中間沒有任何使用者動作,步驟列上 | |
| # 永遠只會一起亮起,2026-07-23 併進「文本上傳」,只留等待上傳的 | |
| # 狀態切換。 | |
| state["current_step"] = 2 | |
| state["state"] = "UPLOAD_WAIT" | |
| log_history = _log_append(log_history, "⏳ 等待上傳文本……") | |
| # 情境已選定,研究選擇區塊收起、換上傳卡片出現。這個 yield 離 | |
| # workspace_row 自身的 True/False 翻轉已經隔了兩個 yield,不受 | |
| # 本函式上方記錄的 Gradio 6 still-mounted-snapback 地雷影響, | |
| # 可以放心跟其他更新同批。對話視窗自此回歸純輸出角色。格式要求 | |
| # 直接寫進上傳卡片本身(upload_hint_md),使用者不用往上捲回 | |
| # 對話視窗找 AI 剛剛說過的規則——卡片本身就要自己講清楚。 | |
| # 2026-08-22(見 docs/開發優化建議.md 第一節「研究語料傳送至第三方 | |
| # AI 服務的資料政策」):上傳的文本內容會在報告撰寫階段組進 prompt | |
| # 送到 OpenRouter(再轉給實際供應商),先前介面完全沒有告知使用者 | |
| # 這件事。獨立成一段(不是格式要求清單的第 5 項),避免被當成單純 | |
| # 的檔案格式規定而略過不看——這是機密性提醒,性質跟前四項不同。 | |
| hint = ( | |
| f"**格式要求**\n\n{sc_info['upload_prompt']}\n\n" | |
| "---\n\n" | |
| "📢 **隱私提醒**:上傳的文本內容將傳送至第三方 AI 服務" | |
| "(OpenRouter 及其上游模型供應商)進行語言分析與報告撰寫。" | |
| "請確認語料不涉及尚未公開發表之機密內容,如有疑慮請先行確認後再上傳。" | |
| ) | |
| yield (chat_history, log_history, state, gr.update(visible=True), | |
| _todo_update(state), gr.update(value=hint), | |
| gr.update(), gr.update(), gr.update(), gr.update(), | |
| gr.update(), gr.update(), gr.update(), gr.update(), | |
| gr.update(visible=False)) | |
| for _sc_num, _sc_btn in (("1", sc_btn_1), ("2", sc_btn_2), ("3", sc_btn_3), ("4", sc_btn_4)): | |
| _sc_btn.click( | |
| fn=functools.partial(on_scenario_pick, _sc_num), | |
| inputs=[chatbot, exec_log, session_state], | |
| outputs=[chatbot, exec_log, session_state, upload_row, app_header_html, | |
| upload_hint_md, homepage_col, workspace_row, result_nav_col, canvas_exec_col, | |
| metadata_row, canvas_idx_col, canvas_stat_col, canvas_report_col, | |
| scenario_select_row], | |
| ).then( | |
| fn=_populate_tabs, | |
| inputs=[session_state], | |
| outputs=_TAB_OUTPUTS, | |
| ) | |
| # ── 「開始新分析」:Bug1 修復——選定情境後先前完全沒有任何路徑能 | |
| # 回頭變更,使用者選錯情境後卡住。這裡補上共用的重置機制:換情境 | |
| # =重新開始,統一走這顆按鈕回到首頁重新選(不恢復對話框打字選 | |
| # 情境的舊路徑)──────────────────────────────────────────────── | |
| # Bug 4-C 修復(2026-07-30):Gradio 6 的 gr.Column/Row(visible=False) | |
| # 會把子元件整段從 DOM 卸載,不只是 CSS 隱藏。原本 on_reset_confirm | |
| # 在同一個 return 裡,一邊把 workspace_row 設 visible=False,一邊又要 | |
| # 重置住在 workspace_row 內部的子元件(metadata_row/file_input/ | |
| # canvas_*_col/computing_html/result_nav_col)——這些子元件那一刻 | |
| # 已經被卸載,重置的 update 會被靜默丟棄,於是 metadata_row 帶著 | |
| # 上一輪 _show_metadata_form 設的 visible=True 回來,file_input 也還 | |
| # 留著舊檔案,就是使用者回報「重置後再進工作區,殘留上一輪畫面」的 | |
| # 根因。修法:拆成兩個串接的獨立事件。第一個事件 | |
| # (on_reset_clear_workspace)沒有 inputs,只做「藏起來/清空」, | |
| # 在 workspace_row 還可見、子元件仍掛載的當下執行,update 才會生效; | |
| # 第二個事件(沿用原名 on_reset_confirm)才把 workspace_row 本身 | |
| # 連同首頁切換一起做掉,session_state 的重建(make_initial_state) | |
| # 也留在這一支。兩支的 outputs 清單不重疊。 | |
| def on_reset_clear_workspace(): | |
| # Phase 11 回歸測試(2026-08-05)發現:情境 3/4 的語料 Metadata | |
| # 表單自由文字欄位與「群組數量」/「比較方式」單選,重置後從未被 | |
| # 清空過——因為它們是獨立於 state 之外的 Gradio 元件值, | |
| # on_reset_confirm 原本只重建 session_state,沒有任何地方碰過 | |
| # 這幾個元件。實測重現:填過一次「原文標題」、選過「三群」後 | |
| # 回首頁重新開始,下一輪(即使換了完全不同的情境/檔案數) | |
| # 這些欄位還是顯示上一輪的舊值。群組層/個體層表格不受影響, | |
| # 因為 _show_metadata_form 每次都會依當下上傳結果重新整份覆蓋。 | |
| # 清空動作放在這一支(而非 on_reset_confirm):跟本函式其餘欄位 | |
| # 同一個理由,metadata_row 這批子元件此刻仍掛載在可見的 | |
| # workspace_row 底下,update 才會生效。 | |
| return ( | |
| gr.update(visible=False), # metadata_row | |
| gr.update(value=None), # file_input(Phase 8:upload_row 變正式入口後一併清空) | |
| gr.update(visible=False), # canvas_idx_col 語言指標分析 | |
| gr.update(visible=False), # canvas_stat_col 統計比較分析 | |
| gr.update(visible=False), # canvas_report_col LLM輔助分析 | |
| gr.update(visible=False), # canvas_exec_col 技術執行紀錄(2026-07-23 轉正後,跟其他三個結果頁一樣重置為隱藏) | |
| gr.update(value=""), # computing_html(Phase 8-D:中途重置要清掉運算中提示) | |
| gr.update(visible=False), # result_nav_col(Phase 8-D:重新開始=回到首頁,導覽收起) | |
| gr.update(value=""), # upload_progress_html(3-C:中途重置要清掉上傳進度提示,同 computing_html) | |
| gr.update(value=""), # meta_title | |
| gr.update(value=""), # meta_author | |
| gr.update(value=""), # meta_lang | |
| gr.update(value=""), # meta_pubyear | |
| gr.update(value=""), # meta_version | |
| gr.update(value="二群"), # meta_ngroups(元件宣告時的預設值) | |
| gr.update(value="群組間比較"), # meta_method(元件宣告時的預設值) | |
| # 2026-08-12:結果導覽的 A2 閘門要一起關回去。這幾顆在 | |
| # _populate_tabs 產出結果時被打開(interactive=True),但重置 | |
| # 從來沒有關回來——實測重置後回到全新首頁,「語言指標分析」等 | |
| # 項目仍是可點狀態,點下去是一片空白的結果頁。 | |
| *([gr.update(interactive=False)] * len(_TAB_NAV_GATES)), | |
| ) | |
| def on_reset_confirm(): | |
| sid = str(uuid.uuid4())[:8] | |
| state = make_initial_state(sid) | |
| # 2026-07-31(Batch E 第 4-d 項):同 on_load_ready,拿掉 | |
| # 「(或下方)」,兩處歡迎詞要保持一致。 | |
| intro = ( | |
| "歡迎使用 TransPrism II 分析引擎 SpectraEngine。\n\n" | |
| "請依照您的研究設計,點選右方最符合的研究情境開始分析:" | |
| ) | |
| new_log = f"{_ts()} 已重新開始分析。" | |
| return ( | |
| [_assistant_msg(intro)], # chatbot | |
| new_log, # exec_log | |
| state, # session_state | |
| gr.update(value=_build_header_html()), # app_header_html | |
| gr.update(visible=False), # upload_row(Phase 8:正式上傳入口改這張卡片) | |
| gr.update(visible=False), # reset_confirm_row(關掉確認彈窗自己) | |
| gr.update(visible=True), # homepage_col(Phase 8:重新開始=回到首頁選情境) | |
| gr.update(visible=False), # workspace_row | |
| ) | |
| reset_btn.click(fn=lambda: gr.update(visible=True), outputs=[reset_confirm_row]) | |
| reset_cancel_btn.click(fn=lambda: gr.update(visible=False), outputs=[reset_confirm_row]) | |
| about_btn.click(fn=lambda: gr.update(visible=True), outputs=[about_overlay]) | |
| about_close_btn.click(fn=lambda: gr.update(visible=False), outputs=[about_overlay]) | |
| # 2026-08-12:reset_confirm_btn 的接線搬到檔案最後(見 create_app 結尾 | |
| # 「重置接線」那段)。原因:它需要 cancels=[分析運算那兩條事件鏈],而 | |
| # 那兩條要等 meta_confirm_btn/meta_cancel_btn 接完才存在。Gradio 的 | |
| # 接線順序不影響行為,只有 cancels 需要拿到事件物件,所以往後搬。 | |
| # ── 檔案上傳確認(streaming generator)────────────────────────────── | |
| def on_upload_confirm(files, chat_history, log_history, state): | |
| if not files or state is None: | |
| yield chat_history, log_history, state, gr.update(visible=True), gr.update(), gr.update() | |
| return | |
| # 2026-08-22(業主回報:對話視窗兩則 AI 訊息之間沒有使用者自己的 | |
| # 發言氣泡,看起來像 AI 自問自答):在流程真正開始處理之前,先補一則 | |
| # 使用者訊息氣泡,把「使用者上傳了哪些檔案」呈現出來,對話才有一來 | |
| # 一往的感覺。 | |
| # 2026-08-24(業主建議):若同一情境下、前一次「確認上傳」的格式驗證 | |
| # 失敗過,這次再按確認上傳就不是「第一次上傳」,而是使用者刪掉檔案、 | |
| # 修正後重新送出——文字改成「重新上傳了」更貼近實際發生的事,讀者 | |
| # 事後看對話紀錄也更容易理解「這是一次修正後的重試」。判斷時機要在 | |
| # 下面把 state["upload"]["validate"]["status"] 覆寫成 "pending" 之前 | |
| # 讀,不然永遠讀到剛被蓋掉的新值。換情境重來(on_back_to_scenario) | |
| # 會把這個狀態重置回 "pending",避免換情境後的第一次上傳被誤標成 | |
| # 「重新上傳」。 | |
| _is_retry = state.get("upload", {}).get("validate", {}).get("status") == "failed" | |
| _uploaded_names = [Path(f.name if hasattr(f, "name") else str(f)).name for f in files] | |
| _verb = "重新上傳了" if _is_retry else "上傳了" | |
| chat_history = chat_history + [_user_msg(f"{_verb} {len(_uploaded_names)} 個檔案:{'、'.join(_uploaded_names)}")] | |
| # 文本上傳(step 2:3-A 新增「研究選擇」為第 1 步後,這裡的步驟 | |
| # 編號全部 +1):開始處理即關閉上傳彈窗,避免遮罩擋住操作。 | |
| # 3-C:同時把「檔案接收」子階段標成 running,寫進 upload_progress_html | |
| # ——業主回報「上傳過程都一片白,只在對話視窗出現文字氣泡,用戶會 | |
| # 不知道發生什麼事」,這裡讓內容區塊跟技術執行紀錄同步顯示進度。 | |
| state["current_step"] = 2 | |
| state["upload"]["receive"]["status"] = "running" | |
| state["upload"]["receive"]["line"] = "接收上傳檔案中……" | |
| state["upload"]["validate"]["status"] = "pending" | |
| state["upload"]["validate"]["line"] = "" | |
| log_history = _log_append(log_history, "⏳ 接收上傳檔案中……") | |
| yield (chat_history, log_history, state, gr.update(visible=False), | |
| _todo_update(state), _upload_update(state)) | |
| recv_msg, state = handle_uploaded_files(files, state) | |
| chat_history = chat_history + [_assistant_msg(recv_msg)] | |
| state["upload"]["receive"]["status"] = "done" | |
| state["upload"]["receive"]["line"] = f"已接收 {len(files)} 個檔案" | |
| log_history = _log_append(log_history, f"✅ 已接收 {len(files)} 個檔案") | |
| yield (chat_history, log_history, state, gr.update(visible=False), | |
| _todo_update(state), _upload_update(state)) | |
| # 格式驗證:2026-07-24 起不再是獨立步驟(見 _STEPS 註解),只是「文本 | |
| # 上傳」這一步內部的一道處理,逐行進技術執行紀錄,不動步驟列。step 2 | |
| # 在格式驗證通過後才標 done——通過前維持「進行中」,失敗時原地留著要求 | |
| # 重新上傳,畫面與步驟列不會互相矛盾。 | |
| state["upload"]["validate"]["status"] = "running" | |
| state["upload"]["validate"]["line"] = "格式驗證中……" | |
| log_history = _log_append(log_history, "⏳ 格式驗證中……") | |
| yield (chat_history, log_history, state, gr.update(visible=False), | |
| _todo_update(state), _upload_update(state)) | |
| check_msg, state, has_errors = run_format_check(state) | |
| chat_history = chat_history + [_assistant_msg(check_msg)] | |
| if has_errors: | |
| state["upload"]["validate"]["status"] = "failed" | |
| state["upload"]["validate"]["line"] = "格式不符,請重新上傳" | |
| log_history = _log_append(log_history, "⚠️ 格式不符,請重新上傳") | |
| state["current_step"] = 2 | |
| yield (chat_history, log_history, state, gr.update(visible=True), | |
| _todo_update(state), _upload_update(state)) | |
| return | |
| state["upload"]["validate"]["status"] = "done" | |
| state["upload"]["validate"]["line"] = "格式驗證通過" | |
| state["done_steps"] = list(set(state.get("done_steps", []) + [2])) | |
| state["current_step"] = 3 | |
| log_history = _log_append(log_history, "✅ 格式驗證通過") | |
| # 格式通過後:顯示 metadata 說明表單(語料說明=step 3),等使用者填寫 | |
| state["state"] = "METADATA_INPUT" | |
| meta_hint = "請填寫語料說明(原文資訊、群組層與個體層),完成後點「確認並開始分析」。" | |
| chat_history = chat_history + [_assistant_msg(meta_hint)] | |
| log_history = _log_append(log_history, "📑 請填寫語料說明") | |
| # 2026-07-31(Batch E 第 5、6 項):業主回報「語料說明」「分析 | |
| # 運算」兩步驟畫面上還留著「文本上傳」的進度區塊(檔案接收 ✓/ | |
| # 格式驗證 ✓),流程往下推進後這塊資訊已經沒用。清空的時機點 | |
| # 選在這裡(格式驗證成功、正要進「語料說明」的這個 yield): | |
| # 往前推進的唯一入口只有這條成功路徑,往後 metadata 表單出現時 | |
| # upload_progress_html 就已經是空字串;格式驗證失敗那條路徑 | |
| # (上面 has_errors 分支)維持呼叫 _upload_update(state)、繼續 | |
| # 停在文本上傳步驟,不會被這裡影響——那時使用者正需要看到失敗 | |
| # 在哪一段。往後「分析運算」步驟本身不會再寫這個元件的值(見 | |
| # _run_computation 的 yield 沒有 upload_progress_html),空字串 | |
| # 會一路帶到分析運算步驟,不需要在那裡另外清一次。跟 computing_html | |
| # 同一種「空字串=不顯示」語意,直接設空字串,不用改動 state。 | |
| yield (chat_history, log_history, state, gr.update(visible=False), | |
| _todo_update(state), gr.update(value="")) | |
| # ── metadata 表單 helpers ─────────────────────────────────────────── | |
| def _build_meta_tables(state): | |
| import os | |
| files = [os.path.basename(p) for p in state.get("cleaned_files", [])] | |
| groups = sorted(set(f[0].upper() for f in files if f)) | |
| # 2026-08-15:群組層改成三組獨立 Textbox 後,這裡不再需要組出 | |
| # Dataframe 的初始列資料,只需要回傳實際偵測到的分組代碼清單 | |
| # (給 _show_metadata_form 決定顯示幾組、標題寫哪個代碼)。 | |
| return groups, [[f, "", "", "", ""] for f in files] | |
| def _df_rows(dfval): | |
| try: | |
| if isinstance(dfval, pd.DataFrame): | |
| return dfval.fillna("").values.tolist() | |
| if isinstance(dfval, dict) and "data" in dfval: | |
| return dfval["data"] | |
| if isinstance(dfval, list): | |
| return dfval | |
| except Exception: | |
| pass | |
| return [] | |
| def _extract_individual_grouping(indiv_rows, method): | |
| """個體間比較模式下,把「個體層資訊」表格整理成 {檔名: 分組標籤} | |
| dict,交給 run_statistical_analysis 取代預設的檔名字首分組 | |
| (2026-08-15 業主要求:選「個體間比較」時,個體層資訊要真的 | |
| 影響分析,不能只是寫進 metadata 文字給 LLM 參考)。 | |
| method 不是「個體間比較」時一律回傳空 dict——run_statistical_ | |
| analysis 收到空 dict 等同沒收到,完全不影響現行「群組間比較」 | |
| 的行為。 | |
| 2026-08-15 二版(業主糾正):「個體間比較」的定義是逐檔案比較, | |
| 不能因為「譯者/系統名稱」剛好打一樣(例如同一位譯者的多篇 | |
| 譯本)就把檔案合併成同一組——那樣等於又退回群組比較,跟 | |
| 「個體間」的字面意思矛盾。這裡改成:同一個名稱出現在多個檔案 | |
| 時,自動用「出版年」加註消歧義(沒填出版年就退而求其次用 | |
| 「重譯序位」,兩者都沒填則直接用檔名本身墊底)——不管怎樣都 | |
| 保證每個檔案分到獨一無二的組,絕不因為名稱撞名而合併。 | |
| 欄位索引對應目前的「個體層」表格:0=檔案、1=譯者/系統名稱、 | |
| 2=出版年、3=譯者母語、4=重譯序位(見上方 meta_indiv_df 宣告, | |
| 2026-08-15 拿掉「後編輯程度」後的新欄位順序)。""" | |
| if method != "個體間比較": | |
| return {} | |
| entries = [] # (檔名, 名稱, 出版年, 重譯序位) | |
| for row in indiv_rows: | |
| if not row or len(row) < 2: | |
| continue | |
| fname = str(row[0]).strip() | |
| name = str(row[1]).strip() | |
| year = str(row[2]).strip() if len(row) > 2 else "" | |
| seq = str(row[4]).strip() if len(row) > 4 else "" | |
| if fname and name: | |
| entries.append((fname, name, year, seq)) | |
| name_counts = {} | |
| for _, name, _, _ in entries: | |
| name_counts[name] = name_counts.get(name, 0) + 1 | |
| mapping = {} | |
| for fname, name, year, seq in entries: | |
| if name_counts[name] > 1: | |
| disambiguator = year or seq or fname | |
| mapping[fname] = f"{name}({disambiguator})" | |
| else: | |
| mapping[fname] = name | |
| return mapping | |
| def _compile_metadata(state, title, author, lang, pubyear, version, | |
| ngroups, method, group_field_values, indiv_df): | |
| """group_field_values:長度 15 的 list,三組 × 5 欄 | |
| (群組標籤/翻譯類型/翻譯方向/譯者背景/目標讀者)攤平排列, | |
| 對應 meta_group_fields 三個 slot、各自 5 個 Textbox 的值。""" | |
| sc = state.get("scenario", "") | |
| L = ["## 語料說明", "", | |
| "### 原文資訊", | |
| f"- 原文標題:{title or '(未填)'}", | |
| f"- 作者:{author or '(未填)'}", | |
| f"- 語言:{lang or '(未填)'}", | |
| f"- 出版年:{pubyear or '(未填)'}", | |
| f"- 版本:{version or '(未填)'}", ""] | |
| if sc in ("3", "4"): | |
| L += ["### 比較結構", | |
| f"- 群組數量:{ngroups}", | |
| f"- 比較方式:{method}", ""] | |
| gh = ["群組標籤", "翻譯類型", "翻譯方向", "譯者背景", "目標讀者"] | |
| # 2026-08-15:每組資訊前面明確標上實際分組代碼(「群組 A:...」), | |
| # 不再只是無標籤的一條條列點——LLM 之前因為看不出這行屬於哪個 | |
| # 分組代碼,把 A/B 的標籤講反過一次(見待辦記錄),這裡從資料 | |
| # 來源端直接堵掉這個歧義。 | |
| codes = sorted(set(Path(fp).name[0].upper() | |
| for fp in state.get("cleaned_files", []) if fp)) | |
| slots = [group_field_values[i*5:(i+1)*5] for i in range(3)] | |
| L += ["### 群組層資訊"] | |
| for code, vals in zip(codes, slots): | |
| filled = [f"{h}:{str(v).strip()}" for h, v in zip(gh, vals) if str(v).strip()] | |
| if filled: | |
| L.append(f"- 群組 {code}:" + ";".join(filled)) | |
| ih = ["檔案", "譯者/系統名稱", "出版年", "譯者母語", "重譯序位"] | |
| L += ["", "### 個體層資訊"] | |
| for r in _df_rows(indiv_df): | |
| if not any(str(c).strip() for c in r): | |
| continue | |
| L.append("- " + ";".join(f"{h}:{str(v).strip()}" for h, v in zip(ih, r) if str(v).strip())) | |
| return "\n".join(L) | |
| def _extract_group_labels(state, group_field_values): | |
| """把三組群組標籤 Textbox 的值整理成 {分組代碼: 使用者填的標籤} | |
| dict,供差異性分析文字/箱形圖 X 軸使用。group_field_values 是 | |
| 長度 15 的攤平 list(見 _compile_metadata),每組取第 0 欄 | |
| (群組標籤)。 | |
| 對應規則是「位置對位置」:_show_metadata_form() 是照 | |
| sorted(群組代碼) 的順序把代碼填進每個 slot 的標題(例如 | |
| 「組別 A」),使用者在該 slot 底下的「群組標籤」欄位填的內容 | |
| 就對應那個代碼——沒填或原樣留著代碼本身的 slot 視同沒填, | |
| 呈現時退回代碼本身。""" | |
| try: | |
| codes = sorted(set(Path(fp).name[0].upper() | |
| for fp in state.get("cleaned_files", []) if fp)) | |
| slot_labels = [group_field_values[i*5] for i in range(3)] | |
| labels = {} | |
| for code, label in zip(codes, slot_labels): | |
| label = str(label or "").strip() | |
| if label and label != code: | |
| labels[code] = label | |
| return labels | |
| except Exception: | |
| return {} | |
| def _run_computation(chat_history, log_history, state): | |
| # 2026-07-23:技術執行紀錄轉正成獨立頁面後,可見性只在情境選定 | |
| # (on_scenario_pick)與重置(on_reset_confirm)時切換一次, | |
| # 這裡不用再逐步更新開合狀態/摘要標籤,改回傳 gr.update() 不動。 | |
| # 「語料說明」(step 3:2026-07-30 新增「研究選擇」為第 1 步後, | |
| # 這裡的步驟編號全部 +1) 在這裡收尾:confirm 與 skip 兩條路徑都會 | |
| # 走進 _run_computation,標記在這一處就好,不用在兩個 handler 各寫 | |
| # 一次。2026-07-24 步驟列收斂到 3 步時是語料說明=2、分析運算=3; | |
| # 分析運算是單一步驟,底下三個子階段的細狀態改由 state["analysis"] | |
| # 承載(見 run_computation_pipeline_streaming),這裡不再逐段搬動 | |
| # current_step,只在最後依子階段結果決定第 4 步是 done 還是 failed。 | |
| state["done_steps"] = list(set(state.get("done_steps", []) + [3])) | |
| state["current_step"] = 4 | |
| log_history = _log_append(log_history, "⏳ 分析運算開始……") | |
| # Bug 4-A 修復(2026-07-30):報告撰寫佔掉幾乎全部等待時間 | |
| # (30-90 秒以上),原本 _populate_tabs 只掛在整段 generator 跑完 | |
| # 後的 .then(),指標計算/統計分析早就做完,使用者卻要等報告 | |
| # 撰寫也結束才看得到——內容區塊的「查看…」連結點了沒反應、左側 | |
| # 導覽也還是 disabled。改成用 _no_tab_change 當預設(大部分 yield | |
| # 不動 _TAB_OUTPUTS),只在下面偵測到某個子階段「剛轉成 done」時 | |
| # 才呼叫一次 _populate_tabs,把當下已完成的結果併進這次 yield。 | |
| _no_tab_change = [gr.update()] * _N_TAB | |
| yield (chat_history, log_history, state, _todo_update(state), | |
| gr.update(), _computing_update(state), *_no_tab_change) | |
| # 記錄每個子階段「上一次看到的狀態是不是 done」,跟目前狀態比對 | |
| # 才知道是不是「剛」轉成 done——避免同一階段已經 done 之後,後面 | |
| # 每個 yield(例如下一階段的 running/done)都重複呼叫一次 | |
| # _populate_tabs(它會讀 CSV、複製 docx、打包 zip,很浪費)。 | |
| prev_done = {k: False for k, _, _ in _ANALYSIS_STAGES} | |
| for prog_msg, is_final, state in run_computation_pipeline_streaming(state): | |
| log_history = _log_append(log_history, prog_msg) | |
| if is_final: | |
| chat_history = chat_history + [_assistant_msg(prog_msg)] | |
| # 三個子階段任一失敗 → 第 4 步標紅(頂部步驟列據實呈現, | |
| # 內容區塊講清楚壞在哪一段);全部成功才標 ✓。 | |
| stages_failed = any( | |
| state["analysis"][k]["status"] == "failed" for k, _, _ in _ANALYSIS_STAGES | |
| ) | |
| state["current_step"] = None | |
| if stages_failed: | |
| state["failed_step"] = 4 | |
| else: | |
| state["done_steps"] = list(set(state.get("done_steps", []) + [4])) | |
| newly_done = False | |
| for k, _, _ in _ANALYSIS_STAGES: | |
| is_done = state["analysis"][k]["status"] == "done" | |
| if is_done and not prev_done[k]: | |
| newly_done = True | |
| prev_done[k] = is_done | |
| tab_updates = _populate_tabs(state) if newly_done else _no_tab_change | |
| yield (chat_history, log_history, state, _todo_update(state), | |
| gr.update(), _computing_update(state), *tab_updates) | |
| # 3(metadata_row)+ 21(3 組 × [欄位可見性, 標題, 5 個 Textbox]) | |
| # + 1(indiv_df)+ 1(compare_group 可見性) | |
| _N_META_FORM_OUTPUTS = 1 + 3 * 7 + 1 + 1 | |
| def _show_metadata_form(state): | |
| _NO = gr.update() | |
| if not state or state.get("state") != "METADATA_INPUT": | |
| return (_NO,) * _N_META_FORM_OUTPUTS | |
| groups, indiv_rows = _build_meta_tables(state) | |
| show_cmp = state.get("scenario", "") in ("3", "4") | |
| group_updates = [] | |
| for i in range(3): | |
| if i < len(groups): | |
| group_updates.append(gr.update(visible=True)) | |
| group_updates.append(gr.update(value=f"組別 {groups[i]}")) | |
| group_updates += [gr.update(value="")] * 5 # 清空上一輪可能殘留的舊值 | |
| else: | |
| group_updates.append(gr.update(visible=False)) | |
| group_updates += [_NO] * 6 # 隱藏中,標題/欄位不用動 | |
| return (gr.update(visible=True), *group_updates, | |
| gr.update(value=indiv_rows), gr.update(visible=show_cmp)) | |
| # 送出期間鎖住按鈕避免重複點擊(2026-07-24 取消鈕移除後只剩確認鈕一顆) | |
| def _lock_upload(): | |
| return gr.update(interactive=False) | |
| def _unlock_upload(): | |
| return gr.update(interactive=True) | |
| upload_btn.click( | |
| fn=_lock_upload, inputs=None, outputs=[upload_btn], | |
| ).then( | |
| fn=on_upload_confirm, | |
| inputs=[file_input, chatbot, exec_log, session_state], | |
| outputs=[chatbot, exec_log, session_state, upload_row, app_header_html, upload_progress_html], | |
| ).then( | |
| fn=_unlock_upload, inputs=None, outputs=[upload_btn], | |
| ).then( | |
| fn=_show_metadata_form, | |
| inputs=[session_state], | |
| outputs=[metadata_row, | |
| *[c for i in range(3) | |
| for c in [meta_group_cols[i], meta_group_heads[i], *meta_group_fields[i]]], | |
| meta_indiv_df, meta_compare_group], | |
| ) | |
| # 2026-07-31(Batch G 第 1 項):「重新選擇研究情境」返回鈕。叫回 | |
| # scenario_select_row、收起 upload_row,步驟列退回「① 研究選擇 | |
| # 進行中」(done_steps 拿掉 1、current_step 設回 1)。session 不重置 | |
| # (不呼叫 make_initial_state,沿用同一個 session_id),跟「回首頁」 | |
| # 是兩件不同的事。 | |
| # | |
| # 這裡「不需要」像 on_start_analysis_show/on_reset_clear_workspace | |
| # 那樣拆成多個 yield/.then():scenario_select_row、upload_row、 | |
| # upload_progress_html 都是「已顯示」的 workspace_row 底下的兄弟節點, | |
| # 這次改動完全沒有動到 workspace_row 本身的可見性(它從頭到尾都是 | |
| # visible=True),所以不會踩本檔開頭記錄的 Gradio 6 地雷(該地雷只在 | |
| # 「祖先容器」從 visible=False 翻回 True 那一刻才會發生)。放同一批 | |
| # gr.update() 即可,不要照抄「回首頁」那套分批模式。 | |
| # | |
| # 檔案清單清空是正確性問題,不是體驗問題:研究情境決定允許的檔名 | |
| # 字首(情境 1、2 只收 A_/B_;情境 3、4 才收 C_),使用者若在情境 3 | |
| # 傳了 C_xx.txt 後改選情境 1,那個檔案會變成不合法卻還留在清單裡。 | |
| # state["uploaded_files"]/state["cleaned_files"] 與 file_input 的 | |
| # value 都要清空,upload_progress_html 也要清成空字串(回到「尚未 | |
| # 上傳」的初始語意,跟 _build_upload_html 對空字串的既有語意一致)。 | |
| def on_back_to_scenario(state): | |
| if state is None: | |
| return (gr.update(visible=True), gr.update(visible=False), gr.update(), | |
| gr.update(value=None), gr.update(value=""), state) | |
| state["uploaded_files"] = [] | |
| state["cleaned_files"] = [] | |
| state["current_step"] = 1 | |
| state["done_steps"] = [s for s in state.get("done_steps", []) if s != 1] | |
| # 2026-08-24:換情境重來時,上一個情境若曾經格式驗證失敗過,這裡要 | |
| # 把狀態重置回初始值,不然換到新情境後的第一次上傳會被 on_upload_ | |
| # confirm 誤判成「重新上傳」(見該處 _is_retry 判斷邏輯)。 | |
| state["upload"] = { | |
| "receive": {"status": "pending", "line": ""}, | |
| "validate": {"status": "pending", "line": ""}, | |
| } | |
| return ( | |
| gr.update(visible=True), # scenario_select_row | |
| gr.update(visible=False), # upload_row | |
| gr.update(value=_build_header_html(state["done_steps"], current_step=1)), # app_header_html | |
| gr.update(value=None), # file_input:清空已選檔案 | |
| gr.update(value=""), # upload_progress_html:清掉上一輪的上傳進度卡片 | |
| state, | |
| ) | |
| back_to_scenario_btn.click( | |
| fn=on_back_to_scenario, | |
| inputs=[session_state], | |
| outputs=[scenario_select_row, upload_row, app_header_html, file_input, upload_progress_html, session_state], | |
| ) | |
| # 2026-07-24(E2):這裡原本有 on_chat_upload_submit(Phase 7 的 | |
| # MultimodalTextbox 轉接生成器),Phase 8 對話框改回純展示後就沒有任何 | |
| # 地方呼叫它,屬死碼,已移除。之後若對話框要恢復接上傳,直接在 chat_upload | |
| # 的 .submit() 包一層拆 {"files":[...]} 丟給 on_upload_confirm 即可。 | |
| _ANALYSIS_START_MSG = ( | |
| "已收到,開始計算語言指標與統計分析,這個步驟可能需要數分鐘,請耐心等候……\n" | |
| "(可於下方「技術執行紀錄」查看執行進度)" | |
| ) | |
| def on_metadata_confirm(title, author, lang, pubyear, version, | |
| ngroups, method, | |
| g1_label, g1_type, g1_dir, g1_bg, g1_aud, | |
| g2_label, g2_type, g2_dir, g2_bg, g2_aud, | |
| g3_label, g3_type, g3_dir, g3_bg, g3_aud, | |
| indiv_df, | |
| chat_history, log_history, state): | |
| group_field_values = [g1_label, g1_type, g1_dir, g1_bg, g1_aud, | |
| g2_label, g2_type, g2_dir, g2_bg, g2_aud, | |
| g3_label, g3_type, g3_dir, g3_bg, g3_aud] | |
| # Bug 4-A 修復(2026-07-30):outputs 併入 _TAB_OUTPUTS(見下方 | |
| # wiring),generator 的每個 yield 元素個數都要跟 outputs 長度 | |
| # 完全一致——這裡先包一份「_TAB_OUTPUTS 全部不動」的預設值, | |
| # 沒有新結果的 yield(early return/running 中)直接複用它。 | |
| _no_tabs = [gr.update()] * _N_TAB | |
| if state is None: | |
| yield (chat_history, log_history, state, gr.update(), _todo_update(state), | |
| gr.update(), gr.update(), *_no_tabs) | |
| return | |
| # 群組數量跟實際上傳分組是否一致:只有情境3/4才會顯示這個選項, | |
| # 情境1/2固定二群沒有歧義不用檢查。時序上這個檢查沒辦法併進 | |
| # run_format_check(使用者選二群/三群是在格式驗證通過之後才填), | |
| # 所以在這裡另開一道檢查點,不一致就擋下、留在表單讓使用者確認 | |
| if state.get("scenario") in ("3", "4"): | |
| actual_prefixes = {Path(fp).name[0].upper() for fp in state.get("cleaned_files", [])} | |
| expected_count = 3 if ngroups == "三群" else 2 | |
| if len(actual_prefixes) != expected_count: | |
| warn_msg = ( | |
| f"⚠️ 您選擇「{ngroups}」,但實際上傳的檔案偵測到 " | |
| f"{len(actual_prefixes)} 組({'、'.join(sorted(actual_prefixes)) or '無'})," | |
| "請確認群組數量與上傳檔案是否一致後再送出。" | |
| ) | |
| chat_history = chat_history + [_assistant_msg(warn_msg)] | |
| log_history = _log_append(log_history, "⚠️ 群組數量與上傳檔案不一致") | |
| yield (chat_history, log_history, state, gr.update(visible=True), | |
| _todo_update(state), gr.update(), gr.update(), *_no_tabs) | |
| return | |
| state["user_metadata"] = _compile_metadata(state, title, author, lang, pubyear, | |
| version, ngroups, method, group_field_values, indiv_df) | |
| state["group_labels"] = _extract_group_labels(state, group_field_values) | |
| state["individual_grouping"] = _extract_individual_grouping(_df_rows(indiv_df), method) | |
| log_history = _log_append(log_history, "✅ 已記錄語料說明,開始分析") | |
| # 2026-08-22:同上傳檔案那段,補一則使用者訊息氣泡,呈現「使用者 | |
| # 填寫完語料說明並送出」這個動作,避免對話看起來像 AI 自問自答。 | |
| chat_history = chat_history + [_user_msg("已送出語料說明(原文資訊、群組層與個體層),請開始分析")] | |
| # 在主對話視窗立即顯示「開始分析」訊息,避免長時間運算期間畫面看起來像沒有反應 | |
| chat_history = chat_history + [_assistant_msg(_ANALYSIS_START_MSG)] | |
| yield (chat_history, log_history, state, gr.update(visible=False), | |
| _todo_update(state), gr.update(), _computing_update(state), *_no_tabs) | |
| # _run_computation 現在也 yield _TAB_OUTPUTS 那一份(見該函式 | |
| # 2026-07-30 註解),用 *tabs 原樣轉發,不在這裡重新組裝 | |
| for ch, lh, st, todo, acc, comp, *tabs in _run_computation(chat_history, log_history, state): | |
| chat_history, log_history, state = ch, lh, st | |
| yield (chat_history, log_history, state, gr.update(visible=False), todo, acc, comp, *tabs) | |
| def _lock_meta(): | |
| return gr.update(interactive=False), gr.update(interactive=False) | |
| def _unlock_meta(): | |
| return gr.update(interactive=True), gr.update(interactive=True) | |
| confirm_run_evt = meta_confirm_btn.click( | |
| fn=_lock_meta, inputs=None, outputs=[meta_confirm_btn, meta_cancel_btn], | |
| ).then( | |
| fn=on_metadata_confirm, | |
| inputs=[meta_title, meta_author, meta_lang, meta_pubyear, meta_version, | |
| meta_ngroups, meta_method, | |
| *meta_group_fields[0], *meta_group_fields[1], *meta_group_fields[2], | |
| meta_indiv_df, | |
| chatbot, exec_log, session_state], | |
| # Bug 4-A 修復(2026-07-30):併入 _TAB_OUTPUTS,讓 on_metadata_confirm | |
| # 能在 streaming 過程中(不用等這條 .then() 鏈跑到最後)就把已完成 | |
| # 子階段的結果 populate 進畫面。結尾這顆 _populate_tabs 仍保留, | |
| # 收尾時做一次完整填充(例如 zip 打包這種只需最後做一次的事)。 | |
| outputs=[chatbot, exec_log, session_state, metadata_row, app_header_html, | |
| canvas_exec_col, computing_html, *_TAB_OUTPUTS], | |
| ).then( | |
| fn=_unlock_meta, inputs=None, outputs=[meta_confirm_btn, meta_cancel_btn], | |
| ).then(fn=_populate_tabs, inputs=[session_state], outputs=_TAB_OUTPUTS) | |
| def on_metadata_skip(chat_history, log_history, state): | |
| # 同 on_metadata_confirm,outputs 併入 _TAB_OUTPUTS 後每個 yield | |
| # 都要補滿這份長度,見該處 2026-07-30 註解。 | |
| _no_tabs = [gr.update()] * _N_TAB | |
| if state is None: | |
| yield (chat_history, log_history, state, gr.update(), _todo_update(state), | |
| gr.update(), gr.update(), *_no_tabs) | |
| return | |
| state["user_metadata"] = state.get("user_metadata", "") or "(使用者未提供語料說明)" | |
| log_history = _log_append(log_history, "⏭️ 已略過語料說明,直接開始分析") | |
| # 2026-08-22:同 on_metadata_confirm,補使用者訊息氣泡(略過版本)。 | |
| chat_history = chat_history + [_user_msg("略過填寫,直接開始分析")] | |
| chat_history = chat_history + [_assistant_msg(_ANALYSIS_START_MSG)] | |
| yield (chat_history, log_history, state, gr.update(visible=False), | |
| _todo_update(state), gr.update(), _computing_update(state), *_no_tabs) | |
| for ch, lh, st, todo, acc, comp, *tabs in _run_computation(chat_history, log_history, state): | |
| chat_history, log_history, state = ch, lh, st | |
| yield (chat_history, log_history, state, gr.update(visible=False), todo, acc, comp, *tabs) | |
| skip_run_evt = meta_cancel_btn.click( | |
| fn=_lock_meta, inputs=None, outputs=[meta_confirm_btn, meta_cancel_btn], | |
| ).then( | |
| fn=on_metadata_skip, | |
| inputs=[chatbot, exec_log, session_state], | |
| outputs=[chatbot, exec_log, session_state, metadata_row, app_header_html, | |
| canvas_exec_col, computing_html, *_TAB_OUTPUTS], | |
| ).then( | |
| fn=_unlock_meta, inputs=None, outputs=[meta_confirm_btn, meta_cancel_btn], | |
| ).then(fn=_populate_tabs, inputs=[session_state], outputs=_TAB_OUTPUTS) | |
| # ── 重置接線(2026-08-12 從上面搬下來)────────────────────────── | |
| # 業主回報:分析運算跑到一半按「回首頁」再重新進來,畫面又冒出上一輪 | |
| # 的分析運算面板、步驟列停在舊狀態、對話還是舊訊息。 | |
| # 真因不是重置漏清(分析跑完後再重置是乾淨的,已實測),而是**重置沒有 | |
| # 中止還在跑的運算**:那條 generator 每個子階段都會 yield 一次畫面, | |
| # 重置把畫面清乾淨之後,它後續的 yield 又把舊內容一格一格畫回去, | |
| # 最後看起來就像「重置沒生效」。 | |
| # 修法用 Gradio 原生的 cancels:按下「確定回首頁」時直接中止那兩條 | |
| # 事件鏈(確認送出/略過 都會進同一支 _run_computation),generator | |
| # 停在原地就不會再有遲到的 yield。這也是這段接線必須放在 | |
| # meta_confirm_btn/meta_cancel_btn 之後的唯一理由——cancels 需要拿到 | |
| # 它們的事件物件。 | |
| reset_confirm_btn.click( | |
| fn=on_reset_clear_workspace, | |
| outputs=[metadata_row, file_input, canvas_idx_col, canvas_stat_col, | |
| canvas_report_col, canvas_exec_col, computing_html, result_nav_col, | |
| upload_progress_html, | |
| meta_title, meta_author, meta_lang, meta_pubyear, meta_version, | |
| meta_ngroups, meta_method, *_TAB_NAV_GATES], | |
| cancels=[confirm_run_evt, skip_run_evt], | |
| ).then( | |
| fn=on_reset_confirm, | |
| # 2026-07-30(2-C 第 5 項):chat_upload 這輪常駐隱藏、不再接任何 | |
| # wiring(見 chat_upload_col 定義處註解),這裡把它從 outputs 移除。 | |
| # E3(2026-08-05):scenario_btn_col 空 Column 已整段拿掉,一併從 | |
| # outputs 移除。回傳 tuple 的元素個數/順序已同步調整為 8 個, | |
| # 跟這份 outputs 清單一一對應。 | |
| outputs=[chatbot, exec_log, session_state, app_header_html, | |
| upload_row, | |
| reset_confirm_row, homepage_col, workspace_row], | |
| ) | |
| return demo | |
| # ============================================================================ | |
| # SECTION 11: Entry Point | |
| # ============================================================================ | |
| if __name__ == "__main__": | |
| print("=" * 60) | |
| print("重譯文本分析系統 AI Agent 啟動中……") | |
| print(f"主 Agent : {LLM1_MODEL}") | |
| print(f"工作程序 : {LLM2_MODEL}") | |
| print(f"BASE_DIR : {BASE_DIR}") | |
| print("=" * 60) | |
| start_session_cleanup_scheduler() | |
| app = create_app() | |
| # 2026-08-22:share=True 原本寫死,代表任何人在任何一台機器執行 | |
| # `python app_agent.py` 都會額外開一條公開的 *.gradio.live 通道,把本機 | |
| # 服務暴露到公開網際網路(且連著執行者自己的 OpenRouter 金鑰),沒有任何 | |
| # 存取控制(見 docs/開發優化建議.md 第六節)。正式機走的是 app.py(見該 | |
| # 檔,share=False 且已加登入畫面),這裡只影響本機開發;改成預設關閉、 | |
| # 只有明確設環境變數才臨時開啟分享連結。 | |
| app.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| share=os.environ.get("GRADIO_SHARE", "false").strip().lower() == "true", | |
| inbrowser=False, | |
| allowed_paths=[str(SESSIONS_DIR)], | |
| head=_APP_HEAD, | |
| footer_links=[], | |
| css_paths=[str(BASE_DIR / "assets" / "app.css")], # E4:原 _CUSTOM_CSS 常數 | |
| ) | |