dsh-calibguard 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +44 -27
- package/README.md +44 -26
- package/cordis.patch.yml +2 -1
- package/docs/configuration.md +2 -2
- package/docs/troubleshooting.md +2 -1
- package/engine/README.md +33 -0
- package/engine/audit_service.py +304 -0
- package/engine/calibguard/__init__.py +2 -0
- package/engine/calibguard/__main__.py +4 -0
- package/engine/calibguard/calibrator.py +78 -0
- package/engine/calibguard/cli.py +74 -0
- package/engine/calibguard/config.py +129 -0
- package/engine/calibguard/constants.py +117 -0
- package/engine/calibguard/feature_extractor.py +161 -0
- package/engine/calibguard/gate.py +17 -0
- package/engine/calibguard/llm_analyzer.py +171 -0
- package/engine/calibguard/model.py +79 -0
- package/engine/calibguard/pipeline.py +305 -0
- package/engine/calibguard/preprocessor.py +218 -0
- package/engine/calibguard/report.py +132 -0
- package/engine/calibguard/schemas.py +53 -0
- package/engine/models/calibration_params.json +1 -0
- package/engine/models/codebert_finetuned/config.json +28 -0
- package/engine/models/codebert_finetuned/merges.txt +50001 -0
- package/engine/models/codebert_finetuned/special_tokens_map.json +51 -0
- package/engine/models/codebert_finetuned/tokenizer.json +250371 -0
- package/engine/models/codebert_finetuned/tokenizer_config.json +58 -0
- package/engine/models/codebert_finetuned/vocab.json +1 -0
- package/engine/requirements-gui.txt +11 -0
- package/lib/bootstrap.js +4 -3
- package/lib/client.js +1 -1
- package/lib/config.js +34 -7
- package/lib/engine.js +1 -1
- package/lib/index.js +1 -1
- package/package.json +4 -3
- package/setup.mjs +278 -48
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""审计任务封装(T-G2.5):把 calibguard.pipeline 包成“任务”模型供 API 层调用。
|
|
2
|
+
|
|
3
|
+
铁律:
|
|
4
|
+
- 后端审计单并发:_JOB_LOCK 一把全局锁,后来的任务排队等待,禁止多 worker 并行;
|
|
5
|
+
- 逐字复用引擎入口(load_config + AuditPipeline),禁止重写审计逻辑;
|
|
6
|
+
- 引擎无阶段回调钩子 → stage 只写 "model" → "report" 两档,禁止伪造细粒度进度;
|
|
7
|
+
- 单文件异常记账后继续下一个文件,异常文本必须能冒泡到 UI。
|
|
8
|
+
|
|
9
|
+
设置(gui-settings.json,2026-10-05 扩展):
|
|
10
|
+
- 是否启用云端 LLM 复核、API Key、Base URL、模型名;
|
|
11
|
+
- 灰区范围(allow/block 阈值,默认 0.15 / 0.85);
|
|
12
|
+
- LLM 送审内容源(精炼证据 / 文件原文)× 投递模式(截断 / 滑窗 / 一次性全部上传)
|
|
13
|
+
及其随附参数(截断字符数、滑窗字符数与重叠字符数)。
|
|
14
|
+
- 应用方式:直接改运行中 pipeline 的 cfg 字段 + 重建 LLM 客户端,**不重载模型**
|
|
15
|
+
(模型加载耗时数十秒,而上述设置都不影响模型权重与校准参数)。
|
|
16
|
+
"""
|
|
17
|
+
import dataclasses
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import threading
|
|
21
|
+
import uuid
|
|
22
|
+
|
|
23
|
+
from calibguard.config import LLM_CONTENT_SOURCES, LLM_DELIVERY_MODES, load_config, validate_config
|
|
24
|
+
from calibguard.llm_analyzer import LLMAnalyzer
|
|
25
|
+
from calibguard.pipeline import AuditPipeline
|
|
26
|
+
|
|
27
|
+
_ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
28
|
+
SETTINGS_PATH = os.path.join(_ROOT, "gui-settings.json")
|
|
29
|
+
|
|
30
|
+
_JOB_LOCK = threading.Lock() # 全局单并发锁
|
|
31
|
+
_JOBS: dict[str, dict] = {} # job_id -> 状态
|
|
32
|
+
_PIPELINE: AuditPipeline | None = None # 引擎流水线单例(模型只加载一次)
|
|
33
|
+
_PENDING_SETTINGS_REFRESH = False # 审计运行期间被暂缓的设置应用请求
|
|
34
|
+
|
|
35
|
+
# 阶段取值封闭集合(引擎无回调钩子,实际只会出现 model / report 两档)
|
|
36
|
+
STAGE_MODEL = "model"
|
|
37
|
+
STAGE_REPORT = "report"
|
|
38
|
+
|
|
39
|
+
# ============================== 设置 ==============================
|
|
40
|
+
|
|
41
|
+
# 默认设置(gui-settings.json 缺键时用这些值;与引擎 AppConfig 默认值保持一致)
|
|
42
|
+
DEFAULT_SETTINGS: dict = {
|
|
43
|
+
"llm_enabled": True,
|
|
44
|
+
"deepseek_api_key": "",
|
|
45
|
+
"llm_base_url": "https://api.deepseek.com",
|
|
46
|
+
"llm_model": "deepseek-chat",
|
|
47
|
+
"allow_threshold": 0.15,
|
|
48
|
+
"block_threshold": 0.85,
|
|
49
|
+
"llm_content_source": "evidence",
|
|
50
|
+
"llm_delivery_mode": "truncate",
|
|
51
|
+
"llm_truncate_chars": 3000,
|
|
52
|
+
"llm_window_chars": 3000,
|
|
53
|
+
"llm_window_overlap": 500,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# 设置键 → 引擎 AppConfig 字段(用于把设置写进运行中的 cfg)
|
|
57
|
+
_CFG_FIELDS = {
|
|
58
|
+
"llm_enabled": "llm_enabled",
|
|
59
|
+
"deepseek_api_key": "llm_api_key",
|
|
60
|
+
"llm_base_url": "llm_base_url",
|
|
61
|
+
"llm_model": "llm_model",
|
|
62
|
+
"allow_threshold": "allow_threshold",
|
|
63
|
+
"block_threshold": "block_threshold",
|
|
64
|
+
"llm_content_source": "llm_content_source",
|
|
65
|
+
"llm_delivery_mode": "llm_delivery_mode",
|
|
66
|
+
"llm_truncate_chars": "llm_truncate_chars",
|
|
67
|
+
"llm_window_chars": "llm_window_chars",
|
|
68
|
+
"llm_window_overlap": "llm_window_overlap",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_SETTINGS: dict = dict(DEFAULT_SETTINGS)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _coerce_settings(raw: dict) -> dict:
|
|
75
|
+
"""把外部输入(JSON)规范化为合法设置字典;任何非法项抛 ValueError(中文说明)。
|
|
76
|
+
|
|
77
|
+
只接受已知键;未知键忽略(向前兼容)。
|
|
78
|
+
"""
|
|
79
|
+
if not isinstance(raw, dict):
|
|
80
|
+
raise ValueError("设置必须是 JSON 对象")
|
|
81
|
+
merged = dict(DEFAULT_SETTINGS)
|
|
82
|
+
for key in DEFAULT_SETTINGS:
|
|
83
|
+
if key in raw:
|
|
84
|
+
merged[key] = raw[key]
|
|
85
|
+
|
|
86
|
+
if not isinstance(merged["llm_enabled"], bool):
|
|
87
|
+
raise ValueError("llm_enabled 必须是布尔值")
|
|
88
|
+
for key in ("deepseek_api_key", "llm_base_url", "llm_model"):
|
|
89
|
+
if not isinstance(merged[key], str):
|
|
90
|
+
raise ValueError(f"{key} 必须是字符串")
|
|
91
|
+
if merged["llm_enabled"] and not merged["llm_base_url"].strip():
|
|
92
|
+
raise ValueError("启用云端复核时 llm_base_url 不能为空")
|
|
93
|
+
if merged["llm_enabled"] and not merged["llm_model"].strip():
|
|
94
|
+
raise ValueError("启用云端复核时 llm_model 不能为空")
|
|
95
|
+
|
|
96
|
+
for key in ("allow_threshold", "block_threshold"):
|
|
97
|
+
try:
|
|
98
|
+
merged[key] = float(merged[key])
|
|
99
|
+
except (TypeError, ValueError) as exc:
|
|
100
|
+
raise ValueError(f"{key} 必须是 0~1 之间的数字") from exc
|
|
101
|
+
if not (0.0 < merged["allow_threshold"] < merged["block_threshold"] < 1.0):
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"灰区范围必须满足 0 < 下限({merged['allow_threshold']}) < "
|
|
104
|
+
f"上限({merged['block_threshold']}) < 1")
|
|
105
|
+
|
|
106
|
+
if merged["llm_content_source"] not in LLM_CONTENT_SOURCES:
|
|
107
|
+
raise ValueError(f"llm_content_source 必须是 {LLM_CONTENT_SOURCES} 之一")
|
|
108
|
+
if merged["llm_delivery_mode"] not in LLM_DELIVERY_MODES:
|
|
109
|
+
raise ValueError(f"llm_delivery_mode 必须是 {LLM_DELIVERY_MODES} 之一")
|
|
110
|
+
for key in ("llm_truncate_chars", "llm_window_chars", "llm_window_overlap"):
|
|
111
|
+
try:
|
|
112
|
+
merged[key] = int(merged[key])
|
|
113
|
+
except (TypeError, ValueError) as exc:
|
|
114
|
+
raise ValueError(f"{key} 必须是整数") from exc
|
|
115
|
+
if merged["llm_truncate_chars"] < 1:
|
|
116
|
+
raise ValueError("截断大小必须 ≥ 1 个字符")
|
|
117
|
+
if merged["llm_window_chars"] < 1:
|
|
118
|
+
raise ValueError("滑窗大小必须 ≥ 1 个字符")
|
|
119
|
+
if not (0 <= merged["llm_window_overlap"] < merged["llm_window_chars"]):
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"滑窗重叠必须满足 0 ≤ 重叠 < 滑窗大小({merged['llm_window_chars']})")
|
|
122
|
+
return merged
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_settings() -> dict:
|
|
126
|
+
"""返回当前生效设置的副本(供 GET /api/settings 与 UI 回填)。"""
|
|
127
|
+
return dict(_SETTINGS)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _apply_settings_to_cfg(cfg, settings: dict) -> None:
|
|
131
|
+
"""把设置写进 AppConfig 对应字段(键名映射见 _CFG_FIELDS)。"""
|
|
132
|
+
for key, field_name in _CFG_FIELDS.items():
|
|
133
|
+
setattr(cfg, field_name, settings[key])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _build_llm(cfg):
|
|
137
|
+
"""按 cfg 构造云端 LLM 客户端(Key/BaseURL/模型名变更后重建)。"""
|
|
138
|
+
return LLMAnalyzer(
|
|
139
|
+
api_key=cfg.llm_api_key, base_url=cfg.llm_base_url, model=cfg.llm_model,
|
|
140
|
+
temperature=cfg.llm_temperature, max_tokens=cfg.llm_max_tokens,
|
|
141
|
+
timeout=cfg.llm_timeout, evidence_max_chars=cfg.evidence_max_chars)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _apply_settings_locked() -> None:
|
|
145
|
+
"""把当前设置应用到运行中的 pipeline(调用方必须已持有 _JOB_LOCK)。
|
|
146
|
+
|
|
147
|
+
只改 cfg 字段 + 重建 LLM 客户端,**不重载模型**:这样设置变更绝不会与正在进行的
|
|
148
|
+
审计并发改写状态(补丁 11 约束),也不付出模型加载代价。
|
|
149
|
+
"""
|
|
150
|
+
global _PENDING_SETTINGS_REFRESH
|
|
151
|
+
if _PIPELINE is None:
|
|
152
|
+
_PENDING_SETTINGS_REFRESH = False # 流水线未建:下次创建自然读新设置
|
|
153
|
+
return
|
|
154
|
+
_apply_settings_to_cfg(_PIPELINE.cfg, _SETTINGS)
|
|
155
|
+
_PIPELINE.llm = _build_llm(_PIPELINE.cfg)
|
|
156
|
+
_PENDING_SETTINGS_REFRESH = False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _refresh_settings() -> bool:
|
|
160
|
+
"""在 _JOB_LOCK 保护下应用设置;审计运行中则暂缓(不阻塞等待)。
|
|
161
|
+
|
|
162
|
+
不阻塞的原因:设置请求若等锁,会在审计期间把 HTTP 请求挂住;补丁 11 要求
|
|
163
|
+
“审计运行中禁止触发重建,UI 提示当前任务完成后生效”,故记待办后立即返回。
|
|
164
|
+
第二次尝试用于消解与任务收尾的竞态(锁刚释放时仍可即时生效)。
|
|
165
|
+
"""
|
|
166
|
+
global _PENDING_SETTINGS_REFRESH
|
|
167
|
+
for _ in range(2):
|
|
168
|
+
if _JOB_LOCK.acquire(blocking=False):
|
|
169
|
+
try:
|
|
170
|
+
_apply_settings_locked()
|
|
171
|
+
return True
|
|
172
|
+
finally:
|
|
173
|
+
_JOB_LOCK.release()
|
|
174
|
+
_PENDING_SETTINGS_REFRESH = True # 有任务在跑:暂缓
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def load_settings() -> None:
|
|
179
|
+
"""读 gui-settings.json,刷新内存设置,并把 Key 注入环境变量 DEEPSEEK_API_KEY。
|
|
180
|
+
|
|
181
|
+
引擎的 load_config 从环境变量读取 Key(主文档 6.2),本函数是 GUI 侧唯一注入点。
|
|
182
|
+
铁律:禁止读写 calibguard 的 config.yaml;禁止把 Key 写进任何日志。
|
|
183
|
+
"""
|
|
184
|
+
global _SETTINGS
|
|
185
|
+
if not os.path.isfile(SETTINGS_PATH):
|
|
186
|
+
return
|
|
187
|
+
with open(SETTINGS_PATH, "r", encoding="utf-8") as handle:
|
|
188
|
+
payload = json.load(handle)
|
|
189
|
+
if not isinstance(payload, dict):
|
|
190
|
+
raise ValueError(f"设置文件格式错误(应为 JSON 对象):{SETTINGS_PATH}")
|
|
191
|
+
_SETTINGS = _coerce_settings(payload)
|
|
192
|
+
_inject_api_key(_SETTINGS["deepseek_api_key"])
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _inject_api_key(key: str) -> None:
|
|
196
|
+
"""把 Key 注入(或清除)环境变量:留空即不使用云端(不留残余环境值)。"""
|
|
197
|
+
if key:
|
|
198
|
+
os.environ["DEEPSEEK_API_KEY"] = key
|
|
199
|
+
else:
|
|
200
|
+
os.environ.pop("DEEPSEEK_API_KEY", None)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def save_settings(payload: dict) -> bool:
|
|
204
|
+
"""合并并落盘设置(UTF-8 无 BOM),立即让配置生效。
|
|
205
|
+
|
|
206
|
+
支持部分更新:payload 中未出现的键保持当前值(旧版只传 deepseek_api_key 的调用
|
|
207
|
+
仍然可用)。返回设置是否已即时生效:False 表示当前有审计任务在跑(_JOB_LOCK 被
|
|
208
|
+
占用),应用已暂缓,等当前任务结束由 _run_job 落地(补丁 11 约束一)。
|
|
209
|
+
"""
|
|
210
|
+
global _SETTINGS
|
|
211
|
+
if not isinstance(payload, dict):
|
|
212
|
+
raise ValueError("设置必须是 JSON 对象")
|
|
213
|
+
merged = dict(_SETTINGS)
|
|
214
|
+
merged.update(payload)
|
|
215
|
+
settings = _coerce_settings(merged)
|
|
216
|
+
|
|
217
|
+
with open(SETTINGS_PATH, "w", encoding="utf-8") as handle:
|
|
218
|
+
json.dump(settings, handle, ensure_ascii=False, indent=2)
|
|
219
|
+
_SETTINGS = settings
|
|
220
|
+
_inject_api_key(settings["deepseek_api_key"])
|
|
221
|
+
return _refresh_settings()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _get_pipeline() -> AuditPipeline:
|
|
225
|
+
"""引擎流水线单例:首次调用加载模型(可耗时数十秒),之后全程复用。"""
|
|
226
|
+
global _PIPELINE
|
|
227
|
+
if _PIPELINE is None:
|
|
228
|
+
# 铁律 7:禁止读写 calibguard 的 config.yaml → 传 None 走引擎内置默认值,
|
|
229
|
+
# 相对路径(models/、reports/)由 server 的 cwd(项目根)解析。
|
|
230
|
+
cfg = load_config()
|
|
231
|
+
_apply_settings_to_cfg(cfg, _SETTINGS)
|
|
232
|
+
error = validate_config(cfg)
|
|
233
|
+
if error:
|
|
234
|
+
raise ValueError(f"设置非法,无法初始化引擎:{error}")
|
|
235
|
+
_PIPELINE = AuditPipeline(cfg)
|
|
236
|
+
return _PIPELINE
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ============================== 任务模型 ==============================
|
|
240
|
+
|
|
241
|
+
def create_job(paths: list[str]) -> str:
|
|
242
|
+
"""建 job(排队态),返回 job_id;执行交给 daemon 线程 + 全局单并发锁。"""
|
|
243
|
+
job_id = uuid.uuid4().hex[:12]
|
|
244
|
+
_JOBS[job_id] = {
|
|
245
|
+
"state": "queued",
|
|
246
|
+
"paths": list(paths),
|
|
247
|
+
"stage": "",
|
|
248
|
+
"done": 0,
|
|
249
|
+
"total": len(paths),
|
|
250
|
+
"results": [],
|
|
251
|
+
}
|
|
252
|
+
threading.Thread(target=_run_job, args=(job_id,), daemon=True).start()
|
|
253
|
+
return job_id
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def job_status(job_id: str) -> dict:
|
|
257
|
+
"""查询任务状态/进度/结果;未知 id 抛 KeyError(API 层转 404)。
|
|
258
|
+
|
|
259
|
+
返回浅拷贝:handler 线程序列化时后台线程仍在推进 job,避免“字典迭代期间改变大小”。
|
|
260
|
+
"""
|
|
261
|
+
return dict(_JOBS[job_id])
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _report_to_dict(report) -> dict:
|
|
265
|
+
"""引擎报告归一化:CGv0.2.2 的 audit_file 直接返回 dict,原样透传(不增删字段)。"""
|
|
266
|
+
if isinstance(report, dict):
|
|
267
|
+
return report
|
|
268
|
+
if hasattr(report, "to_dict"):
|
|
269
|
+
return report.to_dict()
|
|
270
|
+
if dataclasses.is_dataclass(report):
|
|
271
|
+
return dataclasses.asdict(report)
|
|
272
|
+
raise TypeError(f"引擎返回了无法序列化的报告对象:{type(report).__name__}")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _run_job(job_id: str) -> None:
|
|
276
|
+
"""单并发执行任务:逐文件调用引擎,单文件异常记账后继续。"""
|
|
277
|
+
job = _JOBS[job_id]
|
|
278
|
+
with _JOB_LOCK: # 后来的任务在此排队等待
|
|
279
|
+
job["state"] = "running"
|
|
280
|
+
try:
|
|
281
|
+
pipeline = _get_pipeline()
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
# 模型/校准参数/设置不可用属全局失败:显式暴露,绝不假成功
|
|
284
|
+
job["error"] = f"引擎初始化失败:{type(exc).__name__}: {exc}"
|
|
285
|
+
job["stage"] = STAGE_REPORT
|
|
286
|
+
for path in job["paths"]:
|
|
287
|
+
job["results"].append({"error": job["error"], "path": path})
|
|
288
|
+
job["done"] = job["total"]
|
|
289
|
+
job["state"] = "error"
|
|
290
|
+
else:
|
|
291
|
+
for path in job["paths"]:
|
|
292
|
+
job["stage"] = STAGE_MODEL # 引擎无回调钩子:粗粒度两档
|
|
293
|
+
try:
|
|
294
|
+
report = pipeline.audit_file(path)
|
|
295
|
+
job["results"].append(_report_to_dict(report))
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
job["results"].append({"error": f"{type(exc).__name__}: {exc}", "path": path})
|
|
298
|
+
job["stage"] = STAGE_REPORT
|
|
299
|
+
job["done"] += 1
|
|
300
|
+
job["state"] = "done"
|
|
301
|
+
|
|
302
|
+
# 任务已结束(锁已释放):落地审计运行期间被暂缓的设置更新(补丁 11 约束一)
|
|
303
|
+
if _PENDING_SETTINGS_REFRESH:
|
|
304
|
+
_refresh_settings()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""概率校准模块:Platt Scaling。
|
|
2
|
+
|
|
3
|
+
公式(禁止改写):P(y=1 | s) = 1 / (1 + exp(A * s + B))
|
|
4
|
+
与温度缩放的关系(v1.0 不实现温度缩放):P = σ(s/T) 等价于 A = -1/T, B = 0 的特例。
|
|
5
|
+
"""
|
|
6
|
+
import datetime
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
|
|
10
|
+
from sklearn.linear_model import LogisticRegression
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PlattCalibrator:
|
|
14
|
+
"""Platt 校准器:logit → 校准概率;支持拟合与参数存取。"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, A: float = 0.0, B: float = 0.0, method: str = "platt",
|
|
17
|
+
n_samples: int = 0):
|
|
18
|
+
self.A = A
|
|
19
|
+
self.B = B
|
|
20
|
+
self.method = method
|
|
21
|
+
self.n_samples = n_samples
|
|
22
|
+
|
|
23
|
+
def fit(self, logits: list[float], labels: list[int]) -> None:
|
|
24
|
+
"""在(logit, label)校准集上拟合 A、B。"""
|
|
25
|
+
if len(logits) != len(labels) or len(logits) == 0:
|
|
26
|
+
raise ValueError("logits 与 labels 长度必须相等且非空")
|
|
27
|
+
try:
|
|
28
|
+
lr = LogisticRegression(C=1e10, solver="lbfgs")
|
|
29
|
+
lr.fit([[s] for s in logits], labels)
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
# sklearn 在标签只有单一类别时抛 ValueError
|
|
32
|
+
raise RuntimeError("校准集必须同时包含良性与恶意样本") from exc
|
|
33
|
+
# sklearn: P = 1/(1+exp(-(w*s+b))),与目标公式 1/(1+exp(A*s+B)) 对比得:
|
|
34
|
+
self.A = -float(lr.coef_[0][0])
|
|
35
|
+
self.B = -float(lr.intercept_[0])
|
|
36
|
+
self.n_samples = len(labels)
|
|
37
|
+
|
|
38
|
+
def predict_proba(self, logit: float) -> float:
|
|
39
|
+
"""logit → 校准概率(含溢出保护)。"""
|
|
40
|
+
z = self.A * logit + self.B
|
|
41
|
+
if z > 700:
|
|
42
|
+
return 1e-16 # p 趋近 0
|
|
43
|
+
if z < -700:
|
|
44
|
+
return 1.0 - 1e-16 # p 趋近 1
|
|
45
|
+
return 1.0 / (1.0 + math.exp(z))
|
|
46
|
+
|
|
47
|
+
def save(self, path: str) -> None:
|
|
48
|
+
"""写 JSON 参数文件(键名固定)。"""
|
|
49
|
+
payload = {
|
|
50
|
+
"A": self.A,
|
|
51
|
+
"B": self.B,
|
|
52
|
+
"method": self.method,
|
|
53
|
+
"n_samples": self.n_samples,
|
|
54
|
+
"fit_at": datetime.datetime.now().astimezone().isoformat(),
|
|
55
|
+
}
|
|
56
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
57
|
+
json.dump(payload, handle, ensure_ascii=False)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def load(cls, path: str) -> "PlattCalibrator":
|
|
61
|
+
"""读 JSON 还原;文件不存在抛 FileNotFoundError,内容损坏抛 ValueError。"""
|
|
62
|
+
try:
|
|
63
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
64
|
+
payload = json.load(handle)
|
|
65
|
+
calibrator = cls(
|
|
66
|
+
A=float(payload["A"]),
|
|
67
|
+
B=float(payload["B"]),
|
|
68
|
+
method=str(payload.get("method", "platt")),
|
|
69
|
+
n_samples=int(payload.get("n_samples", 0)),
|
|
70
|
+
)
|
|
71
|
+
except (ValueError, KeyError, TypeError, AttributeError) as exc:
|
|
72
|
+
raise ValueError(f"校准参数文件损坏: {path}") from exc
|
|
73
|
+
return calibrator
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def identity(cls) -> "PlattCalibrator":
|
|
77
|
+
"""跳过校准时使用:A=-1, B=0(predict_proba 即 sigmoid)。"""
|
|
78
|
+
return cls(A=-1.0, B=0.0, method="none", n_samples=0)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""命令行入口:argparse 解析、打印摘要、退出码管理。
|
|
2
|
+
|
|
3
|
+
退出码:0 正常 / 1 路径不存在 / 2 配置错误 / 3 校准参数缺失 / 4 模型加载失败 / 5 未知内部错误。
|
|
4
|
+
"""
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from calibguard import __version__
|
|
10
|
+
from calibguard.config import load_config
|
|
11
|
+
from calibguard.pipeline import AuditPipeline
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
"""程序入口,管理退出码。"""
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="calibguard",
|
|
18
|
+
description="CalibGuard:本地 CodeBERT 校准门控 + 云端 LLM 按需分析的安全审计工具。")
|
|
19
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
20
|
+
audit_parser = subparsers.add_parser("audit", help="审计指定文件或目录")
|
|
21
|
+
audit_parser.add_argument("path", type=str, help="待审计的文件或目录路径")
|
|
22
|
+
audit_parser.add_argument("--config", default=None, help="配置文件路径")
|
|
23
|
+
audit_parser.add_argument("--output-dir", default=None, help="报告输出目录(覆盖配置)")
|
|
24
|
+
subparsers.add_parser("version", help="打印版本号")
|
|
25
|
+
|
|
26
|
+
args = parser.parse_args()
|
|
27
|
+
|
|
28
|
+
if args.command == "version":
|
|
29
|
+
print(f"CalibGuard {__version__}")
|
|
30
|
+
return
|
|
31
|
+
if args.command != "audit":
|
|
32
|
+
parser.print_help()
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
# 1. 加载配置(内部已处理退出码 2)
|
|
36
|
+
cfg = load_config(args.config)
|
|
37
|
+
# 2. 命令行覆盖输出目录
|
|
38
|
+
if args.output_dir:
|
|
39
|
+
cfg.report_output_dir = args.output_dir
|
|
40
|
+
# 3. 路径存在性检查
|
|
41
|
+
if not os.path.exists(args.path):
|
|
42
|
+
print(f"[错误] 路径不存在: {args.path}", file=sys.stderr)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
# 4. 构建流水线(模型 / 校准参数)
|
|
45
|
+
try:
|
|
46
|
+
pipeline = AuditPipeline(cfg)
|
|
47
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
48
|
+
print(str(exc), file=sys.stderr)
|
|
49
|
+
sys.exit(3)
|
|
50
|
+
except RuntimeError as exc:
|
|
51
|
+
print(str(exc), file=sys.stderr)
|
|
52
|
+
sys.exit(4)
|
|
53
|
+
|
|
54
|
+
# 5-7. 审计、逐行摘要、统计
|
|
55
|
+
try:
|
|
56
|
+
reports = pipeline.audit_path(args.path)
|
|
57
|
+
total = len(reports)
|
|
58
|
+
counts = {"allow": 0, "block": 0, "suspicious": 0, "manual_review": 0}
|
|
59
|
+
for index, report in enumerate(reports, start=1):
|
|
60
|
+
print(f"[{index}/{total}] {report['file_path']} "
|
|
61
|
+
f"p={report['calibrated_probability']} "
|
|
62
|
+
f"gate={report['gate_decision']} final={report['final_decision']}")
|
|
63
|
+
decision = report.get("final_decision")
|
|
64
|
+
if decision in counts:
|
|
65
|
+
counts[decision] += 1
|
|
66
|
+
print(f"审计完成:共 {total} 个文件 | 放行 {counts['allow']} | 拦截 {counts['block']} "
|
|
67
|
+
f"| 可疑 {counts['suspicious']} | 转人工 {counts['manual_review']}")
|
|
68
|
+
print(f"报告目录:{os.path.abspath(cfg.report_output_dir)}")
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
print(f"[错误] 未知内部错误: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
71
|
+
sys.exit(5)
|
|
72
|
+
|
|
73
|
+
# 8. 正常结束
|
|
74
|
+
sys.exit(0)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""配置加载:config.yaml + 环境变量覆盖。"""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
# LLM 复核内容源(封闭集合):evidence=精炼证据 / raw=预处理后的文件原文
|
|
9
|
+
LLM_CONTENT_SOURCES = ("evidence", "raw")
|
|
10
|
+
# LLM 复核投递模式(封闭集合):truncate=截断 / window=滑窗 / all=一次性全部上传
|
|
11
|
+
LLM_DELIVERY_MODES = ("truncate", "window", "all")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class AppConfig:
|
|
16
|
+
# 模型
|
|
17
|
+
model_base: str = "microsoft/codebert-base"
|
|
18
|
+
model_path: str = "models/codebert_finetuned"
|
|
19
|
+
max_length: int = 512
|
|
20
|
+
window_stride: int = 256
|
|
21
|
+
window_batch_size: int = 8
|
|
22
|
+
# 校准
|
|
23
|
+
calibration_params_path: str = "models/calibration_params.json"
|
|
24
|
+
# 门控
|
|
25
|
+
allow_threshold: float = 0.15
|
|
26
|
+
block_threshold: float = 0.85
|
|
27
|
+
early_stop_threshold: float = 0.9
|
|
28
|
+
# LLM
|
|
29
|
+
llm_enabled: bool = True # 是否使用云端 LLM 复核(False 时灰区按 0.5 切)
|
|
30
|
+
llm_api_key_env: str = "DEEPSEEK_API_KEY" # 读取 API Key 的环境变量名(可被 yaml 覆盖)
|
|
31
|
+
llm_api_key: str = ""
|
|
32
|
+
llm_base_url: str = "https://api.deepseek.com"
|
|
33
|
+
llm_model: str = "deepseek-chat"
|
|
34
|
+
llm_temperature: float = 0.0
|
|
35
|
+
llm_max_tokens: int = 500
|
|
36
|
+
llm_timeout: int = 30
|
|
37
|
+
evidence_max_chars: int = 3000
|
|
38
|
+
# LLM 复核投递(2026-10-05 GUI 需求:内容源 × 投递模式)
|
|
39
|
+
llm_content_source: str = "evidence" # evidence=精炼证据 / raw=文件原文
|
|
40
|
+
llm_delivery_mode: str = "truncate" # truncate / window / all
|
|
41
|
+
llm_truncate_chars: int = 3000 # 截断模式:送审前 N 字符
|
|
42
|
+
llm_window_chars: int = 3000 # 滑窗模式:单窗字符数
|
|
43
|
+
llm_window_overlap: int = 500 # 滑窗模式:相邻窗重叠字符数
|
|
44
|
+
# 报告
|
|
45
|
+
report_output_dir: str = "reports"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate_config(cfg: AppConfig) -> str | None:
|
|
49
|
+
"""校验配置合法性:返回错误文本;None 表示通过。
|
|
50
|
+
|
|
51
|
+
抽成独立函数供两处复用:load_config(非法则退出码 2)与 GUI 设置保存前的预检
|
|
52
|
+
(非法则 400,绝不把非法值写进运行中的 cfg)。
|
|
53
|
+
"""
|
|
54
|
+
if not (0.0 < cfg.allow_threshold < cfg.block_threshold < 1.0):
|
|
55
|
+
return (f"阈值必须满足 0 < allow({cfg.allow_threshold}) < "
|
|
56
|
+
f"block({cfg.block_threshold}) < 1")
|
|
57
|
+
if cfg.max_length > 512:
|
|
58
|
+
return f"max_length 不得超过 512(CodeBERT 上限),当前 {cfg.max_length}"
|
|
59
|
+
if not (1 <= cfg.window_stride < cfg.max_length):
|
|
60
|
+
return (f"window_stride 必须满足 1 ≤ stride < max_length(512),"
|
|
61
|
+
f"当前 {cfg.window_stride}")
|
|
62
|
+
if cfg.window_batch_size < 1:
|
|
63
|
+
return f"window_batch_size 必须 ≥ 1,当前 {cfg.window_batch_size}"
|
|
64
|
+
if not (0.0 < cfg.early_stop_threshold < 1.0):
|
|
65
|
+
return f"early_stop_threshold 必须在 (0,1) 内,当前 {cfg.early_stop_threshold}"
|
|
66
|
+
if cfg.llm_content_source not in LLM_CONTENT_SOURCES:
|
|
67
|
+
return (f"llm_content_source 必须是 {LLM_CONTENT_SOURCES} 之一,"
|
|
68
|
+
f"当前 {cfg.llm_content_source!r}")
|
|
69
|
+
if cfg.llm_delivery_mode not in LLM_DELIVERY_MODES:
|
|
70
|
+
return (f"llm_delivery_mode 必须是 {LLM_DELIVERY_MODES} 之一,"
|
|
71
|
+
f"当前 {cfg.llm_delivery_mode!r}")
|
|
72
|
+
if cfg.llm_truncate_chars < 1:
|
|
73
|
+
return f"llm_truncate_chars 必须 ≥ 1,当前 {cfg.llm_truncate_chars}"
|
|
74
|
+
if cfg.llm_window_chars < 1:
|
|
75
|
+
return f"llm_window_chars 必须 ≥ 1,当前 {cfg.llm_window_chars}"
|
|
76
|
+
if not (0 <= cfg.llm_window_overlap < cfg.llm_window_chars):
|
|
77
|
+
return (f"llm_window_overlap 必须满足 0 ≤ overlap < window_chars"
|
|
78
|
+
f"({cfg.llm_window_chars}),当前 {cfg.llm_window_overlap}")
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_config(path: str | None = None) -> AppConfig:
|
|
83
|
+
"""加载配置。规则:默认值 < config.yaml < 环境变量(仅 API Key)。"""
|
|
84
|
+
cfg = AppConfig()
|
|
85
|
+
if path is not None and os.path.isfile(path):
|
|
86
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
87
|
+
raw = yaml.safe_load(f) or {}
|
|
88
|
+
# 逐键覆盖(键不存在则保留默认值,禁止因缺键报错)
|
|
89
|
+
m = raw.get("model", {}) or {}
|
|
90
|
+
if "base_model" in m: cfg.model_base = str(m["base_model"])
|
|
91
|
+
if "finetuned_path" in m: cfg.model_path = str(m["finetuned_path"])
|
|
92
|
+
if "max_length" in m: cfg.max_length = int(m["max_length"])
|
|
93
|
+
if "window_stride" in m: cfg.window_stride = int(m["window_stride"])
|
|
94
|
+
if "window_batch_size" in m: cfg.window_batch_size = int(m["window_batch_size"])
|
|
95
|
+
c = raw.get("calibration", {}) or {}
|
|
96
|
+
if "params_path" in c: cfg.calibration_params_path = str(c["params_path"])
|
|
97
|
+
g = raw.get("gate", {}) or {}
|
|
98
|
+
if "allow_threshold" in g: cfg.allow_threshold = float(g["allow_threshold"])
|
|
99
|
+
if "block_threshold" in g: cfg.block_threshold = float(g["block_threshold"])
|
|
100
|
+
if "early_stop_threshold" in g: cfg.early_stop_threshold = float(g["early_stop_threshold"])
|
|
101
|
+
llm = raw.get("llm", {}) or {}
|
|
102
|
+
if "enabled" in llm: cfg.llm_enabled = bool(llm["enabled"])
|
|
103
|
+
if "api_key" in llm: cfg.llm_api_key = str(llm["api_key"])
|
|
104
|
+
if "base_url" in llm: cfg.llm_base_url = str(llm["base_url"])
|
|
105
|
+
if "model" in llm: cfg.llm_model = str(llm["model"])
|
|
106
|
+
if "temperature" in llm: cfg.llm_temperature = float(llm["temperature"])
|
|
107
|
+
if "max_tokens" in llm: cfg.llm_max_tokens = int(llm["max_tokens"])
|
|
108
|
+
if "timeout_seconds" in llm: cfg.llm_timeout = int(llm["timeout_seconds"])
|
|
109
|
+
if "evidence_max_chars" in llm: cfg.evidence_max_chars = int(llm["evidence_max_chars"])
|
|
110
|
+
if "api_key_env" in llm: cfg.llm_api_key_env = str(llm["api_key_env"])
|
|
111
|
+
if "content_source" in llm: cfg.llm_content_source = str(llm["content_source"])
|
|
112
|
+
if "delivery_mode" in llm: cfg.llm_delivery_mode = str(llm["delivery_mode"])
|
|
113
|
+
if "truncate_chars" in llm: cfg.llm_truncate_chars = int(llm["truncate_chars"])
|
|
114
|
+
if "window_chars" in llm: cfg.llm_window_chars = int(llm["window_chars"])
|
|
115
|
+
if "window_overlap" in llm: cfg.llm_window_overlap = int(llm["window_overlap"])
|
|
116
|
+
r = raw.get("report", {}) or {}
|
|
117
|
+
if "output_dir" in r: cfg.report_output_dir = str(r["output_dir"])
|
|
118
|
+
# API Key 环境变量优先级最高——必须在 if 块之外执行:
|
|
119
|
+
# 无论是否指定 --config、yaml 是否存在,环境变量都要读取
|
|
120
|
+
# (未加载 yaml 时,环境变量名用字段默认值 "DEEPSEEK_API_KEY")
|
|
121
|
+
env_val = os.environ.get(cfg.llm_api_key_env, "")
|
|
122
|
+
if env_val:
|
|
123
|
+
cfg.llm_api_key = env_val
|
|
124
|
+
# 合法性校验:失败直接退出,退出码 2
|
|
125
|
+
error = validate_config(cfg)
|
|
126
|
+
if error:
|
|
127
|
+
print(f"[配置错误] {error}", file=sys.stderr)
|
|
128
|
+
sys.exit(2)
|
|
129
|
+
return cfg
|