II_SpectraEngine / app_agent.py
minchun's picture
Claude Sonnet 5
補齊「使用手冊」→「使用說明」更名的實際內容改動
9615d53
Raw History Blame Contribute Delete
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 常數
)