poc: initial implementation of Claude code review

This commit is contained in:
ievgenii vdovenko 2025-09-09 13:50:59 +02:00
parent 1f9ff01e39
commit 47b284cdff
3 changed files with 176 additions and 140 deletions

View file

@ -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:

View file

@ -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

View file

@ -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"]: