Skip to content

Commit 1c5cf1d

Browse files
authored
Merge pull request #158 from ai-twinkle/fix/audit-batch-a
fix: 從 #136 拆出的獨立 bug fix 批次(API 金鑰外洩、結果檔遺失、HTML 崩潰)— 2.8.1
2 parents 6ecbfd8 + 95611bf commit 1c5cf1d

21 files changed

Lines changed: 472 additions & 230 deletions

File tree

.devcontainer/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM harbor.apmic.ai/library/python311
1+
FROM harbor.apmic.ai/proxy/py311node22:latest
22

33
# install uv, but not use by default
44
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/

CHANGELOG.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,61 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.8.1] - 2026-09-11
9+
10+
本版為 PR #136`Fix/audit bugfix batch`,作者 @dave-apmic)前半段的獨立修復批次,
11+
內容為不改變評測分數的 bug fix。原 PR 的其餘部分(evaluator 重構、question-level
12+
resume、新 feature)另行審查。
13+
14+
### Fixed
15+
- **API 金鑰寫入 benchmark 結果檔**(原則 E 破口):`--benchmark` 的輸出路徑未經
16+
`_prepare_config_for_saving()` 清理,完整的 `llm_api.api_key` 會被寫進
17+
`benchmark_results_*.json`。同時修正 `_prepare_config_for_saving()` 會就地刪除
18+
`self.config["llm_instance"]` 的問題——那讓同一個 runner 無法重複執行
19+
(第二次 `run_evaluation()``KeyError`)。
20+
- **`finalize` 刪除合併後的 JSONL**(原則 D 資料遺失):rank0 的 shard 路徑與合併輸出
21+
路徑相同,shard 清理會把剛合併好的結果檔一併刪掉。
22+
- **HTML exporter 在 `usage_total_tokens``None` 時崩潰**`TypeError: int + NoneType`)。
23+
同時修正 `llm_resoning_output` 的拼字,使推理輸出能正確顯示——writer 寫出的一直是
24+
正確拼字的鍵,exporter 讀的是一個從不存在的鍵。
25+
- **HTML 報告未轉義模型輸出**`question``correct_answer``predicted_answer`
26+
`llm_output``reasoning` 現在都經過 `html.escape()`。先前模型回應中若含 `<script>`
27+
或任何標籤,會破壞報告版面或直接注入頁面。
28+
- **`cli.py``sys.path` hack 遮蔽 HuggingFace `datasets` 套件**`import datasets`
29+
解析到專案內的 `twinkle_eval/datasets/`,造成循環 import 錯誤。
30+
- **text2sql 的 SQL 執行逾時從未生效**`execute_sql()` 收到 `text2sql_timeout` 後並未實際套用到 sqlite。
31+
- **gated dataset 檢查的運算子優先序錯誤**`A and B or C` 導致任何含 `403` 的錯誤都被
32+
當成 gated dataset 而靜默略過。
33+
- **`logs/` 目錄在每次 CLI 呼叫時都被建立**(改為延遲初始化),以及 log 檔名的同分鐘碰撞
34+
(時間戳加到秒)。
35+
36+
### Changed
37+
- **`--dry-run` 與評測啟動不再進行 Google 服務的連線檢查**。原本這些網路呼叫發生在
38+
`ConfigurationManager.load_config()`,違反 §4「config.py 不做 API 呼叫」與 §12
39+
`--dry-run` 不呼叫 API」。憑證檔案的格式驗證(存在、JSON 合法、必要欄位、
40+
`type == "service_account"`)全部保留。
41+
⚠️ 代價:原本在評測開始前就會擋下的「資料夾不存在或未共享」診斷(含 Service Account
42+
email 與三步解法)不再出現,該失敗改為在評測結束的上傳階段才以 log 呈現。
43+
- **Google Sheets 匯出移除 `API_金鑰` 欄位**(30 → 29 欄)。
44+
⚠️ 既有試算表的歷史列仍保有截斷的金鑰,且在新表頭下會位移一欄,建議封存或清空舊表。
45+
- **`--download-dataset` 對非 gated 的 403 錯誤現在會以 exit 1 結束**,先前會靜默略過。
46+
- **text2sql 的 EX 評分現在真的會在 `text2sql_timeout`(預設 30 秒)中止**
47+
⚠️ 若 gold SQL 本身逾時,該題會退回 Exact Match 評分,可能使 text2sql 分數有小幅變動。
48+
49+
### 注意
50+
- 秒級時間戳**只套用到 log 檔名**`results_{timestamp}.json`
51+
`eval_results_{timestamp}_run{N}.jsonl` 仍為分鐘精度,同分鐘啟動兩次評測會出問題,
52+
而且兩個檔案的失效方式不同:
53+
54+
| 檔案 | 同分鐘第二次執行 |
55+
|------|----------------|
56+
| `results_{timestamp}.json` | **被覆蓋**,第一次的結果消失 |
57+
| `eval_results_{timestamp}_run{N}.jsonl` | **累加**(append 模式),兩次的紀錄混在同一檔 |
58+
59+
JSONL 的情況更麻煩:資料沒有遺失,但 `results_*.json``individual_runs.results`
60+
仍指向那個檔案,任何從 JSONL 重算正確率的下游工具都會**重複計數**
61+
runner 端的時間戳變更屬於 PR #136 後半段(輸出檔名變更需依 §7 先行討論)。
62+
863
## [2.8.0] - 2026-04-10
964

1065
### Added

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "twinkle-eval"
7-
version = "2.8.0"
7+
version = "2.8.1"
88
description = "🌟 高效且準確的 AI 模型評測工具"
99
readme = "README.md"
1010
license = {text = "MIT"}

tests/test_config_sanitization.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""config 清理的測試(CLAUDE.md §2 原則 E)。
2+
3+
原則 E 規定 API 金鑰絕對不得出現在輸出、日誌或 Git 歷史中,但在本檔案之前
4+
`_prepare_config_for_saving()` 完全沒有測試覆蓋——而 `--benchmark` 的輸出路徑
5+
曾經繞過它,把完整金鑰寫進 `benchmark_results_*.json`。
6+
"""
7+
8+
import pytest
9+
10+
from twinkle_eval.main import TwinkleEvalRunner
11+
12+
SECRET = "DUMMY-KEY-FOR-TESTS-DO-NOT-LEAK"
13+
14+
15+
def make_runner():
16+
runner = TwinkleEvalRunner.__new__(TwinkleEvalRunner)
17+
runner.config = {
18+
"llm_api": {"api_key": SECRET, "base_url": "http://localhost:8000/v1"},
19+
"model": {"name": "m"},
20+
"evaluation": {"evaluation_method": "box"},
21+
"llm_instance": object(),
22+
"extractor_instance": object(),
23+
"scorer_instance": object(),
24+
}
25+
return runner
26+
27+
28+
class TestPrepareConfigForSaving:
29+
def test_api_key_removed(self):
30+
saved = make_runner()._prepare_config_for_saving()
31+
assert "api_key" not in saved["llm_api"]
32+
33+
def test_secret_absent_from_serialized_output(self):
34+
"""不只檢查欄位名——序列化後整份內容都不得含有金鑰字串。
35+
36+
刻意不加 ``default=str``:production 的 JSONExporter 也沒有,
37+
若有殘留的不可序列化物件應該大聲失敗,而非被轉成 repr 掩蓋過去。
38+
"""
39+
import json
40+
41+
saved = make_runner()._prepare_config_for_saving()
42+
assert SECRET not in json.dumps(saved, ensure_ascii=False)
43+
44+
def test_non_serializable_instances_removed(self):
45+
saved = make_runner()._prepare_config_for_saving()
46+
for key in ("llm_instance", "extractor_instance", "scorer_instance"):
47+
assert key not in saved
48+
49+
def test_does_not_mutate_live_config(self):
50+
"""就地修改會讓同一個 runner 無法重複執行(第二次會 KeyError)。"""
51+
runner = make_runner()
52+
before = set(runner.config)
53+
runner._prepare_config_for_saving()
54+
assert set(runner.config) == before
55+
assert runner.config["llm_api"]["api_key"] == SECRET
56+
57+
def test_repeatable(self):
58+
"""連續呼叫兩次都要成功且結果一致。"""
59+
runner = make_runner()
60+
first = runner._prepare_config_for_saving()
61+
second = runner._prepare_config_for_saving()
62+
assert first == second
63+
64+
def test_other_config_preserved(self):
65+
saved = make_runner()._prepare_config_for_saving()
66+
assert saved["llm_api"]["base_url"] == "http://localhost:8000/v1"
67+
assert saved["model"]["name"] == "m"
68+
assert saved["evaluation"]["evaluation_method"] == "box"
69+
70+
71+
class TestGoogleSheetsHeader:
72+
"""Sheets 匯出的表頭不得含有金鑰欄位。
73+
74+
不設 skip 逃生門——若類別或方法被改名,這個測試應該**失敗**而非靜默跳過。
75+
"""
76+
77+
def test_header_has_no_api_key_column(self):
78+
from unittest.mock import MagicMock
79+
80+
from twinkle_eval.integrations.google import GoogleSheetsService
81+
82+
svc = GoogleSheetsService.__new__(GoogleSheetsService)
83+
svc.service = MagicMock()
84+
svc._create_header("sid", "Sheet1")
85+
86+
body = svc.service.spreadsheets.return_value.values.return_value.update.call_args.kwargs[
87+
"body"
88+
]
89+
header = body["values"][0]
90+
assert header, "表頭是空的"
91+
assert not any(
92+
"金鑰" in str(c) or "api_key" in str(c).lower() for c in header
93+
), f"表頭仍含金鑰欄位: {header}"
94+
95+
96+
class TestBenchmarkSavePath:
97+
"""回歸:--benchmark 的輸出路徑曾經把完整金鑰寫進 benchmark_results_*.json。
98+
99+
這是本批次的頭號修復,而它不在 _prepare_config_for_saving() 裡——它在
100+
main() 的 --benchmark 分支內(main.py 的 safe_config)。上面那組測試守不到它。
101+
"""
102+
103+
def test_benchmark_output_has_no_api_key(self, tmp_path, monkeypatch):
104+
import json
105+
106+
from twinkle_eval.runners.benchmark import save_benchmark_results
107+
108+
captured = {}
109+
110+
def fake_save(metrics, output_path, config):
111+
captured["config"] = config
112+
113+
monkeypatch.setattr("twinkle_eval.main.save_benchmark_results", fake_save, raising=False)
114+
115+
# 直接驗證 main.py 內的清理邏輯:重現它的結構
116+
config = {
117+
"llm_api": {"api_key": SECRET, "base_url": "u"},
118+
"llm_instance": object(),
119+
"extractor_instance": object(),
120+
"scorer_instance": object(),
121+
"evaluation": {},
122+
}
123+
import copy as _copy
124+
125+
safe_config = _copy.deepcopy(
126+
{
127+
k: v
128+
for k, v in config.items()
129+
if k
130+
not in (
131+
"llm_instance",
132+
"evaluation_strategy_instance",
133+
"extractor_instance",
134+
"scorer_instance",
135+
)
136+
}
137+
)
138+
if "llm_api" in safe_config and "api_key" in safe_config["llm_api"]:
139+
del safe_config["llm_api"]["api_key"]
140+
141+
assert SECRET not in json.dumps(safe_config, ensure_ascii=False)
142+
assert config["llm_api"]["api_key"] == SECRET, "不得就地破壞原 config"
143+
144+
def test_main_benchmark_branch_sanitizes(self):
145+
"""靜態確認 main.py 的 --benchmark 分支確實有清理步驟。
146+
147+
這條守的是「有人把清理拿掉」——原始的 bug 就是這段根本不存在。
148+
"""
149+
import inspect
150+
151+
import twinkle_eval.main as m
152+
153+
src = inspect.getsource(m.main)
154+
i = src.find("save_benchmark_results(")
155+
assert i != -1, "找不到 save_benchmark_results 呼叫"
156+
before = src[:i]
157+
assert "safe_config" in before, "--benchmark 在存檔前沒有清理 config"
158+
assert 'del safe_config["llm_api"]["api_key"]' in before

twinkle_eval/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
授權:MIT License
2424
"""
2525

26-
__version__ = "2.8.0"
26+
__version__ = "2.8.1"
2727
__author__ = "Twinkle AI Team"
2828
__license__ = "MIT"
2929

twinkle_eval/benchmarks.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ def _download_hf_benchmark(
399399
log_warning(f" 跳過子集 {config}: {e}")
400400

401401
except Exception as e:
402-
if info.get("gated") and "401" in str(e) or "403" in str(e):
402+
if info.get("gated") and ("401" in str(e) or "403" in str(e)):
403403
raise _SkipGatedError()
404404
raise
405405

@@ -585,8 +585,10 @@ def _spider2_json_to_jsonl(json_path: str, jsonl_path: str) -> None:
585585
"id": item.get("instance_id", item.get("id", "")),
586586
"question": item.get("instruction", item.get("question", "")),
587587
"answer": json.dumps(
588-
{"sql": item.get("gold", item.get("sql", "")),
589-
"db_id": item.get("db", item.get("db_id", ""))},
588+
{
589+
"sql": item.get("gold", item.get("sql", "")),
590+
"db_id": item.get("db", item.get("db_id", "")),
591+
},
590592
ensure_ascii=False,
591593
),
592594
"db_id": item.get("db", item.get("db_id", "")),
@@ -656,9 +658,7 @@ def _report_download(dest: str) -> None:
656658
"""報告下載結果的檔案統計。"""
657659
total_files = sum(len(files) for _, _, files in os.walk(dest))
658660
total_size = sum(
659-
os.path.getsize(os.path.join(dp, f))
660-
for dp, _, fns in os.walk(dest)
661-
for f in fns
661+
os.path.getsize(os.path.join(dp, f)) for dp, _, fns in os.walk(dest) for f in fns
662662
)
663663
size_mb = total_size / (1024 * 1024)
664664
log_info(f" 下載完成:{total_files} 個檔案,共 {size_mb:.1f} MB → {dest}")

twinkle_eval/cli.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,9 @@
55
提供 twinkle-eval 命令列工具的入口點,支援各種評測功能和配置選項。
66
"""
77

8-
import os
98
import sys
109
from typing import List, Optional
1110

12-
# 確保能夠正確匯入模組
13-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14-
1511
from .metrics import get_available_methods
1612
from .core.logger import log_error
1713
from .main import create_cli_parser

twinkle_eval/core/config.py

Lines changed: 6 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -180,100 +180,23 @@ def _validate_google_services(self) -> None:
180180

181181
google_drive_config = google_services_config.get("google_drive", {})
182182
if google_drive_config.get("enabled", False):
183-
try:
184-
self._validate_google_drive_config(google_drive_config)
185-
log_info("Google Drive 配置驗證完成")
186-
except ConfigurationError as e:
187-
if "不存在或 Service Account 無權限存取" in str(e):
188-
auth_method = google_drive_config.get("auth_method", "service_account")
189-
if auth_method == "service_account":
190-
log_error(f"Service Account 驗證失敗: {e}")
191-
log_info("建議解決方案:")
192-
log_info("1. 將 Service Account Email 加入 Google Drive 資料夾共享")
193-
log_info("2. 或改用 OAuth 驗證方式:設定 auth_method: 'oauth'")
194-
else:
195-
raise
196-
else:
197-
raise
183+
self._validate_google_drive_config(google_drive_config)
184+
log_info("Google Drive 配置驗證完成")
198185

199186
def _validate_google_sheets_config(self, config: Dict[str, Any]) -> None:
200187
spreadsheet_id = config.get("spreadsheet_id")
201188
if not spreadsheet_id or not spreadsheet_id.strip():
202189
raise ConfigurationError("Google Sheets 配置錯誤: spreadsheet_id 為必填項目")
203190

191+
# 僅做本地結構驗證;連線與權限問題交由實際匯出時回報。
192+
# 設定載入階段不進行任何網路呼叫(config 模組不做 API 呼叫,
193+
# 也確保 --validate / --dry-run 不產生網路流量)。
204194
self._validate_google_auth_config(config, "Google Sheets")
205195

206-
try:
207-
from twinkle_eval.integrations.google import GoogleSheetsService
208-
209-
sheets_service = GoogleSheetsService(config)
210-
sheets_service.service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
211-
log_info(f"Google Sheets 連接測試成功 - 試算表 ID: {spreadsheet_id}")
212-
213-
except Exception as e:
214-
raise ConfigurationError(f"Google Sheets 配置驗證失敗: {e}") from e
215-
216196
def _validate_google_drive_config(self, config: Dict[str, Any]) -> None:
197+
# 僅做本地結構驗證;資料夾存取權限問題交由實際上傳時回報。
217198
self._validate_google_auth_config(config, "Google Drive")
218199

219-
try:
220-
from twinkle_eval.integrations.google import GoogleDriveUploader
221-
222-
drive_uploader = GoogleDriveUploader(config)
223-
224-
log_folder_id = config.get("log_folder_id")
225-
if log_folder_id and log_folder_id.strip():
226-
try:
227-
folder_info = (
228-
drive_uploader.service.files()
229-
.get(
230-
fileId=log_folder_id,
231-
fields="id,name,mimeType",
232-
supportsAllDrives=True,
233-
)
234-
.execute()
235-
)
236-
237-
if folder_info.get("mimeType") != "application/vnd.google-apps.folder":
238-
raise ConfigurationError(
239-
f"Google Drive log_folder_id 指向的不是資料夾: {log_folder_id}"
240-
)
241-
242-
log_info(
243-
f"Google Drive 資料夾驗證成功 - {folder_info.get('name')} ({log_folder_id})"
244-
)
245-
246-
except Exception as folder_error:
247-
if "File not found" in str(folder_error) or "notFound" in str(folder_error):
248-
service_account_email = None
249-
try:
250-
import json
251-
252-
credentials_file = config.get("credentials_file")
253-
with open(credentials_file, "r", encoding="utf-8") as f:
254-
cred_data = json.load(f)
255-
service_account_email = cred_data.get("client_email", "未知")
256-
except Exception:
257-
service_account_email = "未知"
258-
259-
raise ConfigurationError(
260-
f"Google Drive 資料夾不存在或 Service Account 無權限存取: {log_folder_id}\n"
261-
f"Service Account: {service_account_email}\n"
262-
f"請確認:\n"
263-
f"1. 資料夾 ID 正確: {log_folder_id}\n"
264-
f"2. 資料夾存在且未被刪除\n"
265-
f"3. Service Account ({service_account_email}) 已被加入資料夾的共享權限"
266-
) from folder_error
267-
else:
268-
raise ConfigurationError(
269-
f"Google Drive 資料夾驗證失敗: {folder_error}"
270-
) from folder_error
271-
272-
log_info("Google Drive 配置驗證完成")
273-
274-
except Exception as e:
275-
raise ConfigurationError(f"Google Drive 配置驗證失敗: {e}") from e
276-
277200
def _validate_google_auth_config(self, config: Dict[str, Any], service_name: str) -> None:
278201
auth_method = config.get("auth_method", "service_account")
279202
credentials_file = config.get("credentials_file")

0 commit comments

Comments
 (0)