From 47b284cdffc8ad69a33f80c3d36579c9e9630d9c Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Tue, 9 Sep 2025 13:50:59 +0200 Subject: [PATCH] poc: initial implementation of Claude code review --- rhodecode/apps/ai_agents/models/base.py | 128 +++++++++++++++++++--- rhodecode/apps/ai_agents/models/claude.py | 86 ++++++++++----- rhodecode/apps/ai_agents/models/gpt.py | 102 +---------------- 3 files changed, 176 insertions(+), 140 deletions(-) diff --git a/rhodecode/apps/ai_agents/models/base.py b/rhodecode/apps/ai_agents/models/base.py index 6733748c..b40f0831 100644 --- a/rhodecode/apps/ai_agents/models/base.py +++ b/rhodecode/apps/ai_agents/models/base.py @@ -1,10 +1,14 @@ from abc import abstractmethod from dataclasses import dataclass from functools import wraps -from typing import Optional, Iterable +from typing import Optional, Iterable, Any from rhodecode.apps.ai_agents.ai_settings import AISettings from rhodecode.lib.codeblocks import DiffSet +from rhodecode.lib.vcs.nodes import FileNode + + +FILE = "FILE:" class AIServiceError(Exception): @@ -23,6 +27,30 @@ class Request: content: str +@dataclass +class PingRequest(Request): + function_name: str + role: str + + +@dataclass +class Review: + role: str + msg: str + + +@dataclass +class CodeReviewRequest(Request): + content: list[Review] + function_name: str + + +@dataclass +class CustomFunctions: + Ping: str = "ping_pong" + Code_review: str = "return_code_review" + + def wrap_ai_exceptions(f): @wraps(f) def wrapper(*args, **kwargs): @@ -84,7 +112,7 @@ class AIServiceBase: + "\n".join(f"- {point}" for point in basic_points) + ("\n\nADDITIONAL CUSTOM INSTRUCTIONS:\n" + custom_instructions if custom_instructions else "") + "\n\nIMPORTANT OUTPUT RULES:\n" - "- Use the function call to return `return_code_review`: a list of tuples " + f"- Use the function call to return `{CustomFunctions.Code_review}`: a list of tuples " " [line_number:int (1-based, exactly same index as it was in the request per file), line_text:str, suggestion:str].\n" "- Only include tuples for lines with a concrete, actionable suggestion.\n" "- Keep suggestions concise and specific.\n" @@ -104,7 +132,8 @@ class AIServiceBase: full_code = content["content"].splitlines() only_include_lines: list[int] = content["changed_lines"] width = max(2, len(str(len(full_code)))) if full_code else 2 - parts.append("FILE: %s\nTYPE: %s" % (content["file_name"], content["file_type"])) + part_template = f"{FILE} %s\nTYPE: %s" + parts.append(part_template % (content["file_name"], content["file_type"])) if full_code: for i, line in enumerate(full_code, start=1): if i in only_include_lines: @@ -115,6 +144,69 @@ class AIServiceBase: parts.append("") # blank line between files return "\n".join(parts).rstrip() + def _get_response_input(self, request: PingRequest | CodeReviewRequest) -> list[dict[str, str]]: + _input = None + if isinstance(request, CodeReviewRequest): + return [ + { + "role": review.role, + "content": review.msg, + } + for review in request.content + ] + + if isinstance(request, PingRequest): + return [ + { + "role": request.role, + "content": request.content, + } + ] + + raise ValueError("Unknown request type: %s" % type(request)) + + def _extract_changes(self, pr_diffset: DiffSet) -> list[Any]: + review_content = [] + for f in pr_diffset.files: + target_file_node: FileNode = f.get("target_filenode", None) + if not target_file_node: + self.log.warning("No target file node, skipping it") + continue + + file_name = f.get("patch", {}).get("filename", "unknown") + if not target_file_node.str_content: + self.log.debug("No content, skipping file: %s", file_name) + continue + + modified_lines_nums = [] + for hunk in f.get("hunks", []): + for line in hunk.get("lines", []): + action = line.get("modified", {}).get("action", "").strip() + if action and action != "-": + modified_lines_nums.append(line.get("modified", {}).get("lineno")) + + file_content = { + "content": target_file_node.str_content, + "changed_lines": modified_lines_nums, + "file_name": file_name, + "file_type": f.target_file_type, + } + + review_content.append(file_content) + return review_content + + def _get_system_message(self) -> str: + return ( + "You are a meticulous senior code reviewer. " + "You must ONLY return your findings by calling the provided function " + f"`{CustomFunctions.Code_review}` with the required JSON shape. " + "Each tuple = [line_number:int (1-based, GLOBAL across all files), line_text:str, suggestion:str]. " + "Only include lines that genuinely warrant a suggestion. " + "The input may mix languages; use each file's TYPE header and the line's syntax to tailor advice. " + "If a suggestion spans lines or files, attach it to the most relevant line and mention the related line numbers. " + "Treat 'Plain Text' as docs/logs/config and suggest clarity/safety where relevant." + ) + def _get_model_name(self, model_name=None): return model_name if model_name else self._get_model_name() @@ -125,12 +217,11 @@ class AIServiceBase: Claude uses the same tools method """ - # TODO: unify tools match name: - case "ping_pong": + case CustomFunctions.Ping: return { "type": "function", - "name": "ping_pong", + "name": CustomFunctions.Ping, "description": "Always return pong", "strict": True, "parameters": { @@ -140,10 +231,10 @@ class AIServiceBase: "additionalProperties": False, }, } - case "return_code_review": + case CustomFunctions.Code_review: return { "type": "function", - "name": "return_code_review", + "name": CustomFunctions.Code_review, "description": ( "Return structured code review per file. Each file uses per-file 1-based " "line numbers. Only include lines that warrant a concrete suggestion." @@ -184,11 +275,9 @@ class AIServiceBase: case _: raise ValueError("Unknown function name: %s" % name) - @abstractmethod def _get_ping_request(self) -> Request: - raise NotImplementedError + return PingRequest(content="ping", function_name=CustomFunctions.Ping, role="user") - @abstractmethod def _get_review_requests( self, pr_diffset: DiffSet, @@ -196,11 +285,22 @@ class AIServiceBase: custom_instructions: Optional[str] = None, *args, **kwargs, - ) -> Request: - raise NotImplementedError + ) -> CodeReviewRequest: + review_content = self._extract_changes(pr_diffset) + + system_msg = self._get_system_message() + + basic_points = list(basic_instructions) if basic_instructions else self.DEFAULT_BASIC_REVIEW_POINTS + + user_msg = self.get_code_review_instructions(basic_points, review_content, custom_instructions) + + return CodeReviewRequest( + content=[Review(role="system", msg=system_msg), Review(role="user", msg=user_msg)], + function_name=CustomFunctions.Code_review, + ) @abstractmethod - def _get_response(self, request: Request | list[Request]) -> Response: + def _get_response(self, request: PingRequest | CodeReviewRequest) -> Response: raise NotImplementedError def _transform(self, resp, model=None) -> Response: diff --git a/rhodecode/apps/ai_agents/models/claude.py b/rhodecode/apps/ai_agents/models/claude.py index 1dea2fd8..86e46de9 100644 --- a/rhodecode/apps/ai_agents/models/claude.py +++ b/rhodecode/apps/ai_agents/models/claude.py @@ -1,47 +1,47 @@ import logging -from dataclasses import dataclass +import math from typing import Optional, Iterable from anthropic import Anthropic -from anthropic.types import MessageParam, ToolParam +from anthropic.types import ToolParam -from rhodecode.apps.ai_agents.ai_settings import AISettings -from rhodecode.apps.ai_agents.models.base import AIServiceBase, Request, Response, AIServiceError +from rhodecode.apps.ai_agents.ai_settings import AISettings, AIModelName, ClaudeVersion +from rhodecode.apps.ai_agents.models.base import ( + AIServiceBase, + Response, + AIServiceError, + PingRequest, + CodeReviewRequest, + Review, + CustomFunctions, + FILE, +) from rhodecode.lib.codeblocks import DiffSet -@dataclass -class ClaudePingRequest(Request): - function_name: str - role: str - - class ClaudeService(AIServiceBase): def __init__(self, model_settings: AISettings): super().__init__(model_settings) self._client = Anthropic(api_key=self.model_settings.api_key) self.log = logging.getLogger(ClaudeService.__name__) - def _get_ping_request(self) -> Request: - return ClaudePingRequest(content="ping", function_name="ping_pong", role="user") + def _get_response(self, request: PingRequest | CodeReviewRequest): + # maximum number of output tokens Claude can generate before stopping (does not include input tokens) + max_tokens = self._calculate_max_tokens(request) + self.log.debug("estimated max output tokens: %s", max_tokens) - def _get_response(self, request: ClaudePingRequest): - if isinstance(request, ClaudePingRequest): - _input: MessageParam = { - "role": request.role, - "content": request.content, - } - else: - raise ValueError("Unknown request type: %s" % type(request)) + _input = self._get_response_input(request) model_full_name = self._get_current_model_version() + function = self._adapt_function(self._get_function(request.function_name)) + return self._client.messages.create( model=model_full_name, - messages=[_input], - max_tokens=1024, + messages=_input, + max_tokens=max_tokens, tools=[ - self._adapt_function(self._get_function(request.function_name)), + function, ], ) @@ -54,8 +54,8 @@ class ClaudeService(AIServiceBase): def _transform(self, resp, model=None) -> Response: for r in resp.content: - # TODO: push function name - if r.type == "tool_use" and r.name == "ping_pong": + self.log.debug("response: %s", r) + if r.type == "tool_use" and r.name in [CustomFunctions.Code_review, CustomFunctions.Ping]: return Response(model=resp.model, message=r.input) raise ValueError("Response has incorrect return type, can't parses it.") @@ -70,6 +70,33 @@ class ClaudeService(AIServiceBase): return model.id raise AIServiceError("Unknown model version: %s" % semi_key) + def _calculate_max_tokens(self, request: PingRequest | CodeReviewRequest): + default = 256 + + if isinstance(request, CodeReviewRequest): + user_msg: Review = next(filter(lambda r: FILE in r.msg, request.content), None) + if user_msg: + user_msg_code_lines = user_msg.msg.count(FILE) + return self._estimate_max_tokens(user_msg_code_lines) + + return default + + def _estimate_max_tokens( + self, + lines: int, + estimated_sentences_per_line: float = 3.0, + average_words_per_english_sentence: float = 20.0, + rough_heuristic_tokens_per_word: float = 4, + safety_buffer_percent: float = 0.15, + ) -> int: + """ + Estimate a safe `max_tokens` (output only) for code review. + """ + raw = ( + lines * estimated_sentences_per_line * average_words_per_english_sentence * rough_heuristic_tokens_per_word + ) + return math.ceil(raw * (1.0 + safety_buffer_percent)) + def _get_review_requests( self, pr_diffset: DiffSet, @@ -77,5 +104,10 @@ class ClaudeService(AIServiceBase): custom_instructions: Optional[str] = None, *args, **kwargs, - ) -> Request: - raise NotImplementedError() + ) -> CodeReviewRequest: + # Claude has only 2 roles: user, assistant + requests = super()._get_review_requests(pr_diffset, basic_instructions, custom_instructions, *args, **kwargs) + for r in requests.content: + r.role = "user" + + return requests diff --git a/rhodecode/apps/ai_agents/models/gpt.py b/rhodecode/apps/ai_agents/models/gpt.py index 03a1fbbb..ff2c2dcd 100644 --- a/rhodecode/apps/ai_agents/models/gpt.py +++ b/rhodecode/apps/ai_agents/models/gpt.py @@ -1,32 +1,10 @@ import json import logging -from dataclasses import dataclass -from typing import Optional, Iterable from openai import OpenAI from rhodecode.apps.ai_agents.ai_settings import AISettings -from rhodecode.apps.ai_agents.models.base import AIServiceBase, Request -from rhodecode.lib.codeblocks import DiffSet -from rhodecode.lib.vcs.nodes import FileNode - - -@dataclass -class GPTPingRequest(Request): - function_name: str - role: str - - -@dataclass -class GPTReview: - role: str - msg: str - - -@dataclass -class GPTCodeReviewRequest(Request): - content: list[GPTReview] - function_name: str +from rhodecode.apps.ai_agents.models.base import AIServiceBase, PingRequest, CodeReviewRequest class GPTService(AIServiceBase): @@ -35,79 +13,8 @@ class GPTService(AIServiceBase): self._client = OpenAI(api_key=self.model_settings.api_key) self.log = logging.getLogger(GPTService.__name__) - def _get_review_requests( - self, - pr_diffset: DiffSet, - basic_instructions: Optional[Iterable[str]] = None, - custom_instructions: Optional[str] = None, - *args, - **kwargs, - ) -> Request: - review_content = [] - for f in pr_diffset.files: - target_file_node: FileNode = f.get("target_filenode", None) - if not target_file_node: - self.log.warning("No target file node, skipping it") - continue - - file_name = f.get("patch", {}).get("filename", "unknown") - if not target_file_node.str_content: - self.log.debug("No content, skipping file: %s", file_name) - continue - - modified_lines_nums = [] - for hunk in f.get("hunks", []): - for line in hunk.get("lines", []): - if line.get("modified") and line.get("modified", {}).get("action", "").strip(): - modified_lines_nums.append(line.get("modified", {}).get("lineno")) - - file_content = { - "content": target_file_node.str_content, - "changed_lines": modified_lines_nums, - "file_name": file_name, - "file_type": f.target_file_type, - } - - review_content.append(file_content) - - system_msg = ( - "You are a meticulous senior code reviewer. " - "You must ONLY return your findings by calling the provided function " - "`return_code_review` with the required JSON shape. " - "Each tuple = [line_number:int (1-based, GLOBAL across all files), line_text:str, suggestion:str]. " - "Only include lines that genuinely warrant a suggestion. " - "The input may mix languages; use each file's TYPE header and the line's syntax to tailor advice. " - "If a suggestion spans lines or files, attach it to the most relevant line and mention the related line numbers. " - "Treat 'Plain Text' as docs/logs/config and suggest clarity/safety where relevant." - ) - - basic_points = list(basic_instructions) if basic_instructions else self.DEFAULT_BASIC_REVIEW_POINTS - - user_msg = self.get_code_review_instructions(basic_points, review_content, custom_instructions) - - return GPTCodeReviewRequest( - content=[GPTReview(role="system", msg=system_msg), GPTReview(role="user", msg=user_msg)], - function_name="return_code_review", - ) - - def _get_response(self, request: GPTPingRequest | GPTCodeReviewRequest): - if isinstance(request, GPTCodeReviewRequest): - _input = [ - { - "role": review.role, - "content": review.msg, - } - for review in request.content - ] - elif isinstance(request, GPTPingRequest): - _input = [ - { - "role": request.role, - "content": request.content, - } - ] - else: - raise ValueError("Unknown request type: %s" % type(request)) + def _get_response(self, request: PingRequest | CodeReviewRequest): + _input = self._get_response_input(request) return self._client.responses.create( model=self._get_model_name(), @@ -119,9 +26,6 @@ class GPTService(AIServiceBase): parallel_tool_calls=False, ) - def _get_ping_request(self) -> Request: - return GPTPingRequest(content="ping", function_name="ping_pong", role="user") - def _transform(self, resp): for item in resp.output: if item.type == "function_call" and item.name in ["ping_pong", "return_code_review"]: