commit-ai-guardian 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,2047 @@
1
+ """AI 审核引擎
2
+
3
+ 核心职责:
4
+ - 构建 Prompt(代码 + 审核维度 + 案例参照 → 发给 AI)
5
+ - 调用 OpenAI API(含重试、超时、错误处理)
6
+ - 解析 AI 的 JSON 响应为结构化数据(ReviewResult)
7
+
8
+ 双模式设计:
9
+ - review_file() → 审核 Git diff(只关注变更部分)
10
+ - review_source() → 审核完整文件(扫描存量代码)
11
+
12
+ 案例系统:
13
+ - 从目标仓库的 .ai-review/cases/ 加载案例(项目自己的规则)
14
+ - 没有内置默认案例!找不到就退回通用规则检查
15
+ - 审核时把匹配编程语言的案例注入 Prompt
16
+
17
+ 结果状态:
18
+ - AI 审核发现 issues → passed=False(阻断提交,由 severity_threshold 控制)
19
+ - AI 审核无问题 → passed=True(放行)
20
+ - JSON 解析失败 → passed=False(让用户知道出问题了,需检查配置)
21
+ - 配置 enabled=false → passed=True(跳过审核,直接放行)
22
+ - API 调用失败 → passed=False(网络/配置问题,需排查)
23
+ """
24
+
25
+ import hashlib
26
+ import json
27
+ import os
28
+ import re
29
+ import time
30
+ from concurrent.futures import ThreadPoolExecutor, as_completed
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+ from typing import Any, Dict, List, Optional
34
+
35
+ try:
36
+ import openai
37
+ import httpx
38
+ except ImportError:
39
+ openai = None
40
+ httpx = None
41
+
42
+ from .prompt_loader import PromptLoader
43
+
44
+
45
+ def _try_parse_json(json_str: str) -> Optional[Dict]:
46
+ """尝试多种策略解析 JSON,返回 dict 或 None
47
+
48
+ 策略(按顺序):
49
+ 1. 直接解析
50
+ 2. 去除 BOM 头
51
+ 3. 将单引号替换为双引号
52
+ 4. 去除 trailing commas
53
+ 5. 去除注释(// 和 /* */)
54
+
55
+ Args:
56
+ json_str: 可能不规范的 JSON 字符串
57
+
58
+ Returns:
59
+ 解析后的 dict,或 None(所有策略都失败)
60
+ """
61
+ if not json_str or not json_str.strip():
62
+ return None
63
+
64
+ candidates = [
65
+ json_str.strip(),
66
+ json_str.strip().lstrip('\ufeff'), # 去 BOM
67
+ ]
68
+
69
+ # 单引号变双引号(注意不替换引号内的单引号,这里做简单处理)
70
+ single_quoted = json_str.strip().replace("'", '"')
71
+ if single_quoted != json_str.strip():
72
+ candidates.append(single_quoted)
73
+
74
+ # 去除 trailing commas(}, 和 ], )
75
+ no_trailing = re.sub(r',(\s*[}\]])', r'\1', json_str.strip())
76
+ if no_trailing != json_str.strip():
77
+ candidates.append(no_trailing)
78
+
79
+ # 去除 // 注释
80
+ no_comment = re.sub(r'//.*?\n', '\n', json_str.strip())
81
+ if no_comment != json_str.strip():
82
+ candidates.append(no_comment)
83
+
84
+ # 修复非法 JSON 转义(AI 在正则表达式中常产生 \] \' 等非法转义)
85
+ # JSON 标准只支持: \" \\ \/ \b \f \n \r \t \uXXXX
86
+ fixed_escapes = json_str.strip().replace("\\'", "'")
87
+ fixed_escapes = re.sub(r'\\([^"\\/bfnrtu])', r'\\\\\1', fixed_escapes)
88
+ if fixed_escapes != json_str.strip():
89
+ candidates.append(fixed_escapes)
90
+
91
+ for candidate in candidates:
92
+ try:
93
+ parsed = json.loads(candidate)
94
+ if isinstance(parsed, dict):
95
+ return parsed
96
+ except (json.JSONDecodeError, ValueError):
97
+ continue
98
+
99
+ # 最后策略:JSON 可能被截断,尝试补全闭合括号
100
+ stripped = json_str.strip()
101
+ if stripped.startswith('{'):
102
+ # 统计未闭合的 {, [, ", '
103
+ open_braces = stripped.count('{') - stripped.count('}')
104
+ open_brackets = stripped.count('[') - stripped.count(']')
105
+ # 简单补全(从末尾开始尝试逐步补全)
106
+ fixed = stripped
107
+ for _ in range(open_brackets):
108
+ fixed += ']'
109
+ for _ in range(open_braces):
110
+ fixed += '}'
111
+ try:
112
+ parsed = json.loads(fixed)
113
+ if isinstance(parsed, dict):
114
+ return parsed
115
+ except (json.JSONDecodeError, ValueError):
116
+ pass
117
+
118
+ return None
119
+
120
+
121
+ def _read_file_full_content(repo_path: str, filename: str) -> str:
122
+ """读取文件的完整内容(diff_mode=full 时使用)
123
+
124
+ 从 repo_path 下读取文件的当前版本内容。
125
+ 文件不存在或读取失败返回空字符串。
126
+
127
+ Args:
128
+ repo_path: 仓库根目录路径
129
+ filename: 文件相对路径(如 src/main.py)
130
+
131
+ Returns:
132
+ 文件完整内容字符串
133
+ """
134
+ if not repo_path:
135
+ return ""
136
+
137
+ file_path = Path(repo_path) / filename
138
+ try:
139
+ return file_path.read_text(encoding='utf-8')
140
+ except Exception:
141
+ return ""
142
+
143
+
144
+ def _build_cases_check_instruction() -> str:
145
+ """构建案例检查指令 — 要求 AI 逐条对照检查清单
146
+
147
+ 当 prompt 中注入了案例时,用这个强指令替代原来的一句话提示,
148
+ 确保 AI 真正逐条检查每个检查清单项。
149
+ """
150
+ return (
151
+ "- 【强约束 - 必须遵守】上方提供了具体的\"问题模式\"案例,包含坏代码示例、好代码示例和检查清单\n"
152
+ "- 案例中的坏代码模式如果在审核代码中出现 → 必须报 issue,绝对不能遗漏\n"
153
+ "- 逐条对照每个检查清单项(☐ 标记),在代码中逐一寻找匹配\n"
154
+ "- 发现匹配时给出对应的好代码示例作为修复建议\n"
155
+ "- 此约束优先级最高:即使其他规则说不要报,案例中的问题也必须报"
156
+ )
157
+
158
+
159
+ @dataclass
160
+ class ReviewIssue:
161
+ """单个审核问题"""
162
+ severity: str = "info" # critical / error / warning / info
163
+ category: str = "best-practice" # bug / security / style / performance / best-practice / documentation
164
+ line_number: Optional[int] = None
165
+ message: str = ""
166
+ suggestion: str = ""
167
+ code_snippet: str = ""
168
+
169
+ def __post_init__(self):
170
+ """验证字段值"""
171
+ valid_severities = ["critical", "error", "warning", "info"]
172
+ if self.severity not in valid_severities:
173
+ self.severity = "info"
174
+
175
+ # category 不校验,AI 返回任意字符串均可
176
+
177
+ # 确保 line_number 是整数或 None
178
+ # AI 可能返回范围格式如 "80-81",提取第一个数字
179
+ if self.line_number is not None:
180
+ try:
181
+ line_str = str(self.line_number).strip()
182
+ # 提取第一个数字序列(如 "80-81" → "80","60" → "60")
183
+ match = re.search(r'\d+', line_str)
184
+ if match:
185
+ self.line_number = int(match.group())
186
+ else:
187
+ self.line_number = None
188
+ except (ValueError, TypeError):
189
+ self.line_number = None
190
+
191
+
192
+ @dataclass
193
+ class ReviewResult:
194
+ """单个文件的审核结果"""
195
+ filename: str = ""
196
+ issues: List[ReviewIssue] = field(default_factory=list)
197
+ summary: str = ""
198
+ passed: bool = True
199
+ raw_response: str = ""
200
+ first_line_number: Optional[int] = None # diff 模式下第一个变更的行号
201
+ cache_md5: str = "" # 缓存 key 的 MD5 前7位短码(文件名头显示用,cache 文件名也是前7位)
202
+
203
+
204
+ def parse_ai_response(response: str, filename: str = "unknown") -> ReviewResult:
205
+ """解析 AI 的原始响应文本为结构化的 ReviewResult(纯函数,不依赖 AIEngine)
206
+
207
+ 用于 debug-log 命令:用户保存 AI 原始响应到文件,本地解析看结果,
208
+ 无需重新调用 AI(不花钱、不耗时间)。
209
+
210
+ 解析策略(层层降级):
211
+ 1. 从 <result> 标签中提取 JSON(prompt 要求 AI 必须用 <result> 包裹)
212
+ 2. 过滤 <think> 标签
213
+ 3. 从 markdown 代码块 ```json ... ``` 中提取 JSON(兼容旧格式)
214
+ 4. 找第一个 {...}
215
+ 5. 尝试修复常见问题(BOM、单引号等)
216
+ 6. 最后都失败 → passed=False(让用户知道出问题了)
217
+
218
+ Args:
219
+ response: AI 返回的原始文本(从 ai.log 文件读取的内容)
220
+ filename: 被审核的文件名(用于展示)
221
+
222
+ Returns:
223
+ ReviewResult。完整复用 AIEngine._parse_response 的解析逻辑
224
+ """
225
+ result = ReviewResult(filename=filename, raw_response=response)
226
+
227
+ # 防御:空响应
228
+ if not response or not response.strip():
229
+ result.summary = "API 返回空响应"
230
+ result.passed = True
231
+ return result
232
+
233
+ # ===== JSON 提取策略(层层降级) =====
234
+
235
+ # 先过滤 <think> 标签(避免其内容干扰后续提取,也减少 token 占用)
236
+ filtered_response = re.sub(r'<think>.*?</think>', '', response, flags=re.DOTALL).strip()
237
+ if filtered_response != response:
238
+ response = filtered_response
239
+ print(f"[信息] 已过滤 <think> 推理标签")
240
+
241
+ # 策略 0(最优先):从 <result> 标签中提取 JSON
242
+ # prompt 已要求 AI 把 JSON 包裹在 <result></result> 中,这是最可靠的提取方式
243
+ json_str = None
244
+ result_match = re.search(r'<result>(.*?)</result>', response, re.DOTALL)
245
+ if result_match:
246
+ json_str = result_match.group(1).strip()
247
+ # <result> 为空或只有空白 → AI 认为没有发现问题,直接通过
248
+ if not json_str:
249
+ result.summary = "审核完成,未发现问题"
250
+ result.passed = True
251
+ return result
252
+
253
+ # 策略 1:从 ```json ... ``` 代码块中提取(兼容旧格式)
254
+ if json_str is None:
255
+ json_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', response, re.DOTALL)
256
+ if json_match:
257
+ json_str = json_match.group(1).strip()
258
+
259
+ # 策略 2:从响应中找第一个 {...}(非贪婪,可能因 code_snippet 中的花括号而提取不完整)
260
+ if json_str is None:
261
+ brace_match = re.search(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', response, re.DOTALL)
262
+ if brace_match:
263
+ json_str = brace_match.group(0).strip()
264
+
265
+ # 策略 3:直接解析整个响应(去掉常见的前缀废话)
266
+ if json_str is None:
267
+ cleaned = response.strip()
268
+ for prefix in ['以下是', '这是', '审核结果', '结果如下', 'JSON 如下', '返回结果']:
269
+ if prefix in cleaned and '{' in cleaned:
270
+ idx = cleaned.find('{')
271
+ if idx > 0:
272
+ cleaned = cleaned[idx:]
273
+ break
274
+ json_str = cleaned
275
+
276
+ if not json_str:
277
+ result.summary = "无法从响应中解析 JSON"
278
+ result.passed = False
279
+ return result
280
+
281
+ # 快速处理:空数组 [] = 审核通过(AI 认为没问题但忘了包装成对象)
282
+ # 必须在 _try_parse_json 之前处理,因为该函数只返回 dict
283
+ stripped = json_str.strip()
284
+ if stripped == '[]':
285
+ result.summary = "审核完成,未发现问题"
286
+ result.passed = True
287
+ return result
288
+
289
+ # 快速处理:空对象 {} = 审核通过
290
+ if stripped == '{}':
291
+ result.summary = "审核完成,未发现问题"
292
+ result.passed = True
293
+ return result
294
+
295
+ # 策略 4:正常 JSON 解析(含多种修复尝试)
296
+ data = _try_parse_json(json_str)
297
+
298
+ if data is None:
299
+ result.summary = "JSON 解析失败"
300
+ result.passed = False
301
+ return result
302
+
303
+ # AI 可能返回非空数组(如 [{issue1}, ...])而不是对象
304
+ if isinstance(data, list):
305
+ # 非空数组 = 有 issues 但没有 summary/passed 包装,交给修复 AI 处理
306
+ result.summary = f"JSON 类型错误: 期望对象 {{...}},实际得到数组({len(data)} 个元素)"
307
+ result.passed = False
308
+ result.raw_response = response
309
+ return result
310
+
311
+ if not isinstance(data, dict):
312
+ result.summary = f"JSON 类型错误: 期望对象 {{...}},实际得到 {type(data).__name__}"
313
+ result.passed = False
314
+ result.raw_response = response
315
+ return result
316
+
317
+ # 检查顶层必需字段
318
+ _REQUIRED_TOP_FIELDS = {'summary', 'passed', 'issues'}
319
+ missing_top = _REQUIRED_TOP_FIELDS - set(data.keys())
320
+ if missing_top:
321
+ result.summary = f"JSON 字段缺失: 缺少顶层必填字段: {', '.join(sorted(missing_top))}"
322
+ result.passed = False
323
+ result.raw_response = response
324
+ return result
325
+
326
+ # 提取各字段
327
+ result.summary = data.get('summary', '审核完成')
328
+ result.passed = bool(data.get('passed', True))
329
+
330
+ # 解析 issues 列表
331
+ # 校验:每个 issue 必须有 message 字段且非空,否则触发 JSON 修复
332
+ _REQUIRED_ISSUE_FIELDS = {'message'}
333
+ issues_data = data.get('issues', [])
334
+ if isinstance(issues_data, list):
335
+ for issue_data in issues_data:
336
+ if isinstance(issue_data, dict):
337
+ # 检查必填字段是否存在且非空
338
+ missing = {f for f in _REQUIRED_ISSUE_FIELDS if not issue_data.get(f) or not str(issue_data[f]).strip()}
339
+ if missing:
340
+ result.summary = f"JSON 字段缺失: issue 缺少必填字段 {missing}"
341
+ result.passed = False
342
+ result.raw_response = response
343
+ return result
344
+
345
+ issue = ReviewIssue(
346
+ severity=issue_data.get('severity', 'info'),
347
+ category=issue_data.get('category', 'best-practice'),
348
+ line_number=issue_data.get('line_number'),
349
+ message=issue_data.get('message', ''),
350
+ suggestion=issue_data.get('suggestion', ''),
351
+ code_snippet=issue_data.get('code_snippet', ''),
352
+ )
353
+ result.issues.append(issue)
354
+
355
+ return result
356
+
357
+ class AIEngine:
358
+ """AI 代码审核引擎
359
+
360
+ 封装了与 OpenAI API 的所有交互,包括:
361
+ - Prompt 构建(审核维度 + 案例参照 + 代码内容)
362
+ - API 调用(含指数退避重试)
363
+ - 响应解析(JSON 提取 + 容错)
364
+ """
365
+
366
+ def __init__(self, config: Any, repo_path: str = "."):
367
+ """初始化
368
+
369
+ Args:
370
+ config: Config 对象,需要 api_key, api_base, model, timeout, proxy 等字段
371
+ repo_path: 目标代码仓库路径(用于加载 .ai-review/cases/ 项目级别案例)
372
+ """
373
+ self.config = config
374
+ self.client = None
375
+ self.repo_path = repo_path
376
+
377
+ # 初始化案例加载器(传入 repo_path,加载 .ai-review/cases/)
378
+ from .case_loader import CaseLoader
379
+ self.case_loader = CaseLoader(repo_path=repo_path)
380
+
381
+ # 初始化 prompt 模板加载器(传入 repo_path,加载 .ai-review/prompts/)
382
+ self.prompt_loader = PromptLoader(repo_path=repo_path)
383
+
384
+ # 初始化缓存目录(.ai-review/cache/)
385
+ self._cache_dir = Path(repo_path) / ".ai-review" / "cache" if repo_path else None
386
+ if self._cache_dir:
387
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
388
+
389
+ # 初始化日志目录(.ai-review/logs/)
390
+ self._logs_dir = Path(repo_path) / ".ai-review" / "logs" if repo_path else None
391
+ if self._logs_dir:
392
+ self._logs_dir.mkdir(parents=True, exist_ok=True)
393
+
394
+ # 检查 openai 包是否安装
395
+ if openai is None:
396
+ raise RuntimeError("openai 包未安装,请运行: pip install openai")
397
+
398
+ # 配置 httpx 客户端(支持代理和超时)
399
+ http_kwargs = {}
400
+ if config.proxy:
401
+ http_kwargs["proxies"] = config.proxy # 设置代理(用于内网/翻墙)
402
+
403
+ # timeout <= 0 时用默认值 60(Config.__post_init__ 允许 0 通过 merge)
404
+ timeout_val = getattr(config, 'timeout', 60)
405
+ if timeout_val <= 0:
406
+ timeout_val = 60
407
+ http_kwargs["timeout"] = httpx.Timeout(timeout_val)
408
+
409
+ # 调试模式:打印 API 配置(不脱敏 key,方便排查 401)
410
+ import os
411
+ if os.environ.get('CAG_DEBUG'):
412
+ print(f"[DEBUG] api_base: {getattr(config, 'api_base', 'default')}")
413
+ print(f"[DEBUG] api_key: {config.api_key[:15]}...{config.api_key[-4:]}")
414
+ print(f"[DEBUG] model: {getattr(config, 'model', 'default')}")
415
+
416
+ # 初始化 OpenAI 客户端(兼容第三方 API:Azure、Gemini、本地部署等)
417
+ try:
418
+ self.client = openai.OpenAI(
419
+ api_key=config.api_key,
420
+ base_url=getattr(config, 'api_base', 'https://api.openai.com/v1'),
421
+ http_client=httpx.Client(**http_kwargs) if httpx else None,
422
+ )
423
+ except Exception as e:
424
+ # 初始化失败不抛异常,后续调用时返回降级结果
425
+ print(f"[警告] OpenAI 客户端初始化失败: {e}")
426
+ self.client = None
427
+
428
+ def _check_prerequisites(self, filename: str) -> Optional[ReviewResult]:
429
+ """检查审核前置条件(enabled / client / api_key)
430
+
431
+ 三个条件任一不满足,返回对应的 ReviewResult(不再继续审核)。
432
+ 全部通过返回 None,调用方继续正常审核流程。
433
+
434
+ Args:
435
+ filename: 被审核的文件名(用于返回结果)
436
+
437
+ Returns:
438
+ ReviewResult(条件不满足时)或 None(全部通过)
439
+ """
440
+ if not getattr(self.config, 'enabled', True):
441
+ return ReviewResult(
442
+ filename=filename,
443
+ summary="AI 审核已禁用(enabled=false),跳过审核",
444
+ passed=True,
445
+ raw_response="",
446
+ )
447
+ if self.client is None:
448
+ return ReviewResult(
449
+ filename=filename,
450
+ summary="AI 客户端未初始化,无法审核",
451
+ passed=False,
452
+ raw_response="",
453
+ )
454
+ if not getattr(self.config, 'api_key', None):
455
+ return ReviewResult(
456
+ filename=filename,
457
+ summary="未配置 API Key,跳过审核",
458
+ passed=False,
459
+ raw_response="",
460
+ )
461
+ return None
462
+
463
+ def review_file(self, file_diff: Any) -> ReviewResult:
464
+ """审核单个文件的 diff(pre-commit 场景)
465
+
466
+ 流程:构建 diff Prompt → 调用 API → 解析响应
467
+
468
+ Args:
469
+ file_diff: FileDiff 对象,需包含 filename, language, diff_content
470
+
471
+ Returns:
472
+ ReviewResult。审核正常返回 AI 结果,异常返回 passed=False
473
+ """
474
+ filename = getattr(file_diff, 'filename', 'unknown')
475
+
476
+ # 检查前置条件
477
+ prereq = self._check_prerequisites(filename)
478
+ if prereq:
479
+ return prereq
480
+
481
+ diff_content = getattr(file_diff, 'diff_content', '')
482
+
483
+ # 根据 diff_mode 决定审核策略
484
+ diff_mode = getattr(self.config, 'diff_mode', 'full')
485
+
486
+ # 计算缓存 key(无论是否启用缓存,都用于 ai.log 命名)
487
+ if diff_mode == 'full':
488
+ full_content = _read_file_full_content(self.repo_path, filename)
489
+ cache_key = hashlib.md5(full_content.encode('utf-8')).hexdigest()
490
+ else:
491
+ full_content = ""
492
+ cache_key = hashlib.md5(diff_content.encode('utf-8')).hexdigest()
493
+
494
+ # 检查缓存(可配置关闭)
495
+ use_cache = getattr(self.config, 'use_cache', True)
496
+ if use_cache:
497
+ cached = self._check_cache(cache_key)
498
+ if cached:
499
+ cached.filename = filename
500
+ cached.cache_md5 = cache_key[:7]
501
+ print(f"[信息] 缓存命中: {filename} 跳过 AI 审核")
502
+ cache_path = Path(self.repo_path) / ".ai-review" / "cache" / f"{cache_key[:7]}.json"
503
+ print(f" {os.path.relpath(cache_path)}")
504
+ return cached
505
+
506
+ # 构建 Prompt:根据 diff_mode 选择策略
507
+ if diff_mode == 'full' and full_content:
508
+ # full 模式:审核完整文件内容,但标注变更部分
509
+ prompt = self._build_full_file_prompt_for_diff(filename, full_content, diff_content, file_diff, cache_key[:7])
510
+ else:
511
+ # diff 模式:只审核变更内容
512
+ prompt = self._build_prompt(file_diff, cache_key[:7])
513
+
514
+ try:
515
+ response = self._call_api(prompt, filename=filename, cache_md5=cache_key[:7])
516
+ result = self._parse_response(response, filename, cache_md5=cache_key[:7])
517
+ # diff 模式下:把第一个变更行号和 MD5 赋给结果(文件名头显示用)
518
+ line_numbers = getattr(file_diff, 'line_numbers', [])
519
+ if line_numbers:
520
+ result.first_line_number = line_numbers[0]
521
+ result.cache_md5 = cache_key[:7]
522
+ # 审核成功,保存到缓存(可配置关闭)
523
+ if use_cache:
524
+ self._save_cache(cache_key, result)
525
+ return result
526
+ except Exception as e:
527
+ # API 调用异常 → 让用户知道出问题了
528
+ print(f"[错误] 审核文件 {filename} 失败: {e}")
529
+ return ReviewResult(
530
+ filename=filename,
531
+ summary=f"审核失败: {str(e)}",
532
+ passed=False, # ← 异常时标记未通过,需排查
533
+ raw_response=str(e),
534
+ cache_md5=cache_key[:7],
535
+ )
536
+
537
+
538
+ def _parse_cache_ttl(self) -> Optional[float]:
539
+ """解析 cache_ttl 配置为秒数
540
+
541
+ 支持的格式:
542
+ "1d" → 86400 秒
543
+ "12h" → 43200 秒
544
+ "30m" → 1800 秒
545
+ "0" → None(不缓存)
546
+
547
+ Returns:
548
+ 秒数,或 None(不缓存/解析失败)
549
+ """
550
+ ttl = getattr(self.config, 'cache_ttl', '1d')
551
+ if not ttl or ttl == '0':
552
+ return None
553
+
554
+ ttl = str(ttl).strip().lower()
555
+ try:
556
+ if ttl.endswith('d'):
557
+ return float(ttl[:-1]) * 86400
558
+ elif ttl.endswith('h'):
559
+ return float(ttl[:-1]) * 3600
560
+ elif ttl.endswith('m'):
561
+ return float(ttl[:-1]) * 60
562
+ else:
563
+ return float(ttl) # 纯数字视为秒
564
+ except (ValueError, TypeError):
565
+ return 86400 # 解析失败默认 1 天
566
+
567
+ def _clean_expired_cache(self) -> None:
568
+ """清理过期的缓存文件
569
+
570
+ 在批量检查缓存前调用,删除超过 cache_ttl 的 .json 缓存文件。
571
+ """
572
+ if not self._cache_dir or not self._cache_dir.exists():
573
+ return
574
+
575
+ ttl_seconds = self._parse_cache_ttl()
576
+ if ttl_seconds is None:
577
+ return # 不缓存,不清理
578
+
579
+ now = time.time()
580
+ cleaned = 0
581
+ # 同时清理 .json 和 broken 缓存({md5}_MMDDHHMMSS.json)
582
+ for cache_file in list(self._cache_dir.glob('*.json')):
583
+ try:
584
+ if now - cache_file.stat().st_mtime > ttl_seconds:
585
+ cache_file.unlink()
586
+ cleaned += 1
587
+ except Exception:
588
+ pass
589
+
590
+ if cleaned > 0:
591
+ print(f"[信息] 清理 {cleaned} 个过期缓存文件")
592
+
593
+ def _parse_log_ttl(self) -> Optional[float]:
594
+ """解析 log_ttl 配置为秒数
595
+
596
+ 支持的格式:
597
+ "1h" → 3600 秒
598
+ "30m" → 1800 秒
599
+ "0" → None(不清理)
600
+
601
+ Returns:
602
+ 秒数,或 None(不清理/解析失败)
603
+ """
604
+ ttl = getattr(self.config, 'log_ttl', '1h')
605
+ if not ttl or ttl == '0':
606
+ return None
607
+
608
+ ttl = str(ttl).strip().lower()
609
+ try:
610
+ if ttl.endswith('h'):
611
+ return float(ttl[:-1]) * 3600
612
+ elif ttl.endswith('m'):
613
+ return float(ttl[:-1]) * 60
614
+ elif ttl.endswith('d'):
615
+ return float(ttl[:-1]) * 86400
616
+ else:
617
+ return float(ttl) # 纯数字视为秒
618
+ except (ValueError, TypeError):
619
+ return 3600 # 解析失败默认 1 小时
620
+
621
+ def _clean_old_logs(self) -> None:
622
+ """清理过期的日志文件
623
+
624
+ 在批量审核前调用,删除超过 log_ttl 的 .ai-review/logs/ 下日志文件。
625
+ 控制台打印清理数量和总大小。
626
+ """
627
+ if not self._logs_dir or not self._logs_dir.exists():
628
+ return
629
+
630
+ ttl_seconds = self._parse_log_ttl()
631
+ if ttl_seconds is None:
632
+ return # 不清理
633
+
634
+ now = time.time()
635
+ cleaned = 0
636
+ total_size = 0
637
+ for log_file in self._logs_dir.glob('*.log'):
638
+ try:
639
+ stat = log_file.stat()
640
+ if now - stat.st_mtime > ttl_seconds:
641
+ total_size += stat.st_size
642
+ log_file.unlink()
643
+ cleaned += 1
644
+ except Exception:
645
+ pass
646
+
647
+ if cleaned > 0:
648
+ size_kb = total_size / 1024
649
+ print(f"[信息] 清理 {cleaned} 个过期日志文件({size_kb:.1f} KB)")
650
+
651
+ def _get_cache_key_for_file(self, file_diff: Any) -> Optional[str]:
652
+ """计算文件的缓存 key(用于批量缓存检查)
653
+
654
+ diff_mode=full 时用完整文件内容 MD5,diff 模式用 diff 内容 MD5。
655
+ 不需要缓存的返回 None。
656
+
657
+ Args:
658
+ file_diff: FileDiff 对象
659
+
660
+ Returns:
661
+ MD5 字符串,或 None
662
+ """
663
+ filename = getattr(file_diff, 'filename', 'unknown')
664
+ diff_mode = getattr(self.config, 'diff_mode', 'full')
665
+
666
+ if diff_mode == 'full':
667
+ full_content = _read_file_full_content(self.repo_path, filename)
668
+ if full_content:
669
+ return hashlib.md5(full_content.encode('utf-8')).hexdigest()
670
+ return None
671
+ else:
672
+ diff_content = getattr(file_diff, 'diff_content', '')
673
+ if diff_content:
674
+ return hashlib.md5(diff_content.encode('utf-8')).hexdigest()
675
+ return None
676
+
677
+ def _review_file_no_cache(self, file_diff: Any) -> ReviewResult:
678
+ """审核文件(不检查缓存,直接调 AI)
679
+
680
+ 供 review_batch 在第二阶段调用(只对未命中缓存的文件)。
681
+
682
+ Args:
683
+ file_diff: FileDiff 对象
684
+
685
+ Returns:
686
+ ReviewResult
687
+ """
688
+ filename = getattr(file_diff, 'filename', 'unknown')
689
+ print(f"[信息] AI 审核中: {filename}\n")
690
+
691
+ diff_content = getattr(file_diff, 'diff_content', '')
692
+ diff_mode = getattr(self.config, 'diff_mode', 'full')
693
+
694
+ # 构建 Prompt(先算 cache_key,传给 prompt builder 用于日志命名)
695
+ if diff_mode == 'full':
696
+ full_content = _read_file_full_content(self.repo_path, filename)
697
+ if full_content:
698
+ cache_key = hashlib.md5(full_content.encode('utf-8')).hexdigest()
699
+ prompt = self._build_full_file_prompt_for_diff(filename, full_content, diff_content, file_diff, cache_key[:7])
700
+ else:
701
+ cache_key = hashlib.md5(diff_content.encode('utf-8')).hexdigest()
702
+ prompt = self._build_prompt(file_diff, cache_key[:7])
703
+ else:
704
+ cache_key = hashlib.md5(diff_content.encode('utf-8')).hexdigest()
705
+ prompt = self._build_prompt(file_diff, cache_key[:7])
706
+
707
+ try:
708
+ response = self._call_api(prompt, filename=filename, cache_md5=cache_key[:7])
709
+ result = self._parse_response(response, filename, cache_md5=cache_key[:7])
710
+ # diff 模式下:把第一个变更行号和 MD5 赋给结果
711
+ line_numbers = getattr(file_diff, 'line_numbers', [])
712
+ if line_numbers:
713
+ result.first_line_number = line_numbers[0]
714
+ result.cache_md5 = cache_key[:7]
715
+ # 保存到缓存(可配置关闭)
716
+ if getattr(self.config, 'use_cache', True):
717
+ self._save_cache(cache_key, result)
718
+ return result
719
+ except Exception as e:
720
+ print(f"[错误] 审核文件 {filename} 失败: {e}")
721
+ return ReviewResult(
722
+ filename=filename,
723
+ summary=f"审核失败: {str(e)}",
724
+ passed=False, # ← 异常时标记未通过
725
+ raw_response=str(e),
726
+ cache_md5=cache_key[:7],
727
+ )
728
+
729
+ def review_batch(self, file_diffs: List[Any]) -> List[ReviewResult]:
730
+ """
731
+ 批量审核多个文件(先检查缓存,再并发调 AI)
732
+
733
+ 两阶段设计:
734
+ 1. 先批量检查缓存 → 命中的直接打印并收集结果
735
+ 2. 再对没命中的文件并发调 AI → 统一在 spinner 中执行
736
+
737
+ 这样缓存命中的打印不会和 AI 调用的日志交错。
738
+
739
+ Args:
740
+ file_diffs: FileDiff 对象列表
741
+
742
+ Returns:
743
+ ReviewResult 列表(按原始文件顺序)
744
+ """
745
+ if not file_diffs:
746
+ return []
747
+
748
+ # 单文件直接走原有逻辑
749
+ if len(file_diffs) == 1:
750
+ return [self.review_file(file_diffs[0])]
751
+
752
+ results: List[Optional[ReviewResult]] = [None] * len(file_diffs)
753
+
754
+ # 先清理过期缓存和日志
755
+ self._clean_expired_cache()
756
+ self._clean_old_logs()
757
+
758
+ # 检查是否启用缓存
759
+ use_cache = getattr(self.config, 'use_cache', True)
760
+
761
+ # ===== 第一阶段:批量检查缓存(可配置关闭)=====
762
+ cache_hit_indices: List[int] = []
763
+ cache_miss_indices: List[int] = list(range(len(file_diffs)))
764
+
765
+ if use_cache:
766
+ cache_hit_indices = []
767
+ cache_miss_indices = []
768
+ for i, file_diff in enumerate(file_diffs):
769
+ cache_key = self._get_cache_key_for_file(file_diff)
770
+ if cache_key:
771
+ cached = self._check_cache(cache_key)
772
+ if cached:
773
+ cached.filename = getattr(file_diff, 'filename', 'unknown')
774
+ results[i] = cached
775
+ cache_hit_indices.append(i)
776
+ continue
777
+ cache_miss_indices.append(i)
778
+
779
+ # 打印缓存命中信息(在 spinner 之前)
780
+ if cache_hit_indices:
781
+ for idx in cache_hit_indices:
782
+ filename = getattr(file_diffs[idx], 'filename', 'unknown')
783
+ cache_key = self._get_cache_key_for_file(file_diffs[idx]) or ""
784
+ print(f"[信息] 缓存命中: {filename} 跳过 AI 审核")
785
+ if cache_key:
786
+ cache_path = Path(self.repo_path) / ".ai-review" / "cache" / f"{cache_key[:7]}.json"
787
+ print(f" {os.path.relpath(cache_path)}")
788
+
789
+ # ===== 第二阶段:并发调 AI(只处理未命中的文件)=====
790
+ if cache_miss_indices:
791
+ with ThreadPoolExecutor(max_workers=4) as executor:
792
+ future_to_index = {
793
+ executor.submit(self._review_file_no_cache, file_diffs[idx]): idx
794
+ for idx in cache_miss_indices
795
+ }
796
+
797
+ for future in as_completed(future_to_index):
798
+ idx = future_to_index[future]
799
+ try:
800
+ results[idx] = future.result()
801
+ except Exception as e:
802
+ filename = getattr(file_diffs[idx], 'filename', 'unknown')
803
+ print(f"[错误] 审核文件 {filename} 并发执行失败: {e}")
804
+ results[idx] = ReviewResult(
805
+ filename=filename,
806
+ summary=f"并发审核失败: {str(e)}",
807
+ passed=False, # 异常默认阻断
808
+ raw_response=str(e),
809
+ )
810
+
811
+ return results
812
+
813
+ @staticmethod
814
+ def _annotate_diff_with_line_numbers(diff_content: str) -> str:
815
+ """给 diff 的每行加上正确的行号前缀
816
+
817
+ 解析 @@ hunk 头,给 + 行和上下文行标注新文件的行号,
818
+ 让 AI 直接看到正确的行号,不受 prompt 前面说明文字的影响。
819
+
820
+ 格式:
821
+ + 145 | +const x = ... ← 新增行,145 是新文件行号
822
+ 146 | context line ← 上下文行
823
+ 147 | context line
824
+
825
+ Args:
826
+ diff_content: git diff 原始文本
827
+
828
+ Returns:
829
+ 带行号前缀的 diff 文本
830
+ """
831
+ if not diff_content:
832
+ return ""
833
+
834
+ lines = diff_content.split('\n')
835
+ result = []
836
+ current_line = 0 # 新文件的当前行号
837
+
838
+ for line in lines:
839
+ if line.startswith('@@'):
840
+ # 解析 hunk 头: @@ -old_start,old_count +new_start,new_count @@
841
+ match = re.search(r'@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@', line)
842
+ if match:
843
+ current_line = int(match.group(1))
844
+ result.append(line)
845
+ elif line.startswith('diff --git'):
846
+ # diff 元信息行:不加行号
847
+ result.append(line)
848
+ elif line.startswith('index '):
849
+ # diff 元信息行:不加行号
850
+ result.append(line)
851
+ elif line.startswith('--- '):
852
+ # diff 元信息行(旧文件路径):不加行号
853
+ result.append(line)
854
+ elif line.startswith('+++ '):
855
+ # diff 元信息行(新文件路径):不加行号
856
+ # ⚠️ 必须以空格结尾,避免和 "+++ b/..." 被误判为新增代码行
857
+ result.append(line)
858
+ elif line.startswith('+'):
859
+ # 新增代码行,使用新文件行号
860
+ result.append(f"+{current_line:4d} | {line}")
861
+ current_line += 1
862
+ elif line.startswith('-'):
863
+ # 删除代码行,不增加新文件行号
864
+ result.append(f" | {line}")
865
+ elif line.startswith('\\'):
866
+ # ""
867
+ result.append(f" | {line}")
868
+ else:
869
+ # 上下文代码行,使用新文件行号
870
+ result.append(f" {current_line:4d} | {line}")
871
+ current_line += 1
872
+
873
+ return '\n'.join(result)
874
+
875
+ @staticmethod
876
+ def _annotate_content_with_line_numbers(content: str) -> str:
877
+ """给文件内容的每行加上行号前缀
878
+
879
+ 让 AI 直接看到正确的行号,不受 prompt 前面说明文字的影响。
880
+
881
+ 格式:
882
+ 145 | let resourceId: number | null = null;
883
+ 146 | const resource = ...
884
+
885
+ Args:
886
+ content: 文件完整内容
887
+
888
+ Returns:
889
+ 带行号前缀的文件内容
890
+ """
891
+ if not content:
892
+ return ""
893
+
894
+ lines = content.split('\n')
895
+ result = []
896
+ for i, line in enumerate(lines, 1):
897
+ result.append(f"{i:4d} | {line}")
898
+ return '\n'.join(result)
899
+
900
+ def _build_full_file_prompt_for_diff(self, filename: str, full_content: str,
901
+ diff_content: str, file_diff: Any,
902
+ cache_md5: str = "") -> str:
903
+ """构建 full 模式的 diff 审核 prompt(审核完整文件,标注变更部分)
904
+
905
+ diff_mode=full 时使用。给 AI 看完整文件内容(带行号),
906
+ 并在开头说明哪些行号是本次变更的,让 AI 重点检查。
907
+
908
+ Args:
909
+ filename: 文件名
910
+ full_content: 文件完整内容
911
+ diff_content: diff 文本(用于提取变更行号)
912
+ file_diff: FileDiff 对象
913
+ cache_md5: MD5 前7位,用于日志文件名命名
914
+
915
+ Returns:
916
+ 完整的 prompt 字符串
917
+ """
918
+ language = getattr(file_diff, 'language', 'unknown')
919
+
920
+ # 提取变更行号列表
921
+ line_numbers = getattr(file_diff, 'line_numbers', [])
922
+
923
+ # full 模式:不截断文件,传完整内容
924
+ # 超长时依赖 max_tokens 配置,截断时 AI 会提示
925
+ annotated_content = self._annotate_content_with_line_numbers(full_content)
926
+
927
+ # 提取变更行号列表
928
+ line_numbers = getattr(file_diff, 'line_numbers', [])
929
+ change_lines_str = ", ".join(str(n) for n in line_numbers[:20])
930
+ if len(line_numbers) > 20:
931
+ change_lines_str += f" 等共 {len(line_numbers)} 行"
932
+
933
+ language_display = {
934
+ 'python': 'Python', 'javascript': 'JavaScript', 'typescript': 'TypeScript',
935
+ 'java': 'Java', 'go': 'Go', 'rust': 'Rust', 'cpp': 'C++',
936
+ 'c': 'C', 'csharp': 'C#', 'ruby': 'Ruby', 'php': 'PHP',
937
+ }.get(language, language)
938
+
939
+ # 加载案例
940
+ cases = self.case_loader.get_cases_for_language(language)
941
+ cases_text = self.case_loader.format_cases_for_prompt(
942
+ cases,
943
+ case_format=getattr(self.config, 'case_format', 'default')
944
+ )
945
+
946
+ # 加载模板
947
+ template = self.prompt_loader.load_diff_review_template()
948
+ prompt = template.replace("{{filename}}", filename)
949
+ prompt = prompt.replace("{{language}}", language)
950
+ prompt = prompt.replace("{{language_display}}", language_display)
951
+ prompt = prompt.replace("{{status}}", getattr(file_diff, 'status', 'modified'))
952
+ prompt = prompt.replace("{{diff_content}}", annotated_content)
953
+ prompt = prompt.replace("{{cases_text}}", cases_text)
954
+
955
+ # 变更行号说明(简洁版,避免和模板中其他"注意"重复)
956
+ change_note = f"""
957
+ ## 本次变更的行号
958
+ {change_lines_str}
959
+
960
+ - 以上是完整文件内容(带行号),**重点检查行号 {change_lines_str}**
961
+ - 也要检查变更对周围代码的影响
962
+ """
963
+ cases_instruction = _build_cases_check_instruction() if cases_text else "- 按通用审核维度进行检查"
964
+ prompt = prompt.replace("{{cases_note}}", cases_instruction + "\n" + change_note)
965
+
966
+ return prompt
967
+
968
+ def _build_prompt(self, file_diff: Any, cache_md5: str = "") -> str:
969
+ """
970
+ 构建 diff 审核提示词(用于 Git pre-commit 场景)
971
+
972
+ 从 .ai-review/prompts/diff_review.md 加载模板,
973
+ 找不到就用内置默认模板。
974
+
975
+ diff 内容会加上行号前缀,AI 返回的 line_number 就是正确的文件行号。
976
+
977
+ Args:
978
+ file_diff: FileDiff 对象
979
+ cache_md5: MD5 前7位,用于日志文件名命名
980
+
981
+ Returns:
982
+ 完整的 prompt 字符串
983
+ """
984
+ filename = getattr(file_diff, 'filename', 'unknown')
985
+ language = getattr(file_diff, 'language', 'unknown')
986
+ status = getattr(file_diff, 'status', 'modified')
987
+ diff_content = getattr(file_diff, 'diff_content', '')
988
+
989
+ # 给 diff 加上行号前缀(关键:让 AI 看到正确的文件行号)
990
+ diff_content = self._annotate_diff_with_line_numbers(diff_content)
991
+
992
+ # 注:已取消 diff 截断。MiniMax 等模型输入上下文 200K+,
993
+ # 8000 字符(约 3000 token)不可能触及限制。
994
+ # 如未来需要限制,可在此恢复截断逻辑。
995
+
996
+ language_display = {
997
+ 'python': 'Python', 'javascript': 'JavaScript', 'typescript': 'TypeScript',
998
+ 'java': 'Java', 'go': 'Go', 'rust': 'Rust', 'cpp': 'C++',
999
+ 'c': 'C', 'csharp': 'C#', 'ruby': 'Ruby', 'php': 'PHP',
1000
+ }.get(language, language)
1001
+
1002
+ # 加载与当前编程语言匹配的案例
1003
+ cases = self.case_loader.get_cases_for_language(language)
1004
+ cases_text = self.case_loader.format_cases_for_prompt(
1005
+ cases,
1006
+ case_format=getattr(self.config, 'case_format', 'default')
1007
+ )
1008
+
1009
+ # 加载模板并渲染
1010
+ template = self.prompt_loader.load_diff_review_template()
1011
+ prompt = template.replace("{{filename}}", filename)
1012
+ prompt = prompt.replace("{{language}}", language)
1013
+ prompt = prompt.replace("{{language_display}}", language_display)
1014
+ prompt = prompt.replace("{{status}}", status)
1015
+ prompt = prompt.replace("{{diff_content}}", diff_content)
1016
+ prompt = prompt.replace("{{cases_text}}", cases_text)
1017
+ prompt = prompt.replace("{{cases_note}}",
1018
+ _build_cases_check_instruction() if cases_text
1019
+ else "- 按通用审核维度进行检查")
1020
+
1021
+ return prompt
1022
+
1023
+ @staticmethod
1024
+ def _sanitize_log_filename(filename: str) -> str:
1025
+ """把文件路径转成安全的日志文件名
1026
+
1027
+ 把 / 替换为 _,去掉开头的 ./,去掉 .ai-review/logs/ 前缀
1028
+
1029
+ 如:
1030
+ src/auth.ts → src_auth_ts
1031
+ ./src/auth.ts → src_auth_ts
1032
+ .ai-review/logs/test.ts → test_ts
1033
+
1034
+ Args:
1035
+ filename: 原始文件路径
1036
+
1037
+ Returns:
1038
+ 安全的日志文件名(不含扩展名,不含路径分隔符)
1039
+ """
1040
+ name = filename
1041
+ for prefix in ['.ai-review/logs/', './']:
1042
+ if name.startswith(prefix):
1043
+ name = name[len(prefix):]
1044
+ return name.replace('/', '_').replace('\\', '_').replace('.', '_')
1045
+
1046
+ def _write_ai_response_log(self, filename: str, response: str,
1047
+ cache_md5: str = "",
1048
+ system_message: str = "",
1049
+ user_message: str = "") -> None:
1050
+ """将 AI 审核的完整对话记录写入 .ai-review/logs/{cache_md5}.ai.log
1051
+
1052
+ 记录完整的 API 调用上下文:system message + user message + AI response,
1053
+ 用分隔线清晰标注各部分,方便调试时定位问题。
1054
+
1055
+ Args:
1056
+ filename: 被审核的文件名(用于日志头部标识)
1057
+ response: AI 返回的原始响应文本
1058
+ cache_md5: MD5 前7位,用于日志文件名
1059
+ system_message: system 角色的消息内容
1060
+ user_message: user 角色的消息内容
1061
+ """
1062
+ if not self.repo_path:
1063
+ return
1064
+
1065
+ logs_dir = Path(self.repo_path) / ".ai-review" / "logs"
1066
+ logs_dir.mkdir(parents=True, exist_ok=True)
1067
+
1068
+ name = cache_md5[:7] if cache_md5 else self._sanitize_log_filename(filename)
1069
+ ai_log = logs_dir / f"{name}.ai.log"
1070
+ try:
1071
+ from datetime import datetime
1072
+ sep_line = "=" * 60
1073
+
1074
+ display_name = filename
1075
+ repo_name = os.path.basename(self.repo_path)
1076
+ if display_name.startswith(repo_name + '/'):
1077
+ display_name = display_name[len(repo_name) + 1:]
1078
+ parts = [
1079
+ f"# ================================================\n"
1080
+ f"# AI Response Log\n"
1081
+ f"# 文件: {display_name}\n"
1082
+ f"# 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
1083
+ f"# ================================================\n"
1084
+ ]
1085
+
1086
+ if system_message:
1087
+ parts.append(
1088
+ f"\n{sep_line}\n"
1089
+ f"--- SYSTEM MESSAGE ---\n"
1090
+ f"{sep_line}\n\n"
1091
+ f"{system_message}"
1092
+ )
1093
+
1094
+ if user_message:
1095
+ parts.append(
1096
+ f"\n{sep_line}\n"
1097
+ f"--- USER MESSAGE ---\n"
1098
+ f"{sep_line}\n\n"
1099
+ f"{user_message}"
1100
+ )
1101
+
1102
+ if response:
1103
+ parts.append(
1104
+ f"\n{sep_line}\n"
1105
+ f"--- AI RESPONSE ---\n"
1106
+ f"{sep_line}\n\n"
1107
+ f"{response}"
1108
+ )
1109
+
1110
+ ai_log.write_text("\n".join(parts), encoding='utf-8')
1111
+ except Exception:
1112
+ pass
1113
+
1114
+
1115
+ def _check_cache(self, content_md5: str) -> Optional[ReviewResult]:
1116
+ """检查缓存是否存在
1117
+
1118
+ 缓存文件路径: .ai-review/cache/{md5前7位}.json
1119
+ 用 MD5 前7位作为文件名(类似 git short hash),节省磁盘空间。
1120
+
1121
+ 如果存在 .json.broken 文件(上次 JSON 解析失败),当作缓存未命中,
1122
+ 下次重新审核。
1123
+
1124
+ Args:
1125
+ content_md5: 文件内容(diff 或完整内容)的完整 MD5 哈希(32位)
1126
+
1127
+ Returns:
1128
+ ReviewResult(缓存命中),或 None(缓存未命中)
1129
+ """
1130
+ if not self._cache_dir:
1131
+ return None
1132
+
1133
+ cache_file = self._cache_dir / f"{content_md5[:7]}.json"
1134
+ # broken 缓存格式: {md5}_MMDDHHMMSS.json
1135
+ broken_files = list(self._cache_dir.glob(f"{content_md5[:7]}_*.json"))
1136
+
1137
+ # 上次 JSON 解析失败,当作缓存未命中,下次重新审核
1138
+ if broken_files:
1139
+ return None
1140
+
1141
+ if not cache_file.exists():
1142
+ return None
1143
+
1144
+ try:
1145
+ data = json.loads(cache_file.read_text(encoding='utf-8'))
1146
+ issues = []
1147
+ for issue_data in data.get('issues', []):
1148
+ if isinstance(issue_data, dict):
1149
+ issues.append(ReviewIssue(
1150
+ severity=issue_data.get('severity', 'info'),
1151
+ category=issue_data.get('category', 'best-practice'),
1152
+ line_number=issue_data.get('line_number'),
1153
+ message=issue_data.get('message', ''),
1154
+ suggestion=issue_data.get('suggestion', ''),
1155
+ code_snippet=issue_data.get('code_snippet', ''),
1156
+ ))
1157
+ # cache_md5 从 JSON 恢复,如果没有则从缓存文件名推断
1158
+ cache_md5 = data.get('cache_md5', '') or content_md5[:7]
1159
+ return ReviewResult(
1160
+ filename=data.get('filename', ''),
1161
+ issues=issues,
1162
+ summary=data.get('summary', ''),
1163
+ passed=data.get('passed', True),
1164
+ raw_response=data.get('raw_response', ''),
1165
+ cache_md5=cache_md5,
1166
+ )
1167
+ except Exception:
1168
+ # 缓存文件损坏,删除它
1169
+ try:
1170
+ cache_file.unlink()
1171
+ except Exception:
1172
+ pass
1173
+ return None
1174
+
1175
+ def _save_cache(self, content_md5: str, result: ReviewResult) -> None:
1176
+ """将审核结果保存到缓存
1177
+
1178
+ 缓存文件路径: .ai-review/cache/{md5前7位}.json
1179
+ 如果 JSON 解析失败(不是真正的审核结果),文件名加 .broken 后缀,
1180
+ 这样 _check_cache 会跳过它,下次重新审核。
1181
+
1182
+ Args:
1183
+ content_md5: 文件内容(diff 或完整内容)的完整 MD5 哈希(32位)
1184
+ result: ReviewResult 审核结果
1185
+ """
1186
+ if not self._cache_dir:
1187
+ return
1188
+
1189
+ from datetime import datetime
1190
+
1191
+ # 判断是否是 broken 缓存(JSON 解析失败,不是真正的审核结果)
1192
+ is_broken = not result.passed and any(
1193
+ kw in result.summary for kw in
1194
+ ("JSON 解析失败", "JSON 字段缺失", "JSON 字段名错误", "JSON 类型错误")
1195
+ )
1196
+
1197
+ # broken 缓存用时间戳标记:{md5}_MMDDHHMMSS.json
1198
+ if is_broken:
1199
+ ts = datetime.now().strftime("%m%d%H%M%S")
1200
+ cache_file = self._cache_dir / f"{content_md5[:7]}_{ts}.json"
1201
+ else:
1202
+ cache_file = self._cache_dir / f"{content_md5[:7]}.json"
1203
+ try:
1204
+ data = {
1205
+ 'filename': result.filename,
1206
+ 'summary': result.summary,
1207
+ 'passed': result.passed,
1208
+ 'raw_response': result.raw_response,
1209
+ 'cache_md5': result.cache_md5 or content_md5[:7],
1210
+ 'issues': [
1211
+ {
1212
+ 'severity': issue.severity,
1213
+ 'category': issue.category,
1214
+ 'line_number': issue.line_number,
1215
+ 'message': issue.message,
1216
+ 'suggestion': issue.suggestion,
1217
+ 'code_snippet': issue.code_snippet,
1218
+ }
1219
+ for issue in result.issues
1220
+ ],
1221
+ }
1222
+ cache_file.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
1223
+ except Exception:
1224
+ # 缓存写入失败不报错
1225
+ pass
1226
+
1227
+ # 审核结果的 JSON Schema,精确约束 AI 输出格式
1228
+ REVIEW_JSON_SCHEMA = {
1229
+ "name": "code_review_result",
1230
+ "strict": True,
1231
+ "schema": {
1232
+ "type": "object",
1233
+ "properties": {
1234
+ "summary": {"type": "string", "description": "总体评价(2-3句话)"},
1235
+ "passed": {"type": "boolean", "description": "true=通过 false=不通过"},
1236
+ "issues": {
1237
+ "type": "array",
1238
+ "items": {
1239
+ "type": "object",
1240
+ "properties": {
1241
+ "severity": {
1242
+ "type": "string",
1243
+ "enum": ["critical", "error", "warning", "info", "致命", "错误", "警告", "提示"],
1244
+ "description": "严重级别(支持中英文)"
1245
+ },
1246
+ "category": {
1247
+ "type": "string",
1248
+ "enum": ["Bug检测", "安全", "代码风格", "性能", "最佳实践", "文档"],
1249
+ "description": "问题类别"
1250
+ },
1251
+ "line_number": {"type": "integer", "description": "行号(单个整数)"},
1252
+ "message": {"type": "string", "description": "问题描述(必填,不能为空)"},
1253
+ "suggestion": {"type": "string", "description": "修复建议"},
1254
+ "code_snippet": {"type": "string", "description": "相关代码片段"}
1255
+ },
1256
+ "required": ["severity", "category", "line_number", "message"],
1257
+ "additionalProperties": False
1258
+ }
1259
+ }
1260
+ },
1261
+ "required": ["summary", "passed", "issues"],
1262
+ "additionalProperties": False
1263
+ }
1264
+ }
1265
+
1266
+ def _call_api_safe(self, **kwargs) -> Any:
1267
+ """调用 API,使用 JSON Schema 精确约束 AI 输出
1268
+
1269
+ 主流模型(GPT/Claude/DeepSeek/MiniMax/Moonshot 等)均支持 response_format,
1270
+ 直接使用 json_schema 精确约束字段名、类型、必填项。
1271
+ 不支持的模型会报错,需要用户升级模型或切换支持 schema 的模型。
1272
+
1273
+ Args:
1274
+ **kwargs: 传给 chat.completions.create 的参数
1275
+
1276
+ Returns:
1277
+ API 响应对象
1278
+ """
1279
+ kwargs_schema = {
1280
+ **kwargs,
1281
+ "response_format": {
1282
+ "type": "json_schema",
1283
+ "json_schema": self.REVIEW_JSON_SCHEMA
1284
+ }
1285
+ }
1286
+ return self.client.chat.completions.create(**kwargs_schema)
1287
+
1288
+ def _get_disable_thinking_params(self, model: str) -> dict:
1289
+ """根据模型名称返回禁用 think/thinking 的 extra_api_params
1290
+
1291
+ 主流国产模型思考过程参数各不相同,在此统一适配。
1292
+ 匹配不到的模型返回空 dict(不额外传参)。
1293
+
1294
+ 适配列表(已验证):
1295
+ - DeepSeek: enable_thinking=false (boolean)
1296
+
1297
+ 待验证(暂不启用,避免 API 400 错误):
1298
+ - MiniMax/Moonshot/Kimi/Qwen/GLM/混元/豆包 的 thinking 参数格式
1299
+ 可能是对象格式 {"type": "disabled"} 而非 boolean
1300
+ """
1301
+ m = model.lower()
1302
+
1303
+ # DeepSeek 系列 — 确认支持 enable_thinking (boolean)
1304
+ if 'deepseek' in m:
1305
+ return {"extra_body": {"enable_thinking": False}}
1306
+
1307
+ # 其他模型暂不传入 thinking 参数(格式不确定,传入 boolean 会导致 API 400)
1308
+ # 如需适配其他模型,请先确认其 API 的 thinking 参数格式
1309
+ # 典型错误: Mismatch type ThinkingConfig with value bool
1310
+ return {}
1311
+
1312
+ def _call_api(self, prompt: str, filename: str = "unknown", cache_md5: str = "") -> str:
1313
+ """调用 AI API,含指数退避重试
1314
+
1315
+ 重试策略(最多3次):
1316
+ - 第1次失败:等 1 秒重试
1317
+ - 第2次失败:等 2 秒重试
1318
+ - 第3次失败:等 4 秒重试
1319
+ - 第3次仍失败:抛异常
1320
+
1321
+ 覆盖的错误类型:
1322
+ - RateLimitError(API 限流)
1323
+ - APITimeoutError(请求超时)
1324
+ - APIError(服务端错误)
1325
+
1326
+ Args:
1327
+ prompt: 完整的审核 Prompt(含代码 + 审核维度说明)
1328
+ filename: 被审核的文件名(用于 ai.log 标识)
1329
+ cache_md5: MD5 前7位,用于 ai.log 文件名命名
1330
+
1331
+ Returns:
1332
+ AI 的文本响应(JSON 格式,markdown 包裹)
1333
+
1334
+ Raises:
1335
+ RuntimeError: 3 次重试后仍失败
1336
+ """
1337
+ model = getattr(self.config, 'model', 'gpt-4o-mini')
1338
+ max_retries = 3
1339
+ # 根据模型名称获取禁用 think 的额外参数
1340
+ extra_params = self._get_disable_thinking_params(model)
1341
+
1342
+ # 加载 system message(只加载一次,所有 retry 共用)
1343
+ system_msg = self.prompt_loader.load_system_message()
1344
+
1345
+ for attempt in range(max_retries):
1346
+ try:
1347
+ response = self._call_api_safe(
1348
+ model=model,
1349
+ messages=[
1350
+ # system 消息从模板加载(.ai-review/prompts/system_message.txt)
1351
+ {"role": "system", "content": system_msg},
1352
+ # user 消息是真正的审核请求(从模板渲染)
1353
+ {"role": "user", "content": prompt}
1354
+ ],
1355
+ temperature=getattr(self.config, 'temperature', 0.3), # 从配置读取,默认 0.3
1356
+ max_tokens=getattr(self.config, 'max_tokens', 4096), # 从配置读取,默认 4096
1357
+ **extra_params, # 主流模型禁用 think 的额外参数(如 enable_thinking=false)
1358
+ )
1359
+ raw_content = response.choices[0].message.content or ""
1360
+
1361
+ # 检测 AI 响应是否可能被截断(JSON 不完整)
1362
+ # 先过滤 <think> 再检测,避免 think 内容干扰判断
1363
+ filtered_for_check = re.sub(r'<think>.*?</think>', '', raw_content, flags=re.DOTALL).strip()
1364
+ has_complete_result = re.search(r'<result>.*?</result>', filtered_for_check, re.DOTALL) is not None
1365
+ is_complete = has_complete_result or filtered_for_check.endswith('}')
1366
+ if filtered_for_check and not is_complete:
1367
+ current_max = getattr(self.config, 'max_tokens', 4096)
1368
+ print(f"\n⚠️ AI 返回内容可能被截断(文件: {filename},当前 max_tokens={current_max})")
1369
+ print(f" 建议: 运行 'commit-ai-guardian configure' 增加 max_tokens 值")
1370
+ print(f" 或: 直接修改 .ai-review/config.yaml 中的 max_tokens\n")
1371
+
1372
+ # 将 AI 返回的原始响应写入 ai.log(不打印到控制台)
1373
+ self._write_ai_response_log(filename, raw_content, cache_md5, system_message=system_msg, user_message=prompt)
1374
+ return raw_content
1375
+
1376
+ except openai.RateLimitError: # API 限流(429)
1377
+ if attempt < max_retries - 1:
1378
+ wait_time = 2 ** attempt # 指数退避:1, 2, 4
1379
+ print(f"[信息] API 速率限制,{wait_time}秒后重试...")
1380
+ time.sleep(wait_time)
1381
+ else:
1382
+ raise RuntimeError("API 速率限制,已达到最大重试次数")
1383
+
1384
+ except openai.APITimeoutError: # 请求超时
1385
+ if attempt < max_retries - 1:
1386
+ wait_time = 2 ** attempt
1387
+ print(f"[信息] API 超时,{wait_time}秒后重试...")
1388
+ time.sleep(wait_time)
1389
+ else:
1390
+ raise RuntimeError("API 调用超时")
1391
+
1392
+ except openai.APIError as e: # 其他 API 错误
1393
+ if attempt < max_retries - 1:
1394
+ wait_time = 2 ** attempt
1395
+ print(f"[信息] API 错误 ({e}),{wait_time}秒后重试...")
1396
+ time.sleep(wait_time)
1397
+ else:
1398
+ raise RuntimeError(f"API 调用失败: {e}")
1399
+
1400
+ except Exception as e: # 兜底:网络断开等
1401
+ if attempt < max_retries - 1:
1402
+ wait_time = 2 ** attempt
1403
+ print(f"[信息] 调用失败 ({e}),{wait_time}秒后重试...")
1404
+ time.sleep(wait_time)
1405
+ else:
1406
+ raise RuntimeError(f"API 调用失败: {e}")
1407
+
1408
+ raise RuntimeError("API 调用失败,已达到最大重试次数")
1409
+
1410
+ def _extract_json_str(self, response: str) -> Optional[str]:
1411
+ """从 AI 响应中提取 JSON 字符串(复用 parse_ai_response 的提取逻辑)
1412
+
1413
+ Args:
1414
+ response: AI 返回的原始文本
1415
+
1416
+ Returns:
1417
+ JSON 字符串,或 None
1418
+ """
1419
+ # 先过滤 <think> 标签
1420
+ filtered = re.sub(r'<think>.*?</think>', '', response, flags=re.DOTALL).strip()
1421
+
1422
+ # 策略 0: 从 <result> 标签提取
1423
+ m = re.search(r'<result>(.*?)</result>', filtered, re.DOTALL)
1424
+ if m:
1425
+ return m.group(1).strip()
1426
+
1427
+ # 策略 1: 从 ```json 代码块提取
1428
+ m = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', filtered, re.DOTALL)
1429
+ if m:
1430
+ return m.group(1).strip()
1431
+
1432
+ # 策略 2: 找第一个 {...}
1433
+ m = re.search(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', filtered, re.DOTALL)
1434
+ if m:
1435
+ return m.group(0).strip()
1436
+
1437
+ return None
1438
+
1439
+ def _write_json_fix_log(self, filename: str, cache_md5: str,
1440
+ system_message: str, user_message: str,
1441
+ ai_response: str) -> None:
1442
+ """将 JSON 修复 AI 的完整对话记录写入 .ai-review/logs/{md5}.json_fix.log
1443
+
1444
+ 每次调用 JSON 修复 AI 都会保存(无论修复成功与否),方便查看定位。
1445
+ 格式与 ai.log 完全一致:header + system + user + ai response。
1446
+
1447
+ Args:
1448
+ filename: 被审核的文件名
1449
+ cache_md5: MD5 前7位,用于日志文件名。为空时用时间戳替代
1450
+ system_message: system 角色消息
1451
+ user_message: user 角色消息
1452
+ ai_response: AI 返回的文本
1453
+ """
1454
+ if not self.repo_path:
1455
+ return
1456
+
1457
+ logs_dir = Path(self.repo_path) / ".ai-review" / "logs"
1458
+ logs_dir.mkdir(parents=True, exist_ok=True)
1459
+
1460
+ name = cache_md5[:7]
1461
+
1462
+ # 文件名格式: {md5}.json_fix.log,和 ai.log ({md5}.ai.log) 对应
1463
+ log_file = logs_dir / f"{name}.json_fix.log"
1464
+ try:
1465
+ from datetime import datetime
1466
+ sep_line = "=" * 60
1467
+
1468
+ display_name = filename
1469
+ repo_name = os.path.basename(self.repo_path)
1470
+ if display_name.startswith(repo_name + '/'):
1471
+ display_name = display_name[len(repo_name) + 1:]
1472
+ parts = [
1473
+ f"# ================================================\n"
1474
+ f"# JSON Fix Log\n"
1475
+ f"# 文件: {display_name}\n"
1476
+ f"# MD5: {name}\n"
1477
+ f"# 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
1478
+ f"# ================================================\n"
1479
+ ]
1480
+
1481
+ if system_message:
1482
+ parts.append(
1483
+ f"\n{sep_line}\n"
1484
+ f"--- SYSTEM MESSAGE ---\n"
1485
+ f"{sep_line}\n\n"
1486
+ f"{system_message}"
1487
+ )
1488
+
1489
+ if user_message:
1490
+ parts.append(
1491
+ f"\n{sep_line}\n"
1492
+ f"--- USER MESSAGE ---\n"
1493
+ f"{sep_line}\n\n"
1494
+ f"{user_message}"
1495
+ )
1496
+
1497
+ if ai_response:
1498
+ parts.append(
1499
+ f"\n{sep_line}\n"
1500
+ f"--- AI RESPONSE ---\n"
1501
+ f"{sep_line}\n\n"
1502
+ f"{ai_response}"
1503
+ )
1504
+
1505
+ log_file.write_text("\n".join(parts), encoding='utf-8')
1506
+ except Exception as e:
1507
+ print(f"[警告] 写入 json_fix.log 失败: {e}")
1508
+
1509
+ def _validate_review_schema(self, data: dict) -> list:
1510
+ """校验审核结果 JSON 是否满足 schema 要求
1511
+
1512
+ 返回具体的错误信息列表,用于反馈给 JSON 修复 AI。
1513
+ 空列表表示校验通过。
1514
+
1515
+ Args:
1516
+ data: 解析后的 JSON dict
1517
+
1518
+ Returns:
1519
+ 错误信息列表(空列表表示通过)
1520
+ """
1521
+ errors = []
1522
+
1523
+ # 1. 顶层必填字段
1524
+ for field in ['summary', 'passed', 'issues']:
1525
+ if field not in data:
1526
+ errors.append(f"缺少顶层必填字段: '{field}'")
1527
+
1528
+ if errors:
1529
+ return errors # 缺少顶层字段,不再检查 issues
1530
+
1531
+ # 2. 类型检查
1532
+ if not isinstance(data.get('summary'), str):
1533
+ errors.append("'summary' 必须是字符串")
1534
+ if not isinstance(data.get('passed'), bool):
1535
+ errors.append("'passed' 必须是布尔值 (true/false)")
1536
+ if not isinstance(data.get('issues'), list):
1537
+ errors.append("'issues' 必须是数组")
1538
+ return errors
1539
+
1540
+ # 3. issues 数组中每个 issue 的校验
1541
+ for i, issue in enumerate(data['issues']):
1542
+ if not isinstance(issue, dict):
1543
+ errors.append(f"issues[{i}] 必须是对象")
1544
+ continue
1545
+
1546
+ # 必填字段
1547
+ for field in ['severity', 'category', 'line_number', 'message']:
1548
+ if field not in issue:
1549
+ errors.append(f"issues[{i}] 缺少必填字段: '{field}'")
1550
+
1551
+ # severity 枚举值
1552
+ sev = issue.get('severity')
1553
+ if sev and sev not in ['critical', 'error', 'warning', 'info']:
1554
+ errors.append(f"issues[{i}].severity 值非法: '{sev}',必须是 critical/error/warning/info 之一")
1555
+
1556
+ # category 不校验,任意字符串均可
1557
+
1558
+ # line_number 类型
1559
+ ln = issue.get('line_number')
1560
+ if ln is not None and not isinstance(ln, int):
1561
+ errors.append(f"issues[{i}].line_number 必须是整数,当前类型: {type(ln).__name__}")
1562
+
1563
+ # message 非空
1564
+ msg = issue.get('message')
1565
+ if msg is not None and (not isinstance(msg, str) or not msg.strip()):
1566
+ errors.append(f"issues[{i}].message 不能为空字符串")
1567
+
1568
+ # 禁止的字段名(别名)
1569
+ invalid_fields = {'description', 'fix_suggestion', 'fix', 'advice', 'title', 'desc', 'code'}
1570
+ found_invalid = invalid_fields & set(issue.keys())
1571
+ if found_invalid:
1572
+ errors.append(f"issues[{i}] 使用了非标准字段名: {found_invalid},请改为标准名称: message/suggestion/code_snippet")
1573
+
1574
+ return errors
1575
+
1576
+ def _fix_json_with_ai(self, broken_json: str, filename: str,
1577
+ cache_md5: str = "") -> Optional[str]:
1578
+ """AI 修复 JSON 语法错误
1579
+
1580
+ 本地所有修复策略都失败后,调用 AI 来修复 JSON。
1581
+ 只修复语法(转义、逗号、括号),不修改内容。最多重试 2 次。
1582
+
1583
+ Args:
1584
+ broken_json: 有语法错误的 JSON 字符串
1585
+ filename: 被审核的文件名
1586
+ cache_md5: MD5 前7位,用于 json_fix 日志文件名
1587
+
1588
+ Returns:
1589
+ 修复后的 JSON 字符串,或 None
1590
+ """
1591
+ if not self.client:
1592
+ return None
1593
+
1594
+ model = getattr(self.config, 'model', 'gpt-4o-mini')
1595
+ max_tokens = getattr(self.config, 'max_tokens', 4096)
1596
+
1597
+ # 根据模型名称获取禁用 think 的额外参数
1598
+ extra_params = self._get_disable_thinking_params(model)
1599
+
1600
+ # 从模板加载 system message 和 user prompt
1601
+ system_msg = self.prompt_loader.load_json_fix_system_message()
1602
+ template = self.prompt_loader.load_json_fix_template()
1603
+ fix_prompt = PromptLoader.render(template, filename=filename, broken_json=broken_json)
1604
+
1605
+ # JSON 修复 AI 上下文策略
1606
+ # full = 累积所有失败的 attempt,last = 只保留最近一次
1607
+ history_mode = getattr(self.config, 'json_fix_history_mode', 'full')
1608
+ attempt_history = [] # 每次失败追加 [assistant(json), user(error)]
1609
+
1610
+ all_attempts_log = [] # 收集所有尝试的日志
1611
+
1612
+ for attempt in range(3):
1613
+ try:
1614
+ # 构造 messages:system + 原始修复请求 + 历史对话
1615
+ messages = [
1616
+ {"role": "system", "content": system_msg},
1617
+ {"role": "user", "content": fix_prompt},
1618
+ *attempt_history,
1619
+ ]
1620
+
1621
+ resp = self._call_api_safe(
1622
+ model=model,
1623
+ messages=messages,
1624
+ temperature=0.0, # 纯格式转换,完全确定性输出
1625
+ max_tokens=max_tokens,
1626
+ **extra_params,
1627
+ )
1628
+ fixed = resp.choices[0].message.content or ""
1629
+
1630
+ # 记录本次尝试
1631
+ all_attempts_log.append(f"--- 尝试 {attempt + 1} ---\n{fixed}")
1632
+
1633
+ # 提取 JSON
1634
+ fixed_json = self._extract_json_str(fixed) or fixed.strip()
1635
+
1636
+ # 验证:先解析,再校验 schema
1637
+ data = _try_parse_json(fixed_json)
1638
+ if not data or not isinstance(data, dict):
1639
+ last_error = "JSON 语法解析失败,请确保是合法的 JSON 格式"
1640
+ continue
1641
+
1642
+ # Schema 校验
1643
+ schema_errors = self._validate_review_schema(data)
1644
+ if not schema_errors:
1645
+ # 校验通过,写入日志(包含所有失败尝试),返回修复后的 JSON
1646
+ self._write_json_fix_log(filename, cache_md5,
1647
+ system_msg, fix_prompt,
1648
+ "\n\n".join(all_attempts_log))
1649
+ return fixed_json # 校验通过,返回修复后的 JSON
1650
+
1651
+ # 校验失败,更新对话历史供下次使用
1652
+ error_msg = "\n".join(schema_errors)
1653
+ print(f"[信息] JSON 修复第 {attempt + 1} 次 schema 校验未通过:{schema_errors[0]}")
1654
+ if history_mode == "last":
1655
+ attempt_history.clear() # 只保留最后一次
1656
+ attempt_history.append({"role": "assistant", "content": fixed_json})
1657
+ attempt_history.append({"role": "user", "content": (
1658
+ f"以上修复结果不满足 schema 要求,具体错误:\n{error_msg}\n"
1659
+ f"\n请根据以上错误修正 JSON,确保满足 schema 约束。"
1660
+ )})
1661
+
1662
+ except Exception as e:
1663
+ error_msg = f"处理异常: {e}"
1664
+ all_attempts_log.append(f"--- 尝试 {attempt + 1}(异常)---\n{str(e)}")
1665
+ if history_mode == "last":
1666
+ attempt_history.clear()
1667
+ attempt_history.append({"role": "user", "content": (
1668
+ f"修复处理异常:{error_msg}\n"
1669
+ f"\n请重新修正 JSON,确保满足 schema 约束。"
1670
+ )})
1671
+ continue
1672
+
1673
+ # 所有尝试都失败了,仍然写入日志(方便查看定位)
1674
+ self._write_json_fix_log(filename, cache_md5,
1675
+ system_msg, fix_prompt,
1676
+ "\n\n".join(all_attempts_log) + "\n\n=== 最终结果:全部 3 次尝试均失败 ===")
1677
+ return None
1678
+
1679
+ def _build_result_from_dict(self, data, filename: str, raw_response: str) -> ReviewResult:
1680
+ """从解析后的 dict/list 构建 ReviewResult(含字段名校验)
1681
+
1682
+ AI 有时会返回数组(如 [])而不是对象,在此自动包装为合规对象。
1683
+ 不盲目信任 AI 返回的 passed 值——最终 passed 根据 issues 的 severity 决定。
1684
+
1685
+ Args:
1686
+ data: 解析后的 JSON(dict 或 list)
1687
+ filename: 被审核的文件名
1688
+ raw_response: AI 原始响应文本
1689
+
1690
+ Returns:
1691
+ ReviewResult
1692
+ """
1693
+ result = ReviewResult(filename=filename, raw_response=raw_response)
1694
+
1695
+ # AI 返回了数组(如 [])——包装为合规对象
1696
+ if isinstance(data, list):
1697
+ print(f"[信息] AI 返回了数组,自动包装为对象")
1698
+ data = {"summary": "AI 返回了数组格式,已自动转换", "passed": False, "issues": []}
1699
+
1700
+ result.summary = data.get('summary', '') or '审核完成'
1701
+ # 先取 AI 返回的 passed,后面会根据 issues 修正
1702
+ result.passed = bool(data.get('passed', True))
1703
+
1704
+ # issue 字段名校验:必须有 message 字段且非空
1705
+ issues_data = data.get('issues', [])
1706
+ if isinstance(issues_data, list):
1707
+ for issue_data in issues_data:
1708
+ if isinstance(issue_data, dict):
1709
+ # 检查必填字段 message
1710
+ message_val = issue_data.get('message', '')
1711
+ if not message_val or not str(message_val).strip():
1712
+ result.summary = "JSON 字段缺失: issue 缺少必填字段 message"
1713
+ result.passed = False
1714
+ result.raw_response = raw_response
1715
+ return result
1716
+
1717
+ # severity 保持英文(本来就返回英文)
1718
+ # category 直接用中文(schema 枚举已改为中文)
1719
+ severity = issue_data.get('severity', 'info')
1720
+ category = issue_data.get('category', '最佳实践')
1721
+
1722
+ issue = ReviewIssue(
1723
+ severity=severity,
1724
+ category=category,
1725
+ line_number=issue_data.get('line_number'),
1726
+ message=message_val,
1727
+ suggestion=issue_data.get('suggestion', ''),
1728
+ code_snippet=issue_data.get('code_snippet', ''),
1729
+ )
1730
+ result.issues.append(issue)
1731
+
1732
+ # 关键修正:根据 issues 的 severity 重新计算 passed
1733
+ # 有 warning/error/critical 的问题时,强制 passed=False
1734
+ # 不盲目信任 AI(尤其 JSON 修复 AI)返回的 passed 值
1735
+ has_real_issues = any(
1736
+ issue.severity in ('warning', 'error', 'critical')
1737
+ for issue in result.issues
1738
+ )
1739
+ if has_real_issues:
1740
+ result.passed = False
1741
+
1742
+ return result
1743
+
1744
+ def _parse_response(self, response: str, filename: str, cache_md5: str = "") -> ReviewResult:
1745
+ """解析 AI 的响应文本为结构化的 ReviewResult
1746
+
1747
+ 解析策略(层层降级):
1748
+ 1. 本地解析(parse_ai_response)
1749
+ 2. 本地修复(_try_parse_json 含多种策略)
1750
+ 3. AI 修复 JSON(_fix_json_with_ai)
1751
+ 4. 都失败 → passed=False
1752
+
1753
+ Args:
1754
+ response: AI 返回的原始文本
1755
+ filename: 被审核的文件名
1756
+ cache_md5: MD5 前7位,用于解析失败时打印日志路径
1757
+
1758
+ Returns:
1759
+ ReviewResult
1760
+ """
1761
+ # ===== 阶段1: 本地解析 =====
1762
+ result = parse_ai_response(response, filename)
1763
+
1764
+ # ===== 阶段2: 解析或校验失败 → AI 修复 =====
1765
+ # JSON 语法解析失败 或 schema 校验不通过(字段缺失/别名/类型错误)都触发修复
1766
+ json_error_keywords = ("JSON 解析失败", "无法从响应中解析 JSON", "JSON 字段缺失", "JSON 字段名错误", "JSON 类型错误")
1767
+ if not result.passed and any(kw in result.summary for kw in json_error_keywords):
1768
+ broken_json = self._extract_json_str(response)
1769
+
1770
+ if broken_json and self.client:
1771
+ print(f"[信息] JSON 本地解析失败,调用 AI 修复...")
1772
+ fixed_json = self._fix_json_with_ai(broken_json, filename, cache_md5=cache_md5)
1773
+
1774
+ if fixed_json:
1775
+ # 用修复后的 JSON 重新解析(接受 dict 或 list)
1776
+ data = _try_parse_json(fixed_json)
1777
+ if data and isinstance(data, (dict, list)):
1778
+ result = self._build_result_from_dict(data, filename, response)
1779
+ # 修复 AI 的 summary 通常是"修复说明"等无意义文字
1780
+ # 如果修复成功且有 issues,替换为基于 issues 的有意义 summary
1781
+ if result.issues:
1782
+ issue_count = len(result.issues)
1783
+ sev_counts = {}
1784
+ for issue in result.issues:
1785
+ sev_counts[issue.severity] = sev_counts.get(issue.severity, 0) + 1
1786
+ sev_parts = [f"{c}个{s}" for s, c in sorted(sev_counts.items(), key=lambda x: -{'critical':4,'error':3,'warning':2,'info':1}.get(x[0],0))]
1787
+ result.summary = f"发现 {issue_count} 个问题({', '.join(sev_parts)})"
1788
+ elif not result.summary or result.summary in ('修复说明', ''):
1789
+ result.summary = 'AI 审核完成,未发现问题'
1790
+ print(f"[信息] AI 修复 JSON 成功,解析通过")
1791
+ else:
1792
+ print(f"[警告] AI 修复后 JSON 仍无法解析")
1793
+ else:
1794
+ print(f"[警告] AI 修复 JSON 失败")
1795
+
1796
+ # 打印日志路径(帮助定位问题,用相对路径)
1797
+ md5_short = cache_md5[:7] if cache_md5 else "unknown"
1798
+ cache_path = Path(self.repo_path) / ".ai-review" / "cache" / f"{md5_short}.json"
1799
+ ai_log = Path(self.repo_path) / ".ai-review" / "logs" / f"{md5_short}.ai.log"
1800
+ json_fix_log = Path(self.repo_path) / ".ai-review" / "logs" / f"{md5_short}.json_fix.log"
1801
+ print(f" {os.path.relpath(cache_path)}")
1802
+ print(f" {os.path.relpath(ai_log)}")
1803
+ print(f" {os.path.relpath(json_fix_log)}")
1804
+
1805
+ # ===== 阶段3: 确保必要字段存在 =====
1806
+ # 默认 passed=False(绝对阻断),只有在明确通过时才设为 True
1807
+ if not result.summary:
1808
+ result.summary = "审核完成(系统异常,默认阻断)"
1809
+ if not hasattr(result, 'passed'):
1810
+ result.passed = False
1811
+ if not hasattr(result, 'issues'):
1812
+ result.issues = []
1813
+
1814
+ return result
1815
+
1816
+ def review_source(self, source_file: Any) -> ReviewResult:
1817
+ """
1818
+ 对完整文件内容进行 AI 审核(非 diff 模式)
1819
+
1820
+ 适用于直接审核指定文件/目录的场景,不依赖 Git diff。
1821
+
1822
+ Args:
1823
+ source_file: SourceFile 对象或类似对象,需包含 filename, language, content 字段
1824
+
1825
+ Returns:
1826
+ ReviewResult 审核结果
1827
+ """
1828
+ filename = getattr(source_file, 'filename', 'unknown')
1829
+
1830
+ # 检查前置条件
1831
+ prereq = self._check_prerequisites(filename)
1832
+ if prereq:
1833
+ return prereq
1834
+
1835
+ content = getattr(source_file, 'content', '')
1836
+
1837
+ # 计算缓存 key(无论是否启用缓存,都用于 ai.log 命名)
1838
+ content_md5 = hashlib.md5(content.encode('utf-8')).hexdigest()
1839
+
1840
+ # 检查缓存(可配置关闭)
1841
+ use_cache = getattr(self.config, 'use_cache', True)
1842
+ if use_cache:
1843
+ cached = self._check_cache(content_md5)
1844
+ if cached:
1845
+ cached.filename = filename
1846
+ cached.cache_md5 = content_md5[:7]
1847
+ print(f"[信息] 缓存命中: {filename} 跳过 AI 审核")
1848
+ cache_path = Path(self.repo_path) / ".ai-review" / "cache" / f"{content_md5[:7]}.json"
1849
+ print(f" {os.path.relpath(cache_path)}")
1850
+ return cached
1851
+
1852
+ prompt = self._build_full_file_prompt(source_file, content_md5[:7])
1853
+
1854
+ try:
1855
+ response = self._call_api(prompt, filename=filename, cache_md5=content_md5[:7])
1856
+ result = self._parse_response(response, filename, cache_md5=content_md5[:7])
1857
+ result.cache_md5 = content_md5[:7]
1858
+ # 审核成功,保存到缓存(可配置关闭)
1859
+ if use_cache:
1860
+ self._save_cache(content_md5, result)
1861
+ return result
1862
+ except Exception as e:
1863
+ print(f"[错误] 审核文件 {filename} 失败: {e}")
1864
+ return ReviewResult(
1865
+ filename=filename,
1866
+ summary=f"审核失败: {str(e)}",
1867
+ passed=False, # ← 异常时标记未通过
1868
+ raw_response=str(e),
1869
+ cache_md5=content_md5[:7],
1870
+ )
1871
+
1872
+ def _get_cache_key_for_source(self, source_file: Any) -> Optional[str]:
1873
+ """计算 SourceFile 的缓存 key
1874
+
1875
+ Args:
1876
+ source_file: SourceFile 对象
1877
+
1878
+ Returns:
1879
+ MD5 字符串,或 None
1880
+ """
1881
+ content = getattr(source_file, 'content', '')
1882
+ if content:
1883
+ return hashlib.md5(content.encode('utf-8')).hexdigest()
1884
+ return None
1885
+
1886
+ def _review_source_no_cache(self, source_file: Any) -> ReviewResult:
1887
+ """审核完整文件(不检查缓存,直接调 AI)
1888
+
1889
+ 供 review_source_batch 在第二阶段调用。
1890
+
1891
+ Args:
1892
+ source_file: SourceFile 对象
1893
+
1894
+ Returns:
1895
+ ReviewResult
1896
+ """
1897
+ filename = getattr(source_file, 'filename', 'unknown')
1898
+ print(f"[信息] AI 审核中: {filename}\n")
1899
+
1900
+ content = getattr(source_file, 'content', '')
1901
+ cache_key = hashlib.md5(content.encode('utf-8')).hexdigest()
1902
+
1903
+ try:
1904
+ prompt = self._build_full_file_prompt(source_file, cache_key[:7])
1905
+ response = self._call_api(prompt, filename=filename, cache_md5=cache_key[:7])
1906
+ result = self._parse_response(response, filename, cache_md5=cache_key[:7])
1907
+ if getattr(self.config, 'use_cache', True):
1908
+ self._save_cache(cache_key, result)
1909
+ return result
1910
+ except Exception as e:
1911
+ print(f"[错误] 审核文件 {filename} 失败: {e}")
1912
+ return ReviewResult(
1913
+ filename=filename,
1914
+ summary=f"审核失败: {str(e)}",
1915
+ passed=False, # ← 异常时标记未通过
1916
+ raw_response=str(e),
1917
+ )
1918
+
1919
+ def review_source_batch(self, source_files: List[Any]) -> List[ReviewResult]:
1920
+ """
1921
+ 批量审核完整文件(先检查缓存,再并发调 AI)
1922
+
1923
+ 两阶段设计:
1924
+ 1. 先批量检查缓存 → 命中的直接打印并收集结果
1925
+ 2. 再对没命中的文件并发调 AI
1926
+
1927
+ Args:
1928
+ source_files: SourceFile 对象列表
1929
+
1930
+ Returns:
1931
+ ReviewResult 列表(按原始文件顺序)
1932
+ """
1933
+ if not source_files:
1934
+ return []
1935
+
1936
+ if len(source_files) == 1:
1937
+ return [self.review_source(source_files[0])]
1938
+
1939
+ results: List[Optional[ReviewResult]] = [None] * len(source_files)
1940
+
1941
+ # 先清理过期缓存和日志
1942
+ self._clean_expired_cache()
1943
+ self._clean_old_logs()
1944
+
1945
+ # 检查是否启用缓存
1946
+ use_cache = getattr(self.config, 'use_cache', True)
1947
+
1948
+ # ===== 第一阶段:批量检查缓存(可配置关闭)=====
1949
+ cache_hit_indices: List[int] = []
1950
+ cache_miss_indices: List[int] = list(range(len(source_files)))
1951
+
1952
+ if use_cache:
1953
+ cache_hit_indices = []
1954
+ cache_miss_indices = []
1955
+ for i, source_file in enumerate(source_files):
1956
+ cache_key = self._get_cache_key_for_source(source_file)
1957
+ if cache_key:
1958
+ cached = self._check_cache(cache_key)
1959
+ if cached:
1960
+ cached.filename = getattr(source_file, 'filename', 'unknown')
1961
+ results[i] = cached
1962
+ cache_hit_indices.append(i)
1963
+ continue
1964
+ cache_miss_indices.append(i)
1965
+
1966
+ # 打印缓存命中信息
1967
+ if cache_hit_indices:
1968
+ for idx in cache_hit_indices:
1969
+ filename = getattr(source_files[idx], 'filename', 'unknown')
1970
+ cache_key = self._get_cache_key_for_source(source_files[idx]) or ""
1971
+ print(f"[信息] 缓存命中: {filename} 跳过 AI 审核")
1972
+ if cache_key:
1973
+ cache_path = Path(self.repo_path) / ".ai-review" / "cache" / f"{cache_key[:7]}.json"
1974
+ print(f" {os.path.relpath(cache_path)}")
1975
+
1976
+ # ===== 第二阶段:并发调 AI =====
1977
+ if cache_miss_indices:
1978
+ with ThreadPoolExecutor(max_workers=4) as executor:
1979
+ future_to_index = {
1980
+ executor.submit(self._review_source_no_cache, source_files[idx]): idx
1981
+ for idx in cache_miss_indices
1982
+ }
1983
+
1984
+ for future in as_completed(future_to_index):
1985
+ idx = future_to_index[future]
1986
+ try:
1987
+ results[idx] = future.result()
1988
+ except Exception as e:
1989
+ filename = getattr(source_files[idx], 'filename', 'unknown')
1990
+ print(f"[错误] 审核文件 {filename} 并发执行失败: {e}")
1991
+ results[idx] = ReviewResult(
1992
+ filename=filename,
1993
+ summary=f"并发审核失败: {str(e)}",
1994
+ passed=False, # 异常默认阻断
1995
+ raw_response=str(e),
1996
+ )
1997
+
1998
+ return results
1999
+
2000
+ def _build_full_file_prompt(self, source_file: Any, cache_md5: str = "") -> str:
2001
+ """
2002
+ 构建完整文件审核的提示词
2003
+
2004
+ 从 .ai-review/prompts/full_file_review.md 加载模板,
2005
+ 找不到就用内置默认模板。
2006
+
2007
+ 文件内容会加上行号前缀(如 "145 | let x = ..."),
2008
+ 让 AI 返回正确的 line_number,不受 prompt 前面说明文字的影响。
2009
+
2010
+ Args:
2011
+ source_file: SourceFile 对象
2012
+ cache_md5: MD5 前7位,用于日志文件名命名
2013
+
2014
+ Returns:
2015
+ 完整的 prompt 字符串
2016
+ """
2017
+ filename = getattr(source_file, 'filename', 'unknown')
2018
+ language = getattr(source_file, 'language', 'unknown')
2019
+ content = getattr(source_file, 'content', '')
2020
+ line_count = getattr(source_file, 'line_count', 0)
2021
+
2022
+ # 注:已取消文件截断。MiniMax 等模型输入上下文 200K+,
2023
+ # 完整文件(通常 < 50K 字符)不可能触及限制。
2024
+ truncated = False
2025
+ truncate_note = ""
2026
+
2027
+ # 给文件内容加上行号前缀(关键:让 AI 看到正确的文件行号)
2028
+ content = self._annotate_content_with_line_numbers(content)
2029
+
2030
+ language_display = {
2031
+ 'python': 'Python', 'javascript': 'JavaScript', 'typescript': 'TypeScript',
2032
+ 'java': 'Java', 'go': 'Go', 'rust': 'Rust', 'cpp': 'C++',
2033
+ 'c': 'C', 'csharp': 'C#', 'ruby': 'Ruby', 'php': 'PHP',
2034
+ }.get(language, language)
2035
+
2036
+ # 加载与当前编程语言匹配的案例
2037
+ cases = self.case_loader.get_cases_for_language(language)
2038
+ cases_text = self.case_loader.format_cases_for_prompt(
2039
+ cases,
2040
+ case_format=getattr(self.config, 'case_format', 'default')
2041
+ )
2042
+
2043
+ # 加载模板并渲染
2044
+ template = self.prompt_loader.load_full_file_template()
2045
+ prompt = template.replace("{{filename}}", filename)
2046
+ prompt = prompt.replace("{{language}}", language)
2047
+ prompt = pr