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"
CRITICAL = 'critical'
WARNING = 'warning'
@dataclass
class Finding:
37@dataclass
38class Finding:
39    """Одно замечание ревью."""
40
41    severity: str  # "critical" | "warning"
42    path: str
43    line: int
44    comment: str

Одно замечание ревью.

Finding(severity: str, path: str, line: int, comment: str)
severity: str
path: str
line: int
comment: str
@dataclass
class ReviewResult:
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: итог и список замечаний.

ReviewResult(summary: str, findings: list[Finding] = <factory>)
summary: str
findings: list[Finding]
has_critical: bool
54    @property
55    def has_critical(self) -> bool:
56        """Есть ли критические замечания."""
57        return any(f.severity == CRITICAL for f in self.findings)

Есть ли критические замечания.

has_any: bool
59    @property
60    def has_any(self) -> bool:
61        """Есть ли хоть какие-то замечания."""
62        return bool(self.findings)

Есть ли хоть какие-то замечания.

PROMPT_NAME = 'code-review'
DATA_DIR = './data'
PROMPT_CACHE_FILE = './data/prompt.txt'
PROMPT_TEMPLATE = 'You are an experienced code reviewer. Review the pull request below and find real problems.\n\nPR title: {{title}}\nPR description: {{description}}\n\nChanged files:\n{{file_list}}\n\nDiff:\n{{diff}}\n\nFind real bugs, security issues, logic errors, crashes, and data loss risks.\nDo NOT invent problems: if the code is correct, return no findings.\nShell scripts (.sh files, files with a sh/bash/zsh shebang, and shell snippets):\nverify that the ENTIRE resulting file would be syntactically valid — as if running\na syntax-only check (`bash -n <file>` for bash, equivalent for sh/zsh). Do not limit\nthis check to added lines: an edit can break syntax elsewhere in the script (unclosed\nquotes/brackets, wrong fi/done/esac pairing, heredoc issues). Report any shell\nsyntax error as "critical".\nDistinguish severity:\n- "critical" — a real bug, security issue, crash, or data loss that should block the merge.\n- "warning" — minor issues: style, naming, duplication, missing edge cases.\n\nReturn ONLY a JSON object, no markdown, no extra text, with this schema:\n{\n "summary": "short overall assessment in Russian, 2-4 sentences",\n "findings": [\n {\n "severity": "critical" or "warning",\n "path": "<exact changed file path>",\n "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>,\n "comment": "specific actionable comment in Russian"\n }\n ]\n}\n'
def cache_prompt() -> None:
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 продолжать работать с последней версией.

def build_prompt(diff: str, title: str, description: str) -> str:
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 или кэша/шаблона).

@observe(name='LLM call', as_type='generation', capture_input=False)
def call_llm(config: config.Config, prompt: str) -> str:
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-поле.

def parse_response(text: str) -> ReviewResult:
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 не ломал постинг ревью.

def truncate_diff(diff: str, max_chars: int) -> str:
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, оставляя маркер обрезания.