minchun Claude Sonnet 5 commited on
Commit
e02ce54
·
1 Parent(s): f1121d3

部署前整備:對話氣泡真人感、隱私告知、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>

Files changed (5) hide show
  1. app.py +31 -0
  2. app_agent.py +624 -127
  3. assets/app.css +30 -4
  4. 使用手冊.docx +2 -2
  5. 使用手冊.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/suhsiung/ckip_segmentation_sumin) 工具完成斷詞。\n"
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/suhsiung/ckip_segmentation_sumin) 工具完成斷詞。\n"
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/suhsiung/ckip_segmentation_sumin) 工具完成斷詞。\n"
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/suhsiung/ckip_segmentation_sumin) 工具完成斷詞。\n"
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
- ### 2.1 語言現代化的跡象
213
  - 詞彙難易度(AC_01, AC_02)是否隨時間呈現規律變化?
214
  - 成語比率(AG_06)在不同時期譯本中的變化方向?
215
  - 若出現非單調變化,明確標示此為反常模式,需進一步解釋。
216
 
217
- ### 2.2 句式與篇章結構的演變
218
  - 段落數、句數(AA_05, AA_06)的變化是否反映閱讀習慣的改變?
219
  - 詞性分布中,連接詞、代詞比例的變化是否呈現顯著化或隱化趨勢?
220
 
221
- ### 2.3 譯本間的親疏關係
222
  - 根據分群結果,時序相鄰的譯本是否聚為一群?
223
  - 若出現時序相隔較遠��譯本反而更相似的情況,標記為需進一步探討的現象。
224
 
225
  ### 禁令補充
226
  - 不得將「較新」直接等同於「較現代」或「較好」。"""
227
 
228
- SCENARIO_MODULE_2 = """## 二、情境特定分析:人機差異分析
229
 
230
- ### 2.1 機器翻譯典型特徵的檢視
231
  根據量化數據檢視機器翻譯是否呈現以下已知特徵:
232
  - 詞彙多樣性是否偏低?(MSTTR, RTTR)
233
  - 句長是否較為整齊(變異數較小)?
234
 
235
- ### 2.2 人工翻譯的差異化特徵
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
- ### 2.1 核心風格指標的差異
261
  - 在哪些指標上,譯者間差異達到統計顯著?
262
 
263
- ### 2.2 風格穩定性的檢視
264
  - 譯者內變異 vs 譯者間變異的比較
265
 
266
  ### 禁令補充
@@ -290,26 +369,49 @@ SYSTEM_PROMPT_BASE = """# 角色定義
290
  【使用者補充關注點】:{user_focus}
291
 
292
  # 格式規範(務必嚴格遵守,直接影響報告的可讀性)
293
- 1. 以下「通用輸出結構」中 `## 一、`、`## 三、`、`## 四、` 這幾個章節標題,輸出時必須原樣保留 `## ` 這個 Markdown 標題語法開頭,不可改寫成「**粗體**」或其他格式。
294
  2. 章節內若需要子標題或小節提示語(例如「總體事實」「觀察」「結論」之類的段落標籤),一律使用 `#### ` 開頭的四級標題語法,不可用「**粗體**」取代標題。
295
  3. `**粗體**` 只能用於一般敘述中的強調字詞,不可整行只放一個粗體片語來充當標題。
296
  4. 表格請使用標準 Markdown 表格語法(`| 欄位 | 欄位 |` 搭配 `|---|---|` 分隔列)。
 
 
 
 
297
 
298
  # 通用輸出結構
299
- 輸出必須包含以下四個區塊,缺一不可:
300
-
301
- ## 一、數據事實描述
302
- 僅陳述統計結果,每項陳述後標註對應的指標代碼與數值。
303
- 區分:達統計顯著的差異(標明 p 值與效果量)、未達顯著但數值差距較大的趨勢、無明顯差異的指標。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "不符合 ckip_segmentation_sumin 的輸出格式。"
469
  )
470
  if not has_ckip_signature:
471
- return False, "未偵測到 CKIP 詞性標記,可能非 ckip_segmentation_sumin 產生的檔案。"
472
 
473
- return True, "格式符合 ckip_segmentation_sumin 輸出。"
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
- sig_cols = sig_df['指標'].tolist()[:12]
 
 
 
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(list(groups))
957
- pv = diff_df.loc[diff_df['指標']==col, 'p值'].values
958
- pv_str = f"p={pv[0]:.4f}" if len(pv)>0 else ""
959
- ax.set_title(f"{col}\n({pv_str})", fontsize=8)
 
 
 
 
 
 
960
  for j in range(len(sig_cols), len(axes)):
961
  axes[j].set_visible(False)
962
- plt.suptitle(f"Significant Variables by {GROUP_COL}", fontsize=11, y=1.01)
 
 
 
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("Feature Importance")
999
- ax.set_title(f"Random Forest — Top {top_n} Feature Importance")
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("Hierarchical Clustering Dendrogram (Ward)")
1016
- ax.set_xlabel("Sample"); ax.set_ylabel("Distance")
 
 
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("Number of Clusters (K)"); ax.set_ylabel("Inertia (SSE)")
1035
- ax.set_title("K-means Elbow Curve"); ax.set_xticks(list(range(1, max_k+1)))
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
- ['Hierarchical Clustering', f'K-means (K={N_CLUSTERS})']):
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"Cluster {cl}", s=100, edgecolors='k')
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"PC1 ({pca.explained_variance_ratio_[0]:.1%})" if X_scaled.shape[1]>=2 else "PC1")
1059
- ax.set_ylabel(f"PC2 ({pca.explained_variance_ratio_[1]:.1%})" if X_scaled.shape[1]>=2 else "PC2")
1060
  ax.set_title(title); ax.legend()
1061
- plt.suptitle("Clustering Results (PCA 2D Projection)", fontsize=12)
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
- result_cols = ['Text','Type1','Type2',GROUP_COL,'HC_Cluster','KM_Cluster']
 
 
 
 
 
 
 
 
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
- df[result_cols].to_csv(str(cluster_csv_path), index=False, encoding='utf-8-sig')
 
 
 
 
 
 
 
 
 
 
 
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
- doc.add_paragraph(f'分組依據:{GROUP_COL}(共 {n_groups} 組)。採用 {method_name},顯著水準 α = {ALPHA}。')
 
 
 
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(stripped[5:], level=4)
1314
- elif stripped.startswith('### '): doc.add_heading(stripped[4:], level=3)
1315
- elif stripped.startswith('## '): doc.add_heading(stripped[3:], level=2)
1316
- elif stripped.startswith('# '): doc.add_heading(stripped[2:], level=1)
1317
- elif stripped.startswith('- '): doc.add_paragraph(stripped[2:], style='List Bullet')
1318
- elif stripped.startswith('* '): doc.add_paragraph(stripped[2:], style='List Bullet')
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
- # 格式驗證失敗時只會清掉不合格的檔案(見 run_format_check),這裡補傳
1556
- # 要跟先前保留下來、已通過驗證的檔案合併,而不是整批覆蓋掉,使用者才
1557
- # 只需要補傳有問題的那幾個檔案,同名檔案則視為修正版本直接取代舊的
1558
- existing = state.get("uploaded_files", [])
1559
- new_names = {Path(p).name for p in saved_paths}
1560
- merged_paths = [p for p in existing if Path(p).name not in new_names] + saved_paths
1561
- state["uploaded_files"] = merged_paths
 
 
 
 
 
 
 
 
 
 
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"[WARNING] 請確認 {'、'.join(sorted(required_prefixes))} 兩組皆已上傳,"
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
- "請用繁體中文告知使用者所有檔案已確認為 ckip_segmentation_sumin 斷詞格式,"
1622
  "即將開始計算指標,30 字以內。"
1623
  )
1624
  try:
1625
  msg = call_worker_agent(sys_p, "所有檔案格式驗證通過。")
1626
  except:
1627
- msg = "[OK] 所有檔案均符合 ckip_segmentation_sumin 斷詞格式,即將開始計算文本指標……"
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
- "部分檔案不符合 ckip_segmentation_sumin 斷詞工具的輸出格式,"
1635
  "必須重新使用該工具斷詞後再上傳,語氣清晰,80 字��內。"
1636
- "請附上工具連結:https://github.com/suhsiung/ckip_segmentation_sumin"
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"[WARNING] 以下檔案不符合 ckip_segmentation_sumin 斷詞格式,請重新處理後再上傳:\n\n"
1644
  f"{fail_list}\n\n"
1645
- "請使用 [ckip_segmentation_sumin](https://github.com/suhsiung/ckip_segmentation_sumin) "
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
- n_ind = max(len(idx_df.columns) - sum(c in idx_df.columns for c in ("Text","Type1","Type2")), 0)
 
 
 
 
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(csv_path, output_dir)
 
 
 
 
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
- zone.scrollIntoView({ behavior: 'smooth', block: 'center' });
 
 
 
 
 
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">75</span> 項語言指標並進行統計分析,'
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
- "下載完整分析資料", icon=str(BASE_DIR / "assets" / "icons" / "download.svg"),
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("### 研究選擇\n請選擇最符合您研究設計的比較情境,選定後將進入下一步「文本上傳」。")
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
- meta_group_df = gr.Dataframe(
3069
- headers=["群組標籤", "翻譯類型", "翻譯方向", "譯者背景", "目標讀者"],
3070
- datatype=["str"] * 5,
3071
- col_count=(5, "fixed"),
3072
- row_count=(1, "dynamic"),
3073
- interactive=True,
3074
- label="群組層",
3075
- )
3076
-
 
 
 
 
 
 
 
 
 
 
 
 
 
3077
  gr.Markdown("**個體層資訊**(每個譯本/檔案)")
3078
  meta_indiv_df = gr.Dataframe(
3079
- headers=["檔案", "譯者/系統名稱", "出版年", "譯者母語", "後編輯程度", "重譯序位"],
3080
- datatype=["str"] * 6,
3081
- col_count=(6, "fixed"),
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
- tab_rpt_md = gr.Markdown(_EMPTY_HINT)
 
 
 
 
 
 
 
 
 
 
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 df.columns]
3289
- n_indicators = len(df.columns) - len(meta_cols)
3290
- R[0] = f"### 語言指標分析\n共 **{len(df)}** 個文本,**{n_indicators}** 個語言指標。"
3291
- R[1] = gr.update(value=df, visible=True)
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)}** 個指標,其中 **{len(sig)}** 個達顯著差異(p < {ALPHA})。{empty_note}",
 
 
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
- R[17] = gr.update(value=cdf, visible=True)
 
 
 
 
 
 
 
 
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
- for _i, _key in ((26, "idx"), (27, "stat"), (28, "report"), (29, "all")):
 
 
 
 
 
 
 
 
 
3430
  if zips.get(_key):
3431
  R[_i] = gr.update(value=zips[_key], visible=True)
3432
- # 只要有任何一包 zip,下載捷徑整組(含分隔線)就顯示(B3)
3433
- if zips:
 
 
 
 
 
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
- return [[g, "", "", "", ""] for g in groups], [[f, "", "", "", "", ""] for f in files]
 
 
 
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, group_df, indiv_df):
 
 
 
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 r in _df_rows(group_df):
4071
- if not any(str(c).strip() for c in r):
4072
- continue
4073
- L.append("- " + ";".join(f"{h}:{str(v).strip()}" for h, v in zip(gh, r) if str(v).strip()))
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, _NO, _NO, _NO
4141
- group_rows, indiv_rows = _build_meta_tables(state)
4142
  show_cmp = state.get("scenario", "") in ("3", "4")
4143
- return (gr.update(visible=True), gr.update(value=group_rows),
 
 
 
 
 
 
 
 
 
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, meta_group_df, meta_indiv_df, meta_compare_group],
 
 
 
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, group_df, indiv_df,
 
 
 
 
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"[WARNING] 您選擇「{ngroups}」,但實際上傳的檔案偵測到 "
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, group_df, indiv_df)
 
 
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, meta_group_df, meta_indiv_df,
 
 
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=True,
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
- /* 需要時單獨還原語言指標分析(76 欄)的橫向捲軸:
2358
- #canvas_idx .virtual-table-viewport { scrollbar-width: thin; scrollbar-color: transparent transparent; }
2359
- #canvas_idx .virtual-table-viewport:hover { scrollbar-color: var(--c-scrollbar) transparent; }
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:68d74c3bcd3a6765725f5d668cce3c7819acb6d379714c065167907de70b0584
3
- size 41783
 
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|分析引擎 Spectra Engine**
4
 
5
  > 本手冊說明系統的安裝啟動、操作流程、檔案格式要求、各分頁功能與疑難排解。
6
 
@@ -11,12 +11,12 @@
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 項)](#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
- │ 分頁列:主頁 │ 語言指標分析 │ 統計比較分析 │ LLM輔助分析 │
75
- ├───────────────────────────────────┬──────────────────┤
76
- │ 對話視窗(與系統互動) │ │
77
- │ │ 執行進度 │
78
- │ [輸入框] [送出] │ (to-do-list) │
79
- │ │ │
80
- │ 執行視窗(即時訊息紀錄,最新在上) │ │
81
- └───────────────────────────────────┴──────────────────┘
 
 
 
82
  ```
83
 
84
- - **對話視窗**:與系統對話、選擇情境、接收引導訊息。
85
- - **輸入框 + 送出**:輸入指令(如情境編號)。
86
- - **執行視窗**:即時顯示系統各步驟的執行訊息(最新置頂)。
87
- - **執行進度(右欄)**:選定情境後出現的 to-do-list,即時勾選 1~8 步驟進度。
88
- - **結果分頁**:分析完成後自動顯示,初始為隱藏。
 
89
 
90
  ---
91
 
92
- ## 4. 操作流程(八大步驟)
93
 
94
- 右欄「執行進度」會即時反映目前所在步驟:
95
 
96
  | 步驟 | 名稱 | 說明 |
97
  |------|------|------|
98
- | **1** | 情境選擇 | 在對話視窗輸入 `1`、`2`、`3` 或 `4` 選擇研究情境 |
99
- | **2** | 上傳引導 | 系統說明檔名規範與斷詞格式要求 |
100
- | **3** | 文本上傳 | 點選上傳卡片,選擇已斷詞的 `.txt` 檔(可多選)→ 確認上傳 |
101
- | **4** | 格式驗證 | 系統自動檢查是否為 ckip_segmentation_sumin 斷詞格式 |
102
- | **5** | 指標計算 | 計算 75 項語言指標 |
103
- | **6** | 統計分析 | 敘述統計、差異檢定、機器學習 |
104
- | **7** | 報告撰寫 | 主 Agent(LLM1)撰寫研究報告 |
105
- | **8** | 下載成果 | 於各結果分頁檢視與下載 |
106
 
107
- > **重要新增步驟**:在步驟 4(格式驗證通過)與步驟 5(指標計算)之間,系統會跳出**「語料 Metadata 說明」表單**,請填寫後按「確認並開始分析」才會進入計算(詳見 [第 7 節](#7-語料-metadata-說明))。
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 表單中的「比較結構」欄位會自動隱藏;情境 **3、4** 則會顯示,可指定群組數量與比較方式。
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
- - 文本**必須先經由 [ckip_segmentation_sumin](https://github.com/suhsiung/ckip_segmentation_sumin) 工具完成斷詞**才能上傳。
134
- - 系統會自動驗證格式,不符者會要求重新處理。
135
 
136
  ### 6.3 內容格式
137
  每行為下列之一:
138
  - `詞_詞性`(例如:`翻譯_VC`)
139
- - `_詞性`
140
  - 空白行(段落分隔)
141
 
142
  ---
@@ -148,81 +148,94 @@ python app_agent.py
148
  ### 一、原文資訊
149
  原文標題、作者、語言、出版年、版本。
150
 
151
- ### 二、比較結構(僅情境 3、4)
152
  - **群組數量**:二群/三群
153
- - **比較方式**:群組間比較/個體間比較
 
 
154
  - (情境 1、2 固定二群,此區自動隱藏)
155
 
156
  ### 三、群組與個體資訊
157
- - **群組層**(可編輯表格,依檔名首字母自動列出各群):群組標籤、翻譯類型、翻譯方向、譯者背景、目標讀者。
158
- - **個體層**(可編輯表格,依上傳檔案自動逐筆列出):譯者/系統名稱、出版年、譯者母語、後編輯程度、重譯序位。
159
 
160
- 填妥後按 **「確認並開始分析」**;若不需填寫可按 **「略過」** 直接分析。所填內容會編譯成結構化說明,作為 LLM 撰寫研究報告的背景脈絡。
161
 
162
  ---
163
 
164
  ## 8. 結果分頁說明
165
 
166
- 分析完成後,頂部分頁自動顯示:
167
 
168
  ### 📊 語言指標分析
169
- - 75 項語言指標的計算結果表格。
170
- - 可下載 CSV / XLSX 原始數據。
 
 
 
 
 
 
 
 
 
 
171
 
172
- ### 📈 統計比較分析(含 5 個子分頁)
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)撰寫的深度研究報告,可下載 Word(.docx)與純文字(.txt)。
 
 
 
 
 
 
 
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
- | **篇章連貫** | 11 | AJ_01 ~ AJ_11(連接詞與各類代詞照應) |
196
- | 詞性標記 | 46 | AF_01 ~ AF_47(除 AF_13) |
197
- | **合計** | **75** | |
198
 
199
- > 「語言指標分析」與「統計比較分析」的指標數一致,皆為 **75**。
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
- 上傳卡片為行內卡片,按「確認上傳」或「略過」後會自動關閉。若仍卡住,請按 `Ctrl+Shift+R` 強制重整。
220
 
221
  **Q2:修改設定後畫面沒更新?**
222
  請完全關閉舊 Python 程序再重啟,並用 `Ctrl+Shift+R` 清除瀏覽器快取。確認 7860 埠的��序是新啟動的實例。
223
 
224
  **Q3:指標數量不一致 / 顯示舊結果?**
225
- 結果分頁的數據在「執行計算」當下才生成。重啟 app 或重整頁面**不會**重算既有結果——需**完整重跑一次分析**(重新上傳→計算)。
226
 
227
  **Q4:埠 7860 被佔用?**
228
  ```powershell
@@ -231,8 +244,14 @@ Get-Process python | Stop-Process -Force
231
  確認 7860 完全釋放後再啟動。
232
 
233
  **Q5:格式驗證一直失敗?**
234
- 確認檔案確實經由 ckip_segmentation_sumin 斷詞,且內容為 `詞_詞性`/`_詞性`/空白行格式。
 
 
 
 
 
 
235
 
236
  ---
237
 
238
- *文件版本:2026-06|對應系統:TransPrism II(Spectra Engine)*
 
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)*