diff --git "a/app_agent.py" "b/app_agent.py" new file mode 100644--- /dev/null +++ "b/app_agent.py" @@ -0,0 +1,3040 @@ +#!/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 +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 +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) + +# ── LLM 設定 ──────────────────────────────────────────────────────────────── +LLM1_API_KEY = os.environ.get("LLM1_API_KEY", "") +LLM1_MODEL = os.environ.get("LLM1_MODEL", "google/gemma-4-31b-it") + +LLM2_API_KEY = os.environ.get("LLM2_API_KEY", "") +LLM2_MODEL = os.environ.get("LLM2_MODEL", "google/gemma-4-26b-a4b-it") + +ALPHA = 0.05 + +# ── 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": ( + "請上傳欲分析的文本,文本檔名設計用 **A_xx.txt、B_xx.txt** " + "作為區分人工或機器翻譯。\n" + "上傳的文本必須使用 [ckip_segmentation_sumin](https://github.com/suhsiung/ckip_segmentation_sumin) " + "工具完成斷詞後的 `.txt` 檔案," + "內容格式為:`詞_詞性`、`_詞性` 或空白行。" + ), + "desc": "焦點在人機差異的語言特徵。", + "scenario_module_key": "scenario_2", + }, + "2": { + "title": "全譯 vs 編譯(��譯、改寫)", + "short": "全譯vs編譯", + "upload_prompt": ( + "請上傳欲分析的文本,文本檔名設計用 **A_xx.txt、B_xx.txt** " + "作為區分全譯或編譯。\n" + "上傳的文本必須使用 [ckip_segmentation_sumin](https://github.com/suhsiung/ckip_segmentation_sumin) " + "工具完成斷詞後的 `.txt` 檔案," + "內容格式為:`詞_詞性`、`_詞性` 或空白行。" + ), + "desc": "焦點在文本完整性和改寫策略。", + "scenario_module_key": "scenario_3", + }, + "3": { + "title": "不同譯者的同期譯本", + "short": "譯者風格", + "upload_prompt": ( + "請上傳欲分析的文本,文本檔名設計用 **A_xx.txt、B_xx.txt、C_xx.txt…** " + "作為區分不同的譯者。\n" + "上傳的文本必須使用 [ckip_segmentation_sumin](https://github.com/suhsiung/ckip_segmentation_sumin) " + "工具完成斷詞後的 `.txt` 檔案," + "內容格式為:`詞_詞性`、`_詞性` 或空白行。" + ), + "desc": "焦點在個人風格差異。排除時代因素,討論譯者個人的語言偏好。", + "scenario_module_key": "scenario_4", + }, + "4": { + "title": "同一原文的不同時期人工譯本", + "short": "歷時重譯", + "upload_prompt": ( + "請上傳欲分析的文本,文本檔名設計用 **A_xx.txt、B_xx.txt、C_xx.txt…** " + "作為區分時期。\n" + "上傳的文本必須使用 [ckip_segmentation_sumin](https://github.com/suhsiung/ckip_segmentation_sumin) " + "工具完成斷詞後的 `.txt` 檔案," + "內容格式為:`詞_詞性`、`_詞性` 或空白行。" + ), + "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 + +# ── LLM 報告用情境模組(同 prog04)───────────────────────────────────────── +SCENARIO_MODULE_1 = """## 二、情境特定分析:歷時變化分析 + +在陳述差異時,必須將「時間順序」作為組織軸線。 +請依照以下子項目分析: + +### 2.1 語言現代化的跡象 +- 詞彙難易度(AC_01, AC_02)是否隨時間呈現規律變化? +- 成語比率(AG_06)在不同時期譯本中的變化方向? +- 若出現非單調變化,明確標示此為反常模式,需進一步解釋。 + +### 2.2 句式與篇章結構的演變 +- 段落數、句數(AA_05, AA_06)的變化是否反映閱讀習慣的改變? +- 詞性分布中,連接詞、代詞比例的變化是否呈現顯著化或隱化趨勢? + +### 2.3 譯本間的親疏關係 +- 根據分群結果,時序相鄰的譯本是否聚為一群? +- 若出現時序相隔較遠的譯本反而更相似的情況,標記為需進一步探討的現象。 + +### 禁令補充 +- 不得將「較新」直接等同於「較現代」或「較好」。""" + +SCENARIO_MODULE_2 = """## 二、情境特定分析:人機差異分析 + +### 2.1 機器翻譯典型特徵的檢視 +根據量化數據檢視機器翻譯是否呈現以下已知特徵: +- 詞彙多樣性是否偏低?(MSTTR, RTTR) +- 句長是否較為整齊(變異數較小)? + +### 2.2 人工翻譯的差異化特徵 +- 人工譯本在哪些指標上顯著偏離機器譯本? + +### 禁令補充 +- 不得對機器翻譯系統的品質做整體評價。 +- 不得預設「人工一定優於機器」或反之。""" + +SCENARIO_MODULE_3 = """## 二、情境特定分析:改寫策略分析 + +【重要限制】:本系統目前未進行原文對齊,所有關於刪減/增補的陳述必須明確標示為「推測」。 + +### 3.1 規模差異的量化 +- 總字數、總詞數(AA_01, AA_03)的差距幅度 + +### 3.2 改寫策略的語言訊號 +- 編譯本的詞彙密度是否反而較高?(可能反映資訊壓縮策略) +- 編譯本的平均句長是否較短?(可能反映簡化策略) + +### 禁令補充 +- 嚴禁使用「忠實度」、「完整性」等規範性詞彙。""" + +SCENARIO_MODULE_4 = """## 二、情境特定分析:個人風格差異分析 + +【前提】:所有譯本產出於相近時期,時代因素已被控制。 + +### 2.1 核心風格指標的差異 +- 在哪些指標上,譯者間差異達到統計顯著? + +### 2.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. 所有統計顯著性的陳述必須基於提供的 p 值或效果量。 + +# 輸入資料結構 +【研究情境】:{scenario_type} +【譯本 metadata】:{metadata} +【量化分析結果】:(見 user message 中的 JSON 資料) +【使用者補充關注點】:{user_focus} + +# 通用輸出結構 +輸出必須包含以下四個區塊,缺一不可: + +## 一、數據事實描述 +僅陳述統計結果,每項陳述後標註對應的指標代碼與數值。 +區分:達統計顯著的差異(標明 p 值與效果量)、未達顯著但數值差距較大的趨勢、無明顯差異的指標。 + +{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.5): + """通用 OpenRouter API 呼叫(同步)。""" + 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%}," + "不符合 ckip_segmentation_sumin 的輸出格式。" + ) + if not has_ckip_signature: + return False, "未偵測到 CKIP 詞性標記,可能非 ckip_segmentation_sumin 產生的檔案。" + + return True, "格式符合 ckip_segmentation_sumin 輸出。" + + +# ============================================================================ +# 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 = datetime.now().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): + n1, n2 = len(g1), len(g2) + if n1 < 2 or n2 < 2: + return 0.0 + var1, var2 = np.var(g1, ddof=1), np.var(g2, ddof=1) + pooled = np.sqrt(((n1-1)*var1 + (n2-1)*var2) / (n1+n2-2)) + return (np.mean(g1) - np.mean(g2)) / pooled if pooled != 0 else 0.0 + + +def eta_squared(group_data): + 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) + return ss_between / ss_total if ss_total != 0 else 0.0 + + +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): + """ + 執行完整統計分析(敘述統計、差異分析、隨機森林、分群)。 + 回傳 dict 包含所有輸出檔路徑與摘要資訊。 + """ + output_dir.mkdir(parents=True, exist_ok=True) + + # ── 1. 讀入資料 ──────────────────────────────────────────────────────────── + df = pd.read_csv(str(csv_path)) + + df['Type1'] = df['Text'].str[0].str.upper() + 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 = cohens_d(gdata[0], gdata[1]) + es_name = "Cohen's d"; method = "t-test" + else: + stat, p = stats.f_oneway(*gdata) + es = eta_squared(gdata) + es_name = "η²"; method = "ANOVA" + row["方法"] = method + row["統計量"] = round(float(stat), 4) if not np.isnan(stat) else None + row["p值"] = round(float(p), 4) if not np.isnan(p) else None + row["顯著"] = ("***" if p is not None and p<0.001 else + "**" if p is not None and p<0.01 else + "*" if p is not None and p<0.05 else "ns") + row["效果量名稱"] = es_name + row["效果量"] = round(float(es), 4) + row["效果大小"] = effect_label(es, method) + diff_results.append(row) + + diff_df = pd.DataFrame(diff_results) + sig_df = diff_df[diff_df['顯著'] != "ns"] + diff_path = output_dir / "02_difference_analysis.csv" + diff_df.to_csv(str(diff_path), index=False, encoding='utf-8-sig') + + # boxplot(最多 12 個顯著指標) + box_path = None + if len(sig_df) > 0: + sig_cols = sig_df['指標'].tolist()[:12] + 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() + 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, labels=groups) + pv = diff_df.loc[diff_df['指標']==col, 'p值'].values + pv_str = f"p={pv[0]:.4f}" if len(pv)>0 else "" + ax.set_title(f"{col}\n({pv_str})", fontsize=8) + for j in range(len(sig_cols), len(axes)): + axes[j].set_visible(False) + plt.suptitle(f"Significant Variables by {GROUP_COL}", 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("Feature Importance") + ax.set_title(f"Random Forest — Top {top_n} Feature Importance") + 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("Hierarchical Clustering Dendrogram (Ward)") + ax.set_xlabel("Sample"); ax.set_ylabel("Distance") + 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("Number of Clusters (K)"); ax.set_ylabel("Inertia (SSE)") + ax.set_title("K-means Elbow Curve"); 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'], + ['Hierarchical Clustering', 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"Cluster {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"PC1 ({pca.explained_variance_ratio_[0]:.1%})" if X_scaled.shape[1]>=2 else "PC1") + ax.set_ylabel(f"PC2 ({pca.explained_variance_ratio_[1]:.1%})" if X_scaled.shape[1]>=2 else "PC2") + ax.set_title(title); ax.legend() + plt.suptitle("Clustering Results (PCA 2D Projection)", 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() + + # 分群結果儲存 + result_cols = ['Text','Type1','Type2',GROUP_COL,'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" + df[result_cols].to_csv(str(cluster_csv_path), index=False, encoding='utf-8-sig') + + # ── 6. 產出 docx 技術報告 ───────────────────���────────────────────────────── + timestamp = datetime.now().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'報告產出時間:{datetime.now().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 + doc.add_paragraph(f'分組方式:{GROUP_COL}', + 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}", + f"{row['變異係數(%)']:.2f}" if row['變異係數(%)'] is not None 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) + doc.add_paragraph(f'分組依據:{GROUP_COL}(共 {n_groups} 組)。採用 {method_name},顯著水準 α = {ALPHA}。') + doc.add_paragraph(f'共 {len(diff_df)} 個指標,其中 {len(sig_df)} 個達顯著差異(p < {ALPHA})。') + 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}"]) + vals.extend([str(row['方法']), + f"{row['統計量']:.4f}" if row['統計量'] is not None else "-", + f"{row['p值']:.4f}" if row['p值'] is not None else "-", + str(row['顯著']), f"{row['效果量']:.4f}", str(row['效果大小'])]) + 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['顯著'] != "ns" and j == len(vals)-4: + r.font.color.rgb = RGBColor(0xCC, 0x00, 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), + "total_indicators": len(diff_df), + "accuracy": accuracy, + "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) -> dict: + """讀取分析資料組成 JSON 結構。""" + data = {} + 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 == "feature_importance_top20": + data[key] = d.head(20).to_dict(orient='records') + else: + data[key] = d.to_dict(orient='records') + if key == "difference_analysis": + sig = d[d['顯著'] != 'ns'] if '顯著' in d.columns else pd.DataFrame() + data["significant_indicators"] = sig.to_dict(orient='records') + data["significant_count"] = len(sig) + data["total_indicators"] = len(d) + 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 + + +def run_report_writing(scenario_key: str, user_metadata: str, user_focus: str, + csv_path: Path, output_dir: Path) -> dict: + """ + 呼叫主 Agent (LLM1) 撰寫研究報告,儲存 docx + txt,回傳路徑 dict。 + """ + output_dir.mkdir(parents=True, exist_ok=True) + analysis_data = load_analysis_data_for_llm(csv_path, output_dir) + + 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, + ) + 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 = datetime.now().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 + doc.add_paragraph(f'研究情境:{scenario_info["title"]}', + style='Normal').alignment = WD_ALIGN_PARAGRAPH.CENTER + doc.add_paragraph(f'報告時間:{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}', + style='Normal').alignment = WD_ALIGN_PARAGRAPH.CENTER + doc.add_page_break() + for line in result_text.split('\n'): + stripped = line.strip() + if stripped.startswith('#### '): doc.add_heading(stripped[5:], level=4) + elif stripped.startswith('### '): doc.add_heading(stripped[4:], level=3) + elif stripped.startswith('## '): doc.add_heading(stripped[3:], level=2) + elif stripped.startswith('# '): doc.add_heading(stripped[2:], level=1) + elif stripped.startswith('- '): doc.add_paragraph(stripped[2:], style='List Bullet') + elif stripped.startswith('* '): doc.add_paragraph(stripped[2:], style='List Bullet') + elif stripped: 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"時間:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + f.write("=" * 60 + "\n\n") + f.write(result_text) + + return { + "docx_path": docx_path, + "txt_path": txt_path, + "result_text": result_text, + } + + +# ============================================================================ +# 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": [], + "zip_path": None, + "user_metadata": "", + "user_focus": "", + "analysis_result": None, + "done_steps": [], # 已完成步驟(1-based) + "current_step": None, # 目前執行步驟 + } + + +def get_intro_message() -> str: + sys_p = ( + "你是「重譯文本分析系統」的主 Agent(智能助理)," + "請用繁體中文、專業而友善的語氣向使用者介紹本系統," + "並說明四種研究情境,最後邀請使用者選擇情境。" + "回覆請控制在 400 字以內。" + ) + user_p = ( + "請介紹本系統並列出以下四種研究情境,請使用者選擇:\n" + "1. 同一原文的人工翻譯 vs 機器翻譯\n" + "2. 全譯 vs 編譯(節譯、改寫)\n" + "3. 不同譯者的同期譯本\n" + "4. 同一原文的不同時期人工譯本\n" + "詢問使用者:「請輸入數字 1、2、3 或 4 選擇您要進行的研究情境。」" + ) + try: + return call_main_agent(sys_p, user_p) + except Exception as e: + return ( + "歡迎使用 TransPrism II 分析引擎 SpectraEngine。\n\n" + "請依照您的研究設計選擇分析情境:\n\n" + "1 人工翻譯 vs 機器翻譯\n" + "2 全譯 vs 編譯/節譯/改寫\n" + "3 同期不同譯者譯本比較\n" + "4 不同時期重譯本的歷時比較\n\n" + "輸入 1、2、3 或 4 。" + ) + + +def _build_todo_list(sc_num: str, done_steps: list = None, current_step: int = None) -> str: + """ + 即時 to-do list 執行進度(確定性,不依賴 LLM)。 + done_steps: 已完成步驟(1-based list) + current_step: 目前執行步驟(1-based int) + """ + done_steps = done_steps or [] + sc_info = SCENARIOS.get(sc_num, {}) + sc_title = sc_info.get("short", sc_info.get("title", "")) + + steps = [ + (1, "情境選擇"), + (2, "上傳引導"), + (3, "文本上傳"), + (4, "格式驗證"), + (5, "指標計算"), + (6, "統計分析"), + (7, "報告撰寫"), + (8, "成果下載"), + ] + + total = len(steps) + done_count = len([s for s in done_steps if s <= total]) + pct = int(done_count / total * 100) + + lines = [ + f"**執行進度|{sc_title}**", + f"`{'█' * (done_count * 2)}{'░' * ((total - done_count) * 2)}` {pct}%", + "", + ] + for num, name in steps: + if num in done_steps: + lines.append(f"✅ ~~{num}. {name}~~") + elif num == current_step: + lines.append(f"⏳ **{num}. {name}**") + else: + lines.append(f"☐ {num}. {name}") + + return "\n\n".join(lines) + + +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 + state["done_steps"] = [1] + state["current_step"] = 2 + + sys_p = ( + "你是重譯文本分析系統的主 Agent,請用繁體中文確認使用者的研究情境選擇," + "並說明接下來將由工作程序 Agent 協助上傳文本。語氣專業友善,50 字以內。" + ) + user_p = f"使用者選擇了研究情境 {sc_num}:{sc_info['title']}" + try: + confirm_text = call_main_agent(sys_p, user_p) + except Exception: + confirm_text = f"已記錄研究情境:**{sc_info['title']}**。" + + state["state"] = "UPLOAD_GUIDE" + return (confirm_text, state) + + +def get_upload_guide_message(scenario_num: str) -> str: + """工作程序 Agent 根據情境提供上傳說明。""" + sc_info = SCENARIOS[scenario_num] + sys_p = ( + "你是重譯文本分析系統的工作程序 Agent,請用繁體中文說明文本上傳格式要求。" + "語氣清晰、條列式,120 字以內。" + "必須強調:文本必須使用 https://github.com/suhsiung/ckip_segmentation_sumin 工具完成斷詞," + "才能上傳。最後提示使用者上傳檔案。" + ) + user_p = f"研究情境:{sc_info['title']}\n上傳說明:\n{sc_info['upload_prompt']}" + try: + return call_worker_agent(sys_p, user_p) + except: + return ( + f"**【工作程序 Agent】文本上傳說明**\n\n" + f"{sc_info['upload_prompt']}\n\n" + "請在下方選擇並上傳文本檔案(可多選)。" + ) + + +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)) + + 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 產生的格式。 + 通過 → CALCULATING;不通過 → UPLOAD_WAIT(要求重新上傳)。 + 返回 (reply_text, new_state, has_errors) + """ + failed = {} + for fp in state["uploaded_files"]: + ok, reason = validate_ckip_format(Path(fp)) + if not ok: + failed[Path(fp).name] = reason + + if not failed: + state["state"] = "CALCULATING" + state["cleaned_files"] = state["uploaded_files"] + sys_p = ( + "你是重譯文本分析系統的工作程序 Agent," + "請用繁體中文告知使用者所有檔案已���認為 ckip_segmentation_sumin 斷詞格式," + "即將開始計算指標,30 字以內。" + ) + try: + msg = call_worker_agent(sys_p, "所有檔案格式驗證通過。") + except: + msg = "[OK] 所有檔案均符合 ckip_segmentation_sumin 斷詞格式,即將開始計算文本指標……" + return msg, state, False + + # 有檔案不符 + fail_list = "\n".join([f"- **{fn}**:{reason}" for fn, reason in failed.items()]) + sys_p = ( + "你是重譯文本分析系統的工作程序 Agent,請用繁體中文告知使用者:" + "部分檔案不符合 ckip_segmentation_sumin 斷詞工具的輸出格式," + "必須重新使用該工具斷詞後再上傳,語氣清晰,80 字以內。" + "請附上工具連結:https://github.com/suhsiung/ckip_segmentation_sumin" + ) + try: + msg = call_worker_agent(sys_p, f"格式不符的檔案:\n{fail_list}") + msg = msg + f"\n\n{fail_list}" + except: + msg = ( + f"[WARNING] 以下檔案不符合 ckip_segmentation_sumin 斷詞格式,請重新處理後再上傳:\n\n" + f"{fail_list}\n\n" + "請使用 [ckip_segmentation_sumin](https://github.com/suhsiung/ckip_segmentation_sumin) " + "工具完成斷詞後,重新上傳檔案。" + ) + state["uploaded_files"] = [] + state["state"] = "UPLOAD_WAIT" + return msg, state, True + + +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 + + # ── 步驟 1:指標計算 ───────────────────────────────────────────────────── + yield "【步驟 1/3】正在計算文本指標……", 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)]) + yield f" [OK] 指標計算完成({len(idx_df)} 個文本)", False, state + except Exception as e: + state["state"] = "COMPLETE" + yield f"[ERROR] 指標計算失敗:{e}", True, state + return + + # ── 步驟 2:統計分析 ───────────────────────────────────────────────────── + yield "【步驟 2/3】正在執行統計分析……", False, state + try: + anal = run_statistical_analysis(csv_path, output_dir) + 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" + } + yield ( + f" [OK] 統計分析完成({anal['sig_count']}/{anal['total_indicators']} 個顯著指標," + f"隨機森林準確率 {anal['accuracy']:.4f})" + ), False, state + except Exception as e: + state["state"] = "COMPLETE" + state["output_files"] = results_paths + yield f"[ERROR] 統計分析失敗:{e}", True, state + return + + # ── 步驟 3:LLM 研究報告 ──────────────────────────────────────────────── + yield "【步驟 3/3】主 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, + ) + results_paths.extend([str(rpt["docx_path"]), str(rpt["txt_path"])]) + yield " [OK] 研究報告撰寫完成", False, state + except Exception as e: + yield f" [WARNING] LLM 報告撰寫失敗({e}),其他分析結果仍可下載。", False, state + + # ── 完成 ───────────────────────────────────────────────────────────────── + state["output_files"] = results_paths + state["state"] = "COMPLETE" + + sig_info = "" + if anal: + sig_info = ( + f"顯著指標:**{anal['sig_count']}/{anal['total_indicators']}** 個(p < {ALPHA})。\n" + f"隨機森林分類準確率:**{anal['accuracy']:.4f}**。\n" + ) + summary = ( + f"**全部分析完成!**\n\n" + f"共分析 **{len(state['cleaned_files'])}** 個文本。\n" + f"{sig_info}\n" + f"各項結果請點選上方各分頁查看。" + ) + yield summary, True, state + + +# ============================================================================ +# SECTION 10: Gradio Interface (Tabbed) +# ============================================================================ + +_EMPTY_HINT = ( + '
尚未產生資料
' + '請先於「主頁」完成分析流程
' + '' + ' TransPrism: An Analytical Framework for Retranslation II' + ' | ' + ' 分析引擎 Spectra Engine' + '
' + '