minchun Claude Sonnet 5 commited on
Commit
b6322f6
·
1 Parent(s): f072ca3

資料完整性檢查與可重複性技術設定

Browse files

【資料完整性檢查】
差異性檢定(t 檢定/ANOVA)在任一組樣本數 N<2 時,scipy 會算出 p=NaN,
但原邏輯的 `NaN < 0.05` 比較恆為 False,會 fallthrough 到「ns」,讓
「資料不足、根本無法檢定」跟「已檢定、無顯著差異」變成同一個值,讀者與
LLM 都無法分辨。「個體間比較」功能允許分組粒度細到每檔案一組,N=1 是
實際會發生的情境,不是假設案例。

- 新增 INSUFFICIENT_DATA_LABEL 常數,p=NaN 時明確標成獨立類別,不計算
效果量(不用 cohens_d()/eta_squared() 對 N<2 的 0.0 保底值),不計入
顯著/不顯著的統計比例
- 同步修正 sig_df 篩選、docx 報告表格(紅字改灰字斜體、None 值防呆)、
送給 LLM 的 JSON(新增 untestable_indicators/資料完整性提醒欄位)、
畫面即時說明文字,四處都要排除新標籤,避免被誤算進「顯著指標」
- system prompt 補規則:看到「資料完整性提醒」的指標只能寫「資料不足,
無法檢定」,不能寫成「無顯著差異」

【可重複性技術設定】
- temperature 預設從 0.5 改成 0
- LLM1_MODEL/LLM2_MODEL 從環境變數可覆寫改成寫死常數,執行期無法覆寫
(對應 docs/開發優化建議.md 3.3/3.4 節記錄過的真實案例:測試環境曾
被覆寫成 openrouter/free 自動路由別名,同代號兩次呼叫導向不同模型)
- 新增 PROMPT_VERSION 常數,隨 system prompt 異動手動遞增,印在 docx/
txt/畫面三處報告輸出的標頭,讓每份報告可回溯當時用的模型與提示詞版本
- .env.example/使用手冊.md/.docx 同步移除已失效的 LLM_MODEL 覆寫說明

以上均以隔離測試腳本驗證(N=1/N=3/混合案例、LLM 資料層輸出、docx 實際
產出內容),未觸及既有正常流程之行為。

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

Files changed (4) hide show
  1. .env.example +3 -3
  2. app_agent.py +123 -22
  3. 使用手冊.docx +2 -2
  4. 使用手冊.md +4 -2
.env.example CHANGED
@@ -2,6 +2,6 @@
2
  LLM1_API_KEY=
3
  LLM2_API_KEY=
4
 
5
- # 可選:覆寫模型名稱
6
- # LLM1_MODEL=google/gemma-4-31b-it
7
- # LLM2_MODEL=google/gemma-4-26b-a4b-it
 
2
  LLM1_API_KEY=
3
  LLM2_API_KEY=
4
 
5
+ # 2026-08-22:模型名稱不再支援用環境變數覆寫(可重複性設計,避免研究結果
6
+ # 隨執行環境的模型覆寫設定而變動)。要換模型請直接改
7
+ # app_agent.py 的 LLM1_MODEL/LLM2_MODEL 常數。
app_agent.py CHANGED
@@ -127,14 +127,45 @@ def start_session_cleanup_scheduler() -> None:
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")
132
-
133
  LLM2_API_KEY = os.environ.get("LLM2_API_KEY", "")
134
- LLM2_MODEL = os.environ.get("LLM2_MODEL", "google/gemma-4-26b-a4b-it")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
  ALPHA = 0.05
137
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  # ── POS 集合(與 Prog02 相同)───────────────────────────────────────────────
139
  Vset = ['VC','VH','VE','VJ','VA','V2','VK','VL','VD','VCL','VHC','VB','VF','VI','VAC','VG']
140
  Nset = ['Na','Nb','Nc','Ncd','Nd','Nf','Nh','Nv']
@@ -397,6 +428,10 @@ SYSTEM_PROMPT_BASE = """# 角色定義
397
  (M=32.5),1989 年組別最低(M=18.2)。」
398
  區分:達統計顯著的差異(標明 p 值、效果量、最高與最低組別及平均數)、未達顯著但數值差距
399
  較大的趨勢(同樣要指出方向)、無明顯差異的指標。
 
 
 
 
400
  2. 監督式學習(隨機森林):說明 feature_importance_top20 裡重要性最高的 3-5 項指標與其重要性數值。
401
  同時陳述「隨機森林分類準確率」的數字,並原樣附上 JSON 裡「隨機森林準確率之樣本外預測意義」
402
  這欄給的完整句子(那句話已經是可以直接使用的中文敘述,不必再改寫或省略)。
@@ -476,8 +511,13 @@ _load_resources()
476
  # SECTION 3: LLM API Wrappers
477
  # ============================================================================
478
 
479
- def _call_openrouter(api_key, model, system_prompt, user_prompt, max_tokens=16000, temperature=0.5):
480
- """通用 OpenRouter API 呼叫(同步)。"""
 
 
 
 
 
481
  url = "https://openrouter.ai/api/v1/chat/completions"
482
  headers = {
483
  "Authorization": f"Bearer {api_key}",
@@ -1043,25 +1083,35 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path, group_labels: dic
1043
  row[f"{g}_SD"] = round(float(np.std(gdata[gi], ddof=1)), 4) if len(gdata[gi])>1 else 0.0
1044
  if n_groups == 2:
1045
  stat, p = stats.ttest_ind(gdata[0], gdata[1], equal_var=False)
1046
- es = cohens_d(gdata[0], gdata[1])
1047
  es_name = "Cohen's d"; method = "t-test"
1048
  else:
1049
  stat, p = stats.f_oneway(*gdata)
1050
- es = eta_squared(gdata)
1051
  es_name = "η²"; method = "ANOVA"
1052
  row["方法"] = method
 
 
 
 
 
 
 
1053
  row["統計量"] = round(float(stat), 4) if not np.isnan(stat) else None
1054
- row["p值"] = round(float(p), 4) if not np.isnan(p) else None
1055
- row["顯著"] = ("***" if p is not None and p<0.001 else
1056
- "**" if p is not None and p<0.01 else
1057
- "*" if p is not None and p<0.05 else "ns")
1058
  row["效果量名稱"] = es_name
1059
- row["效果量"] = round(float(es), 4)
1060
- row["效果大小"] = effect_label(es, method)
 
 
 
 
 
 
 
1061
  diff_results.append(row)
1062
 
1063
  diff_df = pd.DataFrame(diff_results)
1064
- sig_df = diff_df[diff_df['顯著'] != "ns"]
 
1065
  diff_path = output_dir / "02_difference_analysis.csv"
1066
  diff_df.to_csv(str(diff_path), index=False, encoding='utf-8-sig')
1067
 
@@ -1291,7 +1341,9 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path, group_labels: dic
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=標準差。')
@@ -1318,7 +1370,10 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path, group_labels: dic
1318
  vals.extend([str(row['方法']),
1319
  f"{row['統計量']:.4f}" if row['統計量'] is not None else "-",
1320
  f"{row['p值']:.4f}" if row['p值'] is not None else "-",
1321
- str(row['顯著']), f"{row['效果量']:.4f}", str(row['效果大小'])])
 
 
 
1322
  for j, val in enumerate(vals):
1323
  cell = diff_tbl.rows[i].cells[j]
1324
  cell.text = val
@@ -1326,8 +1381,15 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path, group_labels: dic
1326
  p.alignment = WD_ALIGN_PARAGRAPH.CENTER
1327
  for r in p.runs:
1328
  r.font.size = Pt(7)
1329
- if row['顯著'] != "ns" and j == len(vals)-4:
 
 
 
 
1330
  r.font.color.rgb = RGBColor(0xCC, 0x00, 0x00)
 
 
 
1331
  doc.add_paragraph('')
1332
  if box_path and box_path.exists():
1333
  doc.add_heading('顯著指標箱形圖', level=2)
@@ -1373,6 +1435,7 @@ def run_statistical_analysis(csv_path: Path, output_dir: Path, group_labels: dic
1373
  "cluster_csv_path": cluster_csv_path,
1374
  "report_path": report_path,
1375
  "sig_count": len(sig_df),
 
1376
  "total_indicators": len(diff_df),
1377
  "accuracy": accuracy,
1378
  "do_eval": do_eval,
@@ -1446,10 +1509,29 @@ def load_analysis_data_for_llm(csv_path: Path, output_dir: Path, ml_summary: dic
1446
  else:
1447
  data[key] = d.to_dict(orient='records')
1448
  if key == "difference_analysis":
1449
- sig = d[d['顯著'] != 'ns'] if '顯著' in d.columns else pd.DataFrame()
 
 
 
 
1450
  data["significant_indicators"] = sig.to_dict(orient='records')
1451
  data["significant_count"] = len(sig)
1452
  data["total_indicators"] = len(d)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1453
  else:
1454
  data[key] = []
1455
  if "significant_count" not in data:
@@ -1535,7 +1617,12 @@ def run_report_writing(scenario_key: str, user_metadata: str, user_focus: str,
1535
  # 標題後標註使用的 LLM 模型(藍色)
1536
  model_p = doc.add_paragraph()
1537
  model_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
1538
- mr = model_p.add_run(f'本報告由主 Agent(LLM1)模型 {LLM1_MODEL} 生成')
 
 
 
 
 
1539
  mr.font.size = Pt(10)
1540
  mr.font.color.rgb = RGBColor(0x0E, 0x73, 0xB8)
1541
  doc.add_paragraph(f'研究情���:{scenario_info["title"]}',
@@ -1561,6 +1648,10 @@ def run_report_writing(scenario_key: str, user_metadata: str, user_focus: str,
1561
  with open(str(txt_path), 'w', encoding='utf-8') as f:
1562
  f.write(f"研究情境:{scenario_info['title']}\n")
1563
  f.write(f"時間:{now_tw().strftime('%Y-%m-%d %H:%M:%S')}(台灣時間)\n")
 
 
 
 
1564
  # 2026-07-27:原本用 "="*60 當純文字分隔線,但緊接在上兩行文字正下方,
1565
  # 符合 Markdown Setext 標題語法(文字正上方接一整行 = 會被解讀成 h1
1566
  # 底線),導致這兩行 metadata 被渲染成巨大無樣式標題。改成前面留空行
@@ -3619,10 +3710,19 @@ def create_app():
3619
 
3620
  if diff_path.exists():
3621
  diff = pd.read_csv(str(diff_path))
3622
- sig = diff[diff.get('顯著', pd.Series()) != 'ns'] if '顯著' in diff.columns else pd.DataFrame()
 
 
 
 
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 產出
@@ -3649,7 +3749,7 @@ def create_app():
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
  )
@@ -3722,7 +3822,8 @@ def create_app():
3722
  with open(str(txt_files[0]), 'r', encoding='utf-8') as f:
3723
  rpt_text = f.read()
3724
  model_note = (f"<span style='color:var(--c-accent-strong);font-weight:600'>"
3725
- f"本報告由主 Agent(LLM1)模型 {LLM1_MODEL} 生成</span>")
 
3726
  R[18] = f"### LLM 輔助分析 {model_note}\n\n{rpt_text}"
3727
  except Exception:
3728
  R[18] = "### LLM 輔助分析\n\n(讀取報告時發生錯誤)"
 
127
  threading.Thread(target=_loop, daemon=True, name="session-cleanup").start()
128
 
129
  # ── LLM 設定 ────────────────────────────────────────────────────────────────
130
+ # API 金鑰維持環境變數(本來就該是每個部署環境各自設定的機密資訊,不進版控)。
131
  LLM1_API_KEY = os.environ.get("LLM1_API_KEY", "")
 
 
132
  LLM2_API_KEY = os.environ.get("LLM2_API_KEY", "")
133
+
134
+ # 2026-08-22(業主要求:為研究結果的「可重複性」鎖定模型版本):模型代號
135
+ # 原本是 `os.environ.get("LLM1_MODEL", "google/gemma-4-31b-it")`——可以被
136
+ # 環境變數在執行期任意覆寫,且不會留下任何紀錄。docs/開發優化建議.md 第
137
+ # 3.3/3.4 節就記錄過一次真實案例:測試環境曾被覆寫成 `openrouter/free`
138
+ # 這種自動路由別名,同一個代號兩次呼叫被導去兩個不同的實際模型,耗時與
139
+ # 輸出品質都不穩定——這正是「模型版本沒鎖定」會實際發生的風險,不是假設。
140
+ # 改成固定常數:只能透過修改這裡的程式碼(進而走 git commit 留下紀錄)
141
+ # 才能更換模型,執行期無法覆寫,「這次分析用的是哪個模型」才會是一個
142
+ # 可以回頭稽核、不會被悄悄改掉的事實。
143
+ LLM1_MODEL = "google/gemma-4-31b-it"
144
+ LLM2_MODEL = "google/gemma-4-26b-a4b-it"
145
+
146
+ # 2026-08-22(同上,可重複性設計之二:提示詞版本控制):SYSTEM_PROMPT_BASE
147
+ # 與各 SCENARIO_MODULE_* 本身已經隨這個檔案進 git 版控,但報告產出當下
148
+ # 沒有留下任何「這份報告是哪一版提示詞寫的」的紀錄——事後拿到一份舊報告,
149
+ # 沒辦法回推當時的提示詞內容跟現在是否一致。這裡加一個顯式版本號,每次
150
+ # 異動 SYSTEM_PROMPT_BASE 或任一 SCENARIO_MODULE_* 的內容時手動遞增,並且
151
+ # 直接印在每份產出的報告上(見 run_report_writing()),讓版本資訊跟著
152
+ # 報告本身走,不用回頭 git blame 才查得到。
153
+ PROMPT_VERSION = "v1"
154
 
155
  ALPHA = 0.05
156
 
157
+ # 2026-08-22(業主提出「數據完整性檢查」的必要性,追查後確認是真實存在的
158
+ # 問題):差異性檢定(t 檢定/ANOVA)在任一組樣本數 N<2 時,scipy 會算出
159
+ # p=NaN(沒有組內變異,這個檢定工具本來就不適用),但 `NaN < 0.05` 這類比較
160
+ # 在 Python 裡恆為 False,若不特別攔截,會一路 fallthrough 到「無顯著差異」
161
+ # 的判斷分支,跟「有算出結果、但真的沒有顯著差異」變成同一個值,讀者與 LLM
162
+ # 都無法分辨「資料不足、根本沒辦法檢定」跟「檢定過、沒有差異」這兩種本質
163
+ # 不同的情況。「個體間比較」功能(見 _extract_individual_grouping)允許
164
+ # 把分組粒度細到每個檔案自己一組,N=1 per group 是實際會發生的情境,不是
165
+ # 假設的邊角案例。這裡定義一個獨立標籤,任何用到「顯著」欄位的地方都要用
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']
 
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
  這欄給的完整句子(那句話已經是可以直接使用的中文敘述,不必再改寫或省略)。
 
511
  # SECTION 3: LLM API Wrappers
512
  # ============================================================================
513
 
514
+ def _call_openrouter(api_key, model, system_prompt, user_prompt, max_tokens=16000, temperature=0):
515
+ """通用 OpenRouter API 呼叫(同步)。
516
+ 2026-08-22(可重複性設計):temperature 預設從 0.5 改成 0,降低同一組輸入
517
+ 在不同次呼叫間的用字差異。注意這不代表輸出保證逐字相同——OpenRouter
518
+ 後端本身的浮點運算、批次處理等因素仍可能造成極小差異,temperature=0
519
+ 只是把「取樣造成的隨機性」這個變因降到最低,不是完全消除所有變因。
520
+ """
521
  url = "https://openrouter.ai/api/v1/chat/completions"
522
  headers = {
523
  "Authorization": f"Bearer {api_key}",
 
1083
  row[f"{g}_SD"] = round(float(np.std(gdata[gi], ddof=1)), 4) if len(gdata[gi])>1 else 0.0
1084
  if n_groups == 2:
1085
  stat, p = stats.ttest_ind(gdata[0], gdata[1], equal_var=False)
 
1086
  es_name = "Cohen's d"; method = "t-test"
1087
  else:
1088
  stat, p = stats.f_oneway(*gdata)
 
1089
  es_name = "η²"; method = "ANOVA"
1090
  row["方法"] = method
1091
+ # 見上方 INSUFFICIENT_DATA_LABEL 定義處的說明:p=NaN 代表這個指標在
1092
+ # 目前的分組下根本無法檢定(通常是某組 N<2),不是「檢定過、沒有
1093
+ # 顯著差異」。這裡明確分流,不落入下面的顯著性判斷,效果量也不計算
1094
+ # ——cohens_d()/eta_squared() 各自對 N<2 有 0.0 的保底值,那是給
1095
+ # 「算得出來、真的沒差異」用的,跟「根本沒算出來」不該共用同一個
1096
+ # 0.0,會誤導成「效果量趨近於零」。
1097
+ is_untestable = bool(np.isnan(p))
1098
  row["統計量"] = round(float(stat), 4) if not np.isnan(stat) else None
1099
+ row["p值"] = round(float(p), 4) if not is_untestable else None
 
 
 
1100
  row["效果量名稱"] = es_name
1101
+ if is_untestable:
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)
1113
+ sig_df = diff_df[~diff_df['顯著'].isin(["ns", INSUFFICIENT_DATA_LABEL])]
1114
+ untestable_df = diff_df[diff_df['顯著'] == INSUFFICIENT_DATA_LABEL]
1115
  diff_path = output_dir / "02_difference_analysis.csv"
1116
  diff_df.to_csv(str(diff_path), index=False, encoding='utf-8-sig')
1117
 
 
1341
  # 修過的 Type1.1/rf_accuracy_is_meaningful 是同一種「技術變數名稱外
1342
  # 洩漏」問題,改成不點名欄位、只講語意。
1343
  doc.add_paragraph(f'分組依據:語料分組(共 {n_groups} 組)。採用 {method_name},顯著水準 α = {ALPHA}。')
1344
+ _untestable_note = (f"另有 {len(untestable_df)} 個指標因組別樣本數不足(N<2),"
1345
+ f"無法進行統計檢定,不計入上述比例。" if len(untestable_df) > 0 else "")
1346
+ doc.add_paragraph(f'共 {len(diff_df)} 個指標,其中 {len(sig_df)} 個達顯著差異(p < {ALPHA})。{_untestable_note}')
1347
  # 2026-08-15(業主要求):表格欄位裡的 N/M/SD 是統計慣用縮寫,第一次
1348
  # 看到報告的人不一定認得,加一句說明放在表格正上方。
1349
  note_p = doc.add_paragraph('表格中 N=樣本數、M=平均數、SD=標準差。')
 
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
 
1381
  p.alignment = WD_ALIGN_PARAGRAPH.CENTER
1382
  for r in p.runs:
1383
  r.font.size = Pt(7)
1384
+ # 顯著(紅字)跟資料不足(灰字)是兩種不同性質的標記,
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)
 
1435
  "cluster_csv_path": cluster_csv_path,
1436
  "report_path": report_path,
1437
  "sig_count": len(sig_df),
1438
+ "untestable_count": len(untestable_df),
1439
  "total_indicators": len(diff_df),
1440
  "accuracy": accuracy,
1441
  "do_eval": do_eval,
 
1509
  else:
1510
  data[key] = d.to_dict(orient='records')
1511
  if key == "difference_analysis":
1512
+ if '顯著' in d.columns:
1513
+ sig = d[~d['顯著'].isin(['ns', INSUFFICIENT_DATA_LABEL])]
1514
+ untestable = d[d['顯著'] == INSUFFICIENT_DATA_LABEL]
1515
+ else:
1516
+ sig = pd.DataFrame(); untestable = pd.DataFrame()
1517
  data["significant_indicators"] = sig.to_dict(orient='records')
1518
  data["significant_count"] = len(sig)
1519
  data["total_indicators"] = len(d)
1520
+ # 2026-08-22:跟資料層預先中文化同一個原則(見
1521
+ # rf_accuracy_is_meaningful/cluster Type1 的前例)——與其在
1522
+ # system prompt 裡要求 LLM「自己判斷哪些指標資料不足」,
1523
+ # 這裡直接把清單跟現成的中文說明準備好,LLM 只要照抄,不用
1524
+ # 自己從一堆 JSON 欄位裡推斷「顯著=資料不足」代表什麼。
1525
+ data["untestable_indicators"] = untestable.to_dict(orient='records')
1526
+ data["untestable_count"] = len(untestable)
1527
+ data["資料完整性提醒"] = (
1528
+ f"以上 difference_analysis 中有 {len(untestable)} 項指標的「顯著」欄位"
1529
+ f"標示為「{INSUFFICIENT_DATA_LABEL}」,代表該指標在某一組別的樣本數少於 2"
1530
+ "(無法計算組內變異),統計檢定本身就無法執行,p 值與效果量均為 null。"
1531
+ "這些指標必須明確陳述為「資料不足,無法檢定」,絕對不可以寫成「無顯著差異」"
1532
+ "或納入「達統計顯著」/「無明顯差異」的分類統計,兩者是完全不同的結論。"
1533
+ if len(untestable) > 0 else ""
1534
+ )
1535
  else:
1536
  data[key] = []
1537
  if "significant_count" not in data:
 
1617
  # 標題後標註使用的 LLM 模型(藍色)
1618
  model_p = doc.add_paragraph()
1619
  model_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
1620
+ # 2026-08-22(可重複性設計):模型名稱後面補上提示詞版本號,讓每份報告
1621
+ # 都留下「當時是哪一版 system prompt 產出的」這個事後可稽核的紀錄,不用
1622
+ # 回頭 git blame 才查得到——PROMPT_VERSION 定義處、修改時機見該常數註解。
1623
+ mr = model_p.add_run(
1624
+ f'本報告由主 Agent(LLM1)模型 {LLM1_MODEL} 生成(提示詞版本 {PROMPT_VERSION})'
1625
+ )
1626
  mr.font.size = Pt(10)
1627
  mr.font.color.rgb = RGBColor(0x0E, 0x73, 0xB8)
1628
  doc.add_paragraph(f'研究情���:{scenario_info["title"]}',
 
1648
  with open(str(txt_path), 'w', encoding='utf-8') as f:
1649
  f.write(f"研究情境:{scenario_info['title']}\n")
1650
  f.write(f"時間:{now_tw().strftime('%Y-%m-%d %H:%M:%S')}(台灣時間)\n")
1651
+ # 2026-08-22(可重複性設計):docx 版標題下方本來就有模型名稱標註,
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 被渲染成巨大無樣式標題。改成前面留空行
 
3710
 
3711
  if diff_path.exists():
3712
  diff = pd.read_csv(str(diff_path))
3713
+ if '顯著' in diff.columns:
3714
+ sig = diff[~diff['顯著'].isin(['ns', INSUFFICIENT_DATA_LABEL])]
3715
+ untestable_n = int((diff['顯著'] == INSUFFICIENT_DATA_LABEL).sum())
3716
+ else:
3717
+ sig = pd.DataFrame(); untestable_n = 0
3718
  # sig=0 時不會產生箱形圖(見下方 box_path 判斷),若只留白使用者
3719
  # 容易誤以為系統壞掉,這裡改在標題文字附加空狀態說明
3720
  empty_note = "" if len(sig) > 0 else "\n\n本次分析未發現統計顯著的指標差異,因此未產生箱形圖。"
3721
+ untestable_note = (
3722
+ f"\n\n⚠️ 另有 **{untestable_n}** 個指標因組別樣本數不足(N<2),"
3723
+ "無法進行統計檢定,不計入上述比例。"
3724
+ if untestable_n > 0 else ""
3725
+ )
3726
  # 2026-08-14(業主要求):標題句前面加一句「以[檢定方法]比較
3727
  # [分組]文本」,方法與分組名稱都從 diff 本身反推——分組代碼
3728
  # 是「{組別}_N」這組欄名的前綴(run_statistical_analysis 產出
 
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
  )
 
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>")
3827
  R[18] = f"### LLM 輔助分析 {model_note}\n\n{rpt_text}"
3828
  except Exception:
3829
  R[18] = "### LLM 輔助分析\n\n(讀取報告時發生錯誤)"
使用手冊.docx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:85fff99e2cab3d48acda515d9bb12f75ecefc60326c1bec83a88686560a705a8
3
- size 44547
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a25202eddd2838b2eda7cb6dbdee97f6b7997484b74249f3e8ffda944bfee197
3
+ size 44651
使用手冊.md CHANGED
@@ -47,11 +47,13 @@ LLM 透過 OpenRouter 呼叫,於專案目錄的 `.env` 檔設定以下變數
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
  於專案目錄執行:
 
47
 
48
  ```
49
  LLM1_API_KEY 你的 OpenRouter 金鑰(主 Agent)
 
50
  LLM2_API_KEY 你的 OpenRouter 金鑰(工作程序 Agent)
 
51
  ```
52
 
53
+ > 模型名稱(主 Agent:`google/gemma-4-31b-it`;工作程序 Agent:`google/gemma-4-26b-a4b-it`)
54
+ > 為可重複性設計,已固定寫在程式碼裡,不再支援用環境變數覆寫;要換模型需直接修改
55
+ > `app_agent.py` 的 `LLM1_MODEL`/`LLM2_MODEL` 常數。
56
+
57
  ### 啟動系統
58
 
59
  於專案目錄執行: