#!/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 被渲染成巨大無樣式標題。改成前面留空行 # 再接 "---",變成正規的
分隔線(已有 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'' f'!{name}' ) elif num in done_steps: pills.append( f'' f'✓{name}' ) elif num == current_step: pills.append( f'' f'{num}{name}' ) else: pills.append( f'' f'{num}{name}' ) # 2026-07-31(Batch E 第 4-b 項):業主指定頂部列不要標題文字, # 原本的

整行移除,只留步驟列(四顆藥丸)。 # .title-line 這個 class 的 CSS 規則(含深色模式那份漸層文字修正) # 留著不刪:跟前四批同名 class 的踩坑記錄綁在一起,且沒有其他地方 # 在用會自動失效,不影響畫面;真的要清理屬於「順手清理死碼」, # 這批任務範圍不包含。 return ( '

' f'
{"".join(pills)}
' '
' ) 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 = ( '
' '
' '' '' '
' '

尚未產生資料

' '

請先於「AI對話」完成分析流程

' '
' ) # ── SVG 圖示(Phase 8:導覽/操作圖示改用線條 icon,不再用 emoji)─────────── # 手繪 Heroicons 風格(24x24 viewBox、stroke=currentColor、圓角線頭), # 不是逐點對照官方套件,純線條輪廓、視覺重量統一,供 _icon() 組裝使用。 _ICON_PATHS = { "folder": '', "chevron-down": '', "chevron-double-left": '', "bars-3": '', "x": '', "home": '', "chart-bar": '', "trending-up": '', "document": '', "plus": '', "download": '', "moon": '', "sun": '', # 2026-07-30(2-B 第 3 項):「AI對話」改用對話氣泡圖示(跟 home.svg # 同一套 Heroicons 風格手繪,path 需與 assets/icons/chat-bubble.svg # 內容保持一致,這裡是給 _icon() 行內用法的對應版本,見該函式 docstring)。 "chat-bubble": '', # 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": '', # 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": '', } def _icon(name: str, size: int = 20) -> str: """回傳單一 SVG 圖示字串,供 gr.HTML() 內容與 gr.Button(icon=...) 檔案外的行內用法共用。""" path = _ICON_PATHS.get(name, "") return ( f'{path}' ) # 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=...) 注入純 """ # gr.Blocks(js=...) 在此版 Gradio 實測不會執行,所有需要在頁面載入後跑的 JS # 一律改用 launch(head=...) 注入 """ _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( '
' f'{n.zfill(2)}' '' f'{sc["title"]}' f'{sc["desc"]}' '
' ) return f'
{"".join(cards)}
' 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"]) 包起來,不是這裡自己吐一個 大
。 """ return ( '
' # 1-C:業主指定移除第一行「重譯文本量化分析平台」(原本的 # ),下方直接從系列名稱開始。 '

譯彩紛呈:重譯文本分析系統

' '

TransPrism: An Analytical Framework for Retranslation

' '

II 分析引擎 SpectraEngine

' # 1-F:描述文字砍掉原本結尾「請先選擇最符合您研究設計的比較情境:」 # ——情境選擇已搬去工作區,首頁不用再提示選情境。 f'

上傳已斷詞的譯本文本,系統將自動計算 {TOTAL_VISIBLE_INDICATORS} 項語言指標並進行統計分析,' '協助您完成重譯研究的量化論證。

' '
' ) def _build_homepage_footer_html() -> str: """首頁介紹區塊下半部(讀取進度佔位連結+底部說明),接在 1-B 的 「開始研究分析」按鈕下方。見 _build_homepage_hero_html 上方說明。 """ return ( '
' f'{_icon("folder", 16)}讀取進度(功能開發中)' '
' # 1-G:底部說明文字改版,原文「選擇任一情境即開始分析」也不再適用 # (首頁不選情境了)。 '

分析過程需要運算等待時間,建議一次執行一項分析,以確保運算速度。

' ) 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=...) 注入的