Spaces:
Sleeping
Sleeping
部署前整備:對話氣泡真人感、隱私告知、Space 存取控管與 Session 自動清理
Browse files對話流程/文案:
- AI 對話補上使用者動作氣泡(上傳檔案、送出或略過語料 Metadata),避免
兩則 AI 訊息中間看起來像自問自答
- 使用者訊息泡泡改用獨立底色/邊框(淡綠),跟 AI 泡泡的中性藍灰區隔
- 對話中的 [WARNING] 純文字標籤改成 ⚠️ emoji,跟技術執行紀錄面板一致
- 使用者訊息裡殘留的舊工具名稱「ckip_segmentation_sumin」全面改為
「語料淬鍊(CorpusRefinery)」,含格式驗證訊息與失敗回退訊息
- 文本上傳卡片新增隱私提醒:告知使用者上傳內容會送第三方 AI 服務分析
部署與維運(對應 docs/開發優化建議.md 第一、四、六節):
- app.py 新增 APP_AUTH_USERS 環境變數驅動的登入畫面(Gradio 內建 auth),
讓 Space 可保持 Public 但只有知道帳密的特定使用者能進入內容
- app_agent.py 新增 SESSIONS_DIR 自動清理:啟動時清一次、之後每 24 小時
重跑,依資料夾 mtime 判斷是否超過保留天數(預設 7 天,可用
SESSION_RETENTION_DAYS 覆寫),避免使用者研究語料無限期留在磁碟上
- app_agent.py 本機開發用的 share=True 改成看 GRADIO_SHARE 環境變數,
預設關閉,避免地端執行時意外對外開公開分享連結
使用手冊同步更新為目前實際流程(四步驟、CorpusRefinery 改名、群組層
改文字欄位、個體間比較消歧義邏輯、62/75 指標顯示範圍、下載捷徑精簡等)。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- app.py +31 -0
- app_agent.py +624 -127
- assets/app.css +30 -4
- 使用手冊.docx +2 -2
- 使用手冊.md +101 -82
app.py
CHANGED
|
@@ -4,9 +4,38 @@
|
|
| 4 |
app.py - Hugging Face Spaces entry point
|
| 5 |
Imports and launches the main application from app_agent.py
|
| 6 |
"""
|
|
|
|
| 7 |
import app_agent
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
if __name__ == "__main__":
|
|
|
|
| 10 |
app = app_agent.create_app()
|
| 11 |
app.launch(
|
| 12 |
server_name="0.0.0.0",
|
|
@@ -14,4 +43,6 @@ if __name__ == "__main__":
|
|
| 14 |
share=False,
|
| 15 |
allowed_paths=[str(app_agent.SESSIONS_DIR)],
|
| 16 |
head=app_agent._APP_HEAD,
|
|
|
|
|
|
|
| 17 |
)
|
|
|
|
| 4 |
app.py - Hugging Face Spaces entry point
|
| 5 |
Imports and launches the main application from app_agent.py
|
| 6 |
"""
|
| 7 |
+
import os
|
| 8 |
import app_agent
|
| 9 |
|
| 10 |
+
|
| 11 |
+
def _build_auth():
|
| 12 |
+
"""從環境變數 APP_AUTH_USERS 讀取帳密名單,開啟 Gradio 內建的登入畫面,
|
| 13 |
+
讓 Space 只給知道帳密的特定使用者進得去(即使 Space 本身是 Public 可見)。
|
| 14 |
+
|
| 15 |
+
格式(逗號分隔多組帳密,冒號分隔帳號密碼):
|
| 16 |
+
APP_AUTH_USERS = 帳號1:密碼1,帳號2:密碼2
|
| 17 |
+
|
| 18 |
+
在 Hugging Face Space 的 Settings → Variables and secrets 以 Secret
|
| 19 |
+
方式設定這個變數(不要寫進程式碼或 .env 進版控)。本機開發若沒有設定
|
| 20 |
+
這個環境變數,則不啟用登入,維持原本可直接開啟的行為。
|
| 21 |
+
"""
|
| 22 |
+
raw = os.environ.get("APP_AUTH_USERS", "").strip()
|
| 23 |
+
if not raw:
|
| 24 |
+
return None
|
| 25 |
+
pairs = []
|
| 26 |
+
for item in raw.split(","):
|
| 27 |
+
item = item.strip()
|
| 28 |
+
if not item or ":" not in item:
|
| 29 |
+
continue
|
| 30 |
+
user, pwd = item.split(":", 1)
|
| 31 |
+
user, pwd = user.strip(), pwd.strip()
|
| 32 |
+
if user and pwd:
|
| 33 |
+
pairs.append((user, pwd))
|
| 34 |
+
return pairs or None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
if __name__ == "__main__":
|
| 38 |
+
app_agent.start_session_cleanup_scheduler()
|
| 39 |
app = app_agent.create_app()
|
| 40 |
app.launch(
|
| 41 |
server_name="0.0.0.0",
|
|
|
|
| 43 |
share=False,
|
| 44 |
allowed_paths=[str(app_agent.SESSIONS_DIR)],
|
| 45 |
head=app_agent._APP_HEAD,
|
| 46 |
+
auth=_build_auth(),
|
| 47 |
+
auth_message="請輸入管理員提供的帳號與密碼",
|
| 48 |
)
|
app_agent.py
CHANGED
|
@@ -13,7 +13,7 @@ app_agent.py
|
|
| 13 |
# ============================================================================
|
| 14 |
# SECTION 1: Imports & Configuration
|
| 15 |
# ============================================================================
|
| 16 |
-
import sys, io, os, re, json, uuid, shutil, warnings, functools, collections
|
| 17 |
import threading, zipfile, traceback
|
| 18 |
|
| 19 |
# 強制 UTF-8 輸出(避免 Windows cp950 編碼錯誤)
|
|
@@ -71,6 +71,61 @@ CORPUS_INFO = BASE_DIR / "01-Corpus" / "03-Info"
|
|
| 71 |
SESSIONS_DIR = BASE_DIR / "sessions"
|
| 72 |
SESSIONS_DIR.mkdir(exist_ok=True)
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
# ── LLM 設定 ────────────────────────────────────────────────────────────────
|
| 75 |
LLM1_API_KEY = os.environ.get("LLM1_API_KEY", "")
|
| 76 |
LLM1_MODEL = os.environ.get("LLM1_MODEL", "google/gemma-4-31b-it")
|
|
@@ -101,8 +156,8 @@ SCENARIOS = {
|
|
| 101 |
"upload_prompt": (
|
| 102 |
"1. 檔案格式:僅接受 **.txt** 純文字檔。\n"
|
| 103 |
"2. 檔名規範:請以 **A_xx.txt**、**B_xx.txt** 等格式區分人工翻譯與機器翻譯。\n"
|
| 104 |
-
"3. 斷詞要求:必須先使用 [語料淬鍊](https://github.com/
|
| 105 |
-
"4. 內容格式:僅限 `詞_詞性`
|
| 106 |
),
|
| 107 |
"desc": "焦點在人機差異的語言特徵。",
|
| 108 |
"scenario_module_key": "scenario_2",
|
|
@@ -113,8 +168,8 @@ SCENARIOS = {
|
|
| 113 |
"upload_prompt": (
|
| 114 |
"1. 檔案格式:僅接受 **.txt** 純文字檔。\n"
|
| 115 |
"2. 檔名規範:請以 **A_xx.txt**、**B_xx.txt** 等格式區分全譯與編譯。\n"
|
| 116 |
-
"3. 斷詞要求:必須先使用 [語料淬鍊](https://github.com/
|
| 117 |
-
"4. 內容格式:僅限 `詞_詞性`
|
| 118 |
),
|
| 119 |
"desc": "焦點在文本完整性和改寫策略。",
|
| 120 |
"scenario_module_key": "scenario_3",
|
|
@@ -125,8 +180,8 @@ SCENARIOS = {
|
|
| 125 |
"upload_prompt": (
|
| 126 |
"1. 檔案格式:僅接受 **.txt** 純文字檔。\n"
|
| 127 |
"2. 檔名規範:請以 **A_xx.txt**、**B_xx.txt**、**C_xx.txt** 等格式區分不同群組的譯者。\n"
|
| 128 |
-
"3. 斷詞要求:必須先使用 [語料淬鍊](https://github.com/
|
| 129 |
-
"4. 內容格式:僅限 `詞_詞性`
|
| 130 |
),
|
| 131 |
"desc": "焦點在個人風格差異。排除時代因素,討論譯者個人的語言偏好。",
|
| 132 |
"scenario_module_key": "scenario_4",
|
|
@@ -137,8 +192,8 @@ SCENARIOS = {
|
|
| 137 |
"upload_prompt": (
|
| 138 |
"1. 檔案格式:僅接受 **.txt** 純文字檔。\n"
|
| 139 |
"2. 檔名規範:請以 **A_xx.txt**、**B_xx.txt**、**C_xx.txt** 等格式區分不同時期的譯本。\n"
|
| 140 |
-
"3. 斷詞要求:必須先使用 [語料淬鍊](https://github.com/
|
| 141 |
-
"4. 內容格式:僅限 `詞_詞性`
|
| 142 |
),
|
| 143 |
"desc": "分析焦點在歷時變化、語言現代化、翻譯規範的演變。時間維度是核心變數。",
|
| 144 |
"scenario_module_key": "scenario_1",
|
|
@@ -203,43 +258,67 @@ for _cat, _items in INDEX_CATEGORIES.items():
|
|
| 203 |
INDEX_NAME_MAP[_code] = _name
|
| 204 |
INDEX_CATEGORY_MAP[_code] = _cat
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
# ── LLM 報告用情境模組(同 prog04)─────────────────────────────────────────
|
| 207 |
-
SCENARIO_MODULE_1 = """##
|
| 208 |
|
| 209 |
在陳述差異時,必須將「時間順序」作為組織軸線。
|
| 210 |
請依照以下子項目分析:
|
| 211 |
|
| 212 |
-
###
|
| 213 |
- 詞彙難易度(AC_01, AC_02)是否隨時間呈現規律變化?
|
| 214 |
- 成語比率(AG_06)在不同時期譯本中的變化方向?
|
| 215 |
- 若出現非單調變化,明確標示此為反常模式,需進一步解釋。
|
| 216 |
|
| 217 |
-
###
|
| 218 |
- 段落數、句數(AA_05, AA_06)的變化是否反映閱讀習慣的改變?
|
| 219 |
- 詞性分布中,連接詞、代詞比例的變化是否呈現顯著化或隱化趨勢?
|
| 220 |
|
| 221 |
-
###
|
| 222 |
- 根據分群結果,時序相鄰的譯本是否聚為一群?
|
| 223 |
- 若出現時序相隔較遠��譯本反而更相似的情況,標記為需進一步探討的現象。
|
| 224 |
|
| 225 |
### 禁令補充
|
| 226 |
- 不得將「較新」直接等同於「較現代」或「較好」。"""
|
| 227 |
|
| 228 |
-
SCENARIO_MODULE_2 = """##
|
| 229 |
|
| 230 |
-
###
|
| 231 |
根據量化數據檢視機器翻譯是否呈現以下已知特徵:
|
| 232 |
- 詞彙多樣性是否偏低?(MSTTR, RTTR)
|
| 233 |
- 句長是否較為整齊(變異數較小)?
|
| 234 |
|
| 235 |
-
###
|
| 236 |
- 人工譯本在哪些指標上顯著偏離機器譯本?
|
| 237 |
|
| 238 |
### 禁令補充
|
| 239 |
- 不得對機器翻譯系統的品質做整體評價。
|
| 240 |
- 不得預設「人工一定優於機器」或反之。"""
|
| 241 |
|
| 242 |
-
SCENARIO_MODULE_3 = """##
|
| 243 |
|
| 244 |
【重要限制】:本系統目前未進行原文對齊,所有關於刪減/增補的陳述必須明確標示為「推測」。
|
| 245 |
|
|
@@ -253,14 +332,14 @@ SCENARIO_MODULE_3 = """## 二、情境特定分析:改寫策略分析
|
|
| 253 |
### 禁令補充
|
| 254 |
- 嚴禁使用「忠實度」、「完整性」等規範性詞彙。"""
|
| 255 |
|
| 256 |
-
SCENARIO_MODULE_4 = """##
|
| 257 |
|
| 258 |
【前提】:所有譯本產出於相近時期,時代因素已被控制。
|
| 259 |
|
| 260 |
-
###
|
| 261 |
- 在哪些指標上,譯者間差異達到統計顯著?
|
| 262 |
|
| 263 |
-
###
|
| 264 |
- 譯者內變異 vs 譯者間變異的比較
|
| 265 |
|
| 266 |
### 禁令補充
|
|
@@ -290,26 +369,49 @@ SYSTEM_PROMPT_BASE = """# 角色定義
|
|
| 290 |
【使用者補充關注點】:{user_focus}
|
| 291 |
|
| 292 |
# 格式規範(務必嚴格遵守,直接影響報告的可讀性)
|
| 293 |
-
1. 以下「通用輸出結構」中 `## 一、`、`##
|
| 294 |
2. 章節內若需要子標題或小節提示語(例如「總體事實」「觀察」「結論」之類的段落標籤),一律使用 `#### ` 開頭的四級標題語法,不可用「**粗體**」取代標題。
|
| 295 |
3. `**粗體**` 只能用於一般敘述中的強調字詞,不可整行只放一個粗體片語來充當標題。
|
| 296 |
4. 表格請使用標準 Markdown 表格語法(`| 欄位 | 欄位 |` 搭配 `|---|---|` 分隔列)。
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
|
| 298 |
# 通用輸出結構
|
| 299 |
-
輸出必須包含以下
|
| 300 |
-
|
| 301 |
-
## 一、
|
| 302 |
-
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
|
| 305 |
{scenario_module}
|
| 306 |
|
| 307 |
-
##
|
| 308 |
- 需研究者回到原文比對的具體項目
|
| 309 |
- 數據中的異常值或可疑模式
|
| 310 |
- 本分析無法處理的面向
|
| 311 |
|
| 312 |
-
##
|
| 313 |
明確列出:分詞系統潛在誤差、樣本量對統計檢定的影響、指標本身的理論侷限。"""
|
| 314 |
|
| 315 |
# ── matplotlib 中文字型 ──────────────────────────────────────────────────────
|
|
@@ -465,12 +567,12 @@ def validate_ckip_format(filepath: Path):
|
|
| 465 |
if ratio < 0.70:
|
| 466 |
return False, (
|
| 467 |
f"token 中含底線(詞_詞性)的比例僅 {ratio:.0%},"
|
| 468 |
-
"不符合
|
| 469 |
)
|
| 470 |
if not has_ckip_signature:
|
| 471 |
-
return False, "未偵測到 CKIP 詞性標記,可能非
|
| 472 |
|
| 473 |
-
return True, "格式符合
|
| 474 |
|
| 475 |
|
| 476 |
# ============================================================================
|
|
@@ -838,17 +940,39 @@ def effect_label(val, method):
|
|
| 838 |
return "大" if val>=0.14 else "中" if val>=0.06 else "小" if val>=0.01 else "極小"
|
| 839 |
|
| 840 |
|
| 841 |
-
def run_statistical_analysis(csv_path: Path, output_dir: Path
|
|
|
|
| 842 |
"""
|
| 843 |
執行完整統計分析(敘述統計、差異分析、隨機森林、分群)。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
回傳 dict 包含所有輸出檔路徑與摘要資訊。
|
| 845 |
"""
|
| 846 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 847 |
|
| 848 |
# ── 1. 讀入資料 ────────────────────────────────────────────────────────────
|
| 849 |
df = pd.read_csv(str(csv_path))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 850 |
|
| 851 |
df['Type1'] = df['Text'].str[0].str.upper()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 852 |
df['Type2'] = df['Type1']
|
| 853 |
GROUP_COL = 'Type1'
|
| 854 |
N_CLUSTERS = max(2, len(df['Type1'].unique()))
|
|
@@ -942,24 +1066,45 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path):
|
|
| 942 |
diff_df.to_csv(str(diff_path), index=False, encoding='utf-8-sig')
|
| 943 |
|
| 944 |
# boxplot(最多 12 個顯著指標)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 945 |
box_path = None
|
| 946 |
if len(sig_df) > 0:
|
| 947 |
-
|
|
|
|
|
|
|
|
|
|
| 948 |
n_row = max(1, (len(sig_cols) + 2) // 3)
|
| 949 |
fig, axes = plt.subplots(n_row, 3, figsize=(14, 4*n_row))
|
| 950 |
axes = np.array(axes).flatten()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 951 |
for i, col in enumerate(sig_cols):
|
| 952 |
ax = axes[i]
|
| 953 |
pd_data = [df.loc[df[GROUP_COL]==g, col].dropna().values for g in groups]
|
| 954 |
ax.boxplot(pd_data)
|
| 955 |
ax.set_xticks(range(1, len(groups) + 1))
|
| 956 |
-
ax.set_xticklabels(
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 960 |
for j in range(len(sig_cols), len(axes)):
|
| 961 |
axes[j].set_visible(False)
|
| 962 |
-
|
|
|
|
|
|
|
|
|
|
| 963 |
plt.tight_layout()
|
| 964 |
box_path = output_dir / "02_significant_boxplots.png"
|
| 965 |
plt.savefig(str(box_path), dpi=150, bbox_inches='tight')
|
|
@@ -995,8 +1140,8 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path):
|
|
| 995 |
top_n = min(20, len(imp_df))
|
| 996 |
fig, ax = plt.subplots(figsize=(10, 6))
|
| 997 |
ax.barh(imp_df['指標'][:top_n][::-1], imp_df['重要性'][:top_n][::-1], color='steelblue')
|
| 998 |
-
ax.set_xlabel("
|
| 999 |
-
ax.set_title(f"
|
| 1000 |
plt.tight_layout()
|
| 1001 |
imp_fig_path = output_dir / "03_feature_importance.png"
|
| 1002 |
plt.savefig(str(imp_fig_path), dpi=150, bbox_inches='tight')
|
|
@@ -1012,8 +1157,10 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path):
|
|
| 1012 |
linkage_mat = linkage(X_scaled, method='ward')
|
| 1013 |
fig, ax = plt.subplots(figsize=(10, 5))
|
| 1014 |
dendrogram(linkage_mat, labels=df['Text'].values, leaf_rotation=45, leaf_font_size=8, ax=ax)
|
| 1015 |
-
ax.set_title("
|
| 1016 |
-
|
|
|
|
|
|
|
| 1017 |
plt.tight_layout()
|
| 1018 |
dendro_path = output_dir / "04a_dendrogram.png"
|
| 1019 |
plt.savefig(str(dendro_path), dpi=150, bbox_inches='tight')
|
|
@@ -1031,8 +1178,8 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path):
|
|
| 1031 |
inertia.append(km.inertia_)
|
| 1032 |
fig, ax = plt.subplots(figsize=(7, 4))
|
| 1033 |
ax.plot(list(range(1, max_k+1)), inertia, marker='o', color='coral')
|
| 1034 |
-
ax.set_xlabel("
|
| 1035 |
-
ax.set_title("K-means
|
| 1036 |
plt.tight_layout()
|
| 1037 |
elbow_path = output_dir / "04b_kmeans_elbow.png"
|
| 1038 |
plt.savefig(str(elbow_path), dpi=150, bbox_inches='tight')
|
|
@@ -1045,30 +1192,49 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path):
|
|
| 1045 |
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
|
| 1046 |
for ax, lcol, title in zip(axes,
|
| 1047 |
['HC_Cluster','KM_Cluster'],
|
| 1048 |
-
['
|
| 1049 |
ulabels = sorted(df[lcol].unique())
|
| 1050 |
colors = plt.cm.tab10(np.linspace(0, 1, len(ulabels)))
|
| 1051 |
for idx, cl in enumerate(ulabels):
|
| 1052 |
mask = df[lcol] == cl
|
| 1053 |
ax.scatter(X_pca[mask,0], X_pca[mask,1],
|
| 1054 |
-
c=[colors[idx]], label=f"
|
| 1055 |
for i, txt in enumerate(df['Text']):
|
| 1056 |
ax.annotate(txt[:8], (X_pca[i,0], X_pca[i,1]),
|
| 1057 |
fontsize=7, textcoords='offset points', xytext=(5,3))
|
| 1058 |
-
ax.set_xlabel(f"
|
| 1059 |
-
ax.set_ylabel(f"
|
| 1060 |
ax.set_title(title); ax.legend()
|
| 1061 |
-
plt.suptitle("
|
| 1062 |
plt.tight_layout()
|
| 1063 |
cluster_fig_path = output_dir / "04c_clustering_pca2d.png"
|
| 1064 |
plt.savefig(str(cluster_fig_path), dpi=150, bbox_inches='tight')
|
| 1065 |
plt.close()
|
| 1066 |
|
| 1067 |
# 分群結果儲存
|
| 1068 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1069 |
result_cols = [c for c in result_cols if c in df.columns]
|
| 1070 |
cluster_csv_path = output_dir / "05_cluster_results.csv"
|
| 1071 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1072 |
|
| 1073 |
# ── 6. 產出 docx 技術報告 ──────────────────────────────────────────────────
|
| 1074 |
timestamp = now_tw().strftime("%Y%m%d_%H%M%S")
|
|
@@ -1121,8 +1287,16 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path):
|
|
| 1121 |
# 差異性分析
|
| 1122 |
doc.add_page_break()
|
| 1123 |
doc.add_heading('二、差異性分析', level=1)
|
| 1124 |
-
|
|
|
|
|
|
|
|
|
|
| 1125 |
doc.add_paragraph(f'共 {len(diff_df)} 個指標,其中 {len(sig_df)} 個達顯著差異(p < {ALPHA})。')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1126 |
group_sub_headers = []
|
| 1127 |
for g in groups:
|
| 1128 |
group_sub_headers.extend([f"{g}\nN", f"{g}\nM", f"{g}\nSD"])
|
|
@@ -1201,6 +1375,7 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path):
|
|
| 1201 |
"sig_count": len(sig_df),
|
| 1202 |
"total_indicators": len(diff_df),
|
| 1203 |
"accuracy": accuracy,
|
|
|
|
| 1204 |
"groups": list(groups),
|
| 1205 |
"n_groups": n_groups,
|
| 1206 |
"method_name": method_name,
|
|
@@ -1213,9 +1388,31 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path):
|
|
| 1213 |
# SECTION 7: LLM Report Writing (adapted from prog04, uses LLM1)
|
| 1214 |
# ============================================================================
|
| 1215 |
|
| 1216 |
-
def load_analysis_data_for_llm(csv_path: Path, output_dir: Path) -> dict:
|
| 1217 |
-
"""讀取分析資料組成 JSON 結構。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1218 |
data = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1219 |
if csv_path.exists():
|
| 1220 |
raw = pd.read_csv(str(csv_path))
|
| 1221 |
aj_cols = [c for c in raw.columns if c.startswith('AJ_')]
|
|
@@ -1234,6 +1431,16 @@ def load_analysis_data_for_llm(csv_path: Path, output_dir: Path) -> dict:
|
|
| 1234 |
p = output_dir / fname
|
| 1235 |
if p.exists():
|
| 1236 |
d = pd.read_csv(str(p))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1237 |
if key == "feature_importance_top20":
|
| 1238 |
data[key] = d.head(20).to_dict(orient='records')
|
| 1239 |
else:
|
|
@@ -1252,13 +1459,41 @@ def load_analysis_data_for_llm(csv_path: Path, output_dir: Path) -> dict:
|
|
| 1252 |
return data
|
| 1253 |
|
| 1254 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1255 |
def run_report_writing(scenario_key: str, user_metadata: str, user_focus: str,
|
| 1256 |
-
csv_path: Path, output_dir: Path) -> dict:
|
| 1257 |
"""
|
| 1258 |
呼叫主 Agent (LLM1) 撰寫研究報告,儲存 docx + txt,回傳路徑 dict。
|
|
|
|
| 1259 |
"""
|
| 1260 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 1261 |
-
analysis_data = load_analysis_data_for_llm(csv_path, output_dir)
|
| 1262 |
|
| 1263 |
scenario_info = SCENARIOS.get(scenario_key, SCENARIOS["1"])
|
| 1264 |
scenario_mod_key = scenario_info.get("scenario_module_key", "scenario_1")
|
|
@@ -1280,8 +1515,8 @@ def run_report_writing(scenario_key: str, user_metadata: str, user_focus: str,
|
|
| 1280 |
f"請根據以下量化分析結果,按照 system prompt 中的輸出結構進行深度分析。\n\n"
|
| 1281 |
f"【量化分析資料(JSON)】:\n```json\n"
|
| 1282 |
f"{json.dumps(analysis_data, ensure_ascii=False, indent=2)}\n```\n\n"
|
| 1283 |
-
"請嚴格按照
|
| 1284 |
-
"三、待驗證事項 /
|
| 1285 |
)
|
| 1286 |
|
| 1287 |
result_text = call_main_agent(system_prompt, user_prompt)
|
|
@@ -1308,15 +1543,18 @@ def run_report_writing(scenario_key: str, user_metadata: str, user_focus: str,
|
|
| 1308 |
doc.add_paragraph(f'報告時間:{now_tw().strftime("%Y-%m-%d %H:%M:%S")}(台灣時間)',
|
| 1309 |
style='Normal').alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 1310 |
doc.add_page_break()
|
|
|
|
|
|
|
|
|
|
| 1311 |
for line in result_text.split('\n'):
|
| 1312 |
stripped = line.strip()
|
| 1313 |
-
if stripped.startswith('#### '): doc.add_heading(
|
| 1314 |
-
elif stripped.startswith('### '): doc.add_heading(
|
| 1315 |
-
elif stripped.startswith('## '): doc.add_heading(
|
| 1316 |
-
elif stripped.startswith('# '): doc.add_heading(
|
| 1317 |
-
elif stripped.startswith('- '): doc.add_paragraph(
|
| 1318 |
-
elif stripped.startswith('* '): doc.add_paragraph(
|
| 1319 |
-
elif stripped: doc.add_paragraph(stripped)
|
| 1320 |
doc.save(str(docx_path))
|
| 1321 |
|
| 1322 |
# 存 txt(模型標註置於標題下方)
|
|
@@ -1384,6 +1622,8 @@ def make_initial_state(session_id: str) -> dict:
|
|
| 1384 |
"zip_paths": {},
|
| 1385 |
"user_metadata": "",
|
| 1386 |
"user_focus": "",
|
|
|
|
|
|
|
| 1387 |
"analysis_result": None,
|
| 1388 |
"done_steps": [], # 已完成步驟(1-based)
|
| 1389 |
"current_step": None, # 目前執行步驟
|
|
@@ -1435,7 +1675,7 @@ def make_initial_state(session_id: str) -> dict:
|
|
| 1435 |
# on_scenario_pick)。原本①提到「情境選擇搬去首頁後不算步驟列一步」在此
|
| 1436 |
# 已不成立,_STEPS 從 3 步變 4 步,後面三步的編號全部 +1。
|
| 1437 |
_STEPS = [
|
| 1438 |
-
(1, "
|
| 1439 |
(2, "文本上傳"),
|
| 1440 |
(3, "語料說明"),
|
| 1441 |
(4, "分析運算"),
|
|
@@ -1552,13 +1792,23 @@ def handle_uploaded_files(files, state: dict):
|
|
| 1552 |
shutil.copy2(str(src), str(dst))
|
| 1553 |
saved_paths.append(str(dst))
|
| 1554 |
|
| 1555 |
-
#
|
| 1556 |
-
#
|
| 1557 |
-
# 只需
|
| 1558 |
-
|
| 1559 |
-
|
| 1560 |
-
|
| 1561 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1562 |
state["state"] = "FORMAT_CHECK"
|
| 1563 |
|
| 1564 |
# 工作程序 Agent 說明開始檢查
|
|
@@ -1608,7 +1858,7 @@ def run_format_check(state: dict):
|
|
| 1608 |
present_str = "、".join(sorted(present_prefixes)) or "無"
|
| 1609 |
missing_str = "、".join(sorted(missing))
|
| 1610 |
msg = (
|
| 1611 |
-
f"
|
| 1612 |
f"目前偵測到:{present_str},缺少:{missing_str}。"
|
| 1613 |
)
|
| 1614 |
state["state"] = "UPLOAD_WAIT"
|
|
@@ -1618,31 +1868,31 @@ def run_format_check(state: dict):
|
|
| 1618 |
state["cleaned_files"] = state["uploaded_files"]
|
| 1619 |
sys_p = (
|
| 1620 |
"你是重譯文本分析系統的工作程序 Agent,"
|
| 1621 |
-
"請用繁體中文告知使用者所有檔案已確認為
|
| 1622 |
"即將開始計算指標,30 字以內。"
|
| 1623 |
)
|
| 1624 |
try:
|
| 1625 |
msg = call_worker_agent(sys_p, "所有檔案格式驗證通過。")
|
| 1626 |
except:
|
| 1627 |
-
msg = "[OK] 所有檔案均符合
|
| 1628 |
return msg, state, False
|
| 1629 |
|
| 1630 |
# 有檔案不符(格式或字首)
|
| 1631 |
fail_list = "\n".join([f"- **{fn}**:{reason}" for fn, reason in failed.items()])
|
| 1632 |
sys_p = (
|
| 1633 |
"你是重譯文本分析系統的工作程序 Agent,請用繁體中文告知使用者:"
|
| 1634 |
-
"部分檔案不符合
|
| 1635 |
"必須重新使用該工具斷詞後再上傳,語氣清晰,80 字��內。"
|
| 1636 |
-
"請附上工具連結:https://github.com/
|
| 1637 |
)
|
| 1638 |
try:
|
| 1639 |
msg = call_worker_agent(sys_p, f"格式不符的檔案:\n{fail_list}")
|
| 1640 |
msg = msg + f"\n\n{fail_list}"
|
| 1641 |
except:
|
| 1642 |
msg = (
|
| 1643 |
-
f"
|
| 1644 |
f"{fail_list}\n\n"
|
| 1645 |
-
"請使用 [
|
| 1646 |
"工具完成斷詞後,重新上傳檔案。"
|
| 1647 |
)
|
| 1648 |
failed_names = set(failed.keys())
|
|
@@ -1664,7 +1914,7 @@ _ZIP_GROUPS = [
|
|
| 1664 |
("idx", "指標數據"),
|
| 1665 |
("stat", "統計圖表"),
|
| 1666 |
("report", "研究報告"),
|
| 1667 |
-
("all", "
|
| 1668 |
]
|
| 1669 |
|
| 1670 |
|
|
@@ -1726,7 +1976,11 @@ def run_computation_pipeline_streaming(state: dict):
|
|
| 1726 |
state["cleaned_files"], output_dir, _RESOURCES
|
| 1727 |
)
|
| 1728 |
results_paths.extend([str(xlsx_path), str(csv_path)])
|
| 1729 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1730 |
A["idx"]["status"] = "done"
|
| 1731 |
A["idx"]["line"] = f"{len(idx_df)} 個文本 × {n_ind} 項語言指標"
|
| 1732 |
yield f" [OK] 指標計算完成({len(idx_df)} 個文本)", False, state
|
|
@@ -1742,7 +1996,11 @@ def run_computation_pipeline_streaming(state: dict):
|
|
| 1742 |
A["stat"]["status"] = "running"
|
| 1743 |
yield "【統計分析】正在執行統計分析……", False, state
|
| 1744 |
try:
|
| 1745 |
-
anal = run_statistical_analysis(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1746 |
for key in ["desc_path","diff_path","imp_path","imp_fig_path",
|
| 1747 |
"box_path","dendro_path","elbow_path","cluster_fig_path",
|
| 1748 |
"cluster_csv_path","report_path"]:
|
|
@@ -1780,6 +2038,7 @@ def run_computation_pipeline_streaming(state: dict):
|
|
| 1780 |
user_focus = state.get("user_focus", ""),
|
| 1781 |
csv_path = csv_path,
|
| 1782 |
output_dir = output_dir,
|
|
|
|
| 1783 |
)
|
| 1784 |
results_paths.extend([str(rpt["docx_path"]), str(rpt["txt_path"])])
|
| 1785 |
A["report"]["status"] = "done"
|
|
@@ -2418,7 +2677,12 @@ _APP_HEAD_SCRIPT = """
|
|
| 2418 |
}
|
| 2419 |
if (_zonePendingCount[id] < 2) return;
|
| 2420 |
if (visible) {
|
| 2421 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2422 |
} else {
|
| 2423 |
window.scrollTo({ top: 0, behavior: 'smooth' });
|
| 2424 |
}
|
|
@@ -2570,7 +2834,7 @@ def _build_homepage_hero_html() -> str:
|
|
| 2570 |
'<h1><span class="en">II</span> 分析引擎 <span class="en">SpectraEngine</span></h1>'
|
| 2571 |
# 1-F:描述文字砍掉原本結尾「請先選擇最符合您研究設計的比較情境:」
|
| 2572 |
# ——情境選擇已搬去工作區,首頁不用再提示選情境。
|
| 2573 |
-
'<p class="lede">上傳已斷詞的譯本文本,系統將自動計算 <span class="en">
|
| 2574 |
'協助您完成重譯研究的量化論證。</p>'
|
| 2575 |
'</div>'
|
| 2576 |
)
|
|
@@ -2857,7 +3121,7 @@ def create_app():
|
|
| 2857 |
elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary",
|
| 2858 |
)
|
| 2859 |
nav_dl_all_btn = gr.DownloadButton(
|
| 2860 |
-
"下載
|
| 2861 |
visible=False, elem_id="nav_dl_all_btn",
|
| 2862 |
elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary",
|
| 2863 |
)
|
|
@@ -2993,7 +3257,7 @@ def create_app():
|
|
| 2993 |
# 標題+說明文字沿用 metadata_row 那則 Markdown 的寫法
|
| 2994 |
# (純 "### 標題\n說明文字",不套 elem_classes),維持
|
| 2995 |
# 同一批「Canvas 操作區塊」卡片的既有排版慣例。
|
| 2996 |
-
gr.Markdown("###
|
| 2997 |
gr.HTML(_build_scenario_cards_html("workspace_scenario_grid"))
|
| 2998 |
|
| 2999 |
# Phase 8(千卉實測後回頭調整):這張卡片改回正式使用
|
|
@@ -3064,21 +3328,48 @@ def create_app():
|
|
| 3064 |
value="群組間比較", scale=1,
|
| 3065 |
)
|
| 3066 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3067 |
gr.Markdown("**群組層資訊**(共通屬性)")
|
| 3068 |
-
|
| 3069 |
-
|
| 3070 |
-
|
| 3071 |
-
|
| 3072 |
-
|
| 3073 |
-
|
| 3074 |
-
|
| 3075 |
-
|
| 3076 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3077 |
gr.Markdown("**個體層資訊**(每個譯本/檔案)")
|
| 3078 |
meta_indiv_df = gr.Dataframe(
|
| 3079 |
-
headers=["檔案", "譯者/系統名稱", "出版年", "譯者母語", "
|
| 3080 |
-
datatype=["str"] *
|
| 3081 |
-
col_count=(
|
| 3082 |
row_count=(1, "dynamic"),
|
| 3083 |
interactive=True,
|
| 3084 |
label="個體層",
|
|
@@ -3134,9 +3425,14 @@ def create_app():
|
|
| 3134 |
interactive=False, visible=False,
|
| 3135 |
)
|
| 3136 |
# 子分頁 2:差異性分析
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3137 |
with gr.Tab("2. 差異性分析"):
|
| 3138 |
tab_diff_head = gr.Markdown(_EMPTY_HINT)
|
| 3139 |
-
tab_box_img = gr.Image(label="顯著指標箱形圖", visible=False)
|
| 3140 |
tab_diff_df = gr.Dataframe(
|
| 3141 |
label="差異性分析",
|
| 3142 |
interactive=False, visible=False,
|
|
@@ -3145,6 +3441,7 @@ def create_app():
|
|
| 3145 |
label="下載差異性分析",
|
| 3146 |
interactive=False, visible=False,
|
| 3147 |
)
|
|
|
|
| 3148 |
# 子分頁 3:監督式學習
|
| 3149 |
with gr.Tab("3. 監督式學習"):
|
| 3150 |
tab_rf_md = gr.Markdown(_EMPTY_HINT)
|
|
@@ -3179,7 +3476,17 @@ def create_app():
|
|
| 3179 |
)
|
| 3180 |
|
| 3181 |
with gr.Column(visible=False, elem_id="canvas_report", elem_classes=["canvas-result"]) as canvas_report_col:
|
| 3182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3183 |
tab_rpt_file = gr.File(
|
| 3184 |
label="下載LLM輔助分析",
|
| 3185 |
file_count="multiple", interactive=False, visible=False,
|
|
@@ -3284,11 +3591,16 @@ def create_app():
|
|
| 3284 |
xlsx_list = sorted(output_dir.glob("textIndex_calculated_1_*.xlsx"))
|
| 3285 |
if csv_path.exists():
|
| 3286 |
df = pd.read_csv(str(csv_path))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3287 |
# 指標數 = 總欄位數扣除中介欄(Text/Type1/Type2)
|
| 3288 |
-
meta_cols = [c for c in ["Text", "Type1", "Type2"] if c in
|
| 3289 |
-
n_indicators = len(
|
| 3290 |
-
R[0] = f"### 語言指標分析\n共 **{len(
|
| 3291 |
-
R[1] = gr.update(value=
|
| 3292 |
dl = [str(csv_path)]
|
| 3293 |
if xlsx_list:
|
| 3294 |
dl.append(str(xlsx_list[-1]))
|
|
@@ -3311,8 +3623,34 @@ def create_app():
|
|
| 3311 |
# sig=0 時不會產生箱形圖(見下方 box_path 判斷),若只留白使用者
|
| 3312 |
# 容易誤以為系統壞掉,這裡改在標題文字附加空狀態說明
|
| 3313 |
empty_note = "" if len(sig) > 0 else "\n\n本次分析未發現統計顯著的指標差異,因此未產生箱形圖。"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3314 |
R[6] = gr.update(
|
| 3315 |
-
value=f"### 差異性分析\n共 **{len(diff)}** 個指標,
|
|
|
|
|
|
|
| 3316 |
visible=True,
|
| 3317 |
)
|
| 3318 |
R[7] = gr.update(value=diff, visible=True)
|
|
@@ -3361,7 +3699,15 @@ def create_app():
|
|
| 3361 |
R[16] = gr.update(value=str(pca_fig_path), visible=True)
|
| 3362 |
if cluster_path.exists():
|
| 3363 |
cdf = pd.read_csv(str(cluster_path))
|
| 3364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3365 |
cluster_dl = [str(p) for p in [dendro_path, elbow_path, pca_fig_path, cluster_path] if p.exists()]
|
| 3366 |
if cluster_dl:
|
| 3367 |
R[24] = gr.update(value=cluster_dl, visible=True)
|
|
@@ -3426,11 +3772,25 @@ def create_app():
|
|
| 3426 |
"report": dl_rpt,
|
| 3427 |
}, allow_all=report_stage_done)
|
| 3428 |
state["zip_paths"] = zips
|
| 3429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3430 |
if zips.get(_key):
|
| 3431 |
R[_i] = gr.update(value=zips[_key], visible=True)
|
| 3432 |
-
# 只
|
| 3433 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3434 |
R[30] = gr.update(visible=True)
|
| 3435 |
|
| 3436 |
# ── 顯示各分頁 Tab(index 20-22)+結果導覽閘門(31-36,A2)──────
|
|
@@ -3852,8 +4212,17 @@ def create_app():
|
|
| 3852 |
# 可以放心跟其他更新同批。對話視窗自此回歸純輸出角色。格式要求
|
| 3853 |
# 直接寫進上傳卡片本身(upload_hint_md),使用者不用往上捲回
|
| 3854 |
# 對話視窗找 AI 剛剛說過的規則——卡片本身就要自己講清楚。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3855 |
hint = (
|
| 3856 |
-
f"**格式要求**\n\n{sc_info['upload_prompt']}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3857 |
)
|
| 3858 |
yield (chat_history, log_history, state, gr.update(visible=True),
|
| 3859 |
_todo_update(state), gr.update(value=hint),
|
|
@@ -3963,6 +4332,13 @@ def create_app():
|
|
| 3963 |
yield chat_history, log_history, state, gr.update(visible=True), gr.update(), gr.update()
|
| 3964 |
return
|
| 3965 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3966 |
# 文本上傳(step 2:3-A 新增「研究選擇」為第 1 步後,這裡的步驟
|
| 3967 |
# 編號全部 +1):開始處理即關閉上傳彈窗,避免遮罩擋住操作。
|
| 3968 |
# 3-C:同時把「檔案接收」子階段標成 running,寫進 upload_progress_html
|
|
@@ -4037,7 +4413,10 @@ def create_app():
|
|
| 4037 |
import os
|
| 4038 |
files = [os.path.basename(p) for p in state.get("cleaned_files", [])]
|
| 4039 |
groups = sorted(set(f[0].upper() for f in files if f))
|
| 4040 |
-
|
|
|
|
|
|
|
|
|
|
| 4041 |
|
| 4042 |
def _df_rows(dfval):
|
| 4043 |
try:
|
|
@@ -4051,8 +4430,57 @@ def create_app():
|
|
| 4051 |
pass
|
| 4052 |
return []
|
| 4053 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4054 |
def _compile_metadata(state, title, author, lang, pubyear, version,
|
| 4055 |
-
ngroups, method,
|
|
|
|
|
|
|
|
|
|
| 4056 |
sc = state.get("scenario", "")
|
| 4057 |
L = ["## 語料 Metadata", "",
|
| 4058 |
"### 原文資訊",
|
|
@@ -4066,12 +4494,19 @@ def create_app():
|
|
| 4066 |
f"- 群組數量:{ngroups}",
|
| 4067 |
f"- 比較方式:{method}", ""]
|
| 4068 |
gh = ["群組標籤", "翻譯類型", "翻譯方向", "譯者背景", "目標讀者"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4069 |
L += ["### 群組層資訊"]
|
| 4070 |
-
for
|
| 4071 |
-
|
| 4072 |
-
|
| 4073 |
-
|
| 4074 |
-
ih = ["檔案", "譯者/系統名稱", "出版年", "譯者母語", "
|
| 4075 |
L += ["", "### 個體層資訊"]
|
| 4076 |
for r in _df_rows(indiv_df):
|
| 4077 |
if not any(str(c).strip() for c in r):
|
|
@@ -4079,6 +4514,29 @@ def create_app():
|
|
| 4079 |
L.append("- " + ";".join(f"{h}:{str(v).strip()}" for h, v in zip(ih, r) if str(v).strip()))
|
| 4080 |
return "\n".join(L)
|
| 4081 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4082 |
def _run_computation(chat_history, log_history, state):
|
| 4083 |
# 2026-07-23:技術執行紀錄轉正成獨立頁面後,可見性只在情境選定
|
| 4084 |
# (on_scenario_pick)與重置(on_reset_confirm)時切換一次,
|
|
@@ -4134,13 +4592,26 @@ def create_app():
|
|
| 4134 |
yield (chat_history, log_history, state, _todo_update(state),
|
| 4135 |
gr.update(), _computing_update(state), *tab_updates)
|
| 4136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4137 |
def _show_metadata_form(state):
|
| 4138 |
_NO = gr.update()
|
| 4139 |
if not state or state.get("state") != "METADATA_INPUT":
|
| 4140 |
-
return _NO,
|
| 4141 |
-
|
| 4142 |
show_cmp = state.get("scenario", "") in ("3", "4")
|
| 4143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4144 |
gr.update(value=indiv_rows), gr.update(visible=show_cmp))
|
| 4145 |
|
| 4146 |
# 送出期間鎖住按鈕避免重複點擊(2026-07-24 取消鈕移除後只剩確認鈕一顆)
|
|
@@ -4161,7 +4632,10 @@ def create_app():
|
|
| 4161 |
).then(
|
| 4162 |
fn=_show_metadata_form,
|
| 4163 |
inputs=[session_state],
|
| 4164 |
-
outputs=[metadata_row,
|
|
|
|
|
|
|
|
|
|
| 4165 |
)
|
| 4166 |
|
| 4167 |
# 2026-07-31(Batch G 第 1 項):「重新選擇研究情境」返回鈕。叫回
|
|
@@ -4217,8 +4691,15 @@ def create_app():
|
|
| 4217 |
)
|
| 4218 |
|
| 4219 |
def on_metadata_confirm(title, author, lang, pubyear, version,
|
| 4220 |
-
ngroups, method,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4221 |
chat_history, log_history, state):
|
|
|
|
|
|
|
|
|
|
| 4222 |
# Bug 4-A 修復(2026-07-30):outputs 併入 _TAB_OUTPUTS(見下方
|
| 4223 |
# wiring),generator 的每個 yield 元素個數都要跟 outputs 長度
|
| 4224 |
# 完全一致——這裡先包一份「_TAB_OUTPUTS 全部不動」的預設值,
|
|
@@ -4238,7 +4719,7 @@ def create_app():
|
|
| 4238 |
expected_count = 3 if ngroups == "三群" else 2
|
| 4239 |
if len(actual_prefixes) != expected_count:
|
| 4240 |
warn_msg = (
|
| 4241 |
-
f"
|
| 4242 |
f"{len(actual_prefixes)} 組({'、'.join(sorted(actual_prefixes)) or '無'}),"
|
| 4243 |
"請確認群組數量與上傳檔案是否一致後再送出。"
|
| 4244 |
)
|
|
@@ -4249,8 +4730,13 @@ def create_app():
|
|
| 4249 |
return
|
| 4250 |
|
| 4251 |
state["user_metadata"] = _compile_metadata(state, title, author, lang, pubyear,
|
| 4252 |
-
version, ngroups, method,
|
|
|
|
|
|
|
| 4253 |
log_history = _log_append(log_history, "✅ 已記錄語料 Metadata,開始分析")
|
|
|
|
|
|
|
|
|
|
| 4254 |
# 在主對話視窗立即顯示「開始分析」訊息,避免長時間運算期間畫面看起來像沒有反應
|
| 4255 |
chat_history = chat_history + [_assistant_msg(_ANALYSIS_START_MSG)]
|
| 4256 |
yield (chat_history, log_history, state, gr.update(visible=False),
|
|
@@ -4272,7 +4758,9 @@ def create_app():
|
|
| 4272 |
).then(
|
| 4273 |
fn=on_metadata_confirm,
|
| 4274 |
inputs=[meta_title, meta_author, meta_lang, meta_pubyear, meta_version,
|
| 4275 |
-
meta_ngroups, meta_method,
|
|
|
|
|
|
|
| 4276 |
chatbot, exec_log, session_state],
|
| 4277 |
# Bug 4-A 修復(2026-07-30):併入 _TAB_OUTPUTS,讓 on_metadata_confirm
|
| 4278 |
# 能在 streaming 過程中(不用等這條 .then() 鏈跑到最後)就把已完成
|
|
@@ -4294,6 +4782,8 @@ def create_app():
|
|
| 4294 |
return
|
| 4295 |
state["user_metadata"] = state.get("user_metadata", "") or "(使用者未提供語料 Metadata 說明)"
|
| 4296 |
log_history = _log_append(log_history, "⏭️ 已略過 Metadata,直接開始分析")
|
|
|
|
|
|
|
| 4297 |
chat_history = chat_history + [_assistant_msg(_ANALYSIS_START_MSG)]
|
| 4298 |
yield (chat_history, log_history, state, gr.update(visible=False),
|
| 4299 |
_todo_update(state), gr.update(), _computing_update(state), *_no_tabs)
|
|
@@ -4358,11 +4848,18 @@ if __name__ == "__main__":
|
|
| 4358 |
print(f"工作程序 : {LLM2_MODEL}")
|
| 4359 |
print(f"BASE_DIR : {BASE_DIR}")
|
| 4360 |
print("=" * 60)
|
|
|
|
| 4361 |
app = create_app()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4362 |
app.launch(
|
| 4363 |
server_name="0.0.0.0",
|
| 4364 |
server_port=7860,
|
| 4365 |
-
share=
|
| 4366 |
inbrowser=False,
|
| 4367 |
allowed_paths=[str(SESSIONS_DIR)],
|
| 4368 |
head=_APP_HEAD,
|
|
|
|
| 13 |
# ============================================================================
|
| 14 |
# SECTION 1: Imports & Configuration
|
| 15 |
# ============================================================================
|
| 16 |
+
import sys, io, os, re, json, uuid, shutil, warnings, functools, collections, time
|
| 17 |
import threading, zipfile, traceback
|
| 18 |
|
| 19 |
# 強制 UTF-8 輸出(避免 Windows cp950 編碼錯誤)
|
|
|
|
| 71 |
SESSIONS_DIR = BASE_DIR / "sessions"
|
| 72 |
SESSIONS_DIR.mkdir(exist_ok=True)
|
| 73 |
|
| 74 |
+
# ── Session 目錄自動清理(2026-08-22,見 docs/開發優化建議.md 第四節)────────
|
| 75 |
+
# 每個 session 資料夾含使用者上傳的原始語料與全部分析產出,先前沒有任何過期
|
| 76 |
+
# 清理機制,會無限期留在磁碟上(正式機部署在 Hugging Face Spaces,公開對外
|
| 77 |
+
# 使用後這個問題會更明顯)。這裡加一支獨立的清理工具:只依資料夾的最後修改
|
| 78 |
+
# 時間(mtime)判斷是否過期,不讀取、不解析任何 session 內部狀態,因此不算
|
| 79 |
+
# 觸碰《開發優化建議.md》第五節界定的高風險核心運算區。
|
| 80 |
+
SESSION_RETENTION_DAYS_DEFAULT = 7 # 保留天數,可用環境變數 SESSION_RETENTION_DAYS 覆寫
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def cleanup_old_sessions(max_age_days: int = None) -> int:
|
| 84 |
+
"""刪除 SESSIONS_DIR 底下超過保留天數的舊 session 資料夾。
|
| 85 |
+
|
| 86 |
+
以資料夾本身的最後修改時間(mtime)判斷年齡:分析運算過程中會持續寫入
|
| 87 |
+
該資料夾(上傳檔案、指標 CSV、圖表、報告……),mtime 會隨之更新,因此
|
| 88 |
+
不會誤刪還在使用中的 session;只有久沒被寫入、代表使用者早已離開的舊
|
| 89 |
+
資料夾才會被清掉。
|
| 90 |
+
|
| 91 |
+
回傳實際刪除的資料夾數量(供啟動 log 顯示,非必要)。
|
| 92 |
+
"""
|
| 93 |
+
if max_age_days is None:
|
| 94 |
+
max_age_days = int(os.environ.get("SESSION_RETENTION_DAYS", str(SESSION_RETENTION_DAYS_DEFAULT)))
|
| 95 |
+
if not SESSIONS_DIR.exists():
|
| 96 |
+
return 0
|
| 97 |
+
cutoff = datetime.now(timezone.utc).timestamp() - max_age_days * 86400
|
| 98 |
+
deleted = 0
|
| 99 |
+
for entry in SESSIONS_DIR.iterdir():
|
| 100 |
+
if not entry.is_dir():
|
| 101 |
+
continue
|
| 102 |
+
try:
|
| 103 |
+
if entry.stat().st_mtime < cutoff:
|
| 104 |
+
shutil.rmtree(entry, ignore_errors=True)
|
| 105 |
+
deleted += 1
|
| 106 |
+
except FileNotFoundError:
|
| 107 |
+
continue # 極少數情況下清理過程中資料夾被同時刪除,略過即可
|
| 108 |
+
if deleted:
|
| 109 |
+
print(f"【Session 清理】已刪除 {deleted} 個超過 {max_age_days} 天未使用的舊 session 資料夾。")
|
| 110 |
+
return deleted
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def start_session_cleanup_scheduler() -> None:
|
| 114 |
+
"""啟動一支背景 daemon 執行緒:程式啟動時先清一次,之後每 24 小時重跑一次。
|
| 115 |
+
|
| 116 |
+
只在程序啟動時呼叫一次即可。用背景執行緒(而非只在啟動時跑一次)的原因:
|
| 117 |
+
Hugging Face Spaces 若持續有人使用,容器可能長時間不重啟,只在啟動時清
|
| 118 |
+
一次的話,長期掛著不重啟的情況下舊資料夾一樣會無限期累積。
|
| 119 |
+
"""
|
| 120 |
+
def _loop():
|
| 121 |
+
while True:
|
| 122 |
+
try:
|
| 123 |
+
cleanup_old_sessions()
|
| 124 |
+
except Exception as e:
|
| 125 |
+
print(f"【Session 清理】背景清理發生例外(不影響主程式運作):{e}")
|
| 126 |
+
time.sleep(24 * 3600)
|
| 127 |
+
threading.Thread(target=_loop, daemon=True, name="session-cleanup").start()
|
| 128 |
+
|
| 129 |
# ── LLM 設定 ────────────────────────────────────────────────────────────────
|
| 130 |
LLM1_API_KEY = os.environ.get("LLM1_API_KEY", "")
|
| 131 |
LLM1_MODEL = os.environ.get("LLM1_MODEL", "google/gemma-4-31b-it")
|
|
|
|
| 156 |
"upload_prompt": (
|
| 157 |
"1. 檔案格式:僅接受 **.txt** 純文字檔。\n"
|
| 158 |
"2. 檔名規範:請以 **A_xx.txt**、**B_xx.txt** 等格式區分人工翻譯與機器翻譯。\n"
|
| 159 |
+
"3. 斷詞要求:必須先使用 [語料淬鍊](https://github.com/minchun-git/I_CorpusRefinery) 工具完成斷詞。\n"
|
| 160 |
+
"4. 內容格式:僅限 `詞_詞性` 或空白行。"
|
| 161 |
),
|
| 162 |
"desc": "焦點在人機差異的語言特徵。",
|
| 163 |
"scenario_module_key": "scenario_2",
|
|
|
|
| 168 |
"upload_prompt": (
|
| 169 |
"1. 檔案格式:僅接受 **.txt** 純文字檔。\n"
|
| 170 |
"2. 檔名規範:請以 **A_xx.txt**、**B_xx.txt** 等格式區分全譯與編譯。\n"
|
| 171 |
+
"3. 斷詞要求:必須先使用 [語料淬鍊](https://github.com/minchun-git/I_CorpusRefinery) 工具完成斷詞。\n"
|
| 172 |
+
"4. 內容格式:僅限 `詞_詞性` 或空白行。"
|
| 173 |
),
|
| 174 |
"desc": "焦點在文本完整性和改寫策略。",
|
| 175 |
"scenario_module_key": "scenario_3",
|
|
|
|
| 180 |
"upload_prompt": (
|
| 181 |
"1. 檔案格式:僅接受 **.txt** 純文字檔。\n"
|
| 182 |
"2. 檔名規範:請以 **A_xx.txt**、**B_xx.txt**、**C_xx.txt** 等格式區分不同群組的譯者。\n"
|
| 183 |
+
"3. 斷詞要求:必須先使用 [語料淬鍊](https://github.com/minchun-git/I_CorpusRefinery) 工具完成斷詞。\n"
|
| 184 |
+
"4. 內容格式:僅限 `詞_詞性` 或空白行。"
|
| 185 |
),
|
| 186 |
"desc": "焦點在個人風格差異。排除時代因素,討論譯者個人的語言偏好。",
|
| 187 |
"scenario_module_key": "scenario_4",
|
|
|
|
| 192 |
"upload_prompt": (
|
| 193 |
"1. 檔案格式:僅接受 **.txt** 純文字檔。\n"
|
| 194 |
"2. 檔名規範:請以 **A_xx.txt**、**B_xx.txt**、**C_xx.txt** 等格式區分不同時期的譯本。\n"
|
| 195 |
+
"3. 斷詞要求:必須先使用 [語料淬鍊](https://github.com/minchun-git/I_CorpusRefinery) 工具完成斷詞。\n"
|
| 196 |
+
"4. 內容格式:僅限 `詞_詞性` 或空白行。"
|
| 197 |
),
|
| 198 |
"desc": "分析焦點在歷時變化、語言現代化、翻譯規範的演變。時間維度是核心變數。",
|
| 199 |
"scenario_module_key": "scenario_1",
|
|
|
|
| 258 |
INDEX_NAME_MAP[_code] = _name
|
| 259 |
INDEX_CATEGORY_MAP[_code] = _cat
|
| 260 |
|
| 261 |
+
# 2026-08-14(業主要求):相異字數(AA_02)+相異詞數(AA_04)+篇章連貫
|
| 262 |
+
# 全類(AJ_01~AJ_11,共 11 項),合計 13 項暫時整理掉,不刪除計算本身,
|
| 263 |
+
# 只在「結果呈現」的地方隱藏——業主拍板範圍是全面隱藏:敘述統計、差異性
|
| 264 |
+
# 分析、隨機森林特徵重要性、分群分析都不再拿這 13 項當特徵,LLM 報告也
|
| 265 |
+
# 不會提到;語言指標分析分頁的表格畫面同理濾掉。
|
| 266 |
+
# textIndex_calculated.csv/.xlsx(指標計算原始輸出檔+其下載)刻意不動,
|
| 267 |
+
# 仍保留完整 75 項,理由是業主明確說「無須刪除」——這兩個檔案是計算結果的
|
| 268 |
+
# 原始存檔,不算「結果呈現」,之後要復原顯示只要拿掉這個集合的套用即可,
|
| 269 |
+
# 不用重跑指標計算。
|
| 270 |
+
HIDDEN_INDICATOR_CODES = {"AA_02", "AA_04"} | {f"AJ_{i:02d}" for i in range(1, 12)}
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def _drop_hidden_indicators(df):
|
| 274 |
+
"""回傳去掉 HIDDEN_INDICATOR_CODES 欄位後的 DataFrame(欄名為原始指標代碼)。"""
|
| 275 |
+
cols = [c for c in HIDDEN_INDICATOR_CODES if c in df.columns]
|
| 276 |
+
return df.drop(columns=cols) if cols else df
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
# 2026-08-14:介面上凡是提到「系統共計算幾項語言指標」的地方,一律引用這個
|
| 280 |
+
# 常數,不要各自寫死數字——INDEX_NAME_MAP 是系統定義的指標總表(目前 75),
|
| 281 |
+
# 扣掉 HIDDEN_INDICATOR_CODES 就是使用者實際會看到的指標數(目前 62)。
|
| 282 |
+
# 之後隱藏清單增減,這裡跟著自動變動,不用每次巡一遍程式碼改字面數字。
|
| 283 |
+
TOTAL_VISIBLE_INDICATORS = len(INDEX_NAME_MAP) - len(HIDDEN_INDICATOR_CODES)
|
| 284 |
+
|
| 285 |
# ── LLM 報告用情境模組(同 prog04)─────────────────────────────────────────
|
| 286 |
+
SCENARIO_MODULE_1 = """## 三、情境特定分析:歷時變化分析
|
| 287 |
|
| 288 |
在陳述差異時,必須將「時間順序」作為組織軸線。
|
| 289 |
請依照以下子項目分析:
|
| 290 |
|
| 291 |
+
### 3.1 語言現代化的跡象
|
| 292 |
- 詞彙難易度(AC_01, AC_02)是否隨時間呈現規律變化?
|
| 293 |
- 成語比率(AG_06)在不同時期譯本中的變化方向?
|
| 294 |
- 若出現非單調變化,明確標示此為反常模式,需進一步解釋。
|
| 295 |
|
| 296 |
+
### 3.2 句式與篇章結構的演變
|
| 297 |
- 段落數、句數(AA_05, AA_06)的變化是否反映閱讀習慣的改變?
|
| 298 |
- 詞性分布中,連接詞、代詞比例的變化是否呈現顯著化或隱化趨勢?
|
| 299 |
|
| 300 |
+
### 3.3 譯本間的親疏關係
|
| 301 |
- 根據分群結果,時序相鄰的譯本是否聚為一群?
|
| 302 |
- 若出現時序相隔較遠��譯本反而更相似的情況,標記為需進一步探討的現象。
|
| 303 |
|
| 304 |
### 禁令補充
|
| 305 |
- 不得將「較新」直接等同於「較現代」或「較好」。"""
|
| 306 |
|
| 307 |
+
SCENARIO_MODULE_2 = """## 三、情境特定分析:人機差異分析
|
| 308 |
|
| 309 |
+
### 3.1 機器翻譯典型特徵的檢視
|
| 310 |
根據量化數據檢視機器翻譯是否呈現以下已知特徵:
|
| 311 |
- 詞彙多樣性是否偏低?(MSTTR, RTTR)
|
| 312 |
- 句長是否較為整齊(變異數較小)?
|
| 313 |
|
| 314 |
+
### 3.2 人工翻譯的差異化特徵
|
| 315 |
- 人工譯本在哪些指標上顯著偏離機器譯本?
|
| 316 |
|
| 317 |
### 禁令補充
|
| 318 |
- 不得對機器翻譯系統的品質做整體評價。
|
| 319 |
- 不得預設「人工一定優於機器」或反之。"""
|
| 320 |
|
| 321 |
+
SCENARIO_MODULE_3 = """## 三、情境特定分析:改寫策略分析
|
| 322 |
|
| 323 |
【重要限制】:本系統目前未進行原文對齊,所有關於刪減/增補的陳述必須明確標示為「推測」。
|
| 324 |
|
|
|
|
| 332 |
### 禁令補充
|
| 333 |
- 嚴禁使用「忠實度」、「完整性」等規範性詞彙。"""
|
| 334 |
|
| 335 |
+
SCENARIO_MODULE_4 = """## 三、情境特定分析:個人風格差異分析
|
| 336 |
|
| 337 |
【前提】:所有譯本產出於相近時期,時代因素已被控制。
|
| 338 |
|
| 339 |
+
### 3.1 核心風格指標的差異
|
| 340 |
- 在哪些指標上,譯者間差異達到統計顯著?
|
| 341 |
|
| 342 |
+
### 3.2 風格穩定性的檢視
|
| 343 |
- 譯者內變異 vs 譯者間變異的比較
|
| 344 |
|
| 345 |
### 禁令補充
|
|
|
|
| 369 |
【使用者補充關注點】:{user_focus}
|
| 370 |
|
| 371 |
# 格式規範(務必嚴格遵守,直接影響報告的可讀性)
|
| 372 |
+
1. 以下「通用輸出結構」中 `## 一、`、`## 二、`、`## 四、`、`## 五、` 這幾個章節標題,輸出時必須原樣保留 `## ` 這個 Markdown 標題語法開頭,不可改寫成「**粗體**」或其他格式。
|
| 373 |
2. 章節內若需要子標題或小節提示語(例如「總體事實」「觀察」「結論」之類的段落標籤),一律使用 `#### ` 開頭的四級標題語法,不可用「**粗體**」取代標題。
|
| 374 |
3. `**粗體**` 只能用於一般敘述中的強調字詞,不可整行只放一個粗體片語來充當標題。
|
| 375 |
4. 表格請使用標準 Markdown 表格語法(`| 欄位 | 欄位 |` 搭配 `|---|---|` 分隔列)。
|
| 376 |
+
5. 量化分析結果 JSON 裡的英文鍵名(例如 rf_accuracy、rf_accuracy_is_meaningful、HC_Cluster、
|
| 377 |
+
KM_Cluster 等)是給程式讀取用的變數名稱,敘述時一律換成中文說法(例如「隨機森林分類準確率」
|
| 378 |
+
「此準確率是否具備樣本外預測意義」「階層式分群」「K-means 分群」),不得原樣把英文鍵名寫進
|
| 379 |
+
報告內文;指標代碼(如 AF_36、AC_01 這類)例外,這些代碼本身就是報告的一部分,要保留。
|
| 380 |
|
| 381 |
# 通用輸出結構
|
| 382 |
+
輸出必須包含以下五個區塊,缺一不可:
|
| 383 |
+
|
| 384 |
+
## 一、語料說明
|
| 385 |
+
用 2-4 句話摘要上方【譯本 metadata】提供的原文資訊、群組層與個體層背景(例如原文標題、各組別的翻譯類型/方向、個別譯本的譯者或系統名稱等,視實際提供的內容而定)。
|
| 386 |
+
若 metadata 顯示使用者略過未填,僅陳述「使用者未提供語料補充說明」,不得臆測或杜撰內容。這個區塊只做摘要陳述,不做任何統計分析或推論。
|
| 387 |
+
|
| 388 |
+
## 二、數據事實描述
|
| 389 |
+
本區塊須包含以下三類數據事實,缺一不可(量化分析結果 JSON 裡都有對應資料,不得因為篇幅省略):
|
| 390 |
+
1. 差異性分析:每項陳述除了指標代碼、p 值與效果量之外,**必須明確指出各組別中平均數最高與
|
| 391 |
+
最低的是哪一組,並附上實際平均數數值**(difference_analysis 資料裡每個組別都有一欄以該組
|
| 392 |
+
名稱結尾 _M 的平均數欄位)——只寫 p 值與效果量、不寫是哪一組偏高偏低,讀者無法理解差異的
|
| 393 |
+
方向與意義。
|
| 394 |
+
三組以上時是用 ANOVA 檢定,效果量(η²)本身不帶方向性(不像兩組時的 Cohen's d 還有正負號
|
| 395 |
+
可看出方向),更不能省略「哪一組最高、哪一組最低」這句話。
|
| 396 |
+
格式範例(三組以上):「總句數(AA_06):p=0.0068,η²=0.9932(大);2024 年組別最高
|
| 397 |
+
(M=32.5),1989 年組別最低(M=18.2)。」
|
| 398 |
+
區分:達統計顯著的差異(標明 p 值、效果量、最高與最低組別及平均數)、未達顯著但數值差距
|
| 399 |
+
較大的趨勢(同樣要指出方向)、無明顯差異的指標。
|
| 400 |
+
2. 監督式學習(隨機森林):說明 feature_importance_top20 裡重要性最高的 3-5 項指標與其重要性數值。
|
| 401 |
+
同時陳述「隨機森林分類準確率」的數字,並原樣附上 JSON 裡「隨機森林準確率之樣本外預測意義」
|
| 402 |
+
這欄給的完整句子(那句話已經是可以直接使用的中文敘述,不必再改寫或省略)。
|
| 403 |
+
3. 非監督式學習(分群):說明 cluster_results 裡階層式分群(HC_Cluster)與 K-means 分群
|
| 404 |
+
(KM_Cluster)的分群結果,是否與原始分組一致,若不一致明確指出差異所在。提及「原始分組」
|
| 405 |
+
時只用這個中文說法即可,不要在後面加註英文欄名。
|
| 406 |
|
| 407 |
{scenario_module}
|
| 408 |
|
| 409 |
+
## 四、待驗證事項
|
| 410 |
- 需研究者回到原文比對的具體項目
|
| 411 |
- 數據中的異常值或可疑模式
|
| 412 |
- 本分析無法處理的面向
|
| 413 |
|
| 414 |
+
## 五、本次分析的已知限制
|
| 415 |
明確列出:分詞系統潛在誤差、樣本量對統計檢定的影響、指標本身的理論侷限。"""
|
| 416 |
|
| 417 |
# ── matplotlib 中文字型 ──────────────────────────────────────────────────────
|
|
|
|
| 567 |
if ratio < 0.70:
|
| 568 |
return False, (
|
| 569 |
f"token 中含底線(詞_詞性)的比例僅 {ratio:.0%},"
|
| 570 |
+
"不符合語料淬鍊(CorpusRefinery)的輸出格式。"
|
| 571 |
)
|
| 572 |
if not has_ckip_signature:
|
| 573 |
+
return False, "未偵測到 CKIP 詞性標記,可能非語料淬鍊(CorpusRefinery)產生的檔案。"
|
| 574 |
|
| 575 |
+
return True, "格式符合語料淬鍊(CorpusRefinery)輸出。"
|
| 576 |
|
| 577 |
|
| 578 |
# ============================================================================
|
|
|
|
| 940 |
return "大" if val>=0.14 else "中" if val>=0.06 else "小" if val>=0.01 else "極小"
|
| 941 |
|
| 942 |
|
| 943 |
+
def run_statistical_analysis(csv_path: Path, output_dir: Path, group_labels: dict = None,
|
| 944 |
+
individual_grouping: dict = None):
|
| 945 |
"""
|
| 946 |
執行完整統計分析(敘述統計、差異分析、隨機森林、分群)。
|
| 947 |
+
group_labels:{分組代碼: 語料說明填寫的實際標籤} 選填,只影響箱形圖的
|
| 948 |
+
X 軸刻度文字(見下方 boxplot 區塊),不影響任何計算結果。
|
| 949 |
+
individual_grouping:{檔名: 個體間比較的分組標籤} 選填,2026-08-15
|
| 950 |
+
業主要求新增——「語料說明」選「個體間比較」時,用個體層資訊填的
|
| 951 |
+
「譯者/系統名稱」取代預設的檔名字首分組,這個 dict 非空時會真的
|
| 952 |
+
改變 Type1(見下面),因此會連動影響差異性分析、隨機森林、分群
|
| 953 |
+
全部的分組依據,不像 group_labels 只是換個顯示用的名字。沒填的
|
| 954 |
+
檔案自動退回用檔名字首分組(業主拍板的容錯方式)。
|
| 955 |
回傳 dict 包含所有輸出檔路徑與摘要資訊。
|
| 956 |
"""
|
| 957 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 958 |
|
| 959 |
# ── 1. 讀入資料 ────────────────────────────────────────────────────────────
|
| 960 |
df = pd.read_csv(str(csv_path))
|
| 961 |
+
# 2026-08-14:業主要求隱藏的 13 項指標(見 HIDDEN_INDICATOR_CODES 定義處
|
| 962 |
+
# 註解)在這裡就濾掉,之後敘述統計/差異分析/隨機森林/分群全部從這份
|
| 963 |
+
# df 衍生,一次過濾即可讓下游全部不再看到這 13 項,不用每個子區塊各自擋。
|
| 964 |
+
df = _drop_hidden_indicators(df)
|
| 965 |
|
| 966 |
df['Type1'] = df['Text'].str[0].str.upper()
|
| 967 |
+
# 2026-08-15(業主要求「個體間比較」要有實際效果):individual_grouping
|
| 968 |
+
# 非空時,用它取代上面那行檔名字首分組——每個檔案若在「個體層資訊」
|
| 969 |
+
# 填了「譯者/系統名稱」,Type1 就改用那個值;沒填的檔案維持原本的
|
| 970 |
+
# 檔名字首(.map 對應不到的位置是 NaN,用 .fillna 退回原值,不是整批
|
| 971 |
+
# 放棄)。這一改,後面的差異性分析、隨機森林、分群全部自動跟著用新
|
| 972 |
+
# 分組依據算,不用另外改下游程式碼。
|
| 973 |
+
if individual_grouping:
|
| 974 |
+
custom_group = df['Text'].astype(str).str.strip().map(individual_grouping)
|
| 975 |
+
df['Type1'] = custom_group.fillna(df['Type1'])
|
| 976 |
df['Type2'] = df['Type1']
|
| 977 |
GROUP_COL = 'Type1'
|
| 978 |
N_CLUSTERS = max(2, len(df['Type1'].unique()))
|
|
|
|
| 1066 |
diff_df.to_csv(str(diff_path), index=False, encoding='utf-8-sig')
|
| 1067 |
|
| 1068 |
# boxplot(最多 12 個顯著指標)
|
| 1069 |
+
# 2026-08-14(業主要求):原本取前 12 項是照指標在資料表裡的固定欄位
|
| 1070 |
+
# 順序(基礎統計→...→詞性標記)往下數,不是「最顯著/效果量最大」的
|
| 1071 |
+
# 12 項。改成先依效果量絕對值由大到小排序,再取前 12——畫出來的會是
|
| 1072 |
+
# 這次分析裡差異最明顯的那幾項,資訊量比原本的順序高。
|
| 1073 |
box_path = None
|
| 1074 |
if len(sig_df) > 0:
|
| 1075 |
+
sig_df_sorted = sig_df.reindex(
|
| 1076 |
+
sig_df['效果量'].abs().sort_values(ascending=False).index
|
| 1077 |
+
)
|
| 1078 |
+
sig_cols = sig_df_sorted['指標'].tolist()[:12]
|
| 1079 |
n_row = max(1, (len(sig_cols) + 2) // 3)
|
| 1080 |
fig, axes = plt.subplots(n_row, 3, figsize=(14, 4*n_row))
|
| 1081 |
axes = np.array(axes).flatten()
|
| 1082 |
+
# 群組軸標籤:語料說明填過的實際標籤(如「人工翻譯」)優先於原始
|
| 1083 |
+
# 分組代碼(A/B),group_labels 由呼叫端(run_computation_pipeline_
|
| 1084 |
+
# streaming)從 state["group_labels"] 傳入,沒填就退回代碼本身。
|
| 1085 |
+
group_labels = group_labels or {}
|
| 1086 |
+
group_tick_labels = [group_labels.get(g, g) for g in groups]
|
| 1087 |
for i, col in enumerate(sig_cols):
|
| 1088 |
ax = axes[i]
|
| 1089 |
pd_data = [df.loc[df[GROUP_COL]==g, col].dropna().values for g in groups]
|
| 1090 |
ax.boxplot(pd_data)
|
| 1091 |
ax.set_xticks(range(1, len(groups) + 1))
|
| 1092 |
+
ax.set_xticklabels(group_tick_labels)
|
| 1093 |
+
# 2026-08-15(業主要求):小標題原本只有 p 值,加上效果量(連同
|
| 1094 |
+
# 名稱,例如 Cohen's d 或 η²,兩種方法的效果量單位不一樣,只寫
|
| 1095 |
+
# 數字沒名稱會誤讀)一起顯示,一眼就能看出「差多少」不只是
|
| 1096 |
+
# 「有沒有差」。
|
| 1097 |
+
row = diff_df.loc[diff_df['指標']==col]
|
| 1098 |
+
pv_str = f"p={row['p值'].values[0]:.4f}" if len(row)>0 else ""
|
| 1099 |
+
es_str = (f",{row['效果量名稱'].values[0]}={row['效果量'].values[0]:.3f}"
|
| 1100 |
+
if len(row)>0 else "")
|
| 1101 |
+
ax.set_title(f"{col}\n({pv_str}{es_str})", fontsize=8)
|
| 1102 |
for j in range(len(sig_cols), len(axes)):
|
| 1103 |
axes[j].set_visible(False)
|
| 1104 |
+
# 2026-08-14(業主要求):原本「Significant Variables by Type1」直接
|
| 1105 |
+
# 洩漏內部欄位名稱 Type1,使用者看不懂在比較什麼。改成中文、講清楚
|
| 1106 |
+
# 這張圖是「前 12 項+依效果量排序」,不是全部顯著指標。
|
| 1107 |
+
plt.suptitle("各組別顯著指標比較(前 12 項,依效果量排序)", fontsize=11, y=1.01)
|
| 1108 |
plt.tight_layout()
|
| 1109 |
box_path = output_dir / "02_significant_boxplots.png"
|
| 1110 |
plt.savefig(str(box_path), dpi=150, bbox_inches='tight')
|
|
|
|
| 1140 |
top_n = min(20, len(imp_df))
|
| 1141 |
fig, ax = plt.subplots(figsize=(10, 6))
|
| 1142 |
ax.barh(imp_df['指標'][:top_n][::-1], imp_df['重要性'][:top_n][::-1], color='steelblue')
|
| 1143 |
+
ax.set_xlabel("特徵重要性")
|
| 1144 |
+
ax.set_title(f"隨機森林 — 前 {top_n} 項重要特徵")
|
| 1145 |
plt.tight_layout()
|
| 1146 |
imp_fig_path = output_dir / "03_feature_importance.png"
|
| 1147 |
plt.savefig(str(imp_fig_path), dpi=150, bbox_inches='tight')
|
|
|
|
| 1157 |
linkage_mat = linkage(X_scaled, method='ward')
|
| 1158 |
fig, ax = plt.subplots(figsize=(10, 5))
|
| 1159 |
dendrogram(linkage_mat, labels=df['Text'].values, leaf_rotation=45, leaf_font_size=8, ax=ax)
|
| 1160 |
+
ax.set_title("階層式分群樹狀圖(Ward 法)")
|
| 1161 |
+
# 2026-08-15(業主問「Sample 是什麼意思」):橫軸的每一格代表一個被
|
| 1162 |
+
# 分群的文本樣本(下方會列出各檔名),改成中文「樣本」,不用再解釋。
|
| 1163 |
+
ax.set_xlabel("樣本"); ax.set_ylabel("距離")
|
| 1164 |
plt.tight_layout()
|
| 1165 |
dendro_path = output_dir / "04a_dendrogram.png"
|
| 1166 |
plt.savefig(str(dendro_path), dpi=150, bbox_inches='tight')
|
|
|
|
| 1178 |
inertia.append(km.inertia_)
|
| 1179 |
fig, ax = plt.subplots(figsize=(7, 4))
|
| 1180 |
ax.plot(list(range(1, max_k+1)), inertia, marker='o', color='coral')
|
| 1181 |
+
ax.set_xlabel("分群數量(K)"); ax.set_ylabel("群內誤差平方和(SSE)")
|
| 1182 |
+
ax.set_title("K-means 手肘圖"); ax.set_xticks(list(range(1, max_k+1)))
|
| 1183 |
plt.tight_layout()
|
| 1184 |
elbow_path = output_dir / "04b_kmeans_elbow.png"
|
| 1185 |
plt.savefig(str(elbow_path), dpi=150, bbox_inches='tight')
|
|
|
|
| 1192 |
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
|
| 1193 |
for ax, lcol, title in zip(axes,
|
| 1194 |
['HC_Cluster','KM_Cluster'],
|
| 1195 |
+
['階層式分群', f'K-means 分群(K={N_CLUSTERS})']):
|
| 1196 |
ulabels = sorted(df[lcol].unique())
|
| 1197 |
colors = plt.cm.tab10(np.linspace(0, 1, len(ulabels)))
|
| 1198 |
for idx, cl in enumerate(ulabels):
|
| 1199 |
mask = df[lcol] == cl
|
| 1200 |
ax.scatter(X_pca[mask,0], X_pca[mask,1],
|
| 1201 |
+
c=[colors[idx]], label=f"群組 {cl}", s=100, edgecolors='k')
|
| 1202 |
for i, txt in enumerate(df['Text']):
|
| 1203 |
ax.annotate(txt[:8], (X_pca[i,0], X_pca[i,1]),
|
| 1204 |
fontsize=7, textcoords='offset points', xytext=(5,3))
|
| 1205 |
+
ax.set_xlabel(f"主成分一({pca.explained_variance_ratio_[0]:.1%})" if X_scaled.shape[1]>=2 else "主成分一")
|
| 1206 |
+
ax.set_ylabel(f"主成分二({pca.explained_variance_ratio_[1]:.1%})" if X_scaled.shape[1]>=2 else "主成分二")
|
| 1207 |
ax.set_title(title); ax.legend()
|
| 1208 |
+
plt.suptitle("分群結果(主成分分析二維投影)", fontsize=12)
|
| 1209 |
plt.tight_layout()
|
| 1210 |
cluster_fig_path = output_dir / "04c_clustering_pca2d.png"
|
| 1211 |
plt.savefig(str(cluster_fig_path), dpi=150, bbox_inches='tight')
|
| 1212 |
plt.close()
|
| 1213 |
|
| 1214 |
# 分群結果儲存
|
| 1215 |
+
# 2026-08-15(業主回報「分群結果表格有個看不懂的 Type1.1 欄位」,追查
|
| 1216 |
+
# 後發現):GROUP_COL 這個變數的值就是字串 'Type1'(見上面第 887 行),
|
| 1217 |
+
# 這裡卻又把 'Type1' 跟 GROUP_COL 兩個都放進 result_cols,等於同一欄
|
| 1218 |
+
# 選了兩次;df[result_cols] 選出來的 DataFrame 因此有兩欄同名 Type1,
|
| 1219 |
+
# 存成 CSV 後欄名衝突,pandas 存檔/讀回的慣例是把重複欄名的第二個
|
| 1220 |
+
# 加上 ".1" 後綴避免衝突——「Type1.1」就是這樣長出來的,內容跟 Type1
|
| 1221 |
+
# 一模一樣,不是新資訊,純粹是選重複欄位的 bug。拿掉重複的 GROUP_COL
|
| 1222 |
+
# 就好,Type1 本身(真正的分組欄)保留。
|
| 1223 |
+
result_cols = ['Text','Type1','Type2','HC_Cluster','KM_Cluster']
|
| 1224 |
result_cols = [c for c in result_cols if c in df.columns]
|
| 1225 |
cluster_csv_path = output_dir / "05_cluster_results.csv"
|
| 1226 |
+
# 2026-08-15(業主要求):使用者下載這份 CSV 直接看到 Type1/Type2 也
|
| 1227 |
+
# 反映同樣的看不懂問題(先前只改了畫面顯示的複本,見 _populate_tabs
|
| 1228 |
+
# 裡的同名 rename 邏輯),這次直接在存檔時就處理,下載到的檔案本身
|
| 1229 |
+
# 就是乾淨版本,不用開了 Excel 才發現欄名還是英文技術代碼。Type2
|
| 1230 |
+
# 拿掉(一直是 Type1 的原樣複製,無額外資訊)、Type1 改標成「原始
|
| 1231 |
+
# 分組(A/B)」。
|
| 1232 |
+
cluster_export_df = (
|
| 1233 |
+
df[result_cols]
|
| 1234 |
+
.drop(columns=["Type2"], errors="ignore")
|
| 1235 |
+
.rename(columns={"Type1": "原始分組(A/B)"})
|
| 1236 |
+
)
|
| 1237 |
+
cluster_export_df.to_csv(str(cluster_csv_path), index=False, encoding='utf-8-sig')
|
| 1238 |
|
| 1239 |
# ── 6. 產出 docx 技術報告 ──────────────────────────────────────────────────
|
| 1240 |
timestamp = now_tw().strftime("%Y%m%d_%H%M%S")
|
|
|
|
| 1287 |
# 差異性分析
|
| 1288 |
doc.add_page_break()
|
| 1289 |
doc.add_heading('二、差異性分析', level=1)
|
| 1290 |
+
# 2026-08-15:{GROUP_COL} 原本會直接印出內部欄位名稱「Type1」,跟先前
|
| 1291 |
+
# 修過的 Type1.1/rf_accuracy_is_meaningful 是同一種��技術變數名稱外
|
| 1292 |
+
# 洩漏」問題,改成不點名欄位、只講語意。
|
| 1293 |
+
doc.add_paragraph(f'分組依據:語料分組(共 {n_groups} 組)。採用 {method_name},顯著水準 α = {ALPHA}。')
|
| 1294 |
doc.add_paragraph(f'共 {len(diff_df)} 個指標,其中 {len(sig_df)} 個達顯著差異(p < {ALPHA})。')
|
| 1295 |
+
# 2026-08-15(業主要求):表格欄位裡的 N/M/SD 是統計慣用縮寫,第一次
|
| 1296 |
+
# 看到報告的人不一定認得,加一句說明放在表格正上方。
|
| 1297 |
+
note_p = doc.add_paragraph('表格中 N=樣本數、M=平均數、SD=標準差。')
|
| 1298 |
+
note_p.runs[0].italic = True
|
| 1299 |
+
note_p.runs[0].font.size = Pt(8)
|
| 1300 |
group_sub_headers = []
|
| 1301 |
for g in groups:
|
| 1302 |
group_sub_headers.extend([f"{g}\nN", f"{g}\nM", f"{g}\nSD"])
|
|
|
|
| 1375 |
"sig_count": len(sig_df),
|
| 1376 |
"total_indicators": len(diff_df),
|
| 1377 |
"accuracy": accuracy,
|
| 1378 |
+
"do_eval": do_eval,
|
| 1379 |
"groups": list(groups),
|
| 1380 |
"n_groups": n_groups,
|
| 1381 |
"method_name": method_name,
|
|
|
|
| 1388 |
# SECTION 7: LLM Report Writing (adapted from prog04, uses LLM1)
|
| 1389 |
# ============================================================================
|
| 1390 |
|
| 1391 |
+
def load_analysis_data_for_llm(csv_path: Path, output_dir: Path, ml_summary: dict = None) -> dict:
|
| 1392 |
+
"""讀取分析資料組成 JSON 結構。
|
| 1393 |
+
ml_summary:{"accuracy": float, "do_eval": bool} 選填,來自
|
| 1394 |
+
run_statistical_analysis() 的回傳值。之前這裡完全沒有把隨機森林的
|
| 1395 |
+
準確率數字(accuracy)跟「這次是不是真的切了測試集」(do_eval)
|
| 1396 |
+
交給 LLM,LLM 因此連準確率是多少都不知道,等於沒有材料可以討論
|
| 1397 |
+
監督式學習結果——03_feature_importance.csv 只有各指標的重要性排名,
|
| 1398 |
+
沒有整體準確率這個數字。"""
|
| 1399 |
data = {}
|
| 1400 |
+
if ml_summary:
|
| 1401 |
+
# 2026-08-15(業主回報):原本這裡是英文鍵名 rf_accuracy/
|
| 1402 |
+
# rf_accuracy_is_meaningful,靠 system prompt 那條「英文鍵名要
|
| 1403 |
+
# 換成中文說法」的規則要求 LLM 自己翻譯,結果 LLM 沒穩定照辦,
|
| 1404 |
+
# 報告裡直接印出「由於 rf_accuracy_is_meaningful 為 true...」這種
|
| 1405 |
+
# 變數名稱。改成在這裡把「是否具樣本外預測意義」直接寫成完整的
|
| 1406 |
+
# 中文句子存進 JSON——LLM 就算照抄 JSON 內容,抄出來的也已經是
|
| 1407 |
+
# 人看得懂的句子,不用依賴它額外做翻譯這個不保證會做到的步驟。
|
| 1408 |
+
acc = ml_summary.get("accuracy")
|
| 1409 |
+
data["隨機森林分類準確率"] = round(acc, 4) if isinstance(acc, (int, float)) else acc
|
| 1410 |
+
data["隨機森林準確率之樣本外預測意義"] = (
|
| 1411 |
+
"具備樣本外預測意義(本次有切分獨立的測試集)"
|
| 1412 |
+
if ml_summary.get("do_eval")
|
| 1413 |
+
else "不具備樣本外預測意義(本次樣本數過小,訓練集與測試集為同一批資料,"
|
| 1414 |
+
"此準確率僅反映模型對訓練資料本身的擬合程度)"
|
| 1415 |
+
)
|
| 1416 |
if csv_path.exists():
|
| 1417 |
raw = pd.read_csv(str(csv_path))
|
| 1418 |
aj_cols = [c for c in raw.columns if c.startswith('AJ_')]
|
|
|
|
| 1431 |
p = output_dir / fname
|
| 1432 |
if p.exists():
|
| 1433 |
d = pd.read_csv(str(p))
|
| 1434 |
+
if key == "cluster_results":
|
| 1435 |
+
# 2026-08-15(業主要求):跟 rf_accuracy_is_meaningful 同一個
|
| 1436 |
+
# 教訓——原本這裡的欄名是 Type1,LLM 直接照抄,報告寫成
|
| 1437 |
+
# 「原始分組(Type1)」。改成在資料層就把 Type1 換成「原始
|
| 1438 |
+
# 分組」這個中文欄名,Type2 拿掉(一直是 Type1 的原樣複製,
|
| 1439 |
+
# 沒有額外資訊,見 run_statistical_analysis 那行 df['Type2']
|
| 1440 |
+
# = df['Type1'] 的註解),LLM 照抄 JSON 內容時自然就只會
|
| 1441 |
+
# 寫「原始分組」,不會再冒出 Type1 這個技術欄名。
|
| 1442 |
+
d = d.drop(columns=["Type2"], errors="ignore") \
|
| 1443 |
+
.rename(columns={"Type1": "原始分組"})
|
| 1444 |
if key == "feature_importance_top20":
|
| 1445 |
data[key] = d.head(20).to_dict(orient='records')
|
| 1446 |
else:
|
|
|
|
| 1459 |
return data
|
| 1460 |
|
| 1461 |
|
| 1462 |
+
# 2026-08-14(待辦 A1,業主拍板):LLM 報告存成 docx 時,原本的迴圈只認
|
| 1463 |
+
# 區塊層級語法(# 標題/- 項目符號),一行裡的 **粗體**、$LaTeX$ 這類行內
|
| 1464 |
+
# 語法會原封不動貼成文字。這裡補一個行內語法解析:
|
| 1465 |
+
# **文字** → ��體 run
|
| 1466 |
+
# $文字$ → 斜體 run(拆掉 $ 符號;docx 沒有內建 LaTeX 公式排版,
|
| 1467 |
+
# 真要轉成 Word 公式物件(OMML)得另外導入 latex2mathml
|
| 1468 |
+
# 之類的套件,複雜度與風險都高,業主拍板先用「斜體純文字」
|
| 1469 |
+
# 這個較簡單、較不會转換失敗的做法)
|
| 1470 |
+
# 非貪婪 regex 一次切出 **...** 或 $...$ 區段,其餘原樣輸出;兩種語法
|
| 1471 |
+
# 不處理巢狀(例如 **$x$** 或 $**x**$),這份報告的 LLM 輸出目前沒有
|
| 1472 |
+
# 這種寫法,不特別處理以免過度工程化。
|
| 1473 |
+
_INLINE_MD_RE = re.compile(r'(\*\*.+?\*\*|\$.+?\$)')
|
| 1474 |
+
|
| 1475 |
+
|
| 1476 |
+
def _add_inline_markdown_runs(paragraph, text: str) -> None:
|
| 1477 |
+
"""把一行文字依 **粗體** / $LaTeX$ 語法拆成多個 run,加進既有段落。"""
|
| 1478 |
+
for piece in _INLINE_MD_RE.split(text):
|
| 1479 |
+
if not piece:
|
| 1480 |
+
continue
|
| 1481 |
+
if piece.startswith('**') and piece.endswith('**') and len(piece) >= 4:
|
| 1482 |
+
paragraph.add_run(piece[2:-2]).bold = True
|
| 1483 |
+
elif piece.startswith('$') and piece.endswith('$') and len(piece) >= 2:
|
| 1484 |
+
paragraph.add_run(piece[1:-1]).italic = True
|
| 1485 |
+
else:
|
| 1486 |
+
paragraph.add_run(piece)
|
| 1487 |
+
|
| 1488 |
+
|
| 1489 |
def run_report_writing(scenario_key: str, user_metadata: str, user_focus: str,
|
| 1490 |
+
csv_path: Path, output_dir: Path, ml_summary: dict = None) -> dict:
|
| 1491 |
"""
|
| 1492 |
呼叫主 Agent (LLM1) 撰寫研究報告,儲存 docx + txt,回傳路徑 dict。
|
| 1493 |
+
ml_summary:見 load_analysis_data_for_llm() 的說明。
|
| 1494 |
"""
|
| 1495 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 1496 |
+
analysis_data = load_analysis_data_for_llm(csv_path, output_dir, ml_summary=ml_summary)
|
| 1497 |
|
| 1498 |
scenario_info = SCENARIOS.get(scenario_key, SCENARIOS["1"])
|
| 1499 |
scenario_mod_key = scenario_info.get("scenario_module_key", "scenario_1")
|
|
|
|
| 1515 |
f"請根據以下量化分析結果,按照 system prompt 中的輸出結構進行深度分析。\n\n"
|
| 1516 |
f"【量化分析資料(JSON)】:\n```json\n"
|
| 1517 |
f"{json.dumps(analysis_data, ensure_ascii=False, indent=2)}\n```\n\n"
|
| 1518 |
+
"請嚴格按照五個區塊(一、語料說明 / 二、數據事實描述 / "
|
| 1519 |
+
"三、情境特定分析 / 四、待驗證事項 / 五、已知限制)輸出分析報告。"
|
| 1520 |
)
|
| 1521 |
|
| 1522 |
result_text = call_main_agent(system_prompt, user_prompt)
|
|
|
|
| 1543 |
doc.add_paragraph(f'報告時間:{now_tw().strftime("%Y-%m-%d %H:%M:%S")}(台灣時間)',
|
| 1544 |
style='Normal').alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 1545 |
doc.add_page_break()
|
| 1546 |
+
# 2026-08-14(待辦 A1):區塊層級語法(標題/項目符號)沿用原本判斷,
|
| 1547 |
+
# 差別是段落文字改用 _add_inline_markdown_runs() 逐段解析 **粗體**/
|
| 1548 |
+
# $LaTeX$,不再是整行原樣塞給 add_heading()/add_paragraph()。
|
| 1549 |
for line in result_text.split('\n'):
|
| 1550 |
stripped = line.strip()
|
| 1551 |
+
if stripped.startswith('#### '): _add_inline_markdown_runs(doc.add_heading('', level=4), stripped[5:])
|
| 1552 |
+
elif stripped.startswith('### '): _add_inline_markdown_runs(doc.add_heading('', level=3), stripped[4:])
|
| 1553 |
+
elif stripped.startswith('## '): _add_inline_markdown_runs(doc.add_heading('', level=2), stripped[3:])
|
| 1554 |
+
elif stripped.startswith('# '): _add_inline_markdown_runs(doc.add_heading('', level=1), stripped[2:])
|
| 1555 |
+
elif stripped.startswith('- '): _add_inline_markdown_runs(doc.add_paragraph(style='List Bullet'), stripped[2:])
|
| 1556 |
+
elif stripped.startswith('* '): _add_inline_markdown_runs(doc.add_paragraph(style='List Bullet'), stripped[2:])
|
| 1557 |
+
elif stripped: _add_inline_markdown_runs(doc.add_paragraph(), stripped)
|
| 1558 |
doc.save(str(docx_path))
|
| 1559 |
|
| 1560 |
# 存 txt(模型標註置於標題下方)
|
|
|
|
| 1622 |
"zip_paths": {},
|
| 1623 |
"user_metadata": "",
|
| 1624 |
"user_focus": "",
|
| 1625 |
+
"group_labels": {}, # {分組代碼: 語料說明填的實際標籤},略過或未填時維持空 dict
|
| 1626 |
+
"individual_grouping": {}, # {檔名: 個體間比較的分組標籤},非「個體間比較」模式時維持空 dict
|
| 1627 |
"analysis_result": None,
|
| 1628 |
"done_steps": [], # 已完成步驟(1-based)
|
| 1629 |
"current_step": None, # 目前執行步驟
|
|
|
|
| 1675 |
# on_scenario_pick)。原本①提到「情境選擇搬去首頁後不算步驟列一步」在此
|
| 1676 |
# 已不成立,_STEPS 從 3 步變 4 步,後面三步的編號全部 +1。
|
| 1677 |
_STEPS = [
|
| 1678 |
+
(1, "情境選擇"),
|
| 1679 |
(2, "文本上傳"),
|
| 1680 |
(3, "語料說明"),
|
| 1681 |
(4, "分析運算"),
|
|
|
|
| 1792 |
shutil.copy2(str(src), str(dst))
|
| 1793 |
saved_paths.append(str(dst))
|
| 1794 |
|
| 1795 |
+
# 2026-08-15(業主回報「上傳5個刪除2個,語料說明跟計算卻還是用5個」,
|
| 1796 |
+
# 追查後發現):這裡原本是跟 state["uploaded_files"] 合併,不是直接
|
| 1797 |
+
# 覆蓋——原意是想支援「格式驗證失敗時只需補傳有問題的檔案」,但
|
| 1798 |
+
# Gradio 的 file_input 元件送過來的 files 只反映「使用者目前選取的
|
| 1799 |
+
# 檔案是哪些」,沒辦法分辨「這次沒選這個檔案」到底是「使用者刻意刪除
|
| 1800 |
+
# 不要了」還是「使用者沒有要動它、單純沒重新選」——合併邏輯把兩種
|
| 1801 |
+
# 都當成後者處理,使用者刪除的檔案因此被「復活」跟這次選的檔案合併,
|
| 1802 |
+
# 送進下游計算的數量比使用者實際想要的多。
|
| 1803 |
+
# 改成直接覆蓋,不合併:file_input 元件本身在「格式驗證失敗、退回
|
| 1804 |
+
# 上傳畫面」之後不會被清空(見 on_upload_confirm 的 has_errors 分支,
|
| 1805 |
+
# outputs 沒有動 file_input),使用者原本選的檔案(含通過驗證的)
|
| 1806 |
+
# 仍留在畫面上,只要把修正後的檔案加回同一次選取、不用動已經沒問題
|
| 1807 |
+
# 的檔案,「只補傳壞掉的那幾個」這個使用情境本來就能透過 Gradio 元件
|
| 1808 |
+
# 自身的多檔選取狀態達成,不需要在後端另外合併——後端合併反而是這次
|
| 1809 |
+
# bug 的成因,拿掉之後「使用者當下選了什麼,就是什麼」,語意才不會
|
| 1810 |
+
# 跟畫面上看到的檔案清單兜不起來。
|
| 1811 |
+
state["uploaded_files"] = saved_paths
|
| 1812 |
state["state"] = "FORMAT_CHECK"
|
| 1813 |
|
| 1814 |
# 工作程序 Agent 說明開始檢查
|
|
|
|
| 1858 |
present_str = "、".join(sorted(present_prefixes)) or "無"
|
| 1859 |
missing_str = "、".join(sorted(missing))
|
| 1860 |
msg = (
|
| 1861 |
+
f"⚠️ 請確認 {'、'.join(sorted(required_prefixes))} 兩組皆已上傳,"
|
| 1862 |
f"目前偵測到:{present_str},缺少:{missing_str}。"
|
| 1863 |
)
|
| 1864 |
state["state"] = "UPLOAD_WAIT"
|
|
|
|
| 1868 |
state["cleaned_files"] = state["uploaded_files"]
|
| 1869 |
sys_p = (
|
| 1870 |
"你是重譯文本分析系統的工作程序 Agent,"
|
| 1871 |
+
"請用繁體中文告知使用者所有檔案已確認為語料淬鍊(CorpusRefinery)斷詞格式,"
|
| 1872 |
"即將開始計算指標,30 字以內。"
|
| 1873 |
)
|
| 1874 |
try:
|
| 1875 |
msg = call_worker_agent(sys_p, "所有檔案格式驗證通過。")
|
| 1876 |
except:
|
| 1877 |
+
msg = "[OK] 所有檔案均符合語料淬鍊(CorpusRefinery)斷詞格式,即將開始計算文本指標……"
|
| 1878 |
return msg, state, False
|
| 1879 |
|
| 1880 |
# 有檔案不符(格式或字首)
|
| 1881 |
fail_list = "\n".join([f"- **{fn}**:{reason}" for fn, reason in failed.items()])
|
| 1882 |
sys_p = (
|
| 1883 |
"你是重譯文本分析系統的工作程序 Agent,請用繁體中文告知使用者:"
|
| 1884 |
+
"部分檔案不符合語料淬鍊(CorpusRefinery)斷詞工具的輸出格式,"
|
| 1885 |
"必須重新使用該工具斷詞後再上傳,語氣清晰,80 字��內。"
|
| 1886 |
+
"請附上工具連結:https://github.com/minchun-git/I_CorpusRefinery"
|
| 1887 |
)
|
| 1888 |
try:
|
| 1889 |
msg = call_worker_agent(sys_p, f"格式不符的檔案:\n{fail_list}")
|
| 1890 |
msg = msg + f"\n\n{fail_list}"
|
| 1891 |
except:
|
| 1892 |
msg = (
|
| 1893 |
+
f"⚠️ 以下檔案不符合語料淬鍊(CorpusRefinery)斷詞格式,請重新處理後再上傳:\n\n"
|
| 1894 |
f"{fail_list}\n\n"
|
| 1895 |
+
"請使用 [語料淬鍊(CorpusRefinery)](https://github.com/minchun-git/I_CorpusRefinery) "
|
| 1896 |
"工具完成斷詞後,重新上傳檔案。"
|
| 1897 |
)
|
| 1898 |
failed_names = set(failed.keys())
|
|
|
|
| 1914 |
("idx", "指標數據"),
|
| 1915 |
("stat", "統計圖表"),
|
| 1916 |
("report", "研究報告"),
|
| 1917 |
+
("all", "全部資料"),
|
| 1918 |
]
|
| 1919 |
|
| 1920 |
|
|
|
|
| 1976 |
state["cleaned_files"], output_dir, _RESOURCES
|
| 1977 |
)
|
| 1978 |
results_paths.extend([str(xlsx_path), str(csv_path)])
|
| 1979 |
+
# 2026-08-14:這裡顯示的「N 項語言指標」是進度列上的即時字樣,跟畫面
|
| 1980 |
+
# 其他地方一樣要扣掉隱藏指標,不能直接數 idx_df(指標計算的原始輸出,
|
| 1981 |
+
# 仍含完整 75 欄)的欄位數,否則這裡會跟其他分頁顯示的數字對不上。
|
| 1982 |
+
_idx_display_cols = _drop_hidden_indicators(idx_df).columns
|
| 1983 |
+
n_ind = max(len(_idx_display_cols) - sum(c in _idx_display_cols for c in ("Text","Type1","Type2")), 0)
|
| 1984 |
A["idx"]["status"] = "done"
|
| 1985 |
A["idx"]["line"] = f"{len(idx_df)} 個文本 × {n_ind} 項語言指標"
|
| 1986 |
yield f" [OK] 指標計算完成({len(idx_df)} 個文本)", False, state
|
|
|
|
| 1996 |
A["stat"]["status"] = "running"
|
| 1997 |
yield "【統計分析】正在執行統計分析……", False, state
|
| 1998 |
try:
|
| 1999 |
+
anal = run_statistical_analysis(
|
| 2000 |
+
csv_path, output_dir,
|
| 2001 |
+
group_labels=state.get("group_labels", {}),
|
| 2002 |
+
individual_grouping=state.get("individual_grouping", {}),
|
| 2003 |
+
)
|
| 2004 |
for key in ["desc_path","diff_path","imp_path","imp_fig_path",
|
| 2005 |
"box_path","dendro_path","elbow_path","cluster_fig_path",
|
| 2006 |
"cluster_csv_path","report_path"]:
|
|
|
|
| 2038 |
user_focus = state.get("user_focus", ""),
|
| 2039 |
csv_path = csv_path,
|
| 2040 |
output_dir = output_dir,
|
| 2041 |
+
ml_summary = {"accuracy": anal.get("accuracy"), "do_eval": anal.get("do_eval")},
|
| 2042 |
)
|
| 2043 |
results_paths.extend([str(rpt["docx_path"]), str(rpt["txt_path"])])
|
| 2044 |
A["report"]["status"] = "done"
|
|
|
|
| 2677 |
}
|
| 2678 |
if (_zonePendingCount[id] < 2) return;
|
| 2679 |
if (visible) {
|
| 2680 |
+
// 2026-08-15(業主回報「格式驗證通過切到語料說明時,畫面
|
| 2681 |
+
// 會往下移動一下」):behavior:'smooth' 會讓捲動變成一段
|
| 2682 |
+
// 看得見的滑動過程,使用者感受到的正是這段移動動畫本身,
|
| 2683 |
+
// 不是位置算錯。改成 'auto'(瀏覽器原生瞬間跳轉),新區塊
|
| 2684 |
+
// 一出現畫面就直接定在該處,不再有可感知的移動過程。
|
| 2685 |
+
zone.scrollIntoView({ behavior: 'auto', block: 'center' });
|
| 2686 |
} else {
|
| 2687 |
window.scrollTo({ top: 0, behavior: 'smooth' });
|
| 2688 |
}
|
|
|
|
| 2834 |
'<h1><span class="en">II</span> 分析引擎 <span class="en">SpectraEngine</span></h1>'
|
| 2835 |
# 1-F:描述文字砍掉原本結尾「請先選擇最符合您研究設計的比較情境:」
|
| 2836 |
# ——情境選擇已搬去工作區,首頁不用再提示選情境。
|
| 2837 |
+
f'<p class="lede">上傳已斷詞的譯本文本,系統將自動計算 <span class="en">{TOTAL_VISIBLE_INDICATORS}</span> 項語言指標並進行統計分析,'
|
| 2838 |
'協助您完成重譯研究的量化論證。</p>'
|
| 2839 |
'</div>'
|
| 2840 |
)
|
|
|
|
| 3121 |
elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary",
|
| 3122 |
)
|
| 3123 |
nav_dl_all_btn = gr.DownloadButton(
|
| 3124 |
+
"下載全部資料", icon=str(BASE_DIR / "assets" / "icons" / "download.svg"),
|
| 3125 |
visible=False, elem_id="nav_dl_all_btn",
|
| 3126 |
elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary",
|
| 3127 |
)
|
|
|
|
| 3257 |
# 標題+說明文字沿用 metadata_row 那則 Markdown 的寫法
|
| 3258 |
# (純 "### 標題\n說明文字",不套 elem_classes),維持
|
| 3259 |
# 同一批「Canvas 操作區塊」卡片的既有排版慣例。
|
| 3260 |
+
gr.Markdown("### 情境選擇\n請選擇最符合您研究設計的比較情境,選定後將進入下一步「文本上傳」。")
|
| 3261 |
gr.HTML(_build_scenario_cards_html("workspace_scenario_grid"))
|
| 3262 |
|
| 3263 |
# Phase 8(千卉實測後回頭調整):這張卡片改回正式使用
|
|
|
|
| 3328 |
value="群組間比較", scale=1,
|
| 3329 |
)
|
| 3330 |
|
| 3331 |
+
# 2026-08-15(業主回報「填了語料說明但沒生效」,追查後
|
| 3332 |
+
# 發現根因):原本這裡是 gr.Dataframe(row_count=
|
| 3333 |
+
# (1,"dynamic")),實測編輯過一格之後,其他格子會完全
|
| 3334 |
+
# 點不進去、無法再編輯(這版 Gradio 的 Dataframe 儲存格
|
| 3335 |
+
# 編輯器有這個問題,換 row_count 寫法/col_count 寫法都
|
| 3336 |
+
# 一樣)。改成三組獨立的 Textbox(最多支援三群,對應
|
| 3337 |
+
# meta_ngroups 的「二群/三群」上限),完全避開有問題的
|
| 3338 |
+
# Dataframe 儲存格編輯機制。三組固定宣告、依實際偵測到
|
| 3339 |
+
# 的分組數量切換顯示(見 _show_metadata_form),標題會
|
| 3340 |
+
# 動態顯示該組實際的分組代碼(例如「組別 A」),讓使用者
|
| 3341 |
+
# 清楚知道自己在填哪一組——這點也順便修掉另一個問題:
|
| 3342 |
+
# 原本 Dataframe 版本的資料送進 LLM 時沒有明確標示
|
| 3343 |
+
# 「這行屬於哪個分組代碼」,LLM 曾經因此把 A/B 的標籤
|
| 3344 |
+
# 講反。
|
| 3345 |
gr.Markdown("**群組層資訊**(共通屬性)")
|
| 3346 |
+
_META_GROUP_FIELD_LABELS = ["群組標籤", "翻譯類型", "翻譯方向", "譯者背景", "目標讀者"]
|
| 3347 |
+
meta_group_cols = []
|
| 3348 |
+
meta_group_heads = []
|
| 3349 |
+
meta_group_fields = [] # [[label,type,dir,bg,aud], ...] 每組一份
|
| 3350 |
+
for _gi in range(3):
|
| 3351 |
+
with gr.Column(visible=(_gi < 2)) as _gcol:
|
| 3352 |
+
_ghead = gr.Markdown(f"組別 {_gi + 1}")
|
| 3353 |
+
with gr.Row():
|
| 3354 |
+
_f_label = gr.Textbox(label="群組標籤", scale=1)
|
| 3355 |
+
_f_type = gr.Textbox(label="翻譯類型", scale=1)
|
| 3356 |
+
_f_dir = gr.Textbox(label="翻譯方向", scale=1)
|
| 3357 |
+
with gr.Row():
|
| 3358 |
+
_f_bg = gr.Textbox(label="譯者背景", scale=1)
|
| 3359 |
+
_f_aud = gr.Textbox(label="目標讀者", scale=1)
|
| 3360 |
+
meta_group_cols.append(_gcol)
|
| 3361 |
+
meta_group_heads.append(_ghead)
|
| 3362 |
+
meta_group_fields.append([_f_label, _f_type, _f_dir, _f_bg, _f_aud])
|
| 3363 |
+
|
| 3364 |
+
# 2026-08-15(業主要求):拿掉「後編輯程度」欄位,業主
|
| 3365 |
+
# 認為用不到。欄位索引 0(檔案)/1(譯者/系統名稱)
|
| 3366 |
+
# 沒有移動,_extract_individual_grouping() 讀的就是
|
| 3367 |
+
# 這兩欄,拿掉的是原本索引 4 的欄位,不用連動改那邊。
|
| 3368 |
gr.Markdown("**個體層資訊**(每個譯本/檔案)")
|
| 3369 |
meta_indiv_df = gr.Dataframe(
|
| 3370 |
+
headers=["檔案", "譯者/系統名稱", "出版年", "譯者母語", "重譯序位"],
|
| 3371 |
+
datatype=["str"] * 5,
|
| 3372 |
+
col_count=(5, "fixed"),
|
| 3373 |
row_count=(1, "dynamic"),
|
| 3374 |
interactive=True,
|
| 3375 |
label="個體層",
|
|
|
|
| 3425 |
interactive=False, visible=False,
|
| 3426 |
)
|
| 3427 |
# 子分頁 2:差異性分析
|
| 3428 |
+
# 2026-08-15(業主要求):排列順序改成「表格→下載
|
| 3429 |
+
# →箱形圖」,原本是「箱形圖→表格→下載」——把讀者
|
| 3430 |
+
# 最先要看的原始數據放最上面,下載按鈕緊接在表格
|
| 3431 |
+
# 後面,圖表當作視覺化補充放最後。元件本身(identity)
|
| 3432 |
+
# 沒變,只是宣告順序(等於畫面上下順序)調整,不影響
|
| 3433 |
+
# 任何其他 wiring。
|
| 3434 |
with gr.Tab("2. 差異性分析"):
|
| 3435 |
tab_diff_head = gr.Markdown(_EMPTY_HINT)
|
|
|
|
| 3436 |
tab_diff_df = gr.Dataframe(
|
| 3437 |
label="差異性分析",
|
| 3438 |
interactive=False, visible=False,
|
|
|
|
| 3441 |
label="下載差異性分析",
|
| 3442 |
interactive=False, visible=False,
|
| 3443 |
)
|
| 3444 |
+
tab_box_img = gr.Image(label="顯著指標箱形圖", visible=False)
|
| 3445 |
# 子分頁 3:監督式學習
|
| 3446 |
with gr.Tab("3. 監督式學習"):
|
| 3447 |
tab_rf_md = gr.Markdown(_EMPTY_HINT)
|
|
|
|
| 3476 |
)
|
| 3477 |
|
| 3478 |
with gr.Column(visible=False, elem_id="canvas_report", elem_classes=["canvas-result"]) as canvas_report_col:
|
| 3479 |
+
# 2026-08-14(待辦 A1):gr.Markdown 預設 latex_delimiters 只認
|
| 3480 |
+
# $$...$$(區塊數學),LLM 報告裡常見的 $p=0.0223$ 這種行內數學
|
| 3481 |
+
# 不會被 KaTeX 接手,原樣顯示成文字。這裡補上單一 $...$ 規則;
|
| 3482 |
+
# $$ 排在 $ 前面,避免 $$ 被拆成兩個各自不完整的單 $ 配對。
|
| 3483 |
+
tab_rpt_md = gr.Markdown(
|
| 3484 |
+
_EMPTY_HINT,
|
| 3485 |
+
latex_delimiters=[
|
| 3486 |
+
{"left": "$$", "right": "$$", "display": True},
|
| 3487 |
+
{"left": "$", "right": "$", "display": False},
|
| 3488 |
+
],
|
| 3489 |
+
)
|
| 3490 |
tab_rpt_file = gr.File(
|
| 3491 |
label="下載LLM輔助分析",
|
| 3492 |
file_count="multiple", interactive=False, visible=False,
|
|
|
|
| 3591 |
xlsx_list = sorted(output_dir.glob("textIndex_calculated_1_*.xlsx"))
|
| 3592 |
if csv_path.exists():
|
| 3593 |
df = pd.read_csv(str(csv_path))
|
| 3594 |
+
# 2026-08-14:畫面上的表格與指標數統計要濾掉業主要求隱藏的 13 項
|
| 3595 |
+
# (見 HIDDEN_INDICATOR_CODES),但下載的 CSV/XLSX 刻意用未濾過
|
| 3596 |
+
# 的 df(textIndex_calculated 原始檔)——業主說這 13 項「無須
|
| 3597 |
+
# 刪除」,下載檔案代表的是完整原始計算結果,只有畫面呈現要隱藏。
|
| 3598 |
+
df_display = _drop_hidden_indicators(df)
|
| 3599 |
# 指標數 = 總欄位數扣除中介欄(Text/Type1/Type2)
|
| 3600 |
+
meta_cols = [c for c in ["Text", "Type1", "Type2"] if c in df_display.columns]
|
| 3601 |
+
n_indicators = len(df_display.columns) - len(meta_cols)
|
| 3602 |
+
R[0] = f"### 語言指標分析\n共 **{len(df_display)}** 個文本,**{n_indicators}** 個語言指標。"
|
| 3603 |
+
R[1] = gr.update(value=df_display, visible=True)
|
| 3604 |
dl = [str(csv_path)]
|
| 3605 |
if xlsx_list:
|
| 3606 |
dl.append(str(xlsx_list[-1]))
|
|
|
|
| 3623 |
# sig=0 時不會產生箱形圖(見下方 box_path 判斷),若只留白使用者
|
| 3624 |
# 容易誤以為系統壞掉,這裡改在標題文字附加空狀態說明
|
| 3625 |
empty_note = "" if len(sig) > 0 else "\n\n本次分析未發現統計顯著的指標差異,因此未產生箱形圖。"
|
| 3626 |
+
# 2026-08-14(業主要求):標題句前面加一句「以[檢定方法]比較
|
| 3627 |
+
# [分組]文本」,方法與分組名稱都從 diff 本身反推——分組代碼
|
| 3628 |
+
# 是「{組別}_N」這組欄名的前綴(run_statistical_analysis 產出
|
| 3629 |
+
# 時就是這樣命名,見該處 diff_results.append 那段),不是另外
|
| 3630 |
+
# 寫死 A/B,之後研究情境換了分組代號也會自動跟著對。
|
| 3631 |
+
group_codes = [c[:-2] for c in diff.columns if c.endswith('_N')]
|
| 3632 |
+
# 2026-08-14(業主要求):如果「語料說明」有把分組代碼改寫成
|
| 3633 |
+
# 實際標籤(例如把 A 改成「人工翻譯」),這裡要據實呈現那個
|
| 3634 |
+
# 標籤,不是原始代碼——group_labels 沒填的組別維持顯示代碼。
|
| 3635 |
+
group_labels_map = state.get("group_labels", {}) or {}
|
| 3636 |
+
group_names = [group_labels_map.get(g, g) for g in group_codes]
|
| 3637 |
+
if len(group_names) >= 2:
|
| 3638 |
+
group_desc = "、".join(group_names[:-1]) + f" 與 {group_names[-1]}"
|
| 3639 |
+
elif group_names:
|
| 3640 |
+
group_desc = group_names[0]
|
| 3641 |
+
else:
|
| 3642 |
+
group_desc = ""
|
| 3643 |
+
method_raw = diff['方法'].iloc[0] if len(diff) > 0 and '方法' in diff.columns else None
|
| 3644 |
+
method_zh = {"t-test": "獨立樣本 t 檢定(Welch)",
|
| 3645 |
+
"ANOVA": "單因子變異數分析(ANOVA)"}.get(method_raw, method_raw)
|
| 3646 |
+
prefix = f"以{method_zh}比較 {group_desc} 文本," if (method_zh and group_desc) else ""
|
| 3647 |
+
# 2026-08-15(業主要求):下面表格的欄名有 N/M/SD 這種統計
|
| 3648 |
+
# 縮寫,加一句白話說明放在表格正上方,跟 docx 技術報告那份
|
| 3649 |
+
# 的做法一致。
|
| 3650 |
R[6] = gr.update(
|
| 3651 |
+
value=(f"### 差異性分析\n{prefix}共 **{len(diff)}** 個指標,"
|
| 3652 |
+
f"其中 **{len(sig)}** 個達顯著差異(p < {ALPHA})。{empty_note}\n\n"
|
| 3653 |
+
f"下方表格中 N=樣本數、M=平均數、SD=標準差。"),
|
| 3654 |
visible=True,
|
| 3655 |
)
|
| 3656 |
R[7] = gr.update(value=diff, visible=True)
|
|
|
|
| 3699 |
R[16] = gr.update(value=str(pca_fig_path), visible=True)
|
| 3700 |
if cluster_path.exists():
|
| 3701 |
cdf = pd.read_csv(str(cluster_path))
|
| 3702 |
+
# 2026-08-15(業主要求):畫面呈現時 Type1 改標成看得懂的
|
| 3703 |
+
# 「原始分組(A/B)」,Type2 不顯示(它一直是 Type1 的原樣
|
| 3704 |
+
# 複製,沒有額外資訊,見 run_statistical_analysis 那行
|
| 3705 |
+
# df['Type2'] = df['Type1'] 的註解)——下載的 CSV 檔案
|
| 3706 |
+
# 刻意不動,仍保留原始欄名/欄位,跟語言指標分析那張表同一個
|
| 3707 |
+
# 「下載檔案給完整原始資料、畫面只呈現整理過的版本」的原則。
|
| 3708 |
+
cdf_display = cdf.drop(columns=["Type2"], errors="ignore") \
|
| 3709 |
+
.rename(columns={"Type1": "原始分組(A/B)"})
|
| 3710 |
+
R[17] = gr.update(value=cdf_display, visible=True)
|
| 3711 |
cluster_dl = [str(p) for p in [dendro_path, elbow_path, pca_fig_path, cluster_path] if p.exists()]
|
| 3712 |
if cluster_dl:
|
| 3713 |
R[24] = gr.update(value=cluster_dl, visible=True)
|
|
|
|
| 3772 |
"report": dl_rpt,
|
| 3773 |
}, allow_all=report_stage_done)
|
| 3774 |
state["zip_paths"] = zips
|
| 3775 |
+
# 2026-08-15(業主要求):導覽列下載捷徑只留「下載全部資料」一顆,
|
| 3776 |
+
# 「下載指標數據/統計圖表/研究報告」三顆不再顯示。刻意不刪
|
| 3777 |
+
# nav_dl_idx_btn/nav_dl_stat_btn/nav_dl_report_btn 這三個元件
|
| 3778 |
+
# 的宣告、也不動 _TAB_NAV_DL 清單結構——這批 _TAB_OUTPUTS 的 index
|
| 3779 |
+
# 是全站共用的固定順序(見上方「子清單內部就代表最終 index 順序」
|
| 3780 |
+
# 的警語),砍元件��讓後面所有 index 全部錯位,風險太高。改成
|
| 3781 |
+
# 這裡的迴圈只留 "all" 這一組,前三個按鈕沒有任何地方會把它們
|
| 3782 |
+
# 設回 visible=True,永遠維持宣告時的初始隱藏狀態,畫面上就只
|
| 3783 |
+
# 剩第四顆——效果一樣,但完全不影響其他 index。
|
| 3784 |
+
for _i, _key in ((29, "all"),):
|
| 3785 |
if zips.get(_key):
|
| 3786 |
R[_i] = gr.update(value=zips[_key], visible=True)
|
| 3787 |
+
# 2026-08-15:畫面上只剩「下載全部資料」一顆按鈕,容器可見性
|
| 3788 |
+
# 改成只看 "all" 這包在不在——原本「只要有任一包 zip 就顯示」
|
| 3789 |
+
# 在 idx/stat/report 任一包先做出來、但 all 還沒湊齊
|
| 3790 |
+
# (report 階段尚未跑完)時,會出現分隔線卻沒有任何按鈕的空殼
|
| 3791 |
+
# (因為那三顆已經被拿掉),剛好是這段容器邏輯當初想避免的
|
| 3792 |
+
# 「分隔線孤零零留在畫面上」(見上方 B3 註解)。
|
| 3793 |
+
if zips.get("all"):
|
| 3794 |
R[30] = gr.update(visible=True)
|
| 3795 |
|
| 3796 |
# ── 顯示各分頁 Tab(index 20-22)+結果導覽閘門(31-36,A2)──────
|
|
|
|
| 4212 |
# 可以放心跟其他更新同批。對話視窗自此回歸純輸出角色。格式要求
|
| 4213 |
# 直接寫進上傳卡片本身(upload_hint_md),使用者不用往上捲回
|
| 4214 |
# 對話視窗找 AI 剛剛說過的規則——卡片本身就要自己講清楚。
|
| 4215 |
+
# 2026-08-22(見 docs/開發優化建議.md 第一節「研究語料傳送至第三方
|
| 4216 |
+
# AI 服務的資料政策」):上傳的文本內容會在報告撰寫階段組進 prompt
|
| 4217 |
+
# 送到 OpenRouter(再轉給實際供應商),先前介面完全沒有告知使用者
|
| 4218 |
+
# 這件事。獨立成一段(不是格式要求清單的第 5 項),避免被當成單純
|
| 4219 |
+
# 的檔案格式規定而略過不看——這是機密性提醒,性質跟前四項不同。
|
| 4220 |
hint = (
|
| 4221 |
+
f"**格式要求**\n\n{sc_info['upload_prompt']}\n\n"
|
| 4222 |
+
"---\n\n"
|
| 4223 |
+
"📢 **隱私提醒**:上傳的文本內容將傳送至第三方 AI 服務"
|
| 4224 |
+
"(OpenRouter 及其上游模型供應商)進行語言分析與報告撰寫。"
|
| 4225 |
+
"請確認語料不涉及尚未公開發表之機密內容,如有疑慮請先與計畫主持人確認後再上傳。"
|
| 4226 |
)
|
| 4227 |
yield (chat_history, log_history, state, gr.update(visible=True),
|
| 4228 |
_todo_update(state), gr.update(value=hint),
|
|
|
|
| 4332 |
yield chat_history, log_history, state, gr.update(visible=True), gr.update(), gr.update()
|
| 4333 |
return
|
| 4334 |
|
| 4335 |
+
# 2026-08-22(業主回報:對話視窗兩則 AI 訊息之間沒有使用者自己的
|
| 4336 |
+
# 發言氣泡,看起來像 AI 自問自答):在流程真正開始處理之前,先補一則
|
| 4337 |
+
# 使用者訊息氣泡,把「使用者上傳了哪些檔案」呈現出來,對話才有一來
|
| 4338 |
+
# 一往的感覺。
|
| 4339 |
+
_uploaded_names = [Path(f.name if hasattr(f, "name") else str(f)).name for f in files]
|
| 4340 |
+
chat_history = chat_history + [_user_msg(f"上傳了 {len(_uploaded_names)} 個檔案:{'、'.join(_uploaded_names)}")]
|
| 4341 |
+
|
| 4342 |
# 文本上傳(step 2:3-A 新增「研究選擇」為第 1 步後,這裡的步驟
|
| 4343 |
# 編號全部 +1):開始處理即關閉上傳彈窗,避免遮罩擋住操作。
|
| 4344 |
# 3-C:同時把「檔案接收」子階段標成 running,寫進 upload_progress_html
|
|
|
|
| 4413 |
import os
|
| 4414 |
files = [os.path.basename(p) for p in state.get("cleaned_files", [])]
|
| 4415 |
groups = sorted(set(f[0].upper() for f in files if f))
|
| 4416 |
+
# 2026-08-15:群組層改成三組獨立 Textbox 後,這裡不再需要組出
|
| 4417 |
+
# Dataframe 的初始列資料,只需要回傳實際偵測到的分組代碼清單
|
| 4418 |
+
# (給 _show_metadata_form 決定顯示幾組、標題寫哪個代碼)。
|
| 4419 |
+
return groups, [[f, "", "", "", ""] for f in files]
|
| 4420 |
|
| 4421 |
def _df_rows(dfval):
|
| 4422 |
try:
|
|
|
|
| 4430 |
pass
|
| 4431 |
return []
|
| 4432 |
|
| 4433 |
+
def _extract_individual_grouping(indiv_rows, method):
|
| 4434 |
+
"""個體間比較模式下,把「個體層資訊」表格整理成 {檔名: 分組標籤}
|
| 4435 |
+
dict,交給 run_statistical_analysis 取代預設的檔名字首分組
|
| 4436 |
+
(2026-08-15 業主要求:選「個體間比��」時,個體層資訊要真的
|
| 4437 |
+
影響分析,不能只是寫進 metadata 文字給 LLM 參考)。
|
| 4438 |
+
method 不是「個體間比較」時一律回傳空 dict——run_statistical_
|
| 4439 |
+
analysis 收到空 dict 等同沒收到,完全不影響現行「群組間比較」
|
| 4440 |
+
的行為。
|
| 4441 |
+
|
| 4442 |
+
2026-08-15 二版(業主糾正):「個體間比較」的定義是逐檔案比較,
|
| 4443 |
+
不能因為「譯者/系統名稱」剛好打一樣(例如同一位譯者的多篇
|
| 4444 |
+
譯本)就把檔案合併成同一組——那樣等於又退回群組比較,跟
|
| 4445 |
+
「個體間」的字面意思矛盾。這裡改成:同一個名稱出現在多個檔案
|
| 4446 |
+
時,自動用「出版年」加註消歧義(沒填出版年就退而求其次用
|
| 4447 |
+
「重譯序位」,兩者都沒填則直接用檔名本身墊底)——不管怎樣都
|
| 4448 |
+
保證每個檔案分到獨一無二的組,絕不因為名稱撞名而合併。
|
| 4449 |
+
|
| 4450 |
+
欄位索引對應目前的「個體層」表格:0=檔案、1=譯者/系統名稱、
|
| 4451 |
+
2=出版年、3=譯者母語、4=重譯序位(見上方 meta_indiv_df 宣告,
|
| 4452 |
+
2026-08-15 拿掉「後編輯程度」後的新欄位順序)。"""
|
| 4453 |
+
if method != "個體間比較":
|
| 4454 |
+
return {}
|
| 4455 |
+
entries = [] # (檔名, 名稱, 出版年, 重譯序位)
|
| 4456 |
+
for row in indiv_rows:
|
| 4457 |
+
if not row or len(row) < 2:
|
| 4458 |
+
continue
|
| 4459 |
+
fname = str(row[0]).strip()
|
| 4460 |
+
name = str(row[1]).strip()
|
| 4461 |
+
year = str(row[2]).strip() if len(row) > 2 else ""
|
| 4462 |
+
seq = str(row[4]).strip() if len(row) > 4 else ""
|
| 4463 |
+
if fname and name:
|
| 4464 |
+
entries.append((fname, name, year, seq))
|
| 4465 |
+
|
| 4466 |
+
name_counts = {}
|
| 4467 |
+
for _, name, _, _ in entries:
|
| 4468 |
+
name_counts[name] = name_counts.get(name, 0) + 1
|
| 4469 |
+
|
| 4470 |
+
mapping = {}
|
| 4471 |
+
for fname, name, year, seq in entries:
|
| 4472 |
+
if name_counts[name] > 1:
|
| 4473 |
+
disambiguator = year or seq or fname
|
| 4474 |
+
mapping[fname] = f"{name}({disambiguator})"
|
| 4475 |
+
else:
|
| 4476 |
+
mapping[fname] = name
|
| 4477 |
+
return mapping
|
| 4478 |
+
|
| 4479 |
def _compile_metadata(state, title, author, lang, pubyear, version,
|
| 4480 |
+
ngroups, method, group_field_values, indiv_df):
|
| 4481 |
+
"""group_field_values:長度 15 的 list,三組 × 5 欄
|
| 4482 |
+
(群組標籤/翻譯類型/翻譯方向/譯者背景/目標讀者)攤平排列,
|
| 4483 |
+
對應 meta_group_fields 三個 slot、各自 5 個 Textbox 的值。"""
|
| 4484 |
sc = state.get("scenario", "")
|
| 4485 |
L = ["## 語料 Metadata", "",
|
| 4486 |
"### 原文資訊",
|
|
|
|
| 4494 |
f"- 群組數量:{ngroups}",
|
| 4495 |
f"- 比較方式:{method}", ""]
|
| 4496 |
gh = ["群組標籤", "翻譯類型", "翻譯方向", "譯者背景", "目標讀者"]
|
| 4497 |
+
# 2026-08-15:每組資訊前面明確標上實際分組代碼(「群組 A:...」),
|
| 4498 |
+
# 不再只是無標籤的一條條列點——LLM 之前因為看不出這行屬於哪個
|
| 4499 |
+
# 分組代碼,把 A/B 的標籤講反過一次(見待辦記錄),這裡從資料
|
| 4500 |
+
# 來源端直接堵掉這個歧義。
|
| 4501 |
+
codes = sorted(set(Path(fp).name[0].upper()
|
| 4502 |
+
for fp in state.get("cleaned_files", []) if fp))
|
| 4503 |
+
slots = [group_field_values[i*5:(i+1)*5] for i in range(3)]
|
| 4504 |
L += ["### 群組層資訊"]
|
| 4505 |
+
for code, vals in zip(codes, slots):
|
| 4506 |
+
filled = [f"{h}:{str(v).strip()}" for h, v in zip(gh, vals) if str(v).strip()]
|
| 4507 |
+
if filled:
|
| 4508 |
+
L.append(f"- 群組 {code}:" + ";".join(filled))
|
| 4509 |
+
ih = ["檔案", "譯者/系統名稱", "出版年", "譯者母語", "重譯序位"]
|
| 4510 |
L += ["", "### 個體層資訊"]
|
| 4511 |
for r in _df_rows(indiv_df):
|
| 4512 |
if not any(str(c).strip() for c in r):
|
|
|
|
| 4514 |
L.append("- " + ";".join(f"{h}:{str(v).strip()}" for h, v in zip(ih, r) if str(v).strip()))
|
| 4515 |
return "\n".join(L)
|
| 4516 |
|
| 4517 |
+
def _extract_group_labels(state, group_field_values):
|
| 4518 |
+
"""把三組群組標籤 Textbox 的值整理成 {分組代碼: 使用者填的標籤}
|
| 4519 |
+
dict,供差異性分析文字/箱形圖 X 軸使用。group_field_values 是
|
| 4520 |
+
長度 15 的攤平 list(見 _compile_metadata),每組取第 0 欄
|
| 4521 |
+
(群組標籤)。
|
| 4522 |
+
對應規則是「位置對位置」:_show_metadata_form() 是照
|
| 4523 |
+
sorted(群組代碼) 的順序把代碼填進每個 slot 的標題(例如
|
| 4524 |
+
「組別 A」),使用者在該 slot 底下的「群組標籤」欄位填的內容
|
| 4525 |
+
就對應那個代碼——沒填或原樣留著代碼本身的 slot 視同沒填,
|
| 4526 |
+
呈現時退回代碼本身。"""
|
| 4527 |
+
try:
|
| 4528 |
+
codes = sorted(set(Path(fp).name[0].upper()
|
| 4529 |
+
for fp in state.get("cleaned_files", []) if fp))
|
| 4530 |
+
slot_labels = [group_field_values[i*5] for i in range(3)]
|
| 4531 |
+
labels = {}
|
| 4532 |
+
for code, label in zip(codes, slot_labels):
|
| 4533 |
+
label = str(label or "").strip()
|
| 4534 |
+
if label and label != code:
|
| 4535 |
+
labels[code] = label
|
| 4536 |
+
return labels
|
| 4537 |
+
except Exception:
|
| 4538 |
+
return {}
|
| 4539 |
+
|
| 4540 |
def _run_computation(chat_history, log_history, state):
|
| 4541 |
# 2026-07-23:技術執行紀錄轉正成獨立頁面後,可見性只在情境選定
|
| 4542 |
# (on_scenario_pick)與重置(on_reset_confirm)時切換一次,
|
|
|
|
| 4592 |
yield (chat_history, log_history, state, _todo_update(state),
|
| 4593 |
gr.update(), _computing_update(state), *tab_updates)
|
| 4594 |
|
| 4595 |
+
# 3(metadata_row)+ 21(3 組 × [欄位可見性, 標題, 5 個 Textbox])
|
| 4596 |
+
# + 1(indiv_df)+ 1(compare_group 可見性)
|
| 4597 |
+
_N_META_FORM_OUTPUTS = 1 + 3 * 7 + 1 + 1
|
| 4598 |
+
|
| 4599 |
def _show_metadata_form(state):
|
| 4600 |
_NO = gr.update()
|
| 4601 |
if not state or state.get("state") != "METADATA_INPUT":
|
| 4602 |
+
return (_NO,) * _N_META_FORM_OUTPUTS
|
| 4603 |
+
groups, indiv_rows = _build_meta_tables(state)
|
| 4604 |
show_cmp = state.get("scenario", "") in ("3", "4")
|
| 4605 |
+
group_updates = []
|
| 4606 |
+
for i in range(3):
|
| 4607 |
+
if i < len(groups):
|
| 4608 |
+
group_updates.append(gr.update(visible=True))
|
| 4609 |
+
group_updates.append(gr.update(value=f"組別 {groups[i]}"))
|
| 4610 |
+
group_updates += [gr.update(value="")] * 5 # 清空上一輪可能殘留的舊值
|
| 4611 |
+
else:
|
| 4612 |
+
group_updates.append(gr.update(visible=False))
|
| 4613 |
+
group_updates += [_NO] * 6 # 隱藏中,標題/欄位不用動
|
| 4614 |
+
return (gr.update(visible=True), *group_updates,
|
| 4615 |
gr.update(value=indiv_rows), gr.update(visible=show_cmp))
|
| 4616 |
|
| 4617 |
# 送出期間鎖住按鈕避免重複點擊(2026-07-24 取消鈕移除後只剩確認鈕一顆)
|
|
|
|
| 4632 |
).then(
|
| 4633 |
fn=_show_metadata_form,
|
| 4634 |
inputs=[session_state],
|
| 4635 |
+
outputs=[metadata_row,
|
| 4636 |
+
*[c for i in range(3)
|
| 4637 |
+
for c in [meta_group_cols[i], meta_group_heads[i], *meta_group_fields[i]]],
|
| 4638 |
+
meta_indiv_df, meta_compare_group],
|
| 4639 |
)
|
| 4640 |
|
| 4641 |
# 2026-07-31(Batch G 第 1 項):「重新選擇研究情境」返回鈕。叫回
|
|
|
|
| 4691 |
)
|
| 4692 |
|
| 4693 |
def on_metadata_confirm(title, author, lang, pubyear, version,
|
| 4694 |
+
ngroups, method,
|
| 4695 |
+
g1_label, g1_type, g1_dir, g1_bg, g1_aud,
|
| 4696 |
+
g2_label, g2_type, g2_dir, g2_bg, g2_aud,
|
| 4697 |
+
g3_label, g3_type, g3_dir, g3_bg, g3_aud,
|
| 4698 |
+
indiv_df,
|
| 4699 |
chat_history, log_history, state):
|
| 4700 |
+
group_field_values = [g1_label, g1_type, g1_dir, g1_bg, g1_aud,
|
| 4701 |
+
g2_label, g2_type, g2_dir, g2_bg, g2_aud,
|
| 4702 |
+
g3_label, g3_type, g3_dir, g3_bg, g3_aud]
|
| 4703 |
# Bug 4-A 修復(2026-07-30):outputs 併入 _TAB_OUTPUTS(見下方
|
| 4704 |
# wiring),generator 的每個 yield 元素個數都要跟 outputs 長度
|
| 4705 |
# 完全一致——這裡先包一份「_TAB_OUTPUTS 全部不動」的預設值,
|
|
|
|
| 4719 |
expected_count = 3 if ngroups == "三群" else 2
|
| 4720 |
if len(actual_prefixes) != expected_count:
|
| 4721 |
warn_msg = (
|
| 4722 |
+
f"⚠️ 您選擇「{ngroups}」,但實際上傳的檔案偵測到 "
|
| 4723 |
f"{len(actual_prefixes)} 組({'、'.join(sorted(actual_prefixes)) or '無'}),"
|
| 4724 |
"請確認群組數量與上傳檔案是否一致後再送出。"
|
| 4725 |
)
|
|
|
|
| 4730 |
return
|
| 4731 |
|
| 4732 |
state["user_metadata"] = _compile_metadata(state, title, author, lang, pubyear,
|
| 4733 |
+
version, ngroups, method, group_field_values, indiv_df)
|
| 4734 |
+
state["group_labels"] = _extract_group_labels(state, group_field_values)
|
| 4735 |
+
state["individual_grouping"] = _extract_individual_grouping(_df_rows(indiv_df), method)
|
| 4736 |
log_history = _log_append(log_history, "✅ 已記錄語料 Metadata,開始分析")
|
| 4737 |
+
# 2026-08-22:同上傳檔案那段,補一則使用者訊息氣泡,呈現「使用者
|
| 4738 |
+
# 填寫完語料說明並送出」這個動作,避免對話看起來像 AI 自問自答。
|
| 4739 |
+
chat_history = chat_history + [_user_msg("已填寫語料 Metadata 說明(原文資訊、群組層與個體層),開始分析")]
|
| 4740 |
# 在主對話視窗立即顯示「開始分析」訊息,避免長時間運算期間畫面看起來像沒有反應
|
| 4741 |
chat_history = chat_history + [_assistant_msg(_ANALYSIS_START_MSG)]
|
| 4742 |
yield (chat_history, log_history, state, gr.update(visible=False),
|
|
|
|
| 4758 |
).then(
|
| 4759 |
fn=on_metadata_confirm,
|
| 4760 |
inputs=[meta_title, meta_author, meta_lang, meta_pubyear, meta_version,
|
| 4761 |
+
meta_ngroups, meta_method,
|
| 4762 |
+
*meta_group_fields[0], *meta_group_fields[1], *meta_group_fields[2],
|
| 4763 |
+
meta_indiv_df,
|
| 4764 |
chatbot, exec_log, session_state],
|
| 4765 |
# Bug 4-A 修復(2026-07-30):併入 _TAB_OUTPUTS,讓 on_metadata_confirm
|
| 4766 |
# 能在 streaming 過程中(不用等這條 .then() 鏈跑到最後)就把已完成
|
|
|
|
| 4782 |
return
|
| 4783 |
state["user_metadata"] = state.get("user_metadata", "") or "(使用者未提供語料 Metadata 說明)"
|
| 4784 |
log_history = _log_append(log_history, "⏭️ 已略過 Metadata,直接開始分析")
|
| 4785 |
+
# 2026-08-22:同 on_metadata_confirm,補使用者訊息氣泡(略過版本)。
|
| 4786 |
+
chat_history = chat_history + [_user_msg("略過填寫,直接開始分析")]
|
| 4787 |
chat_history = chat_history + [_assistant_msg(_ANALYSIS_START_MSG)]
|
| 4788 |
yield (chat_history, log_history, state, gr.update(visible=False),
|
| 4789 |
_todo_update(state), gr.update(), _computing_update(state), *_no_tabs)
|
|
|
|
| 4848 |
print(f"工作程序 : {LLM2_MODEL}")
|
| 4849 |
print(f"BASE_DIR : {BASE_DIR}")
|
| 4850 |
print("=" * 60)
|
| 4851 |
+
start_session_cleanup_scheduler()
|
| 4852 |
app = create_app()
|
| 4853 |
+
# 2026-08-22:share=True 原本寫死,代表任何人在任何一台機器執行
|
| 4854 |
+
# `python app_agent.py` 都會額外開一條公開的 *.gradio.live 通道,把本機
|
| 4855 |
+
# 服務暴露到公開網際網路(且連著執行者自己的 OpenRouter 金鑰),沒有任何
|
| 4856 |
+
# 存取控制(見 docs/開發優化建議.md 第六節)。正式機走的是 app.py(見該
|
| 4857 |
+
# 檔,share=False 且已加登入畫面),這裡只影響本機開發;改成預設關閉、
|
| 4858 |
+
# 只有明確設環境變數才臨時開啟分享連結。
|
| 4859 |
app.launch(
|
| 4860 |
server_name="0.0.0.0",
|
| 4861 |
server_port=7860,
|
| 4862 |
+
share=os.environ.get("GRADIO_SHARE", "false").strip().lower() == "true",
|
| 4863 |
inbrowser=False,
|
| 4864 |
allowed_paths=[str(SESSIONS_DIR)],
|
| 4865 |
head=_APP_HEAD,
|
assets/app.css
CHANGED
|
@@ -640,6 +640,18 @@ div[role="log"][aria-label="chatbot conversation"],
|
|
| 640 |
}
|
| 641 |
.main-chat .message-row.user,
|
| 642 |
.main-chat .message-row.user-row { margin-bottom: 1.1rem !important; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 643 |
|
| 644 |
/* AI 訊息(主對話視窗):淺灰底色跟白色頁面背景錯開,不用外框線 */
|
| 645 |
.main-chat .message-row.bot .message,
|
|
@@ -2354,10 +2366,24 @@ label:not(:has(input)) > span {
|
|
| 2354 |
.virtual-table-viewport {
|
| 2355 |
scrollbar-width: none !important;
|
| 2356 |
}
|
| 2357 |
-
/*
|
| 2358 |
-
|
| 2359 |
-
|
| 2360 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2361 |
|
| 2362 |
/* ── 15. FOCUS & ACCESSIBILITY ─────────────────────────────────── */
|
| 2363 |
:focus-visible {
|
|
|
|
| 640 |
}
|
| 641 |
.main-chat .message-row.user,
|
| 642 |
.main-chat .message-row.user-row { margin-bottom: 1.1rem !important; }
|
| 643 |
+
/* 2026-08-22(業主:使用者泡泡外框/底色希望跟 AI 不同):真正畫出氣泡底色與
|
| 644 |
+
邊框的是內層 .md(見上面「AI 訊息」區塊那條共用選擇器 .message-row .message
|
| 645 |
+
.md),使用者與 AI 訊息目前吃到同一份 --c-bubble-bg/--c-bubble-border,
|
| 646 |
+
看起來一樣。這裡只針對使用者泡泡的 .md 覆寫,改用專屬的 --c-user-bg/
|
| 647 |
+
--c-user-border/--c-user-text(原本就有定義、只是從沒接到任何選擇器上)
|
| 648 |
+
——淺色是淡綠、深色是半透明綠,跟 AI 泡泡的中性藍灰做出區隔。 */
|
| 649 |
+
.main-chat .message-row.user .message .md,
|
| 650 |
+
.main-chat .message-row.user-row .user.message .md {
|
| 651 |
+
background: var(--c-user-bg) !important;
|
| 652 |
+
border: 1px solid var(--c-user-border) !important;
|
| 653 |
+
color: var(--c-user-text) !important;
|
| 654 |
+
}
|
| 655 |
|
| 656 |
/* AI 訊息(主對話視窗):淺灰底色跟白色頁面背景錯開,不用外框線 */
|
| 657 |
.main-chat .message-row.bot .message,
|
|
|
|
| 2366 |
.virtual-table-viewport {
|
| 2367 |
scrollbar-width: none !important;
|
| 2368 |
}
|
| 2369 |
+
/* 2026-08-14(業主回報「語言指標分析表格被截斷」):語言指標分析欄數多
|
| 2370 |
+
(目前實際顯示 62 項指標,欄數隨 HIDDEN_INDICATOR_CODES 增減),橫向
|
| 2371 |
+
捲軸被上面那條全站規則隱形後,使用者只能看到「右緣被切一半的欄位」,
|
| 2372 |
+
沒有提示還能左右拖曳看完整表格。單獨還原這裡的橫向捲軸(沿用第 14 節
|
| 2373 |
+
「預設透明、懸停才出現」那套視覺邏輯,只是這裡是 scrollbar-color 的
|
| 2374 |
+
標準屬性版本,不是 ::-webkit-scrollbar 偽元素)。
|
| 2375 |
+
!important 是必要的:上面那條全站規則本身也是 !important,CSS 的
|
| 2376 |
+
important 優先於一般宣告、跟選擇器精確度無關,這裡不加就贏不了它
|
| 2377 |
+
(實測套用後 scrollbar-width 電腦算出來還是 none,就是漏了這個)。
|
| 2378 |
+
2026-08-15:業主回報差異性分析表格(欄數也不少:指標+每組 N/M/SD+
|
| 2379 |
+
方法/統計量/p值/顯著/效果量名稱/效果量/效果大小)右側欄位一樣
|
| 2380 |
+
被切掉,同一顆坑,範圍擴大成 #canvas_stat(統計比較分析——敘述統計/
|
| 2381 |
+
差異性分析/監督式/非監督式共用這個容器)。敘述統計那張表欄數少、
|
| 2382 |
+
本來就不會超出容器寬度,橫向捲軸不會出現,套用這條規則對它沒有副作用。 */
|
| 2383 |
+
#canvas_idx .virtual-table-viewport,
|
| 2384 |
+
#canvas_stat .virtual-table-viewport { scrollbar-width: thin !important; scrollbar-color: transparent transparent !important; }
|
| 2385 |
+
#canvas_idx .virtual-table-viewport:hover,
|
| 2386 |
+
#canvas_stat .virtual-table-viewport:hover { scrollbar-color: var(--c-scrollbar) transparent !important; }
|
| 2387 |
|
| 2388 |
/* ── 15. FOCUS & ACCESSIBILITY ─────────────────────────────────── */
|
| 2389 |
:focus-visible {
|
使用手冊.docx
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:85fff99e2cab3d48acda515d9bb12f75ecefc60326c1bec83a88686560a705a8
|
| 3 |
+
size 44547
|
使用手冊.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# 譯彩紛呈:重譯文本分析系統 — 使用手冊
|
| 2 |
|
| 3 |
-
**TransPrism: An Analytical Framework for Retranslation II|分析引擎
|
| 4 |
|
| 5 |
> 本手冊說明系統的安裝啟動、操作流程、檔案格式要求、各分頁功能與疑難排解。
|
| 6 |
|
|
@@ -11,12 +11,12 @@
|
|
| 11 |
1. [系統簡介](#1-系統簡介)
|
| 12 |
2. [系統需求與啟動](#2-系統需求與啟動)
|
| 13 |
3. [介面總覽](#3-介面總覽)
|
| 14 |
-
4. [操作流程(
|
| 15 |
5. [四種研究情境](#5-四種研究情境)
|
| 16 |
6. [檔案格式要求](#6-檔案格式要求)
|
| 17 |
7. [語料 Metadata 說明](#7-語料-metadata-說明)
|
| 18 |
8. [結果分頁說明](#8-結果分頁說明)
|
| 19 |
-
9. [語言指標分類(75 項)](#9-語言指標分類75-項)
|
| 20 |
10. [統計方法說明](#10-統計方法說明)
|
| 21 |
11. [常見問題與疑難排解](#11-常見問題與疑難排解)
|
| 22 |
|
|
@@ -29,9 +29,9 @@
|
|
| 29 |
| Agent | 模型 | 職責 |
|
| 30 |
|-------|------|------|
|
| 31 |
| **主 Agent(LLM1)** | `google/gemma-4-31b-it` | 研究情境引導、最終研究報告撰寫 |
|
| 32 |
-
| **工作程序 Agent(LLM2)** | `google/gemma-4-26b-a4b-it` | 上傳引導、格式驗證 |
|
| 33 |
|
| 34 |
-
系統會對上傳的中文譯本計算 **75 項語言指標**,進行**敘述統計、差異性檢定、機器學習分群與分類**,最後由 LLM 產出一份研究報告。
|
| 35 |
|
| 36 |
---
|
| 37 |
|
|
@@ -39,72 +39,73 @@
|
|
| 39 |
|
| 40 |
### 環境需求
|
| 41 |
- Python 3.x
|
| 42 |
-
- 套件:`gradio`、`pandas`、`numpy`、`scikit-learn`、`matplotlib`、`python-docx`
|
| 43 |
- OpenRouter API 金鑰(供 LLM 呼叫)
|
| 44 |
|
| 45 |
### 環境變數設定
|
| 46 |
-
LLM 透過 OpenRouter 呼叫,
|
| 47 |
|
| 48 |
```
|
| 49 |
-
LLM1_API_KEY 你的 OpenRouter 金鑰
|
| 50 |
-
LLM1_MODEL google/gemma-4-31b-it(預設)
|
| 51 |
-
LLM2_API_KEY 你的 OpenRouter 金鑰
|
| 52 |
-
LLM2_MODEL google/gemma-4-26b-a4b-it(預設)
|
| 53 |
```
|
| 54 |
|
| 55 |
### 啟動系統
|
| 56 |
|
|
|
|
|
|
|
| 57 |
```powershell
|
| 58 |
-
cd D:\D11-AIAgentForTranslation_main_v2
|
| 59 |
python app_agent.py
|
| 60 |
```
|
| 61 |
|
| 62 |
啟動後開啟瀏覽器前往:**http://localhost:7860**
|
| 63 |
|
| 64 |
-
> 💡 修改程式碼後,務必**完全關閉舊的 Python 程序**再重啟,否則舊程序可能仍佔用 7860 埠導致看到舊版畫面。
|
| 65 |
|
| 66 |
---
|
| 67 |
|
| 68 |
## 3. 介面總覽
|
| 69 |
|
| 70 |
```
|
| 71 |
-
┌──────────────────────────────────────────────────────┐
|
| 72 |
-
│
|
| 73 |
-
|
| 74 |
-
│
|
| 75 |
-
|
| 76 |
-
│ 對
|
| 77 |
-
│
|
| 78 |
-
│
|
| 79 |
-
│
|
| 80 |
-
│
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
| 82 |
```
|
| 83 |
|
| 84 |
-
- **
|
| 85 |
-
- **
|
| 86 |
-
- **
|
| 87 |
-
- **執行
|
| 88 |
-
- **結果分頁**:分析完成後自動
|
|
|
|
| 89 |
|
| 90 |
---
|
| 91 |
|
| 92 |
-
## 4. 操作流程(
|
| 93 |
|
| 94 |
-
|
| 95 |
|
| 96 |
| 步驟 | 名稱 | 說明 |
|
| 97 |
|------|------|------|
|
| 98 |
-
| **1** | 情境選擇 |
|
| 99 |
-
| **2** | 上傳
|
| 100 |
-
| **3** |
|
| 101 |
-
| **4** |
|
| 102 |
-
| **5** | 指標計算 | 計算 75 項語言指標 |
|
| 103 |
-
| **6** | 統計分析 | 敘述統計、差異檢定、機器學習 |
|
| 104 |
-
| **7** | 報告撰寫 | 主 Agent(LLM1)撰寫研究報告 |
|
| 105 |
-
| **8** | 下載成果 | 於各結果分頁檢視與下載 |
|
| 106 |
|
| 107 |
-
>
|
| 108 |
|
| 109 |
---
|
| 110 |
|
|
@@ -117,7 +118,7 @@ python app_agent.py
|
|
| 117 |
| **3** | 不同譯者的同期譯本 | 二群以上(A/B/C…) | 個人風格差異(排除時代因素) |
|
| 118 |
| **4** | 同一原文的不同時期人工譯本 | 二群以上(A/B/C…) | 歷時變化、語言現代化、翻譯規範演變 |
|
| 119 |
|
| 120 |
-
> 情境 **1、2 固定為二群**,故 Metadata 表單中的「比較
|
| 121 |
|
| 122 |
---
|
| 123 |
|
|
@@ -130,13 +131,12 @@ python app_agent.py
|
|
| 130 |
- 範例:`A_CH1_seg.txt`、`B_DB1_seg.txt`(A 群、B 群各自的個體檔)
|
| 131 |
|
| 132 |
### 6.2 斷詞要求(必要條件)
|
| 133 |
-
- 文本**必須先經由 [
|
| 134 |
-
- 系統會自動驗證格式,不符者會要求重新處理。
|
| 135 |
|
| 136 |
### 6.3 內容格式
|
| 137 |
每行為下列之一:
|
| 138 |
- `詞_詞性`(例如:`翻譯_VC`)
|
| 139 |
-
- `_詞性`
|
| 140 |
- 空白行(段落分隔)
|
| 141 |
|
| 142 |
---
|
|
@@ -148,81 +148,94 @@ python app_agent.py
|
|
| 148 |
### 一、原文資訊
|
| 149 |
原文標題、作者、語言、出版年、版本。
|
| 150 |
|
| 151 |
-
### 二、比較
|
| 152 |
- **群組數量**:二群/三群
|
| 153 |
-
- **比較方式**:
|
|
|
|
|
|
|
| 154 |
- (情境 1、2 固定二群,此區自動隱藏)
|
| 155 |
|
| 156 |
### 三、群組與個體資訊
|
| 157 |
-
- **群組層**(
|
| 158 |
-
- **個體層**(可編輯表格,依上傳檔案自動逐筆列出):譯者/系統名稱、出版年、譯者母語、
|
| 159 |
|
| 160 |
-
填妥後按 **「確認並開始分析」**;若不需填寫可按 **「略過」** 直接分析。所填內容會編譯成結構化說明,作為 LLM 撰寫研究報告的背景脈絡。
|
| 161 |
|
| 162 |
---
|
| 163 |
|
| 164 |
## 8. 結果分頁說明
|
| 165 |
|
| 166 |
-
分析完成後,
|
| 167 |
|
| 168 |
### 📊 語言指標分析
|
| 169 |
-
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
-
###
|
| 173 |
-
|
| 174 |
-
|--------|------|
|
| 175 |
-
| **1. 敘述統計** | 75 項指標的平均數、中位數、標準差、最小/最大值、變異係數 |
|
| 176 |
-
| **2. 差異性分析** | 群組間差異檢定(t 檢定/ANOVA)、p 值、顯著性、效果量 + 顯著指標箱形圖 |
|
| 177 |
-
| **3. 監督式學習** | 隨機森林分類準確率、特徵重要性 Top 20 |
|
| 178 |
-
| **4. 非監督式學習** | 層次分群樹狀圖、K-means Elbow、PCA 2D 視覺化、分群結果 |
|
| 179 |
-
| **5. 其他** | 下載統計輸出檔案 |
|
| 180 |
|
| 181 |
-
### 📝 LLM輔助分析
|
| 182 |
-
- 主 Agent(LLM1)撰寫的深度研究報告,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
---
|
| 185 |
|
| 186 |
-
## 9. 語言指標分類(75 項)
|
| 187 |
|
| 188 |
-
| 類別 | 指標數 | 代碼範圍 |
|
| 189 |
-
|------|--------|----------|
|
| 190 |
-
| 基礎統計 | 6 | AA_01 ~ AA_06 |
|
| 191 |
-
| 詞彙難易度 | 2 | AC_01、AC_02 |
|
| 192 |
-
| 詞彙多樣性 | 2 | AD_02、AD_03 |
|
| 193 |
-
| 語意複雜度 | 4 | AG_01、AG_02、AG_05、AG_06 |
|
| 194 |
-
| 語法複雜度 | 4 | AH_01、AH_03、AH_06、AH_08 |
|
| 195 |
-
|
|
| 196 |
-
| 詞性標記 | 46 | AF_01 ~ AF_47(除 AF_13) |
|
| 197 |
-
| **合計** | **75** | |
|
| 198 |
|
| 199 |
-
> 「語言指標分析」
|
| 200 |
|
| 201 |
---
|
| 202 |
|
| 203 |
## 10. 統計方法說明
|
| 204 |
|
| 205 |
-
- **分組依據**:檔名第一個字母(A、B、C…)。
|
| 206 |
- **差異檢定**:
|
| 207 |
- 二群 → Independent t-test(Welch)
|
| 208 |
- 三群以上 → One-way ANOVA
|
| 209 |
- 顯著水準 α = **0.05**
|
| 210 |
-
- **效果量**:t 檢定用 Cohen's d;ANOVA 用 η²(含大/中/小/極小判讀)。
|
| 211 |
-
- **監督式學習**:隨機森林分類器,輸出準確率與特徵重要性。
|
| 212 |
-
- **非監督式學習**:階層式分群(Ward)、K-means(含 Elbow)、PCA 2D 降維視覺化。
|
| 213 |
|
| 214 |
---
|
| 215 |
|
| 216 |
## 11. 常見問題與疑難排解
|
| 217 |
|
| 218 |
**Q1:上傳後畫面卡住、無法點選?**
|
| 219 |
-
上傳卡片為行內卡片,按「確認上傳」
|
| 220 |
|
| 221 |
**Q2:修改設定後畫面沒更新?**
|
| 222 |
請完全關閉舊 Python 程序再重啟,並用 `Ctrl+Shift+R` 清除瀏覽器快取。確認 7860 埠的��序是新啟動的實例。
|
| 223 |
|
| 224 |
**Q3:指標數量不一致 / 顯示舊結果?**
|
| 225 |
-
結果分頁的數據在「
|
| 226 |
|
| 227 |
**Q4:埠 7860 被佔用?**
|
| 228 |
```powershell
|
|
@@ -231,8 +244,14 @@ Get-Process python | Stop-Process -Force
|
|
| 231 |
確認 7860 完全釋放後再啟動。
|
| 232 |
|
| 233 |
**Q5:格式驗證一直失敗?**
|
| 234 |
-
確認檔案確實經由
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
---
|
| 237 |
|
| 238 |
-
*文件版本:2026-
|
|
|
|
| 1 |
# 譯彩紛呈:重譯文本分析系統 — 使用手冊
|
| 2 |
|
| 3 |
+
**TransPrism: An Analytical Framework for Retranslation II|分析引擎 SpectraEngine**
|
| 4 |
|
| 5 |
> 本手冊說明系統的安裝啟動、操作流程、檔案格式要求、各分頁功能與疑難排解。
|
| 6 |
|
|
|
|
| 11 |
1. [系統簡介](#1-系統簡介)
|
| 12 |
2. [系統需求與啟動](#2-系統需求與啟動)
|
| 13 |
3. [介面總覽](#3-介面總覽)
|
| 14 |
+
4. [操作流程(四大步驟)](#4-操作流程四大步驟)
|
| 15 |
5. [四種研究情境](#5-四種研究情境)
|
| 16 |
6. [檔案格式要求](#6-檔案格式要求)
|
| 17 |
7. [語料 Metadata 說明](#7-語料-metadata-說明)
|
| 18 |
8. [結果分頁說明](#8-結果分頁說明)
|
| 19 |
+
9. [語言指標分類(75 項,畫面顯示 62 項)](#9-語言指標分類75-項畫面顯示-62-項)
|
| 20 |
10. [統計方法說明](#10-統計方法說明)
|
| 21 |
11. [常見問題與疑難排解](#11-常見問題與疑難排解)
|
| 22 |
|
|
|
|
| 29 |
| Agent | 模型 | 職責 |
|
| 30 |
|-------|------|------|
|
| 31 |
| **主 Agent(LLM1)** | `google/gemma-4-31b-it` | 研究情境引導、最終研究報告撰寫 |
|
| 32 |
+
| **工作程序 Agent(LLM2)** | `google/gemma-4-26b-a4b-it` | 上傳引導、格式驗證回覆 |
|
| 33 |
|
| 34 |
+
系統會對上傳的中文譯本計算 **75 項語言指標**(畫面顯示與 LLM 報告引用 **62 項**,詳見 [第 9 節](#9-語言指標分類75-項畫面顯示-62-項)),進行**敘述統計、差異性檢定、機器學習分群與分類**,最後由 LLM 產出一份研究報告。上傳的文本須先經由姊妹系統「**語料淬鍊(CorpusRefinery)**」完成斷詞與詞性標註。
|
| 35 |
|
| 36 |
---
|
| 37 |
|
|
|
|
| 39 |
|
| 40 |
### 環境需求
|
| 41 |
- Python 3.x
|
| 42 |
+
- 套件:`gradio`、`pandas`、`numpy`、`scipy`、`scikit-learn`、`matplotlib`、`python-docx`、`openpyxl`(完整清單見 `requirements_agent.txt`)
|
| 43 |
- OpenRouter API 金鑰(供 LLM 呼叫)
|
| 44 |
|
| 45 |
### 環境變數設定
|
| 46 |
+
LLM 透過 OpenRouter 呼叫,於專案目錄的 `.env` 檔設定以下變數(可複製 `.env.example` 修改):
|
| 47 |
|
| 48 |
```
|
| 49 |
+
LLM1_API_KEY 你的 OpenRouter 金鑰(主 Agent)
|
| 50 |
+
LLM1_MODEL google/gemma-4-31b-it(預設,可不填)
|
| 51 |
+
LLM2_API_KEY 你的 OpenRouter 金鑰(工作程序 Agent)
|
| 52 |
+
LLM2_MODEL google/gemma-4-26b-a4b-it(預設,可不填)
|
| 53 |
```
|
| 54 |
|
| 55 |
### 啟動系統
|
| 56 |
|
| 57 |
+
於專案目錄執行:
|
| 58 |
+
|
| 59 |
```powershell
|
|
|
|
| 60 |
python app_agent.py
|
| 61 |
```
|
| 62 |
|
| 63 |
啟動後開啟瀏覽器前往:**http://localhost:7860**
|
| 64 |
|
| 65 |
+
> 💡 修改程式碼後,務必**完全關閉舊的 Python 程序**再重啟,否則舊程序可能仍佔用 7860 埠導致看到舊版畫面;瀏覽器端也建議 `Ctrl+Shift+R` 強制重整以清除快取。
|
| 66 |
|
| 67 |
---
|
| 68 |
|
| 69 |
## 3. 介面總覽
|
| 70 |
|
| 71 |
```
|
| 72 |
+
┌──────────────────────────────────────────────────���─────────────┐
|
| 73 |
+
│ 左側導覽列 │ 右側主內容區 │
|
| 74 |
+
│ ───────────── │ ────────────── │
|
| 75 |
+
│ 回首頁 │ 【首頁】研究情境選擇卡片 │
|
| 76 |
+
│ AI 對話 │ 【AI 對話】對話視窗(含步驟指示列) │
|
| 77 |
+
│ 語言指標分析 │ 【結果分頁】各分頁對應內容 │
|
| 78 |
+
│ 敘述統計 │ │
|
| 79 |
+
│ 差異性分析 │ │
|
| 80 |
+
│ 監督式學習 │ │
|
| 81 |
+
│ 非監督式學習 │ │
|
| 82 |
+
│ LLM 輔助分析 │ │
|
| 83 |
+
│ 技術執行紀錄 │ │
|
| 84 |
+
│ 使用手冊(下載捷徑) │ │
|
| 85 |
+
└────────────────────────────────────────────────────────────────┘
|
| 86 |
```
|
| 87 |
|
| 88 |
+
- **左側導覽列**:固定顯示於畫面左側,可切換淺色/深色模式、收合導覽列;分析完成前,除「回首頁」「AI 對話」外的分頁項目維持不可點選(灰階),分析完成後才會解鎖。
|
| 89 |
+
- **AI 對話**:與系統互動的主要介面,採對話泡泡形式呈現——系統訊息(AI 頭像,彩虹弧線圖示)靠左、使用者動作(如「選擇了研究情境」「上傳了 N 個檔案」「已填寫語料 Metadata 說明」)靠右,兩者以不同底色區分,讓對話一來一往的脈絡清楚可辨。
|
| 90 |
+
- **步驟指示列**:對話視窗上方即時顯示目前所在步驟(情境選擇/文本上傳/語料說明/分析運算),已完成步驟標示 ✓。
|
| 91 |
+
- **技術執行紀錄**:獨立分頁,逐行記錄系統各階段的技術性訊息(格式驗證、指標計算、統計分析、報告撰寫等),供排查問題使用。
|
| 92 |
+
- **結果分頁**:語言指標分析、敘述統計、差異性分析、監督式學習、非監督式學習、LLM 輔助分析,分析完成後自動可瀏覽。
|
| 93 |
+
- **使用手冊**:導覽列最底提供下載捷徑,可直接下載本手冊(`.docx`)。
|
| 94 |
|
| 95 |
---
|
| 96 |
|
| 97 |
+
## 4. 操作流程(四大步驟)
|
| 98 |
|
| 99 |
+
對話視窗上方的步驟指示列會即時反映目前所在步驟:
|
| 100 |
|
| 101 |
| 步驟 | 名稱 | 說明 |
|
| 102 |
|------|------|------|
|
| 103 |
+
| **1** | 情境選擇 | 於首頁卡片點選四種研究情境之一 |
|
| 104 |
+
| **2** | 文本上傳 | 上傳已斷詞的 `.txt` 檔(可多選)→ 按「確認上傳」;系統會先接收檔案,再自動驗證是否為「語料淬鍊(CorpusRefinery)」斷詞格式,不符者會列出原因並要求重新上傳 |
|
| 105 |
+
| **3** | 語料說明 | 格式驗證通過後,跳出「語料 Metadata 說明」表單,填寫後按「確認並開始分析」,或按「略過」直接進入下一步(詳見 [第 7 節](#7-語料-metadata-說明)) |
|
| 106 |
+
| **4** | 分析運算 | 依序完成三個子階段:**指標計算**(語言指標)→ **統計分析**(敘述統計、差異性檢定、機器學習)→ **報告撰寫**(主 Agent 撰寫研究報告);每個子階段完成後可立即點擊對應連結查看該分頁結果,不需等全部跑完 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
+
> ⚠️ 分析運算期間請保持分頁在前景、電腦勿休眠,直到看到「分析完成」為止,避免連線中斷���致畫面卡住。運算中途仍可按「回首頁」中止分析並重新開始。
|
| 109 |
|
| 110 |
---
|
| 111 |
|
|
|
|
| 118 |
| **3** | 不同譯者的同期譯本 | 二群以上(A/B/C…) | 個人風格差異(排除時代因素) |
|
| 119 |
| **4** | 同一原文的不同時期人工譯本 | 二群以上(A/B/C…) | 歷時變化、語言現代化、翻譯規範演變 |
|
| 120 |
|
| 121 |
+
> 情境 **1、2 固定為二群**,故 Metadata 表單中的「群組數量」「比較方式」欄位會自動隱藏;情境 **3、4** 則會顯示,可指定群組數量(二群/三群)與比較方式(群組間比較/個體間比較)。
|
| 122 |
|
| 123 |
---
|
| 124 |
|
|
|
|
| 131 |
- 範例:`A_CH1_seg.txt`、`B_DB1_seg.txt`(A 群、B 群各自的個體檔)
|
| 132 |
|
| 133 |
### 6.2 斷詞要求(必要條件)
|
| 134 |
+
- 文本**必須先經由 [語料淬鍊(CorpusRefinery)](https://github.com/minchun-git/I_CorpusRefinery) 工具完成斷詞**才能上傳。
|
| 135 |
+
- 系統會自動驗證格式(取樣前 100 行,判斷含底線 `_` 的 token 佔比是否達 70% 以上、且偵測到至少一個 CKIP 詞性特徵),不符者會列出檔名與原因,要求重新處理後再上傳。
|
| 136 |
|
| 137 |
### 6.3 內容格式
|
| 138 |
每行為下列之一:
|
| 139 |
- `詞_詞性`(例如:`翻譯_VC`)
|
|
|
|
| 140 |
- 空白行(段落分隔)
|
| 141 |
|
| 142 |
---
|
|
|
|
| 148 |
### 一、原文資訊
|
| 149 |
原文標題、作者、語言、出版年、版本。
|
| 150 |
|
| 151 |
+
### 二、群組數量與比較方式(僅情境 3、4 顯示)
|
| 152 |
- **群組數量**:二群/三群
|
| 153 |
+
- **比較方式**:
|
| 154 |
+
- **群組間比較**:沿用檔名字首分組(A/B/C…),為預設方式。
|
| 155 |
+
- **個體間比較**:改依「個體層資訊」所填的「譯者/系統名稱」作為實際分組依據,**會真正影響差異性分析、機器學習等統計結果**,不只是報告文字的表面敘述。若同一名稱重複出現於多筆個體(例如同一譯者跨檔案比較),系統**不會**把它們合併成同一組,而是依「出版年」→「重譯序位」→「檔名」依序消歧義,組成如「陳大文(2001)」的唯一分組標籤,確保逐檔案比較不因同名而被誤併。
|
| 156 |
- (情境 1、2 固定二群,此區自動隱藏)
|
| 157 |
|
| 158 |
### 三、群組與個體資訊
|
| 159 |
+
- **群組層**(依檔名首字母自動列出各組別,如「組別 A」「組別 B」;每組各為一組獨立欄位):群組標籤、翻譯類型、翻譯方向、譯者背景、目標讀者。
|
| 160 |
+
- **個體層**(可編輯表格,依上傳檔案自動逐筆列出):檔案、譯者/系統名稱、出版年、譯者母語、重譯序位。
|
| 161 |
|
| 162 |
+
填妥後按 **「確認並開始分析」**;若不需填寫可按 **「略過」** 直接分析。所填內容會編譯成結構化說明,作為 LLM 撰寫研究報告的背景脈絡;「比較方式」若選「個體間比較」,還會進一步實際改變統計分組(見上)。
|
| 163 |
|
| 164 |
---
|
| 165 |
|
| 166 |
## 8. 結果分頁說明
|
| 167 |
|
| 168 |
+
分析完成後,左側導覽列的各分頁自動解鎖:
|
| 169 |
|
| 170 |
### 📊 語言指標分析
|
| 171 |
+
- 62 項語言指標(畫面顯示範圍,詳見 [第 9 節](#9-語言指標分類75-項畫面顯示-62-項))的計算結果表格。
|
| 172 |
+
|
| 173 |
+
### 📈 敘述統計
|
| 174 |
+
- 各指標的樣本數(N)、平均數(M)、標準差(SD)、中位數、最小/最大值、變異係數。
|
| 175 |
+
|
| 176 |
+
### 📉 差異性分析
|
| 177 |
+
- 群組間差異檢定(t 檢定/ANOVA)、p 值、顯著性、效果量,並明確標示各組別中平均數最高/最低者(附實際數值)。
|
| 178 |
+
- 表格上方附「N=樣本數、M=平均數、SD=標準差」圖例說明。
|
| 179 |
+
- 顯著指標箱形圖(依效果量絕對值排序,取前 12 項),圖題同時標示 p 值與效果量,橫軸使用語料說明所填的實際群組標籤。
|
| 180 |
+
|
| 181 |
+
### 🌲 監督式學習
|
| 182 |
+
- 隨機森林分類準確率、特徵重要性 Top 20(中文標題與座標軸)。
|
| 183 |
|
| 184 |
+
### 🔀 非監督式學習
|
| 185 |
+
- 階層式分群樹狀圖(Ward 法)、K-means 手肘圖、PCA 二維視覺化分群結果,座標軸與圖例皆為中文。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
+
### 📝 LLM 輔助分析
|
| 188 |
+
- 主 Agent(LLM1)撰寫的深度研究報告,含語料說明、數據事實描述(差異性分析/監督式學習/非監督式學習皆須明確指出方向與數值)、情境特定分析、待驗證事項、已知限制五大段落。
|
| 189 |
+
- 可下載 Word(`.docx`)與純文字(`.txt`)。
|
| 190 |
+
|
| 191 |
+
### ⚙️ 技術執行紀錄
|
| 192 |
+
- 逐行顯示系統各階段的技術性執行訊息,供排查問題使用。
|
| 193 |
+
|
| 194 |
+
### 下載
|
| 195 |
+
- 左側導覽列提供單一「**下載全部資料**」捷徑,一次取得語言指標原始檔(CSV/XLSX,完整 75 項)、統計與機器學習輸出(含 `05_cluster_results.csv`,欄位已中文化為「原始分組(A/B)」)、圖表與研究報告(docx/txt)之壓縮檔(ZIP)。
|
| 196 |
|
| 197 |
---
|
| 198 |
|
| 199 |
+
## 9. 語言指標分類(75 項,畫面顯示 62 項)
|
| 200 |
|
| 201 |
+
| 類別 | 指標數 | 代碼範圍 | 畫面顯示 |
|
| 202 |
+
|------|--------|----------|----------|
|
| 203 |
+
| 基礎統計 | 6 | AA_01 ~ AA_06 | 4(AA_02 相異字數、AA_04 相異詞數隱藏) |
|
| 204 |
+
| 詞彙難易度 | 2 | AC_01、AC_02 | 2 |
|
| 205 |
+
| 詞彙多樣性 | 2 | AD_02、AD_03 | 2 |
|
| 206 |
+
| 語意複雜度 | 4 | AG_01、AG_02、AG_05、AG_06 | 4 |
|
| 207 |
+
| 語法複雜度 | 4 | AH_01、AH_03、AH_06、AH_08 | 4 |
|
| 208 |
+
| 篇章連貫 | 11 | AJ_01 ~ AJ_11 | 0(全數隱藏) |
|
| 209 |
+
| 詞性標記 | 46 | AF_01 ~ AF_47(除 AF_13) | 46 |
|
| 210 |
+
| **合計** | **75** | | **62** |
|
| 211 |
|
| 212 |
+
> **關於隱藏指標**:AA_02、AA_04 與 AJ_01~AJ_11 共 13 項指標,因與目前分析焦點關聯性較弱,自「語言指標分析」表格、統計分頁與 LLM 報告中**隱藏不顯示**。此隱藏僅作用於畫面與報告,**不影響原始資料**——下載的 `textIndex_calculated.csv`/`.xlsx` 仍完整保留全部 **75 項**指標,供有進階需求的研究者取用。
|
| 213 |
|
| 214 |
---
|
| 215 |
|
| 216 |
## 10. 統計方法說明
|
| 217 |
|
| 218 |
+
- **分組依據**:預設為檔名第一個字母(A、B、C…);若語料說明選「個體間比較」,則改依填寫的譯者/系統名稱(含消歧義後綴)分組(見 [第 7 節](#7-語料-metadata-說明))。
|
| 219 |
- **差異檢定**:
|
| 220 |
- 二群 → Independent t-test(Welch)
|
| 221 |
- 三群以上 → One-way ANOVA
|
| 222 |
- 顯著水準 α = **0.05**
|
| 223 |
+
- **效果量**:t 檢定用 Cohen's d;ANOVA 用 η²(含大/中/小/極小判讀)。報告文字一律須明確指出各組別中平均數最高/最低者,不會只列出統計量而不說明方向。
|
| 224 |
+
- **監督式學習**:隨機森林分類器(500 棵樹),輸出準確率、樣本外預測意義判讀,與特徵重要性 Top 20。
|
| 225 |
+
- **非監督式學習**:階層式分群(Ward)、K-means(含 Elbow 法決定群數)、PCA 2D 降維視覺化;分群結果下載檔已將內部欄位 `Type1`/`Type2` 整理為「原始分組(A/B)」單一欄位。
|
| 226 |
|
| 227 |
---
|
| 228 |
|
| 229 |
## 11. 常見問題與疑難排解
|
| 230 |
|
| 231 |
**Q1:上傳後畫面卡住、無法點選?**
|
| 232 |
+
上傳卡片為行內卡片,按「確認上傳」後會自動關閉並顯示處理進度。若仍卡住,請按 `Ctrl+Shift+R` 強制重整。
|
| 233 |
|
| 234 |
**Q2:修改設定後畫面沒更新?**
|
| 235 |
請完全關閉舊 Python 程序再重啟,並用 `Ctrl+Shift+R` 清除瀏覽器快取。確認 7860 埠的��序是新啟動的實例。
|
| 236 |
|
| 237 |
**Q3:指標數量不一致 / 顯示舊結果?**
|
| 238 |
+
結果分頁的數據在「分析運算」當下才生成。重啟 app 或重整頁面**不會**重算既有結果——需**完整重跑一次分析**(重新上傳→計算)。
|
| 239 |
|
| 240 |
**Q4:埠 7860 被佔用?**
|
| 241 |
```powershell
|
|
|
|
| 244 |
確認 7860 完全釋放後再啟動。
|
| 245 |
|
| 246 |
**Q5:格式驗證一直失敗?**
|
| 247 |
+
確認檔案確實經由「語料淬鍊(CorpusRefinery)」斷詞,且內容為 `詞_詞性` 或空白行格式;若上傳前已刪除部分檔案,請確認畫面上實際顯示的檔案清單為目前想上傳的全部檔案(系統以畫面所見為準,不會保留先前已刪除的檔案)。
|
| 248 |
+
|
| 249 |
+
**Q6:語言指標分析表格為什麼看起來比 75 項少?**
|
| 250 |
+
共 13 項指標(相異字數 AA_02、相異詞數 AA_04、篇章連貫 AJ_01~AJ_11)於畫面與報告中預設隱藏,詳見 [第 9 節](#9-語言指標分類75-項畫面顯示-62-項);完整 75 項仍保留於下載的原始 CSV/XLSX 檔案中。
|
| 251 |
+
|
| 252 |
+
**Q7:「個體間比較」選了之後,統計結果好像沒變?**
|
| 253 |
+
請確認語料說明的「個體層資訊」表格中,各筆「譯者/系統名稱」欄位已確實填寫;若留白,該筆將無法納入個體分組。
|
| 254 |
|
| 255 |
---
|
| 256 |
|
| 257 |
+
*文件版本:2026-08-22|對應系統:TransPrism II(Spectra Engine)*
|