poc: initial implementation of Claude code review
This commit is contained in:
parent
1f9ff01e39
commit
47b284cdff
3 changed files with 176 additions and 140 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue