#!/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 被渲染成巨大無樣式標題。改成前面留空行 # 再接 "---",變成正規的
整行移除,只留步驟列(四顆藥丸)。 # .title-line 這個 class 的 CSS 規則(含深色模式那份漸層文字修正) # 留著不刪:跟前四批同名 class 的踩坑記錄綁在一起,且沒有其他地方 # 在用會自動失效,不影響畫面;真的要清理屬於「順手清理死碼」, # 這批任務範圍不包含。 return ( '
尚未產生資料
' '請先於「AI對話」完成分析流程
' '譯彩紛呈:重譯文本分析系統
' 'TransPrism: An Analytical Framework for Retranslation
' '上傳已斷詞的譯本文本,系統將自動計算 {TOTAL_VISIBLE_INDICATORS} 項語言指標並進行統計分析,' '協助您完成重譯研究的量化論證。
' '分析過程需要運算等待時間,建議一次執行一項分析,以確保運算速度。
' ) 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=...) 注入的