reviewer
LLM-ревью кода.
Берёт diff PR, отправляет его в LLM (OpenAI-compatible API) и разбирает JSON-ответ в структурированный список замечаний с уровнями критичности. Модель и параметры вызова берутся из конфигурации (env).
1""" 2LLM-ревью кода. 3 4Берёт diff PR, отправляет его в LLM (OpenAI-compatible API) и разбирает 5JSON-ответ в структурированный список замечаний с уровнями критичности. 6Модель и параметры вызова берутся из конфигурации (env). 7""" 8 9from __future__ import annotations 10 11import json 12import os 13from dataclasses import dataclass, field 14 15import httpx 16from loguru import logger 17 18from config import Config 19from langfuse_client import get_langfuse 20 21try: 22 from langfuse import observe 23except ImportError: 24 25 def observe(**kwargs): 26 def decorator(fn): 27 return fn 28 29 return decorator 30 31 32CRITICAL = "critical" 33WARNING = "warning" 34 35 36@dataclass 37class Finding: 38 """Одно замечание ревью.""" 39 40 severity: str # "critical" | "warning" 41 path: str 42 line: int 43 comment: str 44 45 46@dataclass 47class ReviewResult: 48 """Разобранный ответ LLM: итог и список замечаний.""" 49 50 summary: str 51 findings: list[Finding] = field(default_factory=list) 52 53 @property 54 def has_critical(self) -> bool: 55 """Есть ли критические замечания.""" 56 return any(f.severity == CRITICAL for f in self.findings) 57 58 @property 59 def has_any(self) -> bool: 60 """Есть ли хоть какие-то замечания.""" 61 return bool(self.findings) 62 63 64PROMPT_NAME = "code-review" 65DATA_DIR = os.getenv("DATA_DIR", "./data") 66PROMPT_CACHE_FILE = os.path.join(DATA_DIR, "prompt.txt") 67 68PROMPT_TEMPLATE = """\ 69You are an experienced code reviewer. Review the pull request below and find real problems. 70 71PR title: {{title}} 72PR description: {{description}} 73 74Changed files: 75{{file_list}} 76 77Diff: 78{{diff}} 79 80Find real bugs, security issues, logic errors, crashes, and data loss risks. 81Do NOT invent problems: if the code is correct, return no findings. 82Shell scripts (.sh files, files with a sh/bash/zsh shebang, and shell snippets): 83verify that the ENTIRE resulting file would be syntactically valid — as if running 84a syntax-only check (`bash -n <file>` for bash, equivalent for sh/zsh). Do not limit 85this check to added lines: an edit can break syntax elsewhere in the script (unclosed 86quotes/brackets, wrong fi/done/esac pairing, heredoc issues). Report any shell 87syntax error as "critical". 88Distinguish severity: 89- "critical" — a real bug, security issue, crash, or data loss that should block the merge. 90- "warning" — minor issues: style, naming, duplication, missing edge cases. 91 92Return ONLY a JSON object, no markdown, no extra text, with this schema: 93{ 94 "summary": "short overall assessment in Russian, 2-4 sentences", 95 "findings": [ 96 { 97 "severity": "critical" or "warning", 98 "path": "<exact changed file path>", 99 "line": <line number in the NEW file: count from "+N" in the nearest hunk header "@@ ... +N,M @@"; the line must be an ADDED line ("+") of the diff; use 0 if you are not sure or the line is not an added line; never report lines beyond the "... (diff truncated ...)" marker>, 100 "comment": "specific actionable comment in Russian" 101 } 102 ] 103} 104""" 105 106_prompt_template: str | None = None 107 108 109def _cache_prompt(text: str) -> None: 110 """Сохраняет промпт в файл кэша (фолбэк при недоступности Langfuse).""" 111 try: 112 os.makedirs(DATA_DIR, exist_ok=True) 113 with open(PROMPT_CACHE_FILE, "w", encoding="utf-8") as f: 114 f.write(text) 115 except OSError as e: 116 logger.warning(f"Langfuse: не удалось записать кэш промпта: {e}") 117 118 119def _read_cached_prompt() -> str | None: 120 """Возвращает промпт из файла кэша или None.""" 121 try: 122 with open(PROMPT_CACHE_FILE, encoding="utf-8") as f: 123 return f.read() 124 except OSError: 125 return None 126 127 128def cache_prompt() -> None: 129 """ 130 Кэширует промпт из Langfuse в data/prompt.txt. 131 132 Langfuse — источник правды. Вызывается при старте приложения: получает 133 текущий промпт и сохраняет его текст, чтобы при недоступности Langfuse 134 продолжать работать с последней версией. 135 """ 136 try: 137 client = get_langfuse().get_prompt(PROMPT_NAME) 138 text = getattr(client, "prompt", None) 139 if text: 140 _cache_prompt(text) 141 logger.info( 142 f"Langfuse: промпт '{PROMPT_NAME}' закэширован " 143 f"(version={getattr(client, 'version', '?')})" 144 ) 145 except Exception as e: 146 logger.warning(f"Langfuse: не удалось получить промпт '{PROMPT_NAME}': {e}") 147 148 149def _resolve_prompt_template() -> str: 150 """ 151 Возвращает шаблон промпта: Langfuse → локальный кэш → PROMPT_TEMPLATE. 152 153 Сначала пробует Langfuse (источник правды); при успехе обновляет кэш. 154 Если Langfuse недоступен — берёт текст из data/prompt.txt, а если и его 155 нет — из PROMPT_TEMPLATE (bootstrap). Результат мемоизируется. 156 """ 157 global _prompt_template 158 if _prompt_template is not None: 159 return _prompt_template 160 template: str | None = None 161 try: 162 client = get_langfuse().get_prompt(PROMPT_NAME) 163 text = getattr(client, "prompt", None) 164 if text: 165 template = text 166 _cache_prompt(text) 167 except Exception as e: 168 logger.warning(f"Langfuse: не удалось получить промпт '{PROMPT_NAME}': {e}") 169 if template is None: 170 template = _read_cached_prompt() 171 if template is None: 172 template = PROMPT_TEMPLATE 173 _prompt_template = template 174 return _prompt_template 175 176 177def _render_template( 178 template: str, title: str, description: str, file_list: str, diff: str 179) -> str: 180 """Подставляет параметры ревью в шаблон промпта.""" 181 return ( 182 template.replace("{{title}}", title) 183 .replace("{{description}}", description) 184 .replace("{{file_list}}", file_list) 185 .replace("{{diff}}", diff) 186 ) 187 188 189def build_prompt(diff: str, title: str, description: str) -> str: 190 """Собирает текст промпта для LLM (из Langfuse или кэша/шаблона).""" 191 file_list = "\n".join( 192 line[4:] for line in diff.splitlines() if line.startswith("+++ b/") 193 ) 194 if not file_list: 195 file_list = "(empty)" 196 return _render_template( 197 _resolve_prompt_template(), 198 title=title, 199 description=(description or "").strip() or "(no description)", 200 file_list=file_list, 201 diff=diff, 202 ) 203 204 205def _record_usage(config: Config, prompt: str, usage: dict) -> None: 206 """ 207 Пишет токены usage в текущую generation-обсервацию Langfuse. 208 209 @observe(as_type="generation") создаёт observation, но токены туда не 210 попадают автоматически — их надо передать через update_current_generation. 211 """ 212 try: 213 get_langfuse().update_current_generation( 214 input=prompt, 215 model=config.llm_model, 216 model_parameters={ 217 "temperature": config.llm_temperature, 218 "max_tokens": config.llm_max_tokens, 219 }, 220 usage_details={ 221 "prompt_tokens": usage.get("prompt_tokens") or 0, 222 "completion_tokens": usage.get("completion_tokens") or 0, 223 "total_tokens": usage.get("total_tokens") or 0, 224 }, 225 ) 226 except Exception as e: 227 logger.warning(f"Langfuse: не удалось записать usage в generation: {e}") 228 229 230@observe(name="LLM call", as_type="generation", capture_input=False) 231def call_llm(config: Config, prompt: str) -> str: 232 """ 233 Вызывает LLM и возвращает сырой текст ответа. 234 235 gpt-oss (reasoning-модель) может положить вывод в reasoning вместо 236 content, поэтому при пустом content читаем reasoning-поле. 237 """ 238 payload = { 239 "model": config.llm_model, 240 "messages": [{"role": "user", "content": prompt}], 241 "temperature": config.llm_temperature, 242 "max_tokens": config.llm_max_tokens, 243 } 244 headers = {"Authorization": f"Bearer {config.llm_api_key}"} 245 url = config.llm_base_url.rstrip("/") + "/chat/completions" 246 247 logger.info(f"LLM: запрос {config.llm_model} → {url} (prompt={len(prompt)} симв.)") 248 for attempt in range(3): 249 try: 250 with httpx.Client(timeout=httpx.Timeout(300.0)) as client: 251 resp = client.post(url, json=payload, headers=headers) 252 resp.raise_for_status() 253 data = resp.json() 254 usage = data.get("usage") or {} 255 logger.info( 256 f"LLM: ответ {config.llm_model}: " 257 f"prompt={usage.get('prompt_tokens', '?')} tok, " 258 f"completion={usage.get('completion_tokens', '?')} tok" 259 ) 260 _record_usage(config, prompt, usage) 261 message = data["choices"][0]["message"] 262 content = (message.get("content") or "").strip() 263 if content: 264 return content 265 for key in ("reasoning_content", "reasoning"): 266 reasoning = (message.get(key) or "").strip() 267 if reasoning: 268 return reasoning 269 raise ValueError("LLM вернул пустой content и reasoning") 270 except Exception as e: 271 logger.error(f"LLM: ошибка вызова (попытка {attempt + 1}/3): {e}") 272 273 raise RuntimeError("LLM не ответил после 3 попыток") 274 275 276def _extract_json(text: str) -> dict: 277 """Достаёт JSON-объект из текста (с код-фенсами или мусором вокруг).""" 278 text = text.strip() 279 if text.startswith("```"): 280 text = text.strip("`") 281 if text.startswith("json"): 282 text = text[4:] 283 start = text.find("{") 284 end = text.rfind("}") 285 if start == -1 or end == -1 or end <= start: 286 raise ValueError(f"В ответе LLM нет JSON-объекта: {text[:200]!r}") 287 return json.loads(text[start : end + 1]) 288 289 290def parse_response(text: str) -> ReviewResult: 291 """ 292 Разбирает ответ LLM в ReviewResult. 293 294 Невалидные замечания (битый severity/path/line) отбрасываются, 295 чтобы мусор от LLM не ломал постинг ревью. 296 """ 297 data = _extract_json(text) 298 summary = str(data.get("summary", "")).strip() or "Ревью завершено." 299 findings: list[Finding] = [] 300 301 for item in data.get("findings", []): 302 if not isinstance(item, dict): 303 continue 304 severity = str(item.get("severity", "")).strip().lower() 305 if severity not in (CRITICAL, WARNING): 306 severity = WARNING 307 path = str(item.get("path", "")).strip() 308 comment = str(item.get("comment", "")).strip() 309 if not path or not comment: 310 continue 311 try: 312 line = int(item.get("line", 0) or 0) 313 except (TypeError, ValueError): 314 line = 0 315 findings.append( 316 Finding(severity=severity, path=path, line=line, comment=comment) 317 ) 318 319 return ReviewResult(summary=summary, findings=findings) 320 321 322def truncate_diff(diff: str, max_chars: int) -> str: 323 """Обрезает diff до max_chars, оставляя маркер обрезания.""" 324 if len(diff) <= max_chars: 325 return diff 326 head = diff[: max_chars - 40] 327 return head + "\n... (diff truncated: too large for review) ...\n"
37@dataclass 38class Finding: 39 """Одно замечание ревью.""" 40 41 severity: str # "critical" | "warning" 42 path: str 43 line: int 44 comment: str
Одно замечание ревью.
47@dataclass 48class ReviewResult: 49 """Разобранный ответ LLM: итог и список замечаний.""" 50 51 summary: str 52 findings: list[Finding] = field(default_factory=list) 53 54 @property 55 def has_critical(self) -> bool: 56 """Есть ли критические замечания.""" 57 return any(f.severity == CRITICAL for f in self.findings) 58 59 @property 60 def has_any(self) -> bool: 61 """Есть ли хоть какие-то замечания.""" 62 return bool(self.findings)
Разобранный ответ LLM: итог и список замечаний.
129def cache_prompt() -> None: 130 """ 131 Кэширует промпт из Langfuse в data/prompt.txt. 132 133 Langfuse — источник правды. Вызывается при старте приложения: получает 134 текущий промпт и сохраняет его текст, чтобы при недоступности Langfuse 135 продолжать работать с последней версией. 136 """ 137 try: 138 client = get_langfuse().get_prompt(PROMPT_NAME) 139 text = getattr(client, "prompt", None) 140 if text: 141 _cache_prompt(text) 142 logger.info( 143 f"Langfuse: промпт '{PROMPT_NAME}' закэширован " 144 f"(version={getattr(client, 'version', '?')})" 145 ) 146 except Exception as e: 147 logger.warning(f"Langfuse: не удалось получить промпт '{PROMPT_NAME}': {e}")
Кэширует промпт из Langfuse в data/prompt.txt.
Langfuse — источник правды. Вызывается при старте приложения: получает текущий промпт и сохраняет его текст, чтобы при недоступности Langfuse продолжать работать с последней версией.
190def build_prompt(diff: str, title: str, description: str) -> str: 191 """Собирает текст промпта для LLM (из Langfuse или кэша/шаблона).""" 192 file_list = "\n".join( 193 line[4:] for line in diff.splitlines() if line.startswith("+++ b/") 194 ) 195 if not file_list: 196 file_list = "(empty)" 197 return _render_template( 198 _resolve_prompt_template(), 199 title=title, 200 description=(description or "").strip() or "(no description)", 201 file_list=file_list, 202 diff=diff, 203 )
Собирает текст промпта для LLM (из Langfuse или кэша/шаблона).
231@observe(name="LLM call", as_type="generation", capture_input=False) 232def call_llm(config: Config, prompt: str) -> str: 233 """ 234 Вызывает LLM и возвращает сырой текст ответа. 235 236 gpt-oss (reasoning-модель) может положить вывод в reasoning вместо 237 content, поэтому при пустом content читаем reasoning-поле. 238 """ 239 payload = { 240 "model": config.llm_model, 241 "messages": [{"role": "user", "content": prompt}], 242 "temperature": config.llm_temperature, 243 "max_tokens": config.llm_max_tokens, 244 } 245 headers = {"Authorization": f"Bearer {config.llm_api_key}"} 246 url = config.llm_base_url.rstrip("/") + "/chat/completions" 247 248 logger.info(f"LLM: запрос {config.llm_model} → {url} (prompt={len(prompt)} симв.)") 249 for attempt in range(3): 250 try: 251 with httpx.Client(timeout=httpx.Timeout(300.0)) as client: 252 resp = client.post(url, json=payload, headers=headers) 253 resp.raise_for_status() 254 data = resp.json() 255 usage = data.get("usage") or {} 256 logger.info( 257 f"LLM: ответ {config.llm_model}: " 258 f"prompt={usage.get('prompt_tokens', '?')} tok, " 259 f"completion={usage.get('completion_tokens', '?')} tok" 260 ) 261 _record_usage(config, prompt, usage) 262 message = data["choices"][0]["message"] 263 content = (message.get("content") or "").strip() 264 if content: 265 return content 266 for key in ("reasoning_content", "reasoning"): 267 reasoning = (message.get(key) or "").strip() 268 if reasoning: 269 return reasoning 270 raise ValueError("LLM вернул пустой content и reasoning") 271 except Exception as e: 272 logger.error(f"LLM: ошибка вызова (попытка {attempt + 1}/3): {e}") 273 274 raise RuntimeError("LLM не ответил после 3 попыток")
Вызывает LLM и возвращает сырой текст ответа.
gpt-oss (reasoning-модель) может положить вывод в reasoning вместо content, поэтому при пустом content читаем reasoning-поле.
291def parse_response(text: str) -> ReviewResult: 292 """ 293 Разбирает ответ LLM в ReviewResult. 294 295 Невалидные замечания (битый severity/path/line) отбрасываются, 296 чтобы мусор от LLM не ломал постинг ревью. 297 """ 298 data = _extract_json(text) 299 summary = str(data.get("summary", "")).strip() or "Ревью завершено." 300 findings: list[Finding] = [] 301 302 for item in data.get("findings", []): 303 if not isinstance(item, dict): 304 continue 305 severity = str(item.get("severity", "")).strip().lower() 306 if severity not in (CRITICAL, WARNING): 307 severity = WARNING 308 path = str(item.get("path", "")).strip() 309 comment = str(item.get("comment", "")).strip() 310 if not path or not comment: 311 continue 312 try: 313 line = int(item.get("line", 0) or 0) 314 except (TypeError, ValueError): 315 line = 0 316 findings.append( 317 Finding(severity=severity, path=path, line=line, comment=comment) 318 ) 319 320 return ReviewResult(summary=summary, findings=findings)
Разбирает ответ LLM в ReviewResult.
Невалидные замечания (битый severity/path/line) отбрасываются, чтобы мусор от LLM не ломал постинг ревью.
323def truncate_diff(diff: str, max_chars: int) -> str: 324 """Обрезает diff до max_chars, оставляя маркер обрезания.""" 325 if len(diff) <= max_chars: 326 return diff 327 head = diff[: max_chars - 40] 328 return head + "\n... (diff truncated: too large for review) ...\n"
Обрезает diff до max_chars, оставляя маркер обрезания.