minchun Claude Sonnet 5 commited on
Commit
f1c47a3
·
1 Parent(s): fb8098c

統計穩定性警示、system prompt 補強、關於面板、多項 bug 修正

Browse files

統計正確性:
- cohens_d()/eta_squared() 分母趨近於零時回傳 None 而非失真數值
(新增 EFFECT_UNSTABLE_LABEL),修掉效果量算出天文數字的 bug
- 新增 ZERO_VARIANCE_WARNING:任一組標準差趨近於 0 但檢定仍算出
p 值的退化情況,加註「統計穩定性有限」警示,不隱藏顯著性判斷
- 新增 ZERO_PROPORTION_WARNING(門檻 50%):任一組半數以上樣本值
為 0、SD 未趨近於零但平均數可能被離群值主導的情況,獨立於上面
的 SD 判準另外偵測,兩者可疊加、不重複描述同一組資料
- 修正 docx 差異性分析表格欄位定位改用欄名查表(原本 len(vals)-4
位置推算已對錯欄)
- None/NaN 判斷全面改用 pd.isna(),修掉 docx 表格出現 "nan" 文字
- 群組標籤(如「人工翻譯」)修正為會實際套用到送給 LLM 的
difference_analysis 資料,不再只停留在畫面/圖表
- 差異性分析箱形圖:達顯著指標全部畫出(取消前 12 項上限),排序
改為先依組別、組內再依效果量絕對值
- 兩群比較用詞「較高/較低」、三群以上用詞「最高/最低」

System prompt(LLM1):
- 補上角色邊界、11 條嚴格禁令(原 5 條)、指標代碼對照表、
metadata 一致性檢查指示
- 新增 INDICATOR_REFERENCE_TABLE:從 INDEX_CATEGORIES 動態產生,
取代手動謄寫,避免與實際指標定義漂移
- 每次分析新增 06_system_prompt.txt 可重現性附件,保留當次實際
送出的完整 system prompt 原文

介面:
- 新增「關於」面板(系統版本、免責聲明、姊妹工具、開發紀錄;版權
/聯絡方式/引用格式因論文尚未發表,暫不公開真實姓名或 email)
- 情境 1、4 卡片標題拿掉「同一原文的」;情境選擇小標改為
「請選擇最符合您針對同一原文的重譯研究設計的比較情境」
- 「語料 Metadata 說明」全面改稱「語料說明」(含隱形橋接按鈕文字
同步)
- 隱私提醒文案調整(移除「先與計畫主持人確認」,改為「先行確認」)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Files changed (3) hide show
  1. app_agent.py +468 -66
  2. assets/app.css +48 -5
  3. assets/icons/info-circle.svg +1 -0
app_agent.py CHANGED
@@ -152,6 +152,48 @@ LLM2_MODEL = "google/gemma-4-26b-a4b-it"
152
  # 報告本身走,不用回頭 git blame 才查得到。
153
  PROMPT_VERSION = "v1"
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  ALPHA = 0.05
156
 
157
  # 2026-08-22(業主提出「數據完整性檢查」的必要性,追查後確認是真實存在的
@@ -166,6 +208,42 @@ ALPHA = 0.05
166
  # 這個常數判斷,不要漏掉、也不要各自硬寫字串。
167
  INSUFFICIENT_DATA_LABEL = "資料不足"
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  # ── POS 集合(與 Prog02 相同)───────────────────────────────────────────────
170
  Vset = ['VC','VH','VE','VJ','VA','V2','VK','VL','VD','VCL','VHC','VB','VF','VI','VAC','VG']
171
  Nset = ['Na','Nb','Nc','Ncd','Nd','Nf','Nh','Nv']
@@ -182,7 +260,7 @@ Aset = ['A']
182
  # ── 研究情境定義 ─────────────────────────────────────────────────────────────
183
  SCENARIOS = {
184
  "1": {
185
- "title": "同一原文的人工翻譯 vs 機器翻譯",
186
  "short": "人機對比",
187
  "upload_prompt": (
188
  "1. 檔案格式:僅接受 **.txt** 純文字檔。\n"
@@ -218,7 +296,7 @@ SCENARIOS = {
218
  "scenario_module_key": "scenario_4",
219
  },
220
  "4": {
221
- "title": "同一原文的不同時期人工譯本",
222
  "short": "歷時重譯",
223
  "upload_prompt": (
224
  "1. 檔案格式:僅接受 **.txt** 純文字檔。\n"
@@ -313,6 +391,28 @@ def _drop_hidden_indicators(df):
313
  # 之後隱藏清單增減,這裡跟著自動變動,不用每次巡一遍程式碼改字面數字。
314
  TOTAL_VISIBLE_INDICATORS = len(INDEX_NAME_MAP) - len(HIDDEN_INDICATOR_CODES)
315
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  # ── LLM 報告用情境模組(同 prog04)─────────────────────────────────────────
317
  SCENARIO_MODULE_1 = """## 三、情境特定分析:歷時變化分析
318
 
@@ -346,7 +446,6 @@ SCENARIO_MODULE_2 = """## 三、情境特定分析:人機差異分析
346
  - 人工譯本在哪些指標上顯著偏離機器譯本?
347
 
348
  ### 禁令補充
349
- - 不得對機器翻譯系統的品質做整體評價。
350
  - 不得預設「人工一定優於機器」或反之。"""
351
 
352
  SCENARIO_MODULE_3 = """## 三、情境特定分析:改寫策略分析
@@ -371,10 +470,7 @@ SCENARIO_MODULE_4 = """## 三、情境特定分析:個人風格差異分析
371
  - 在哪些指標上,譯者間差異達到統計顯著?
372
 
373
  ### 3.2 風格穩定性的檢視
374
- - 譯者內變異 vs 譯者間變異的比較
375
-
376
- ### 禁令補充
377
- - 不得將個人風格與「翻譯能力」掛鉤。"""
378
 
379
  SCENARIO_MODULES = {
380
  "scenario_1": SCENARIO_MODULE_1,
@@ -386,12 +482,39 @@ SCENARIO_MODULES = {
386
  SYSTEM_PROMPT_BASE = """# 角色定義
387
  你是一位翻譯研究的量化分析助理。你的工作是將統計數據轉化為結構化的觀察與假設,供研究者進一步驗證。
388
 
 
 
 
 
 
389
  # 嚴格禁令
390
- 1. 僅根據本次提供的量化數據進行分析,不得引入外部知識。
391
- 2. 不得使用「優秀」、「忠實」、「準確」、「流暢」等評價性詞彙。
392
- 3. 不得推測譯��的主觀意圖,除非數據直接支持且標明為推論。
393
- 4. 遇到數據中未涵蓋的問題,明確回答「本次數據無法回答此問題」。
394
- 5. 所有統計顯著性的陳述必須基於提供的 p 值或效果量。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
 
396
  # 輸入資料結構
397
  【研究情境】:{scenario_type}
@@ -415,23 +538,44 @@ SYSTEM_PROMPT_BASE = """# 角色定義
415
  ## 一、語料說明
416
  用 2-4 句話摘要上方【譯本 metadata】提供的原文資訊、群組層與個體層背景(例如原文標題、各組別的翻譯類型/方向、個別譯本的譯者或系統名稱等,視實際提供的內容而定)。
417
  若 metadata 顯示使用者略過未填,僅陳述「使用者未提供語料補充說明」,不得臆測或杜撰內容。這個區塊只做摘要陳述,不做任何統計分析或推論。
 
418
 
419
  ## 二、數據事實描述
420
  本區塊須包含以下三類數據事實,缺一不可(量化分析結果 JSON 裡都有對應資料,不得因為篇幅省略):
421
- 1. 差異性分析:每項陳述除了指標代碼、p 值與效果量之外,**必須明確指出各組別中平均數最高與
422
- 最低的是哪一組,並附上實際平均數數值**(difference_analysis 資料裡每個組別都有一欄以該組
423
  名稱結尾 _M 的平均數欄位)——只寫 p 值與效果量、不寫是哪一組偏高偏低,讀者無法理解差異的
424
  方向與意義。
425
  三組以上時是用 ANOVA 檢定,效果量(η²)本身不帶方向性(不像兩組時的 Cohen's d 還有正負號
426
- 可看出方向),更不能省略「哪一組最高、哪一組最低」這句話。
427
- 格式範例(三組以上):「總句數(AA_06):p=0.0068,η²=0.9932(大);2024 年組別最高
428
- (M=32.5),1989 年組別最低(M=18.2)。」
429
- 區分:達統計顯著的差異(標明 p 值、效果量、最高與最低組別及平均數)、未達顯著但數值差距
430
- 較大的趨勢(同樣要指出方向)、無明顯差異的指標。
 
 
 
 
 
 
 
431
  【資料完整性】若 JSON 附有「資料完整性提醒」欄位(表示部分指標因組別樣本數不足、根本無法
432
  進行統計檢定),這些指標一律只能陳述為「資料不足,無法檢定」,絕對不可以寫成「無顯著差異」,
433
  也不可以把它們計入「達統計顯著」或「無明顯差異」的統計比例——這是兩種完全不同的結論,混為
434
  一談會誤導讀者以為系統已經檢定過、確認沒有差異。
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  2. 監督式學習(隨機森林):說明 feature_importance_top20 裡重要性最高的 3-5 項指標與其重要性數值。
436
  同時陳述「隨機森林分類準確率」的數字,並原樣附上 JSON 裡「隨機森林準確率之樣本外預測意義」
437
  這欄給的完整句子(那句話已經是可以直接使用的中文敘述,不必再改寫或省略)。
@@ -956,20 +1100,38 @@ def run_indicator_calculation(file_paths: list, output_dir: Path, resources: dic
956
  # ============================================================================
957
 
958
  def cohens_d(g1, g2):
 
 
 
 
 
 
 
 
 
 
 
959
  n1, n2 = len(g1), len(g2)
960
  if n1 < 2 or n2 < 2:
961
- return 0.0
962
  var1, var2 = np.var(g1, ddof=1), np.var(g2, ddof=1)
963
  pooled = np.sqrt(((n1-1)*var1 + (n2-1)*var2) / (n1+n2-2))
964
- return (np.mean(g1) - np.mean(g2)) / pooled if pooled != 0 else 0.0
 
 
 
965
 
966
 
967
  def eta_squared(group_data):
 
968
  all_data = np.concatenate(group_data)
969
  grand_mean = all_data.mean()
970
  ss_between = sum(len(g) * (np.mean(g) - grand_mean)**2 for g in group_data)
971
  ss_total = np.sum((all_data - grand_mean)**2)
972
- return ss_between / ss_total if ss_total != 0 else 0.0
 
 
 
973
 
974
 
975
  def effect_label(val, method):
@@ -1102,11 +1264,33 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path, group_labels: dic
1102
  row["顯著"] = INSUFFICIENT_DATA_LABEL
1103
  row["效果量"] = None
1104
  row["效果大小"] = None
 
1105
  else:
1106
  es = cohens_d(gdata[0], gdata[1]) if n_groups == 2 else eta_squared(gdata)
1107
- row["顯著"] = ("***" if p<0.001 else "**" if p<0.01 else "*" if p<0.05 else "ns")
1108
- row["效果量"] = round(float(es), 4)
1109
- row["效果大小"] = effect_label(es, method)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1110
  diff_results.append(row)
1111
 
1112
  diff_df = pd.DataFrame(diff_results)
@@ -1115,17 +1299,40 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path, group_labels: dic
1115
  diff_path = output_dir / "02_difference_analysis.csv"
1116
  diff_df.to_csv(str(diff_path), index=False, encoding='utf-8-sig')
1117
 
1118
- # boxplot(最多 12 個顯著指標)
1119
  # 2026-08-14(業主要求):原本取前 12 項是照指標在資料表裡的固定欄位
1120
  # 順序(基礎統計→...→詞性標記)往下數,不是「最顯著/效果量最大」的
1121
- # 12 項。改成先依效果量絕對值由大到小排序,再取前 12——畫出來的會是
1122
- # 這次分析裡差異最明顯的那幾項,資訊量比原本的順序高。
 
 
 
1123
  box_path = None
1124
  if len(sig_df) > 0:
1125
- sig_df_sorted = sig_df.reindex(
1126
- sig_df['效果量'].abs().sort_values(ascending=False).index
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1127
  )
1128
- sig_cols = sig_df_sorted['指標'].tolist()[:12]
1129
  n_row = max(1, (len(sig_cols) + 2) // 3)
1130
  fig, axes = plt.subplots(n_row, 3, figsize=(14, 4*n_row))
1131
  axes = np.array(axes).flatten()
@@ -1146,15 +1353,23 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path, group_labels: dic
1146
  # 「有沒有差」。
1147
  row = diff_df.loc[diff_df['指標']==col]
1148
  pv_str = f"p={row['p值'].values[0]:.4f}" if len(row)>0 else ""
1149
- es_str = (f",{row['效果量名稱'].values[0]}={row['效果量'].values[0]:.3f}"
1150
- if len(row)>0 else "")
 
 
 
 
 
 
 
1151
  ax.set_title(f"{col}\n({pv_str}{es_str})", fontsize=8)
1152
  for j in range(len(sig_cols), len(axes)):
1153
  axes[j].set_visible(False)
1154
  # 2026-08-14(業主要求):原本「Significant Variables by Type1」直接
1155
- # 洩漏內部欄位名稱 Type1,使用者看不懂在比較什麼。改成中文、講清楚
1156
- # 這張圖是「前 12 項+依效果量排序」,不是全部顯著指標。
1157
- plt.suptitle("各組別顯著指標比較(前 12 項,依效果量排序)", fontsize=11, y=1.01)
 
1158
  plt.tight_layout()
1159
  box_path = output_dir / "02_significant_boxplots.png"
1160
  plt.savefig(str(box_path), dpi=150, bbox_inches='tight')
@@ -1325,7 +1540,9 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path, group_labels: dic
1325
  f"{row['平均數']:.4f}", f"{row['中位數']:.4f}",
1326
  f"{row['標準差']:.4f}", f"{row['最小值']:.4f}",
1327
  f"{row['最大值']:.4f}",
1328
- f"{row['變異係數(%)']:.2f}" if row['變異係數(%)'] is not None else "-"]
 
 
1329
  for j, val in enumerate(vals):
1330
  cell = tbl.rows[i].cells[j]
1331
  cell.text = val
@@ -1352,7 +1569,7 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path, group_labels: dic
1352
  group_sub_headers = []
1353
  for g in groups:
1354
  group_sub_headers.extend([f"{g}\nN", f"{g}\nM", f"{g}\nSD"])
1355
- diff_headers = ["指標"] + group_sub_headers + ["方法","統計量","p值","顯著","效果量","效果大小"]
1356
  diff_tbl = doc.add_table(rows=1+len(diff_df), cols=len(diff_headers))
1357
  diff_tbl.style = 'Light Shading Accent 1'
1358
  for j, h in enumerate(diff_headers):
@@ -1367,13 +1584,30 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path, group_labels: dic
1367
  vals.extend([str(int(row[f"{g}_N"])),
1368
  f"{row[f'{g}_M']:.4f}",
1369
  f"{row[f'{g}_SD']:.4f}"])
 
 
 
 
 
 
 
 
 
 
1370
  vals.extend([str(row['方法']),
1371
- f"{row['統計量']:.4f}" if row['統計量'] is not None else "-",
1372
- f"{row['p值']:.4f}" if row['p值'] is not None else "-",
1373
  str(row['顯著']),
1374
- f"{row['效果量']:.4f}" if row['效果量'] is not None else "-",
1375
- str(row['效果大小']) if row['效果大小'] is not None else "-"])
 
1376
  _is_untestable_row = row['顯著'] == INSUFFICIENT_DATA_LABEL
 
 
 
 
 
 
1377
  for j, val in enumerate(vals):
1378
  cell = diff_tbl.rows[i].cells[j]
1379
  cell.text = val
@@ -1385,11 +1619,16 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path, group_labels: dic
1385
  # 不能共用紅字——紅字在這份報告的視覺語言裡代表「這是
1386
  # 值得注意的發現」,「資料不足」剛好相反,是「這項不能
1387
  # 拿來當發現看」,用紅字反而會讓人誤以為它也是顯著結果。
1388
- if row['顯著'] not in ("ns", INSUFFICIENT_DATA_LABEL) and j == len(vals)-4:
1389
  r.font.color.rgb = RGBColor(0xCC, 0x00, 0x00)
1390
  elif _is_untestable_row:
1391
  r.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
1392
  r.italic = True
 
 
 
 
 
1393
  doc.add_paragraph('')
1394
  if box_path and box_path.exists():
1395
  doc.add_heading('顯著指標箱形圖', level=2)
@@ -1451,14 +1690,22 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path, group_labels: dic
1451
  # SECTION 7: LLM Report Writing (adapted from prog04, uses LLM1)
1452
  # ============================================================================
1453
 
1454
- def load_analysis_data_for_llm(csv_path: Path, output_dir: Path, ml_summary: dict = None) -> dict:
 
1455
  """讀取分析資料組成 JSON 結構。
1456
  ml_summary:{"accuracy": float, "do_eval": bool} 選填,來自
1457
  run_statistical_analysis() 的回傳值。之前這裡完全沒有把隨機森林的
1458
  準確率數字(accuracy)跟「這次是不是真的切了測試集」(do_eval)
1459
  交給 LLM,LLM 因此連準確率是多少都不知道,等於沒有材料可以討論
1460
  監督式學習結果——03_feature_importance.csv 只有各指標的重要性排名,
1461
- 沒有整體準確率這個數字。"""
 
 
 
 
 
 
 
1462
  data = {}
1463
  if ml_summary:
1464
  # 2026-08-15(業主回報):原本這裡是英文鍵名 rf_accuracy/
@@ -1494,6 +1741,17 @@ def load_analysis_data_for_llm(csv_path: Path, output_dir: Path, ml_summary: dic
1494
  p = output_dir / fname
1495
  if p.exists():
1496
  d = pd.read_csv(str(p))
 
 
 
 
 
 
 
 
 
 
 
1497
  if key == "cluster_results":
1498
  # 2026-08-15(業主要求):跟 rf_accuracy_is_meaningful 同一個
1499
  # 教訓——原本這裡的欄名是 Type1,LLM 直接照抄,報告寫成
@@ -1532,6 +1790,27 @@ def load_analysis_data_for_llm(csv_path: Path, output_dir: Path, ml_summary: dic
1532
  "或納入「達統計顯著」/「無明顯差異」的分類統計,兩者是完全不同的結論。"
1533
  if len(untestable) > 0 else ""
1534
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1535
  else:
1536
  data[key] = []
1537
  if "significant_count" not in data:
@@ -1569,13 +1848,16 @@ def _add_inline_markdown_runs(paragraph, text: str) -> None:
1569
 
1570
 
1571
  def run_report_writing(scenario_key: str, user_metadata: str, user_focus: str,
1572
- csv_path: Path, output_dir: Path, ml_summary: dict = None) -> dict:
 
1573
  """
1574
  呼叫主 Agent (LLM1) 撰寫研究報告,儲存 docx + txt,回傳路徑 dict。
1575
  ml_summary:見 load_analysis_data_for_llm() 的說明。
 
1576
  """
1577
  output_dir.mkdir(parents=True, exist_ok=True)
1578
- analysis_data = load_analysis_data_for_llm(csv_path, output_dir, ml_summary=ml_summary)
 
1579
 
1580
  scenario_info = SCENARIOS.get(scenario_key, SCENARIOS["1"])
1581
  scenario_mod_key = scenario_info.get("scenario_module_key", "scenario_1")
@@ -1592,7 +1874,27 @@ def run_report_writing(scenario_key: str, user_metadata: str, user_focus: str,
1592
  metadata = metadata_str,
1593
  user_focus = user_focus if user_focus else "無",
1594
  scenario_module = scenario_module,
 
1595
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1596
  user_prompt = (
1597
  f"請根據以下量化分析結果,按照 system prompt 中的輸出結構進行深度分析。\n\n"
1598
  f"【量化分析資料(JSON)】:\n```json\n"
@@ -1629,6 +1931,26 @@ def run_report_writing(scenario_key: str, user_metadata: str, user_focus: str,
1629
  style='Normal').alignment = WD_ALIGN_PARAGRAPH.CENTER
1630
  doc.add_paragraph(f'報告時間:{now_tw().strftime("%Y-%m-%d %H:%M:%S")}(台灣時間)',
1631
  style='Normal').alignment = WD_ALIGN_PARAGRAPH.CENTER
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1632
  doc.add_page_break()
1633
  # 2026-08-14(待辦 A1):區塊層級語法(標題/項目符號)沿用原本判斷,
1634
  # 差別是段落文字改用 _add_inline_markdown_runs() 逐段解析 **粗體**/
@@ -1652,6 +1974,15 @@ def run_report_writing(scenario_key: str, user_metadata: str, user_focus: str,
1652
  # txt 版之前完全沒寫,這裡補上、順便帶提示詞版本號,兩種格式的
1653
  # 稽核資訊要一致,不能只有 docx 有。
1654
  f.write(f"模型:主 Agent(LLM1){LLM1_MODEL}(提示詞版本 {PROMPT_VERSION})\n")
 
 
 
 
 
 
 
 
 
1655
  # 2026-07-27:原本用 "="*60 當純文字分隔線,但緊接在上兩行文字正下方,
1656
  # 符合 Markdown Setext 標題語法(文字正上方接一整行 = 會被解讀成 h1
1657
  # 底線),導致這兩行 metadata 被渲染成巨大無樣式標題。改成前面留空行
@@ -1661,9 +1992,10 @@ def run_report_writing(scenario_key: str, user_metadata: str, user_focus: str,
1661
  f.write(result_text)
1662
 
1663
  return {
1664
- "docx_path": docx_path,
1665
- "txt_path": txt_path,
1666
- "result_text": result_text,
 
1667
  }
1668
 
1669
 
@@ -2130,8 +2462,9 @@ def run_computation_pipeline_streaming(state: dict):
2130
  csv_path = csv_path,
2131
  output_dir = output_dir,
2132
  ml_summary = {"accuracy": anal.get("accuracy"), "do_eval": anal.get("do_eval")},
 
2133
  )
2134
- results_paths.extend([str(rpt["docx_path"]), str(rpt["txt_path"])])
2135
  A["report"]["status"] = "done"
2136
  A["report"]["line"] = "研究報告已產出"
2137
  yield " [OK] 研究報告撰寫完成", False, state
@@ -3000,11 +3333,11 @@ def create_app():
3000
  # 邏輯(on_scenario_pick)完全不用改。
3001
  sc_btn_1 = gr.Button("1 人工翻譯 vs 機器翻譯", variant="secondary",
3002
  elem_id="sc_btn_1", elem_classes=["sc-bridge-btn"])
3003
- sc_btn_2 = gr.Button("2 全譯 vs 編譯/節譯/改寫", variant="secondary",
3004
  elem_id="sc_btn_2", elem_classes=["sc-bridge-btn"])
3005
- sc_btn_3 = gr.Button("3 同期不同譯者譯本比較", variant="secondary",
3006
  elem_id="sc_btn_3", elem_classes=["sc-bridge-btn"])
3007
- sc_btn_4 = gr.Button("4 不同時期重譯本的歷時比較", variant="secondary",
3008
  elem_id="sc_btn_4", elem_classes=["sc-bridge-btn"])
3009
 
3010
  # Phase 8-E:手機漢堡開關+抽屜背景遮罩。跟 sc_btn_1..4 同一類坑:
@@ -3070,6 +3403,19 @@ def create_app():
3070
  reset_confirm_btn = gr.Button("確定回首頁", elem_id="reset_confirm_btn",
3071
  scale=1, variant="primary")
3072
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3073
  # Phase 8-B:三欄式工作區——nav-col(窄,導覽/重置)+chat-col
3074
  # (對話視窗+輸入框,獨立捲動)+canvas-col(步驟列+上傳/表單/
3075
  # 執行紀錄,獨立捲動)。手機寬度見 CSS 的 media query 改上下堆疊。
@@ -3249,6 +3595,17 @@ def create_app():
3249
  elem_classes=["result-nav-btn", "result-nav-btn--muted"],
3250
  variant="secondary",
3251
  )
 
 
 
 
 
 
 
 
 
 
 
3252
  # ↑ nav_items_scroll 到此結束(中段可捲動區)。
3253
 
3254
  # 底部工具列(固定,不隨中段捲動):明暗切換/收合鈕原本在頂端
@@ -3348,7 +3705,7 @@ def create_app():
3348
  # 標題+說明文字沿用 metadata_row 那則 Markdown 的寫法
3349
  # (純 "### 標題\n說明文字",不套 elem_classes),維持
3350
  # 同一批「Canvas 操作區塊」卡片的既有排版慣例。
3351
- gr.Markdown("### 情境選擇\n請選擇最符合您研究設計的比較情境,選定後將進入下一步「文本上傳」。")
3352
  gr.HTML(_build_scenario_cards_html("workspace_scenario_grid"))
3353
 
3354
  # Phase 8(千卉實測後回頭調整):這張卡片改回正式使用
@@ -3389,7 +3746,7 @@ def create_app():
3389
 
3390
  # ── 語料 Metadata 說明表單(上傳後、分析前)───────
3391
  with gr.Column(visible=False, elem_id="metadata_zone", elem_classes=["metadata-zone"]) as metadata_row:
3392
- gr.Markdown("### 📑 語料 Metadata 說明\n請於正式分析前說明語料來源、範圍與比較結構,將寫入研究報告。")
3393
 
3394
  gr.Markdown("**原文資訊**")
3395
  with gr.Row():
@@ -3723,6 +4080,18 @@ def create_app():
3723
  "無法進行統計檢定,不計入上述比例。"
3724
  if untestable_n > 0 else ""
3725
  )
 
 
 
 
 
 
 
 
 
 
 
 
3726
  # 2026-08-14(業主要求):標題句前面加一句「以[檢定方法]比較
3727
  # [分組]文本」,方法與分組名稱都從 diff 本身反推——分組代碼
3728
  # 是「{組別}_N」這組欄名的前綴(run_statistical_analysis 產出
@@ -3749,7 +4118,7 @@ def create_app():
3749
  # 的做法一致。
3750
  R[6] = gr.update(
3751
  value=(f"### 差異性分析\n{prefix}共 **{len(diff)}** 個指標,"
3752
- f"其中 **{len(sig)}** 個達顯著差異(p < {ALPHA})。{empty_note}{untestable_note}\n\n"
3753
  f"下方表格中 N=樣本數、M=平均數、SD=標準差。"),
3754
  visible=True,
3755
  )
@@ -3821,6 +4190,13 @@ def create_app():
3821
  try:
3822
  with open(str(txt_files[0]), 'r', encoding='utf-8') as f:
3823
  rpt_text = f.read()
 
 
 
 
 
 
 
3824
  model_note = (f"<span style='color:var(--c-accent-strong);font-weight:600'>"
3825
  f"本報告由主 Agent(LLM1)模型 {LLM1_MODEL} 生成"
3826
  f"(提示詞版本 {PROMPT_VERSION})</span>")
@@ -3855,6 +4231,12 @@ def create_app():
3855
  if not llm_dst.exists():
3856
  shutil.copy(str(docx_files[0]), str(llm_dst))
3857
  dl_rpt.append(str(llm_dst))
 
 
 
 
 
 
3858
  if dl_rpt:
3859
  R[19] = gr.update(value=dl_rpt, visible=True)
3860
 
@@ -4323,7 +4705,7 @@ def create_app():
4323
  "---\n\n"
4324
  "📢 **隱私提醒**:上傳的文本內容將傳送至第三方 AI 服務"
4325
  "(OpenRouter 及其上游模型供應商)進行語言分析與報告撰寫。"
4326
- "請確認語料不涉及尚未公開發表之機密內容,如有疑慮請先與計畫主持人確認後再上傳。"
4327
  )
4328
  yield (chat_history, log_history, state, gr.update(visible=True),
4329
  _todo_update(state), gr.update(value=hint),
@@ -4422,6 +4804,9 @@ def create_app():
4422
 
4423
  reset_btn.click(fn=lambda: gr.update(visible=True), outputs=[reset_confirm_row])
4424
  reset_cancel_btn.click(fn=lambda: gr.update(visible=False), outputs=[reset_confirm_row])
 
 
 
4425
  # 2026-08-12:reset_confirm_btn 的接線搬到檔案最後(見 create_app 結尾
4426
  # 「重置接線」那段)。原因:它需要 cancels=[分析運算那兩條事件鏈],而
4427
  # 那兩條要等 meta_confirm_btn/meta_cancel_btn 接完才存在。Gradio 的
@@ -4437,8 +4822,18 @@ def create_app():
4437
  # 發言氣泡,看起來像 AI 自問自答):在流程真正開始處理之前,先補一則
4438
  # 使用者訊息氣泡,把「使用者上傳了哪些檔案」呈現出來,對話才有一來
4439
  # 一往的感覺。
 
 
 
 
 
 
 
 
 
4440
  _uploaded_names = [Path(f.name if hasattr(f, "name") else str(f)).name for f in files]
4441
- chat_history = chat_history + [_user_msg(f"上傳了 {len(_uploaded_names)} 個檔案:{'、'.join(_uploaded_names)}")]
 
4442
 
4443
  # 文本上傳(step 2:3-A 新增「研究選擇」為第 1 步後,這裡的步驟
4444
  # 編號全部 +1):開始處理即關閉上傳彈窗,避免遮罩擋住操作。
@@ -4491,9 +4886,9 @@ def create_app():
4491
  log_history = _log_append(log_history, "✅ 格式驗證通過")
4492
  # 格式通過後:顯示 metadata 說明表單(語料說明=step 3),等使用者填寫
4493
  state["state"] = "METADATA_INPUT"
4494
- meta_hint = "請填寫語料 Metadata 說明(原文資訊、群組層與個體層),完成後點「確認並開始分析」。"
4495
  chat_history = chat_history + [_assistant_msg(meta_hint)]
4496
- log_history = _log_append(log_history, "📑 請填寫語料 Metadata 說明")
4497
  # 2026-07-31(Batch E 第 5、6 項):業主回報「語料說明」「分析
4498
  # 運算」兩步驟畫面上還留著「文本上傳」的進度區塊(檔案接收 ✓/
4499
  # 格式驗證 ✓),流程往下推進後這塊資訊已經沒用。清空的時機點
@@ -4583,7 +4978,7 @@ def create_app():
4583
  (群組標籤/翻譯類型/翻譯方向/譯者背景/目標讀者)攤平排列,
4584
  對應 meta_group_fields 三個 slot、各自 5 個 Textbox 的值。"""
4585
  sc = state.get("scenario", "")
4586
- L = ["## 語料 Metadata", "",
4587
  "### 原文資訊",
4588
  f"- 原文標題:{title or '(未填)'}",
4589
  f"- 作者:{author or '(未填)'}",
@@ -4767,6 +5162,13 @@ def create_app():
4767
  state["cleaned_files"] = []
4768
  state["current_step"] = 1
4769
  state["done_steps"] = [s for s in state.get("done_steps", []) if s != 1]
 
 
 
 
 
 
 
4770
  return (
4771
  gr.update(visible=True), # scenario_select_row
4772
  gr.update(visible=False), # upload_row
@@ -4834,10 +5236,10 @@ def create_app():
4834
  version, ngroups, method, group_field_values, indiv_df)
4835
  state["group_labels"] = _extract_group_labels(state, group_field_values)
4836
  state["individual_grouping"] = _extract_individual_grouping(_df_rows(indiv_df), method)
4837
- log_history = _log_append(log_history, "✅ 已記錄語料 Metadata,開始分析")
4838
  # 2026-08-22:同上傳檔案那段,補一則使用者訊息氣泡,呈現「使用者
4839
  # 填寫完語料說明並送出」這個動作,避免對話看起來像 AI 自問自答。
4840
- chat_history = chat_history + [_user_msg("已填寫語料 Metadata 說明(原文資訊、群組層與個體層),開始分析")]
4841
  # 在主對話視窗立即顯示「開始分析」訊息,避免長時間運算期間畫面看起來像沒有反應
4842
  chat_history = chat_history + [_assistant_msg(_ANALYSIS_START_MSG)]
4843
  yield (chat_history, log_history, state, gr.update(visible=False),
@@ -4881,8 +5283,8 @@ def create_app():
4881
  yield (chat_history, log_history, state, gr.update(), _todo_update(state),
4882
  gr.update(), gr.update(), *_no_tabs)
4883
  return
4884
- state["user_metadata"] = state.get("user_metadata", "") or "(使用者未提供語料 Metadata 說明)"
4885
- log_history = _log_append(log_history, "⏭️ 已略過 Metadata,直接開始分析")
4886
  # 2026-08-22:同 on_metadata_confirm,補使用者訊息氣泡(略過版本)。
4887
  chat_history = chat_history + [_user_msg("略過填寫,直接開始分析")]
4888
  chat_history = chat_history + [_assistant_msg(_ANALYSIS_START_MSG)]
 
152
  # 報告本身走,不用回頭 git blame 才查得到。
153
  PROMPT_VERSION = "v1"
154
 
155
+ # 2026-08-26(「關於本系統」面板用):跟 PROMPT_VERSION 是兩件不同的事——
156
+ # PROMPT_VERSION 只追蹤 LLM 提示詞內容,這個追蹤的是整個系統(畫面、流程、
157
+ # 各項修正)的版本,給使用者回報問題時對照用。用日期字串(而非 v1.0 這種
158
+ # 語意化版號)是刻意的:日期比較不會忘記更新,且跟 使用手冊.md 文末的
159
+ # 「文件版本」日期同一套慣例。有實質功能異動時手動更新這個日期即可。
160
+ APP_VERSION = "2026-08-26"
161
+
162
+ # 2026-08-26(業主指定內容):導覽列「關於」按鈕彈出的說明面板。版權聲明/
163
+ # 聯絡方式/Bug 回報/引用格式這四項業主明確要求「論文發表前不公開姓名或
164
+ # 信箱」,這裡不放任何真實聯絡資訊、也不留 [方括號] 這種看起來像沒填完的
165
+ # 佔位符,改寫成正式的「將於論文發表後公開」——對使用者來說是完整、有意義
166
+ # 的句子,之後論文發表了直接把這幾行換成真實資訊即可。
167
+ ABOUT_CONTENT_MD = f"""## 關於本系統
168
+
169
+ **譯彩紛呈 II 分析引擎 SpectraEngine**(TransPrism: An Analytical Framework for Retranslation II)
170
+
171
+ 系統版本:{APP_VERSION}  回報問題時請附上此版本號
172
+
173
+ ---
174
+
175
+ ### 免責聲明
176
+ 本系統分析結果僅供研究參考,不構成正式學術判斷;上傳內容將傳送至第三方 AI 服務進行分析,請勿上傳尚未公開發表之機密內容。
177
+
178
+ ### 姊妹工具
179
+ 文本須先經由「[語料淬鍊(CorpusRefinery)](https://github.com/minchun-git/I_CorpusRefinery)」完成斷詞後才能上傳本系統分析。
180
+
181
+ ### 開發紀錄
182
+ 系統功能與版本異動,請參見導覽列「使用手冊」文末的文件版本紀錄。
183
+
184
+ ### 版權聲明
185
+ © 2026|作者姓名/單位將於論文發表後公開
186
+
187
+ ### 聯絡方式
188
+ 將於論文發表後公開
189
+
190
+ ### Bug 回報
191
+ 聯絡窗口將於論文發表後公開
192
+
193
+ ### 引用格式
194
+ 建議引用格式將於論文發表後一併提供
195
+ """
196
+
197
  ALPHA = 0.05
198
 
199
  # 2026-08-22(業主提出「數據完整性檢查」的必要性,追查後確認是真實存在的
 
208
  # 這個常數判斷,不要漏掉、也不要各自硬寫字串。
209
  INSUFFICIENT_DATA_LABEL = "資料不足"
210
 
211
+ # 2026-08-24(業主回報:報告上出現「Cohen's d=-9028313184615308.0」這種
212
+ # 誇張數字):跟上面 INSUFFICIENT_DATA_LABEL 是兩種不同性質的問題,不要混用
213
+ # ——INSUFFICIENT_DATA_LABEL 代表「p=NaN,檢定本身無法執行」;這個標籤代表
214
+ # 「p 值有效、檢定確實跑出顯著結果,但 Cohen's d/η² 的公式分母(組內變異)
215
+ # 趨近於零,除出來的數字在數值上不穩定、失真」。兩組數值幾乎沒有組內變異卻
216
+ # 彼此不同,是統計上最乾淨俐落的一種顯著差異,不該被當成「檢定失敗」處理,
217
+ # 顯著性判斷(p 值)仍然有效、正常參與統計比例;只有效果量這個數字本身
218
+ # 換成這個描述性標籤,不強行印出失真的天文數字。
219
+ EFFECT_UNSTABLE_LABEL = "效果量不穩定(組內變異趨近於零)"
220
+
221
+ # 2026-08-24(業主回報:AG_05 在 MT 組標準差為 0,Welch t 檢定仍算出
222
+ # p=.047 並被 LLM 列為顯著發現,但這個結果不可靠):這是跟上面兩種都不同
223
+ # 的第三種退化情況——Welch t 檢定公式是 t = (mean1-mean2) / sqrt(var1/n1 +
224
+ # var2/n2),只要「其中一組」變異數不是零,分母就不會退化成 0 或 NaN,
225
+ # 檢定照樣算得出一個數值上正常、但統計上不可靠的 p 值;INSUFFICIENT_DATA_
226
+ # LABEL(p=NaN)跟 EFFECT_UNSTABLE_LABEL(兩組都幾乎零變異、Cohen's d 分母
227
+ # 趨近於零)都攔不到這種「只有一組零變異」的情況。這裡不動指標計算方式
228
+ # 本身(AG_05 算出來是 0 就是事實),只在檢定這一層加警示標記——p 值與
229
+ # 效果量照常計算、正常參與顯著性統計,只是額外附註「這個結果的統計穩定性
230
+ # 存疑」,供人與 LLM 判讀時謹慎看待,不當成跟其他乾淨顯著結果同等份量
231
+ # 的證據。
232
+ ZERO_VARIANCE_WARNING = "⚠️ 其中一組標準差為 0,檢定結果之統計穩定性有限,建議謹慎解讀"
233
+
234
+ # 2026-08-28(業主追問「零膨脹」偵測):上面 ZERO_VARIANCE_WARNING 只抓「整組標準差
235
+ # 趨近於 0」——通常代表該組幾乎全部樣本值相同(多半是全部掛零)。但還有一種較不極端、
236
+ # SD 判準抓不到的退化情況:某組樣本值大量集中在 0,只靠少數幾筆非零值撐出「看起來
237
+ # 正常」的變異數,平均數與檢定結果其實被那幾筆離群值主導,不能代表該組的整體特徵。
238
+ # 這裡另開一條獨立判準:任一組零值比例達到 ZERO_PROPORTION_THRESHOLD(預設
239
+ # 50%,過半數樣本為 0)就額外標記。門檻定 50% ��不是更低(如 20%、30%)的理由:
240
+ # 許多語言指標(如成語、特定詞性頻率)本身就容易有不少檔案掛零,門檻太低會對
241
+ # 「只是資料偏稀疏但正常」的指標大量誤報、稀釋警示的可信度;50%(過半數)才是
242
+ # 「零值已經是這組的主流樣態」的合理起點。刻意只在該組「尚未被 SD 判準抓到」時
243
+ # 才檢查零值比例(見下方偵測邏輯),避免同一組資料被兩條規則重複描述。
244
+ ZERO_PROPORTION_THRESHOLD = 0.5
245
+ ZERO_PROPORTION_WARNING = "⚠️ 其中一組逾半數樣本值為 0,資料集中於零值,檢定結果建議謹慎解讀"
246
+
247
  # ── POS 集合(與 Prog02 相同)───────────────────────────────────────────────
248
  Vset = ['VC','VH','VE','VJ','VA','V2','VK','VL','VD','VCL','VHC','VB','VF','VI','VAC','VG']
249
  Nset = ['Na','Nb','Nc','Ncd','Nd','Nf','Nh','Nv']
 
260
  # ── 研究情境定義 ─────────────────────────────────────────────────────────────
261
  SCENARIOS = {
262
  "1": {
263
+ "title": "人工翻譯 vs 機器翻譯",
264
  "short": "人機對比",
265
  "upload_prompt": (
266
  "1. 檔案格式:僅接受 **.txt** 純文字檔。\n"
 
296
  "scenario_module_key": "scenario_4",
297
  },
298
  "4": {
299
+ "title": "不同時期人工譯本",
300
  "short": "歷時重譯",
301
  "upload_prompt": (
302
  "1. 檔案格式:僅接受 **.txt** 純文字檔。\n"
 
391
  # 之後隱藏清單增減,這裡跟著自動變動,不用每次巡一遍程式碼改字面數字。
392
  TOTAL_VISIBLE_INDICATORS = len(INDEX_NAME_MAP) - len(HIDDEN_INDICATOR_CODES)
393
 
394
+ # 2026-08-24(業主提供 system_prompts_v1.3_0529.md 設計稿,要求補回原始設計
395
+ # 裡有、但現行實作缺少的內容):v1.3 在 system prompt 裡內嵌了一份完整的
396
+ # 指標代碼對照表,讓 LLM 有明確依據判斷每個代碼的意思,並規定「未列於
397
+ # 對照表的代碼視為未定義指標」(見嚴格禁令第 10 條)。這裡不手動謄寫一份
398
+ # 靜態文字(那樣容易跟 INDEX_CATEGORIES 各自走鐘、兩份對不起來),改成
399
+ # 直接從 INDEX_CATEGORIES 動態產生,且比照 HIDDEN_INDICATOR_CODES 的規則
400
+ # 排除隱藏指標——LLM 實際收到的 JSON 資料本來就已經濾掉這 13 項,對照表
401
+ # 若照樣列出它們,LLM 反而會誤以為資料裡漏了這些代碼、去猜測或杜撰。
402
+ def _build_indicator_reference_table() -> str:
403
+ lines = []
404
+ for cat, items in INDEX_CATEGORIES.items():
405
+ visible = {c: n for c, n in items.items() if c not in HIDDEN_INDICATOR_CODES}
406
+ if not visible:
407
+ continue
408
+ lines.append(f"## {cat}")
409
+ for code, name in visible.items():
410
+ lines.append(f"- {code} {name}")
411
+ return "\n".join(lines)
412
+
413
+
414
+ INDICATOR_REFERENCE_TABLE = _build_indicator_reference_table()
415
+
416
  # ── LLM 報告用情境模組(同 prog04)─────────────────────────────────────────
417
  SCENARIO_MODULE_1 = """## 三、情境特定分析:歷時變化分析
418
 
 
446
  - 人工譯本在哪些指標上顯著偏離機器譯本?
447
 
448
  ### 禁令補充
 
449
  - 不得預設「人工一定優於機器」或反之。"""
450
 
451
  SCENARIO_MODULE_3 = """## 三、情境特定分析:改寫策略分析
 
470
  - 在哪些指標上,譯者間差異達到統計顯著?
471
 
472
  ### 3.2 風格穩定性的檢視
473
+ - 譯者內變異 vs 譯者間變異的比較"""
 
 
 
474
 
475
  SCENARIO_MODULES = {
476
  "scenario_1": SCENARIO_MODULE_1,
 
482
  SYSTEM_PROMPT_BASE = """# 角色定義
483
  你是一位翻譯研究的量化分析助理。你的工作是將統計數據轉化為結構化的觀察與假設,供研究者進一步驗證。
484
 
485
+ 你的角色邊界:
486
+ - 你是數據的描述者與模式的辨識者,不是結論的下定者。
487
+ - 你產出的是「初步假設」,不是「研究發現」。
488
+ - 所有假設都需要研究者基於專業知識進行最終的詮釋、驗證與修正。
489
+
490
  # 嚴格禁令
491
+ 以下禁令為絕對規則,違反任一項即為失敗輸出:
492
+
493
+ 1. 僅根據本次提供的量化數據進行分析。不得引入外部知識,不得引用任何關於具體譯本、譯者、作品的已有評價、學術討論或文學史定位。
494
+ 2. 不得使用以下類型的評價性詞彙描述任何譯本:「優秀」、「忠實」、「準確」、「流暢」、「高品質」、「拙劣」、「粗糙」、「典雅」、「洗鍊」。
495
+ 3. 不得推測譯者的主觀意圖、情感、動機或翻譯理念,除非數據直接支持且標明為推論。
496
+ 4. 不得將個人風格差異與「翻譯能力」或「翻譯素養」掛鉤。語言特徵的差異僅反映選擇的不同,不代表能力的高低。
497
+ 5. 遇到數據中未涵蓋的問題,明確回答「本次數據無法回答此問題」,不得編造或外推。
498
+ 6. 所有關於統計顯著性的陳述必須基於提供的 p 值或效果量。未提供 p 值的指標差異,使用「數值上的差距」而非「顯著差異」來描述;不得自行使用「明顯」、「大幅」、「顯著」等程度副詞,除非有統計依據。
499
+ 7. 不得對翻譯品質做整體評價或排名。
500
+ 8. metadata 處理規則:
501
+ a. 語料 metadata 中標示為「(未填)」或完全未提及的欄位:分析時不得對該欄位做任何推論或臆測。
502
+ b. 群組層資訊中,若某一組別完全未出現任何說明(該組別整段未列出):視同該組別背景未知,不得杜撰其翻譯類型、方向或譯者背景。
503
+ c. 個體層資訊中,若某一檔案完全未列出對應說明:視同該檔案背景未知,不得杜撰其譯者、出版年等資訊。
504
+ d. 群組層資訊中所有組別皆未填寫「群組標籤」:在「一、語料說明」中明確標記「群組標籤未提供,以下分析僅基於數值差異,不涉及群組身份的討論」。
505
+ e. 使用者於語料 Metadata 步驟選擇略過:僅陳述「使用者未提供語料補充說明」,不得臆測或杜撰任何背景內容。
506
+ 9. metadata 僅用於框定分析範圍與數據結構關係,不得用於構建敘事性的譯者畫像或歷史背景描述。
507
+ 10. 未定義指標處理規則:若量化分析結果中出現未列於下方「指標代碼對照表」的指標代碼,不得猜測其含義、不得將其納入任何分析步驟,須在「四、待驗證事項」中標記為「未定義指標:{{代碼}}」。
508
+ 11. 跨指標邏輯連結許可:允許連結多個指標的結果進行邏輯推論,但須滿足:
509
+ a. 所連結的每個指標均有明確的數據支持(數值或統計檢定結果)。
510
+ b. 連結的邏輯基礎來自下方指標代碼對照表中的定義關係,不得憑空聯想。
511
+ c. 必須在陳述中明確標示「此為跨指標推論」,以區分直接的數據觀察。
512
+ d. 不得基於外部知識進行跨指標連結。
513
+
514
+ # 指標代碼對照表
515
+ 以下對照表僅供符號辨識用,不代表任何詮釋框架。未列於此對照表的指標代碼依嚴格禁令第 10 條處理。
516
+
517
+ {indicator_reference_table}
518
 
519
  # 輸入資料結構
520
  【研究情境】:{scenario_type}
 
538
  ## 一、語料說明
539
  用 2-4 句話摘要上方【譯本 metadata】提供的原文資訊、群組層與個體層背景(例如原文標題、各組別的翻譯類型/方向、個別譯本的譯者或系統名稱等,視實際提供的內容而定)。
540
  若 metadata 顯示使用者略過未填,僅陳述「使用者未提供語料補充說明」,不得臆測或杜撰內容。這個區塊只做摘要陳述,不做任何統計分析或推論。
541
+ 【metadata 一致性檢查】摘要時若發現群組層與個體層資訊彼此矛盾(例如某組別標示為「人工翻譯」,但該組底下某檔案的譯者/系統名稱明顯是 AI 模型或翻譯系統名稱;或研究情境選的是歷時比較,但個體層完全沒有任何出版年資訊),須在本區塊結尾明確標記該矛盾之處,供使用者確認,**不得自行判斷何者為準、不得擅自修正或忽略其中一方**。若沒有發現矛盾,不需要特別聲明「一致性檢查通過」,維持原本的摘要陳述即可。
542
 
543
  ## 二、數據事實描述
544
  本區塊須包含以下三類數據事實,缺一不可(量化分析結果 JSON 裡都有對應資料,不得因為篇幅省略):
545
+ 1. 差異性分析:每項陳述除了指標代碼、p 值與效果量之外,**必須明確指出各組別中平均數偏高與
546
+ 偏低的是哪一組,並附上實際平均數數值**(difference_analysis 資料裡每個組別都有一欄以該組
547
  名稱結尾 _M 的平均數欄位)——只寫 p 值與效果量、不寫是哪一組偏高偏低,讀者無法理解差異的
548
  方向與意義。
549
  三組以上時是用 ANOVA 檢定,效果量(η²)本身不帶方向性(不像兩組時的 Cohen's d 還有正負號
550
+ 可看出方向),更不能省略指出方向的那句話。
551
+ 【用詞規則,務必遵守】JSON 裡的 groups/difference_analysis 只會有 2 或 3 個以上的組別,
552
+ 依組別數量決定用詞,不可混用:
553
+ - **恰好兩組**:只能用「較高」「較低」,不可以用「最高」「最低」——只有兩個東西比較時,
554
+ 沒有「最」這種在三者以上才成立的最高級語意,用「最高」「最低」邏輯上是錯的。
555
+ 範例:「總字數(AA_01):p=0.0461,Cohen's d=-1.5621(大);機器翻譯較高(M=60537.5),
556
+ 人工翻譯較低(M=54313.6)。」
557
+ - **三組以上**:用「最高」「最低」(是在多個組別中挑出最大最小值,最高級語意才成立)。
558
+ 範例:「總句數(AA_06):p=0.0068,η²=0.9932(大);2024 年組別最高(M=32.5),
559
+ 1989 年組別最低(M=18.2)。」
560
+ 區分:達統計顯著的差異(標明 p 值、效果量、方向與平均數)、未達顯著但數值差距較大的趨勢
561
+ (同樣要指出方向)、無明顯差異的指標。
562
  【資料完整性】若 JSON 附有「資料完整性提醒」欄位(表示部分指標因組別樣本數不足、根本無法
563
  進行統計檢定),這些指標一律只能陳述為「資料不足,無法檢定」,絕對不可以寫成「無顯著差異」,
564
  也不可以把它們計入「達統計顯著」或「無明顯差異」的統計比例——這是兩種完全不同的結論,混為
565
  一談會誤導讀者以為系統已經檢定過、確認沒有差異。
566
+ 【效果量無法計算】若某指標的「效果大小」欄位顯示「效果量不穩定(組內變異趨近於零)」、
567
+ 「效果量」欄位為 null,代表 p 值仍然有效、該指標**確實達統計顯著**(顯著性判斷正常參與統計
568
+ 比例),只是組內變異趨近於零,Cohen's d/η² 這個公式本身除不出有意義的數字。陳述時正常
569
+ 引用 p 值與各組平均數(依上面【用詞規則】,兩組寫「較高/較低」、三組以上寫「最高/最低」),
570
+ 效果量部分改寫成「效果量因組內變異趨近於零而無法計算」,不得省略、也不得自己編造一個數字
571
+ 或寫成「效果量極大」(那是失真的浮點數,不是真的統計結論)。這跟【資料完整性】的「資料
572
+ 不足」是兩種不同情況,不要混為一談:後者連檢定都做不了,前者是檢定做了、結果確實顯著,
573
+ 只是效果量這個數字算不出來。
574
+ 【資料穩定性提醒】若 JSON 附有「資料穩定性提醒」欄位(表示部分指標至少有一組出現「樣本
575
+ 標準差趨近於 0」或「半數以上樣本值為 0」之一),這些指標的 p 值與效果量正常計算、正常算入
576
+ 「達統計顯著」的統計比例,**不要**排除或改標成「資料不足」;但陳述時必須額外加一句保留
577
+ 語氣,例如「此結果統計穩定性有限,建議謹慎解讀」,不可用跟其他變異正常的顯著指標完全相同
578
+ 的肯定語氣呈現,避免讀者把統計上脆弱的單組結果誤判為跟其他證據同等可信。
579
  2. 監督式學習(隨機森林):說明 feature_importance_top20 裡重要性最高的 3-5 項指標與其重要性數值。
580
  同時陳述「隨機森林分類準確率」的數字,並原樣附上 JSON 裡「隨機森林準確率之樣本外預測意義」
581
  這欄給的完整句子(那句話已經是可以直接使用的中文敘述,不必再改寫或省略)。
 
1100
  # ============================================================================
1101
 
1102
  def cohens_d(g1, g2):
1103
+ """回傳 Cohen's d;合併標準差相對於資料尺度趨近於零時回傳 None(而非硬除
1104
+ 出失真的天文數字)。
1105
+
1106
+ 2026-08-24(業主回報:報告上出現「Cohen's d=-9028313184615308.0」這種
1107
+ 誇張數字):原本只用 `pooled != 0` 擋分母為零,但當兩組內部數值幾乎(並非
1108
+ 精確)相同時,浮點運算會讓 pooled 變成一個極小但非零的數(例如 1e-13),
1109
+ 這個檢查完全擋不住,除下去就爆出巨大到毫無意義的數字。改用「相對於資料
1110
+ 尺度的容忍值」判斷:pooled 小於合併平均數絕對值的極小比例時,視為組內
1111
+ 變異趨近於零、效果量在數值上不穩定,回傳 None。注意:這不代表兩組沒有
1112
+ 差異——p 值與平均數本身仍然有效,只是 Cohen's d 這個統計量的公式在
1113
+ 「組內幾乎零變異」的情況下本身就定義不良,硬算出一個數字反而失真。"""
1114
  n1, n2 = len(g1), len(g2)
1115
  if n1 < 2 or n2 < 2:
1116
+ return None
1117
  var1, var2 = np.var(g1, ddof=1), np.var(g2, ddof=1)
1118
  pooled = np.sqrt(((n1-1)*var1 + (n2-1)*var2) / (n1+n2-2))
1119
+ scale = max(abs(np.mean(np.concatenate([g1, g2]))), 1e-12)
1120
+ if pooled < scale * 1e-8:
1121
+ return None
1122
+ return (np.mean(g1) - np.mean(g2)) / pooled
1123
 
1124
 
1125
  def eta_squared(group_data):
1126
+ """回傳 η²;ss_total 相對於資料尺度趨近於零時回傳 None,理由同 cohens_d()。"""
1127
  all_data = np.concatenate(group_data)
1128
  grand_mean = all_data.mean()
1129
  ss_between = sum(len(g) * (np.mean(g) - grand_mean)**2 for g in group_data)
1130
  ss_total = np.sum((all_data - grand_mean)**2)
1131
+ scale = max(abs(grand_mean), 1e-12)
1132
+ if ss_total < (scale ** 2) * 1e-8:
1133
+ return None
1134
+ return ss_between / ss_total
1135
 
1136
 
1137
  def effect_label(val, method):
 
1264
  row["顯著"] = INSUFFICIENT_DATA_LABEL
1265
  row["效果量"] = None
1266
  row["效果大小"] = None
1267
+ row["資料品質警示"] = "" # 已經整列標成「資料不足」,不用再疊加這個警示
1268
  else:
1269
  es = cohens_d(gdata[0], gdata[1]) if n_groups == 2 else eta_squared(gdata)
1270
+ row["顯著"] = ("***" if p<0.001 else "**" if p<0.01 else "*" if p<0.05 else "ns")
1271
+ if es is None:
1272
+ # 見 EFFECT_UNSTABLE_LABEL 定義處:p 值仍然有效、正常判斷顯著性
1273
+ # (上面那行不受影響),只有效果量這個數字換成描述性標籤。
1274
+ row["效果量"] = None
1275
+ row["效果大小"] = EFFECT_UNSTABLE_LABEL
1276
+ else:
1277
+ row["效果量"] = round(float(es), 4)
1278
+ row["效果大小"] = effect_label(es, method)
1279
+ # 見 ZERO_VARIANCE_WARNING/ZERO_PROPORTION_WARNING 定義處:兩條獨立
1280
+ # 判準都只看「有真的算出變異數」的情況——N<2 已經在 is_untestable 那支
1281
+ # 處理過。SD 判準抓「整組幾乎沒變異」;零值比例判準抓「SD 沒有趨近於
1282
+ # 零,但組內樣本值仍大量集中在 0」——後者刻意排除已被 SD 判準抓到的
1283
+ # 組別(`not (... < 1e-9)`),避免同一組資料被兩條規則重複疊加描述。
1284
+ # 都不影響顯著性與效果量的計算,純附註警示;兩者可同時列出(不同組別
1285
+ # 各自命中不同判準時)。
1286
+ _dq_warnings = []
1287
+ if any(len(g) >= 2 and np.std(g, ddof=1) < 1e-9 for g in gdata):
1288
+ _dq_warnings.append(ZERO_VARIANCE_WARNING)
1289
+ if any(len(g) >= 2 and np.std(g, ddof=1) >= 1e-9
1290
+ and (np.sum(g == 0) / len(g)) >= ZERO_PROPORTION_THRESHOLD
1291
+ for g in gdata):
1292
+ _dq_warnings.append(ZERO_PROPORTION_WARNING)
1293
+ row["資料品質警示"] = ";".join(_dq_warnings)
1294
  diff_results.append(row)
1295
 
1296
  diff_df = pd.DataFrame(diff_results)
 
1299
  diff_path = output_dir / "02_difference_analysis.csv"
1300
  diff_df.to_csv(str(diff_path), index=False, encoding='utf-8-sig')
1301
 
1302
+ # boxplot(2026-08-24 起:達顯著差異的指標全部畫出,不再只取前 12 項)
1303
  # 2026-08-14(業主要求):原本取前 12 項是照指標在資料表裡的固定欄位
1304
  # 順序(基礎統計→...→詞性標記)往下數,不是「最顯著/效果量最大」的
1305
+ # 12 項,改成依效果量排序。
1306
+ # 2026-08-24(業主要求):取消「只取前 12 項」的上限,達顯著差異的指標
1307
+ # 全部畫出來;排序改成「先依組別、組��再依效果量」——先看哪一組在這批
1308
+ # 顯著指標裡整體偏高,同一組內再依效果量絕對值由大到小排,比單純照
1309
+ # 效果量從大到小混排更容易看出「兩組的差異主要往哪個方向集中」。
1310
  box_path = None
1311
  if len(sig_df) > 0:
1312
+ sig_df_sorted = sig_df.copy()
1313
+ _group_order = {g: i for i, g in enumerate(groups)}
1314
+ sig_df_sorted['_highest_group'] = sig_df_sorted.apply(
1315
+ lambda row: max(groups, key=lambda g: row.get(f"{g}_M", float('-inf'))),
1316
+ axis=1,
1317
+ )
1318
+ sig_df_sorted['_group_rank'] = sig_df_sorted['_highest_group'].map(_group_order)
1319
+ # 2026-08-24:效果量為 None(EFFECT_UNSTABLE_LABEL 情況,見該常數定義
1320
+ # 處)代表組內幾乎零變異卻仍達顯著——是最乾淨俐落的一種差異,排序時
1321
+ # 視為效果量無限大、排在同一組別群組的最前面,不能因為是 None 就被
1322
+ # sort_values 排到最後面去(NaN 預設排序行為)。
1323
+ # 2026-08-24(實測崩潰:TypeError: bad operand type for abs(): 'NoneType'):
1324
+ # 當顯著指標裡有不少(甚至全部)是 EFFECT_UNSTABLE_LABEL 情況(效果量=
1325
+ # None),這欄在 pandas 眼裡可能整欄維持 object dtype(而不是像單一
1326
+ # 摻雜幾個 None 的情況那樣被自動轉成 float64、None 變 NaN),直接對
1327
+ # object dtype 呼叫 .abs() 會真的去對 Python None 物件呼叫內建 abs()
1328
+ # 而炸掉,不是 NaN 安全語意。先用 pd.to_numeric(errors='coerce') 強制
1329
+ # 轉成數值(None、NaN 都會變成 NaN,非數值也不會拋例外),再 .abs()
1330
+ # 就穩了。
1331
+ sig_df_sorted['_abs_es'] = pd.to_numeric(sig_df_sorted['效果量'], errors='coerce').abs().fillna(float('inf'))
1332
+ sig_df_sorted = sig_df_sorted.sort_values(
1333
+ by=['_group_rank', '_abs_es'], ascending=[True, False]
1334
  )
1335
+ sig_cols = sig_df_sorted['指標'].tolist()
1336
  n_row = max(1, (len(sig_cols) + 2) // 3)
1337
  fig, axes = plt.subplots(n_row, 3, figsize=(14, 4*n_row))
1338
  axes = np.array(axes).flatten()
 
1353
  # 「有沒有差」。
1354
  row = diff_df.loc[diff_df['指標']==col]
1355
  pv_str = f"p={row['p值'].values[0]:.4f}" if len(row)>0 else ""
1356
+ _es_val = row['效果量'].values[0] if len(row)>0 else None
1357
+ if len(row) > 0 and _es_val is not None and not pd.isna(_es_val):
1358
+ es_str = f",{row['效果量名稱'].values[0]}={_es_val:.3f}"
1359
+ elif len(row) > 0:
1360
+ # 2026-08-24:效果量為 None 時(見 EFFECT_UNSTABLE_LABEL),
1361
+ # 標題別再硬套 :.3f 格式化(會直接拋例外),改顯示描述性標籤。
1362
+ es_str = f",{EFFECT_UNSTABLE_LABEL}"
1363
+ else:
1364
+ es_str = ""
1365
  ax.set_title(f"{col}\n({pv_str}{es_str})", fontsize=8)
1366
  for j in range(len(sig_cols), len(axes)):
1367
  axes[j].set_visible(False)
1368
  # 2026-08-14(業主要求):原本「Significant Variables by Type1」直接
1369
+ # 洩漏內部欄位名稱 Type1,使用者看不懂在比較什麼,改成中文標題。
1370
+ # 2026-08-24(業主要求):不再只取前 12 項,標題文字同步更新為
1371
+ # 「依組別與效果量排序」,不再提「前 12 項」(已無此上限)。
1372
+ plt.suptitle("顯著指標比較(依組別與效果量排序)", fontsize=11, y=1.01)
1373
  plt.tight_layout()
1374
  box_path = output_dir / "02_significant_boxplots.png"
1375
  plt.savefig(str(box_path), dpi=150, bbox_inches='tight')
 
1540
  f"{row['平均數']:.4f}", f"{row['中位數']:.4f}",
1541
  f"{row['標準差']:.4f}", f"{row['最小值']:.4f}",
1542
  f"{row['最大值']:.4f}",
1543
+ # 同下方差異性分析表格:改用 pd.isna(),避免 None 被
1544
+ # pandas 建表時轉型成 NaN 後,is not None 抓不到而印出「nan」。
1545
+ f"{row['變異係數(%)']:.2f}" if not pd.isna(row['變異係數(%)']) else "-"]
1546
  for j, val in enumerate(vals):
1547
  cell = tbl.rows[i].cells[j]
1548
  cell.text = val
 
1569
  group_sub_headers = []
1570
  for g in groups:
1571
  group_sub_headers.extend([f"{g}\nN", f"{g}\nM", f"{g}\nSD"])
1572
+ diff_headers = ["指標"] + group_sub_headers + ["方法","統計量","p值","顯著","效果量","效果大小","資料品質警示"]
1573
  diff_tbl = doc.add_table(rows=1+len(diff_df), cols=len(diff_headers))
1574
  diff_tbl.style = 'Light Shading Accent 1'
1575
  for j, h in enumerate(diff_headers):
 
1584
  vals.extend([str(int(row[f"{g}_N"])),
1585
  f"{row[f'{g}_M']:.4f}",
1586
  f"{row[f'{g}_SD']:.4f}"])
1587
+ # 2026-08-24(業主回報表格出現「nan」字樣):diff_results 是一份
1588
+ # dict list,其中某些列的「統計量」/「p值」/「效果量」是 Python
1589
+ # None(見 INSUFFICIENT_DATA_LABEL/EFFECT_UNSTABLE_LABEL 兩處),
1590
+ # 但 `pd.DataFrame(diff_results)` 建表時,只要同一欄裡其餘列是數字,
1591
+ # pandas 會把整欄轉成 float64、None 也跟著被轉型成 NaN——`is not
1592
+ # None` 檢查完全抓不到轉型後的 NaN(NaN 不是 None 物件),因此原本
1593
+ # 該顯示「-」的欄位印出了「nan」這個字面字串。改用 pd.isna(),能
1594
+ # 同時抓到 None 與轉型後的 NaN 兩種情況。
1595
+ _warn_text = row.get('資料品質警示', '')
1596
+ _warn_text = '' if pd.isna(_warn_text) else str(_warn_text)
1597
  vals.extend([str(row['方法']),
1598
+ f"{row['統計量']:.4f}" if not pd.isna(row['統計量']) else "-",
1599
+ f"{row['p值']:.4f}" if not pd.isna(row['p值']) else "-",
1600
  str(row['顯著']),
1601
+ f"{row['效果量']:.4f}" if not pd.isna(row['效果量']) else "-",
1602
+ str(row['效果大小']) if not pd.isna(row['效果大小']) else "-",
1603
+ _warn_text if _warn_text else "-"])
1604
  _is_untestable_row = row['顯著'] == INSUFFICIENT_DATA_LABEL
1605
+ # 2026-08-24:改用欄名查表格索引,不再用 len(vals) 往回數固定格數
1606
+ # ——原本 len(vals)-4 在只有 6 個尾端欄位時其實已經對不準「顯著」
1607
+ # 那一格(差一格,實際指到 p值),這次多加一欄「資料品質警示」後
1608
+ # 用位置數只會更容易錯位,藉這次一起修成用欄名查,之後再加欄也不怕。
1609
+ _sig_col_idx = diff_headers.index('顯著')
1610
+ _warn_col_idx = diff_headers.index('資料品質警示')
1611
  for j, val in enumerate(vals):
1612
  cell = diff_tbl.rows[i].cells[j]
1613
  cell.text = val
 
1619
  # 不能共用紅字——紅字在這份報告的視覺語言裡代表「這是
1620
  # 值得注意的發現」,「資料不足」剛好相反,是「這項不能
1621
  # 拿來當發現看」,用紅字反而會讓人誤以為它也是顯著結果。
1622
+ if row['顯著'] not in ("ns", INSUFFICIENT_DATA_LABEL) and j == _sig_col_idx:
1623
  r.font.color.rgb = RGBColor(0xCC, 0x00, 0x00)
1624
  elif _is_untestable_row:
1625
  r.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
1626
  r.italic = True
1627
+ # 見 ZERO_VARIANCE_WARNING:琥珀色,跟「顯著」的紅、
1628
+ # 「資料不足」的灰都不同——這不是「無法使用」(灰)也不是
1629
+ # 「重點發現」(紅),是「可用但要謹慎看待」的第三種性質。
1630
+ elif j == _warn_col_idx and _warn_text:
1631
+ r.font.color.rgb = RGBColor(0xB4, 0x7A, 0x00)
1632
  doc.add_paragraph('')
1633
  if box_path and box_path.exists():
1634
  doc.add_heading('顯著指標箱形圖', level=2)
 
1690
  # SECTION 7: LLM Report Writing (adapted from prog04, uses LLM1)
1691
  # ============================================================================
1692
 
1693
+ def load_analysis_data_for_llm(csv_path: Path, output_dir: Path, ml_summary: dict = None,
1694
+ group_labels: dict = None) -> dict:
1695
  """讀取分析資料組成 JSON 結構。
1696
  ml_summary:{"accuracy": float, "do_eval": bool} 選填,來自
1697
  run_statistical_analysis() 的回傳值。之前這裡完全沒有把隨機森林的
1698
  準確率數字(accuracy)跟「這次是不是真的切了測試集」(do_eval)
1699
  交給 LLM,LLM 因此連準確率是多少都不知道,等於沒有材料可以討論
1700
  監督式學習結果——03_feature_importance.csv 只有各指標的重要性排名,
1701
+ 沒有整體準確率這個數字。
1702
+ group_labels:{分組代碼: 語料說明填的實際標籤},選填。2026-08-24(業主
1703
+ 回報):畫面上的差異性分析標題/箱形圖標籤早就會用語料說明填的實際
1704
+ 標籤(如「人工翻譯」),但 LLM 報告內文卻一路只看得到原始代碼 A/B,
1705
+ 寫成「群組 A 最高」——追查後發現 difference_analysis 送給 LLM 的 JSON
1706
+ 欄名({組別}_N/{組別}_M/{組別}_SD)從頭到尾都是原始代碼,從沒套用過
1707
+ 這組標籤,等於畫面跟報告各自套用了不同版本的分組名稱。這裡補上同一套
1708
+ 轉換,讓兩邊使用同一個資料來源。"""
1709
  data = {}
1710
  if ml_summary:
1711
  # 2026-08-15(業主回報):原本這裡是英文鍵名 rf_accuracy/
 
1741
  p = output_dir / fname
1742
  if p.exists():
1743
  d = pd.read_csv(str(p))
1744
+ if key == "difference_analysis" and group_labels:
1745
+ group_codes = [c[:-2] for c in d.columns if c.endswith('_N')]
1746
+ rename_map = {}
1747
+ for g in group_codes:
1748
+ label = group_labels.get(g)
1749
+ if label and label != g:
1750
+ rename_map[f"{g}_N"] = f"{label}_N"
1751
+ rename_map[f"{g}_M"] = f"{label}_M"
1752
+ rename_map[f"{g}_SD"] = f"{label}_SD"
1753
+ if rename_map:
1754
+ d = d.rename(columns=rename_map)
1755
  if key == "cluster_results":
1756
  # 2026-08-15(業主要求):跟 rf_accuracy_is_meaningful 同一個
1757
  # 教訓——原本這裡的欄名是 Type1,LLM 直接照抄,報告寫成
 
1790
  "或納入「達統計顯著」/「無明顯差異」的分類統計,兩者是完全不同的結論。"
1791
  if len(untestable) > 0 else ""
1792
  )
1793
+ # 2026-08-24(業主回報 AG_05 案例,見 ZERO_VARIANCE_WARNING 定義
1794
+ # 處):「資料品質警示」欄位非空的指標,p 值與效果量都是有效
1795
+ # 計算結果、正常參與顯著性統計,跟上面的「資料不足」是不同
1796
+ # 性質,不能用同一句話說明,另外準備一段中文提醒。
1797
+ if '資料品質警示' in d.columns:
1798
+ zero_var = d[d['資料品質警示'].fillna('') != '']
1799
+ else:
1800
+ zero_var = pd.DataFrame()
1801
+ data["zero_variance_warning_indicators"] = zero_var['指標'].tolist() if len(zero_var) > 0 else []
1802
+ data["zero_variance_warning_count"] = len(zero_var)
1803
+ data["資料穩定性提醒"] = (
1804
+ f"以上 difference_analysis 中有 {len(zero_var)} 項指標的「資料品質警示」欄位非空"
1805
+ "(列於 zero_variance_warning_indicators),代表該指標至少有一組出現以下情況之一:"
1806
+ "①該組所有文本在此指標上數值完全相同,標準差趨近於 0;或 ②該組半數以上文本在"
1807
+ "此指標上數值為 0,樣本集中於零值,平均數可能被少數非零離群值主導。這兩種情況下 "
1808
+ "p 值與效果量仍是正常計算、正常算入「達統計顯著」的統計比例,不像「資料不足」那樣"
1809
+ "要整項排除;但陳述這些指標時,必須額外註明「此結果統計穩定性有限,建議謹慎解讀」"
1810
+ "這類保留語氣,不可跟其他變異正常的顯著指標用完全相同的肯定語氣陳述,避免讀者把"
1811
+ "統計上脆弱的結果當成跟其他乾淨顯著結果同等份量的證據。"
1812
+ if len(zero_var) > 0 else ""
1813
+ )
1814
  else:
1815
  data[key] = []
1816
  if "significant_count" not in data:
 
1848
 
1849
 
1850
  def run_report_writing(scenario_key: str, user_metadata: str, user_focus: str,
1851
+ csv_path: Path, output_dir: Path, ml_summary: dict = None,
1852
+ group_labels: dict = None) -> dict:
1853
  """
1854
  呼叫主 Agent (LLM1) 撰寫研究報告,儲存 docx + txt,回傳路徑 dict。
1855
  ml_summary:見 load_analysis_data_for_llm() 的說明。
1856
+ group_labels:見 load_analysis_data_for_llm() 的說明。
1857
  """
1858
  output_dir.mkdir(parents=True, exist_ok=True)
1859
+ analysis_data = load_analysis_data_for_llm(csv_path, output_dir, ml_summary=ml_summary,
1860
+ group_labels=group_labels)
1861
 
1862
  scenario_info = SCENARIOS.get(scenario_key, SCENARIOS["1"])
1863
  scenario_mod_key = scenario_info.get("scenario_module_key", "scenario_1")
 
1874
  metadata = metadata_str,
1875
  user_focus = user_focus if user_focus else "無",
1876
  scenario_module = scenario_module,
1877
+ indicator_reference_table = INDICATOR_REFERENCE_TABLE,
1878
  )
1879
+
1880
+ # 2026-08-26(業主要求:可重複性設計的最後一塊——目前報告只印「提示詞
1881
+ # 版本 v1」這個版本號,使用者/論文口委無從查證這個版本實際規定了
1882
+ # 什麼,只能回頭翻原始碼):把這次分析實際套用(.format() 展開後、
1883
+ # 已代入研究情境/metadata/指標對照表的完整版本)的 system prompt
1884
+ # 原文存成一份檔案,跟其他分析產出放在同一個 output_dir,隨「下載
1885
+ # 全部資料」一起打包。直接從程式實際使用的字串存出來,不是另外維護
1886
+ # 一份文件,未來 SYSTEM_PROMPT_BASE 異動也不會有兩邊對不起來的風險。
1887
+ system_prompt_path = output_dir / "06_system_prompt.txt"
1888
+ with open(str(system_prompt_path), 'w', encoding='utf-8') as f:
1889
+ f.write(f"研究情境:{scenario_info['title']}\n")
1890
+ f.write(f"提示詞版本:{PROMPT_VERSION}\n")
1891
+ f.write(f"模型:主 Agent(LLM1){LLM1_MODEL}\n")
1892
+ f.write(
1893
+ "\n本檔案為這次分析實際送給 LLM 的完整 system prompt 原文\n"
1894
+ "(已代入本次的研究情境、語料 metadata 與指標代碼對照表)。\n"
1895
+ )
1896
+ f.write("\n---\n\n")
1897
+ f.write(system_prompt)
1898
  user_prompt = (
1899
  f"請根據以下量化分析結果,按照 system prompt 中的輸出結構進行深度分析。\n\n"
1900
  f"【量化分析資料(JSON)】:\n```json\n"
 
1931
  style='Normal').alignment = WD_ALIGN_PARAGRAPH.CENTER
1932
  doc.add_paragraph(f'報告時間:{now_tw().strftime("%Y-%m-%d %H:%M:%S")}(台灣時間)',
1933
  style='Normal').alignment = WD_ALIGN_PARAGRAPH.CENTER
1934
+ # 2026-08-24(業主回報:之前加過的 N/M/SD 圖例只在 Final_Report.docx
1935
+ # 那份技術報告的表格上方,這份 LLM 敘述報告是完全獨立的檔案,內文一樣
1936
+ # 大量使用「M=...」但從沒解釋過,使用者只拿到這份報告的話完全看不懂。
1937
+ # 兩份報告各自獨立、讀者不一定兩份都會看,圖例需要在各自檔案裡都出現,
1938
+ # 不能假設讀者一定也看過另一份。
1939
+ legend_p = doc.add_paragraph(
1940
+ '本報告中 N=樣本數、M=平均數、SD=標準差;p 為統計顯著性之機率值,'
1941
+ '效果量以 Cohen\'s d(二群比較)或 η²(三群以上)表示。'
1942
+ )
1943
+ legend_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
1944
+ legend_p.runs[0].italic = True
1945
+ legend_p.runs[0].font.size = Pt(9)
1946
+ # 2026-08-26:提示詞版本號旁邊只有一個代號(v1),讀者查不到內容本身
1947
+ # 具體規定了什麼——這裡指向同一次下載包裡的完整原文檔案。
1948
+ sp_note = doc.add_paragraph(
1949
+ f'本報告據以撰寫的完整 system prompt 原文,請見同一份下載包中的 06_system_prompt.txt。'
1950
+ )
1951
+ sp_note.alignment = WD_ALIGN_PARAGRAPH.CENTER
1952
+ sp_note.runs[0].italic = True
1953
+ sp_note.runs[0].font.size = Pt(9)
1954
  doc.add_page_break()
1955
  # 2026-08-14(待辦 A1):區塊層級語法(標題/項目符號)沿用原本判斷,
1956
  # 差別是段落文字改用 _add_inline_markdown_runs() 逐段解析 **粗體**/
 
1974
  # txt 版之前完全沒寫,這裡補上、順便帶提示詞版本號,兩種格式的
1975
  # 稽核資訊要一致,不能只有 docx 有。
1976
  f.write(f"模型:主 Agent(LLM1){LLM1_MODEL}(提示詞版本 {PROMPT_VERSION})\n")
1977
+ # 2026-08-24(��主回報 N/M/SD 圖例只加在 Final_Report.docx,這份 LLM
1978
+ # 敘述報告內文一樣大量出現「M=...」卻沒有解釋,兩份報告是獨立檔案、
1979
+ # 讀者不一定都看過,圖例要各自都有):
1980
+ f.write(
1981
+ "本報告中 N=樣本數、M=平均數、SD=標準差;p 為統計顯著性之機率值,"
1982
+ "效果量以 Cohen's d(二群比較)或 η²(三群以上)表示。\n"
1983
+ )
1984
+ # 2026-08-26:同 docx 版,指向同一份下載包裡的完整 system prompt 原文。
1985
+ f.write("本報告據以撰寫的完整 system prompt 原文,請見同一份下載包中的 06_system_prompt.txt。\n")
1986
  # 2026-07-27:原本用 "="*60 當純文字分隔線,但緊接在上兩行文字正下方,
1987
  # 符合 Markdown Setext 標題語法(文字正上方接一整行 = 會被解讀成 h1
1988
  # 底線),導致這兩行 metadata 被渲染成巨大無樣式標題。改成前面留空行
 
1992
  f.write(result_text)
1993
 
1994
  return {
1995
+ "docx_path": docx_path,
1996
+ "txt_path": txt_path,
1997
+ "result_text": result_text,
1998
+ "system_prompt_path": system_prompt_path,
1999
  }
2000
 
2001
 
 
2462
  csv_path = csv_path,
2463
  output_dir = output_dir,
2464
  ml_summary = {"accuracy": anal.get("accuracy"), "do_eval": anal.get("do_eval")},
2465
+ group_labels = state.get("group_labels", {}),
2466
  )
2467
+ results_paths.extend([str(rpt["docx_path"]), str(rpt["txt_path"]), str(rpt["system_prompt_path"])])
2468
  A["report"]["status"] = "done"
2469
  A["report"]["line"] = "研究報告已產出"
2470
  yield " [OK] 研究報告撰寫完成", False, state
 
3333
  # 邏輯(on_scenario_pick)完全不用改。
3334
  sc_btn_1 = gr.Button("1 人工翻譯 vs 機器翻譯", variant="secondary",
3335
  elem_id="sc_btn_1", elem_classes=["sc-bridge-btn"])
3336
+ sc_btn_2 = gr.Button("2 全譯 vs 編譯(節譯、改寫)", variant="secondary",
3337
  elem_id="sc_btn_2", elem_classes=["sc-bridge-btn"])
3338
+ sc_btn_3 = gr.Button("3 不同譯者的同期譯本", variant="secondary",
3339
  elem_id="sc_btn_3", elem_classes=["sc-bridge-btn"])
3340
+ sc_btn_4 = gr.Button("4 不同時期人工譯本", variant="secondary",
3341
  elem_id="sc_btn_4", elem_classes=["sc-bridge-btn"])
3342
 
3343
  # Phase 8-E:手機漢堡開關+抽屜背景遮罩。跟 sc_btn_1..4 同一類坑:
 
3403
  reset_confirm_btn = gr.Button("確定回首頁", elem_id="reset_confirm_btn",
3404
  scale=1, variant="primary")
3405
 
3406
+ # 2026-08-26:「關於」面板,跟上面 reset_confirm_row 同一個理由放在
3407
+ # workspace_row 外層(避免被手機版 nav-col 的 transform 抽屜關住),
3408
+ # 沿用同一套 overlay 機制(elem_classes 掛 reset-confirm-overlay 觸發
3409
+ # 既有的置中 + display:none 控制邏輯),面板本身另開較寬的
3410
+ # about-panel class(見 assets/app.css 註解)。
3411
+ with gr.Column(visible=False, elem_id="about_overlay",
3412
+ elem_classes=["reset-confirm-overlay"]) as about_overlay:
3413
+ with gr.Column(elem_id="about_panel", elem_classes=["about-panel"]):
3414
+ gr.Markdown(ABOUT_CONTENT_MD)
3415
+ with gr.Row(elem_classes=["about-close-row"]):
3416
+ about_close_btn = gr.Button("關閉", elem_id="about_close_btn",
3417
+ scale=0, variant="secondary")
3418
+
3419
  # Phase 8-B:三欄式工作區——nav-col(窄,導覽/重置)+chat-col
3420
  # (對話視窗+輸入框,獨立捲動)+canvas-col(步驟列+上傳/表單/
3421
  # 執行紀錄,獨立捲動)。手機寬度見 CSS 的 media query 改上下堆疊。
 
3595
  elem_classes=["result-nav-btn", "result-nav-btn--muted"],
3596
  variant="secondary",
3597
  )
3598
+ # ── 關於(2026-08-26,業主要求)───────────────────────
3599
+ # 跟使用手冊同��顆待遇:住在 result_nav_col 外面,同一份
3600
+ # elem_classes(沿用 #nav_guide_btn 那組樣式選擇器,見
3601
+ # assets/app.css)。這顆是彈窗不是下載,用一般 gr.Button
3602
+ # 搭配下面的 about_overlay 面板,toggle 顯示/隱藏。
3603
+ about_btn = gr.Button(
3604
+ "關於", icon=str(BASE_DIR / "assets" / "icons" / "info-circle.svg"),
3605
+ elem_id="nav_about_btn",
3606
+ elem_classes=["result-nav-btn", "result-nav-btn--muted"],
3607
+ variant="secondary",
3608
+ )
3609
  # ↑ nav_items_scroll 到此結束(中段可捲動區)。
3610
 
3611
  # 底部工具列(固定,不隨中段捲動):明暗切換/收合鈕原本在頂端
 
3705
  # 標題+說明文字沿用 metadata_row 那則 Markdown 的寫法
3706
  # (純 "### 標題\n說明文字",不套 elem_classes),維持
3707
  # 同一批「Canvas 操作區塊」卡片的既有排版慣例。
3708
+ gr.Markdown("### 情境選擇\n請選擇最符合您針對同一原文的重譯研究設計的比較情境,選定後將進入下一步「文本上傳」。")
3709
  gr.HTML(_build_scenario_cards_html("workspace_scenario_grid"))
3710
 
3711
  # Phase 8(千卉實測後回頭調整):這張卡片改回正式使用
 
3746
 
3747
  # ── 語料 Metadata 說明表單(上傳後、分析前)───────
3748
  with gr.Column(visible=False, elem_id="metadata_zone", elem_classes=["metadata-zone"]) as metadata_row:
3749
+ gr.Markdown("### 📑 語料說明\n請於正式分析前說明語料來源、範圍與比較結構,將寫入研究報告。")
3750
 
3751
  gr.Markdown("**原文資訊**")
3752
  with gr.Row():
 
4080
  "無法進行統計檢定,不計入上述比例。"
4081
  if untestable_n > 0 else ""
4082
  )
4083
+ # 見 ZERO_VARIANCE_WARNING/ZERO_PROPORTION_WARNING 定義處:這些
4084
+ # 指標仍計入顯著/不顯著比例(p 值照常有效),只是額外提醒統計
4085
+ # 穩定性存疑,跟上面「資料不足」(完全無法檢定、不計入比例)是
4086
+ # 不同性質,故用不同語氣、且不寫「不計入上述比例」。這裡的句子
4087
+ # 刻意不細分是哪一條判準命中——個別是哪一種,下方表格會逐列標註。
4088
+ warn_n = int((diff.get('資料品質警示', pd.Series(dtype=str)).fillna('') != '').sum())
4089
+ zero_var_note = (
4090
+ f"\n\n⚠️ 其中 **{warn_n}** 個顯著指標的資料品質有疑慮"
4091
+ "(某組標準差趨近於 0,或某組半數以上樣本為 0),"
4092
+ "統計穩定性有限,建議謹慎解讀(詳見下方表格個別標註)。"
4093
+ if warn_n > 0 else ""
4094
+ )
4095
  # 2026-08-14(業主要求):標題句前面加一句「以[檢定方法]比較
4096
  # [分組]文本」,方法與分組名稱都從 diff 本身反推——分組代碼
4097
  # 是「{組別}_N」這組欄名的前綴(run_statistical_analysis 產出
 
4118
  # 的做法一致。
4119
  R[6] = gr.update(
4120
  value=(f"### 差異性分析\n{prefix}共 **{len(diff)}** 個指標,"
4121
+ f"其中 **{len(sig)}** 個達顯著差異(p < {ALPHA})。{empty_note}{untestable_note}{zero_var_note}\n\n"
4122
  f"下方表格中 N=樣本數、M=平均數、SD=標準差。"),
4123
  visible=True,
4124
  )
 
4190
  try:
4191
  with open(str(txt_files[0]), 'r', encoding='utf-8') as f:
4192
  rpt_text = f.read()
4193
+ # 2026-08-24(業主回報畫面上模型資訊重複顯示兩次):txt 檔本身
4194
+ # 從 2026-08-22 起(可重複性設計)就會把「模型:...」這行寫進
4195
+ # 檔頭,是給下載後獨立開啟 txt 檔的人看的;但畫面這裡另外又疊了
4196
+ # 一行同樣內容的 model_note(有���色樣式、給畫面用),兩者一起
4197
+ # 顯示就變成重複。畫面上只保留有樣式的 model_note,rpt_text
4198
+ # 裡那行純文字的「模型:」拿掉,txt 檔本身仍完整保留不受影響。
4199
+ rpt_text = re.sub(r'^模型:.*\n', '', rpt_text, count=1, flags=re.MULTILINE)
4200
  model_note = (f"<span style='color:var(--c-accent-strong);font-weight:600'>"
4201
  f"本報告由主 Agent(LLM1)模型 {LLM1_MODEL} 生成"
4202
  f"(提示詞版本 {PROMPT_VERSION})</span>")
 
4231
  if not llm_dst.exists():
4232
  shutil.copy(str(docx_files[0]), str(llm_dst))
4233
  dl_rpt.append(str(llm_dst))
4234
+ # 2026-08-26:這次分析實際套用的 system prompt 原文,跟報告
4235
+ # 一起提供下載——固定檔名(不像上面兩份加時間戳),本來就
4236
+ # 只會有一份,不需要另外複製改名。
4237
+ sp_path = output_dir / "06_system_prompt.txt"
4238
+ if sp_path.exists():
4239
+ dl_rpt.append(str(sp_path))
4240
  if dl_rpt:
4241
  R[19] = gr.update(value=dl_rpt, visible=True)
4242
 
 
4705
  "---\n\n"
4706
  "📢 **隱私提醒**:上傳的文本內容將傳送至第三方 AI 服務"
4707
  "(OpenRouter 及其上游模型供應商)進行語言分析與報告撰寫。"
4708
+ "請確認語料不涉及尚未公開發表之機密內容,如有疑慮請先行確認後再上傳。"
4709
  )
4710
  yield (chat_history, log_history, state, gr.update(visible=True),
4711
  _todo_update(state), gr.update(value=hint),
 
4804
 
4805
  reset_btn.click(fn=lambda: gr.update(visible=True), outputs=[reset_confirm_row])
4806
  reset_cancel_btn.click(fn=lambda: gr.update(visible=False), outputs=[reset_confirm_row])
4807
+
4808
+ about_btn.click(fn=lambda: gr.update(visible=True), outputs=[about_overlay])
4809
+ about_close_btn.click(fn=lambda: gr.update(visible=False), outputs=[about_overlay])
4810
  # 2026-08-12:reset_confirm_btn 的接線搬到檔案最後(見 create_app 結尾
4811
  # 「重置接線」那段)。原因:它需要 cancels=[分析運算那兩條事件鏈],而
4812
  # 那兩條要等 meta_confirm_btn/meta_cancel_btn 接完才存在。Gradio 的
 
4822
  # 發言氣泡,看起來像 AI 自問自答):在流程真正開始處理之前,先補一則
4823
  # 使用者訊息氣泡,把「使用者上傳了哪些檔案」呈現出來,對話才有一來
4824
  # 一往的感覺。
4825
+ # 2026-08-24(業主建議):若同一情境下、前一次「確認上傳」的格式驗證
4826
+ # 失敗過,這次再按確認上傳就不是「第一次上傳」,而是使用者刪掉檔案、
4827
+ # 修正後重新送出——文字改成「重新上傳了」更貼近實際發生的事,讀者
4828
+ # 事後看對話紀錄也更容易理解「這是一次修正後的重試」。判斷時機要在
4829
+ # 下面把 state["upload"]["validate"]["status"] 覆寫成 "pending" 之前
4830
+ # 讀,不然永遠讀到剛被蓋掉的新值。換情境重來(on_back_to_scenario)
4831
+ # 會把這個狀態重置回 "pending",避免換情境後的第一次上傳被誤標成
4832
+ # 「重新上傳」。
4833
+ _is_retry = state.get("upload", {}).get("validate", {}).get("status") == "failed"
4834
  _uploaded_names = [Path(f.name if hasattr(f, "name") else str(f)).name for f in files]
4835
+ _verb = "重新上傳了" if _is_retry else "上傳了"
4836
+ chat_history = chat_history + [_user_msg(f"{_verb} {len(_uploaded_names)} 個檔案:{'、'.join(_uploaded_names)}")]
4837
 
4838
  # 文本上傳(step 2:3-A 新增「研究選擇」為第 1 步後,這裡的步驟
4839
  # 編號全部 +1):開始處理即關閉上傳彈窗,避免遮罩擋住操作。
 
4886
  log_history = _log_append(log_history, "✅ 格式驗證通過")
4887
  # 格式通過後:顯示 metadata 說明表單(語料說明=step 3),等使用者填寫
4888
  state["state"] = "METADATA_INPUT"
4889
+ meta_hint = "請填寫語料說明(原文資訊、群組層與個體層),完成後點「確認並開始分析」。"
4890
  chat_history = chat_history + [_assistant_msg(meta_hint)]
4891
+ log_history = _log_append(log_history, "📑 請填寫語料說明")
4892
  # 2026-07-31(Batch E 第 5、6 項):業主回報「語料說明」「分析
4893
  # 運算」兩步驟畫面上還留著「文本上傳」的進度區塊(檔案接收 ✓/
4894
  # 格式驗證 ✓),流程往下推進後這塊資訊已經沒用。清空的時機點
 
4978
  (群組標籤/翻譯類型/翻譯方向/譯者背景/目標讀者)攤平排列,
4979
  對應 meta_group_fields 三個 slot、各自 5 個 Textbox 的值。"""
4980
  sc = state.get("scenario", "")
4981
+ L = ["## 語料說明", "",
4982
  "### 原文資訊",
4983
  f"- 原文標題:{title or '(未填)'}",
4984
  f"- 作者:{author or '(未填)'}",
 
5162
  state["cleaned_files"] = []
5163
  state["current_step"] = 1
5164
  state["done_steps"] = [s for s in state.get("done_steps", []) if s != 1]
5165
+ # 2026-08-24:換情境重來時,上一個情境若曾經格式驗證失敗過,這裡要
5166
+ # 把狀態重置回初始值,不然換到新情境後的第一次上傳會被 on_upload_
5167
+ # confirm 誤判成「重新上傳」(見該處 _is_retry 判斷邏輯)。
5168
+ state["upload"] = {
5169
+ "receive": {"status": "pending", "line": ""},
5170
+ "validate": {"status": "pending", "line": ""},
5171
+ }
5172
  return (
5173
  gr.update(visible=True), # scenario_select_row
5174
  gr.update(visible=False), # upload_row
 
5236
  version, ngroups, method, group_field_values, indiv_df)
5237
  state["group_labels"] = _extract_group_labels(state, group_field_values)
5238
  state["individual_grouping"] = _extract_individual_grouping(_df_rows(indiv_df), method)
5239
+ log_history = _log_append(log_history, "✅ 已記錄語料說明,開始分析")
5240
  # 2026-08-22:同上傳檔案那段,補一則使用者訊息氣泡,呈現「使用者
5241
  # 填寫完語料說明並送出」這個動作,避免對話看起來像 AI 自問自答。
5242
+ chat_history = chat_history + [_user_msg("已送出語料說明(原文資訊、群組層與個體層),請開始分析")]
5243
  # 在主對話視窗立即顯示「開始分析」訊息,避免長時間運算期間畫面看起來像沒有反應
5244
  chat_history = chat_history + [_assistant_msg(_ANALYSIS_START_MSG)]
5245
  yield (chat_history, log_history, state, gr.update(visible=False),
 
5283
  yield (chat_history, log_history, state, gr.update(), _todo_update(state),
5284
  gr.update(), gr.update(), *_no_tabs)
5285
  return
5286
+ state["user_metadata"] = state.get("user_metadata", "") or "(使用者未提供語料說明)"
5287
+ log_history = _log_append(log_history, "⏭️ 已略過語料說明,直接開始分析")
5288
  # 2026-08-22:同 on_metadata_confirm,補使用者訊息氣泡(略過版本)。
5289
  chat_history = chat_history + [_user_msg("略過填寫,直接開始分析")]
5290
  chat_history = chat_history + [_assistant_msg(_ANALYSIS_START_MSG)]
assets/app.css CHANGED
@@ -1314,6 +1314,7 @@ img.button-icon { opacity: .82; }
1314
  的改動,都要回頭盤同名 class 的其他規則有沒有被連帶壓掉。 */
1315
  .nav-col.collapsed #reset_btn,
1316
  .nav-col.collapsed #nav_guide_btn,
 
1317
  .nav-col.collapsed #result_nav_col .result-nav-btn {
1318
  justify-content: center !important;
1319
  padding: .45rem !important; /* 2026-07-24 收窄:原本 .6rem 把圖示擠到 8px(見下方註解) */
@@ -1599,6 +1600,43 @@ img.button-icon { opacity: .82; }
1599
  border-radius: 10px !important;
1600
  font-weight: 700 !important;
1601
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1602
  /* Phase 8-D:結果導覽(情境選定後顯示在開始新分析按鈕下方),純文字
1603
  標籤(暫不用圖示,等下一輪視覺打磨補 Heroicons SVG),維持跟
1604
  scenario-buttons 一致的「貼齊左邊、無強調色底」低視覺重量風格,
@@ -1628,7 +1666,8 @@ img.button-icon { opacity: .82; }
1628
  Gradio 的 button.svelte-xxx.secondary,不需要額外的祖先。 */
1629
  #result_nav_col .result-nav-btn,
1630
  #reset_btn,
1631
- #nav_guide_btn {
 
1632
  width: 100% !important;
1633
  justify-content: flex-start !important;
1634
  text-align: left !important;
@@ -1641,7 +1680,8 @@ img.button-icon { opacity: .82; }
1641
  不貼齊欄邊。拿掉原本 Bootstrap nav-underline 的左緣線(千卉指定改填色)。 */
1642
  #result_nav_col .result-nav-btn,
1643
  #reset_btn,
1644
- #nav_guide_btn {
 
1645
  padding: .5rem .7rem !important;
1646
  font-size: var(--font-size-base) !important;
1647
  font-weight: 500 !important;
@@ -1651,13 +1691,15 @@ img.button-icon { opacity: .82; }
1651
  }
1652
  #result_nav_col .result-nav-btn:hover:not(:disabled),
1653
  #reset_btn:hover:not(:disabled),
1654
- #nav_guide_btn:hover:not(:disabled) {
 
1655
  background: rgba(var(--c-accent-rgb),.08) !important;
1656
  color: var(--c-text) !important;
1657
  }
1658
  #result_nav_col .result-nav-btn:active:not(:disabled),
1659
  #reset_btn:active:not(:disabled),
1660
- #nav_guide_btn:active:not(:disabled) {
 
1661
  background: rgba(var(--c-accent-rgb),.16) !important;
1662
  }
1663
  /* 歷史紀錄:這裡原本有一整組 .result-nav-subbtn 樣式,把統計比較分析底下
@@ -1702,7 +1744,8 @@ img.button-icon { opacity: .82; }
1702
  flex: 0 0 auto !important;
1703
  }
1704
  #result_nav_col .result-nav-btn--muted,
1705
- #nav_guide_btn.result-nav-btn--muted {
 
1706
  font-size: var(--font-size-base) !important;
1707
  font-weight: 500 !important;
1708
  color: var(--c-text-muted) !important;
 
1314
  的改動,都要回頭盤同名 class 的其他規則有沒有被連帶壓掉。 */
1315
  .nav-col.collapsed #reset_btn,
1316
  .nav-col.collapsed #nav_guide_btn,
1317
+ .nav-col.collapsed #nav_about_btn,
1318
  .nav-col.collapsed #result_nav_col .result-nav-btn {
1319
  justify-content: center !important;
1320
  padding: .45rem !important; /* 2026-07-24 收窄:原本 .6rem 把圖示擠到 8px(見下方註解) */
 
1600
  border-radius: 10px !important;
1601
  font-weight: 700 !important;
1602
  }
1603
+ /* 2026-08-26:「關於」面板沿用 .reset-confirm-overlay 同一套置中 modal
1604
+ 機制(同一個 elem_classes 掛在 overlay 上),但內容是說明文字不是
1605
+ 一兩句確認詞,.reset-confirm-panel 的 400px 版寬會把整段文字擠成
1606
+ 窄豆腐塊,所以另開 .about-panel 加寬;內容也比確認框長,視窗矮的時候
1607
+ (筆電、手機橫向)要能捲動,不能被 overlay 裁掉看不到下半段。文字
1608
+ 對齊也從確認框的置中改左靠——長段落置中會很難讀。 */
1609
+ .about-panel {
1610
+ margin: auto !important;
1611
+ width: 100% !important;
1612
+ max-width: 560px !important;
1613
+ max-height: min(80vh, 640px) !important;
1614
+ overflow-y: auto !important;
1615
+ flex: none !important;
1616
+ gap: 1rem !important;
1617
+ padding: 1.6rem 1.8rem !important;
1618
+ border: 1px solid var(--c-border-strong) !important;
1619
+ border-radius: 14px !important;
1620
+ background: var(--c-surface) !important;
1621
+ box-shadow: 0 20px 50px rgba(15, 23, 42, .3) !important;
1622
+ text-align: left !important;
1623
+ }
1624
+ .about-panel h2, .about-panel h3 {
1625
+ text-align: left !important;
1626
+ }
1627
+ .about-panel hr {
1628
+ border: none !important;
1629
+ border-top: 1px solid var(--c-border-strong) !important;
1630
+ margin: .6rem 0 !important;
1631
+ }
1632
+ .about-panel .about-close-row {
1633
+ display: flex !important;
1634
+ justify-content: flex-end !important;
1635
+ }
1636
+ .about-panel .about-close-row button {
1637
+ border-radius: 10px !important;
1638
+ font-weight: 700 !important;
1639
+ }
1640
  /* Phase 8-D:結果導覽(情境選定後顯示在開始新分析按鈕下方),純文字
1641
  標籤(暫不用圖示,等下一輪視覺打磨補 Heroicons SVG),維持跟
1642
  scenario-buttons 一致的「貼齊左邊、無強調色底」低視覺重量風格,
 
1666
  Gradio 的 button.svelte-xxx.secondary,不需要額外的祖先。 */
1667
  #result_nav_col .result-nav-btn,
1668
  #reset_btn,
1669
+ #nav_guide_btn,
1670
+ #nav_about_btn {
1671
  width: 100% !important;
1672
  justify-content: flex-start !important;
1673
  text-align: left !important;
 
1680
  不貼齊欄邊。拿掉原本 Bootstrap nav-underline 的左緣線(千卉指定改填色)。 */
1681
  #result_nav_col .result-nav-btn,
1682
  #reset_btn,
1683
+ #nav_guide_btn,
1684
+ #nav_about_btn {
1685
  padding: .5rem .7rem !important;
1686
  font-size: var(--font-size-base) !important;
1687
  font-weight: 500 !important;
 
1691
  }
1692
  #result_nav_col .result-nav-btn:hover:not(:disabled),
1693
  #reset_btn:hover:not(:disabled),
1694
+ #nav_guide_btn:hover:not(:disabled),
1695
+ #nav_about_btn:hover:not(:disabled) {
1696
  background: rgba(var(--c-accent-rgb),.08) !important;
1697
  color: var(--c-text) !important;
1698
  }
1699
  #result_nav_col .result-nav-btn:active:not(:disabled),
1700
  #reset_btn:active:not(:disabled),
1701
+ #nav_guide_btn:active:not(:disabled),
1702
+ #nav_about_btn:active:not(:disabled) {
1703
  background: rgba(var(--c-accent-rgb),.16) !important;
1704
  }
1705
  /* 歷史紀錄:這裡原本有一整組 .result-nav-subbtn 樣式,把統計比較分析底下
 
1744
  flex: 0 0 auto !important;
1745
  }
1746
  #result_nav_col .result-nav-btn--muted,
1747
+ #nav_guide_btn.result-nav-btn--muted,
1748
+ #nav_about_btn.result-nav-btn--muted {
1749
  font-size: var(--font-size-base) !important;
1750
  font-weight: 500 !important;
1751
  color: var(--c-text-muted) !important;
assets/icons/info-circle.svg ADDED