오늘은 PR-Agent 오픈소스 프로젝트의 algo
폴더를 분석하고 이를 리팩토링하여 기여할 수 있는 방법에 대해 알아보겠습니다.
PR-Agent의 algo
폴더와 관련된 주요 이슈들은 다음과 같습니다:
이슈 #1053: Error Handling Improvements
이슈 #1589: Integration broken for non-Tier 3 OpenAI accounts due to o3-mini requirement
이슈 #1461: Add Support for Mistral API
이슈 #1422: Using authenticated Ollama instance
이슈 #1557: Need help setting headers to azure open ai
이러한 이슈들은 algo
폴더의 AI 핸들러, 토큰 처리, 오류 처리 등의 개선이 필요함을 보여줍니다. 특히 오류 처리 체계화(#1053), 모델 호환성 개선(#1589, #1461), 그리고 인증 메커니즘 강화(#1422, #1557)가 가장 중점적으로 다뤄야 할 영역으로 보입니다.
algo
폴더 구조와 역할algo
폴더는 PR-Agent의 핵심 알고리즘을 담고 있는 중요한 디렉토리입니다. 코드베이스를 분석한 결과, 이 폴더는 다음과 같은 주요 파일들로 구성되어 있습니다:
ai_handlers/ - AI 모델과의 상호작용을 처리하는 핸들러들
base_ai_handler.py
- 기본 추상 클래스langchain_ai_handler.py
- Langchain 기반 핸들러litellm_ai_handler.py
- LiteLLM 기반 핸들러openai_ai_handler.py
- OpenAI 직접 연동 핸들러git_patch_processing.py - Git 패치 처리 알고리즘
pr_processing.py - PR 처리 로직
token_handler.py - 토큰 관리 및 계산
file_filter.py - 파일 필터링 로직
language_handler.py - 언어 처리
utils.py - 각종 유틸리티 함수
types.py - 타입 정의
cli_args.py - CLI 인자 처리
리팩토링을 위해 중점적으로 살펴볼 파일은 git_patch_processing.py
, pr_processing.py
, token_handler.py
, 그리고 ai_handlers
디렉토리의 파일들입니다.
코드베이스와 오픈 이슈를 분석한 결과, 다음과 같은 개선 포인트를 발견했습니다:
현재 문제점:
개선 방안:
# 기존 코드 (pr_processing.py)
try:
# 일부 로직
except Exception as e:
get_logger().warning(f"Failed to extend patch: {e}",
artifact={"traceback": traceback.format_exc()})
return patch_str # 조용히 원본 패치 반환
# 개선된 코드 예시
class ErrorLevel(Enum):
IGNORE = 0
WARNING = 1
FATAL = 2
def handle_error(e: Exception, message: str, level: ErrorLevel, context=None):
context = context or {}
if level == ErrorLevel.IGNORE:
get_logger().debug(f"{message}: {e}", artifact={"context": context})
elif level == ErrorLevel.WARNING:
get_logger().warning(f"{message}: {e}",
artifact={"context": context, "traceback": traceback.format_exc()})
elif level == ErrorLevel.FATAL:
get_logger().error(f"{message}: {e}",
artifact={"context": context, "traceback": traceback.format_exc()})
raise PRAgentException(f"{message}: {e}") from e
try:
# 일부 로직
except Exception as e:
handle_error(e, "Failed to extend patch", ErrorLevel.WARNING, {"filename": filename})
return patch_str
이러한 방식으로 오류 처리 로직을 일관되게 적용하면, 서비스 운영 시 문제 발생 지점을 명확히 식별할 수 있고, 실패 원인을 쉽게 추적할 수 있습니다.
현재 문제점:
개선 방안:
# 기존 코드의 일부 (token_handler.py)
def _get_all_models(model_type: ModelType = ModelType.REGULAR) -> List[str]:
if model_type == ModelType.WEAK:
model = get_model('model_weak')
elif model_type == ModelType.REASONING:
model = get_model('model_reasoning')
elif model_type == ModelType.REGULAR:
model = get_settings().config.model
else:
model = get_settings().config.model
fallback_models = get_settings().config.fallback_models
if not isinstance(fallback_models, list):
fallback_models = [m.strip() for m in fallback_models.split(",")]
all_models = [model] + fallback_models
return all_models
# 개선된 코드 예시
def is_model_available(model_name: str) -> bool:
"""주어진 모델이 현재 환경에서 사용 가능한지 확인합니다."""
# OpenAI 모델인 경우
if 'gpt' in model_name.lower() or 'o' in model_name.lower():
# OpenAI API를 사용하여 모델 가용성 확인
try:
# API 요청을 통해 모델 확인
return True
except Exception:
return False
# 다른 모델 제공자에 대한 검사
return True # 기본값
def get_available_models(model_type: ModelType = ModelType.REGULAR) -> List[str]:
"""사용 가능한 모델 목록을 반환합니다. 주 모델이 사용 불가능한 경우 자동으로 대체 모델로 전환합니다."""
primary_model = get_primary_model(model_type)
fallback_models = get_fallback_models()
all_models = [primary_model] + fallback_models
available_models = [model for model in all_models if is_model_available(model)]
if not available_models:
get_logger().warning(f"No available models found for {model_type}. Using default safe model.")
# 항상 사용 가능한 기본 모델 반환
return ["gpt-3.5-turbo"]
return available_models
이렇게 모델 가용성을 확인하고 실제로 사용 가능한 모델만 반환하도록 개선하면, 사용자 환경에 따른 오류를 줄일 수 있습니다.
현재 문제점:
개선 방안:
# 기존 코드 (litellm_ai_handler.py의 일부)
def __init__(self):
self._api_base = get_settings().get("ollama.api_base", None)
# ...
# 개선된 코드 예시
def __init__(self):
self._api_base = get_settings().get("ollama.api_base", None)
self._headers = self._get_auth_headers()
# ...
def _get_auth_headers(self) -> Dict[str, str]:
"""서비스별 인증 헤더를 생성합니다."""
headers = {}
# Ollama 인증 헤더
ollama_api_key = get_settings().get("ollama.api_key", None)
if ollama_api_key:
headers["X-API-Key"] = ollama_api_key
# Azure OpenAI 커스텀 헤더
if get_settings().get("openai.api_type") == "azure":
azure_headers_str = get_settings().get("openai.default_headers", "{}")
try:
azure_headers = json.loads(azure_headers_str)
headers.update(azure_headers)
except json.JSONDecodeError as e:
get_logger().warning(f"Failed to parse Azure headers: {e}")
return headers
async def chat_completion(self, model: str, system: str, user: str, temperature: float = 0.2, img_path: str = None):
# 헤더 적용
extra_params = {"headers": self._headers}
# ...
이러한 접근 방식으로 다양한 인증 메커니즘을 지원할 수 있습니다. 특히 Ollama나 Azure OpenAI와 같은 다양한 서비스에 대한 인증 헤더를 유연하게 처리할 수 있습니다.
현재 문제점:
pr_processing.py
와 git_patch_processing.py
가 각각 26K, 22K 라인으로 너무 큼개선 방안:
먼저 큰 파일을 기능별로 분리합니다:
git_patch_processing.py
를 다음과 같이 분리:
patch_extension.py
- 패치 확장 로직patch_hunk_handler.py
- 헝크 처리 로직patch_line_numbers.py
- 라인 번호 처리patch_utils.py
- 패치 관련 유틸리티pr_processing.py
를 다음과 같이 분리:
pr_diff_generator.py
- PR 차이점 생성 로직pr_compression.py
- PR 압축 전략pr_model_handler.py
- 모델 관련 로직pr_metadata.py
- 메타데이터 처리복잡한 함수를 더 작은 단위로 분리하는 예시:
# 기존 코드 (pr_processing.py)
def generate_full_patch(convert_hunks_to_line_numbers, file_dict, max_tokens_model, remaining_files_list_prev, token_handler):
total_tokens = token_handler.prompt_tokens # initial tokens
patches = []
remaining_files_list_new = []
files_in_patch_list = []
for filename, data in file_dict.items():
if filename not in remaining_files_list_prev:
continue
patch = data['patch']
new_patch_tokens = data['tokens']
edit_type = data['edit_type']
# Hard Stop, no more tokens
if total_tokens > max_tokens_model - OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD:
get_logger().warning(f"File was fully skipped, no more tokens: {filename}.")
continue
# 많은 다른 조건과 로직...
return total_tokens, patches, remaining_files_list_new, files_in_patch_list
# 개선된 코드 예시
def should_skip_file(filename, remaining_files_list):
"""파일을 건너뛰어야 하는지 확인합니다."""
return filename not in remaining_files_list
def is_token_limit_exceeded(total_tokens, max_tokens_model):
"""토큰 제한이 초과되었는지 확인합니다."""
return total_tokens > max_tokens_model - OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD
def is_patch_too_large(total_tokens, patch_tokens, max_tokens_model):
"""패치가 너무 큰지 확인합니다."""
return total_tokens + patch_tokens > max_tokens_model - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD
def format_patch(filename, patch, convert_hunks_to_line_numbers):
"""패치를 포맷팅합니다."""
if not convert_hunks_to_line_numbers:
return f"\n\n## File: '{filename.strip()}'\n\n{patch.strip()}\n"
else:
return "\n\n" + patch.strip()
def generate_full_patch(convert_hunks_to_line_numbers, file_dict, max_tokens_model, remaining_files_list_prev, token_handler):
total_tokens = token_handler.prompt_tokens
patches = []
remaining_files_list_new = []
files_in_patch_list = []
for filename, data in file_dict.items():
if should_skip_file(filename, remaining_files_list_prev):
continue
patch = data['patch']
new_patch_tokens = data['tokens']
if is_token_limit_exceeded(total_tokens, max_tokens_model):
get_logger().warning(f"File was fully skipped, no more tokens: {filename}.")
continue
if is_patch_too_large(total_tokens, new_patch_tokens, max_tokens_model):
remaining_files_list_new.append(filename)
continue
if patch:
patch_final = format_patch(filename, patch, convert_hunks_to_line_numbers)
patches.append(patch_final)
total_tokens += token_handler.count_tokens(patch_final)
files_in_patch_list.append(filename)
return total_tokens, patches, remaining_files_list_new, files_in_patch_list
이러한 리팩토링으로 각 함수의 책임이 명확해지고, 코드의 가독성과 유지보수성이 크게 향상됩니다.
현재 문제점:
개선 방안:
# 기존 코드 (token_handler.py)
def count_tokens(self, patch: str, force_accurate=False) -> int:
encoder_estimate = len(self.encoder.encode(patch, disallowed_special=()))
if not force_accurate:
return encoder_estimate
model = get_settings().config.model.lower()
if 'claude' in model and get_settings(use_context=False).get('anthropic.key'):
return self.calc_claude_tokens(patch)
return self.estimate_token_count_for_non_anth_claude_models(model, encoder_estimate)
# 개선된 코드 예시
@functools.lru_cache(maxsize=128)
def _count_tokens_cached(self, text: str) -> int:
"""토큰 수를 계산하고 결과를 캐시합니다."""
return len(self.encoder.encode(text, disallowed_special=()))
def count_tokens(self, patch: str, force_accurate=False) -> int:
"""패치의 토큰 수를 계산합니다."""
# 매우 큰 텍스트는 청크로 나눠 계산
if len(patch) > 50000: # 매우 큰 패치
return self._count_tokens_for_large_text(patch, force_accurate)
# 일반적인 크기의 텍스트
encoder_estimate = self._count_tokens_cached(patch)
if not force_accurate:
return encoder_estimate
# 모델별 정확한 토큰 계산
return self._get_accurate_token_count_by_model(patch, encoder_estimate)
def _count_tokens_for_large_text(self, text: str, force_accurate=False) -> int:
"""대용량 텍스트의 토큰 수를 청크 단위로 계산합니다."""
# 텍스트를 청크로 나누기
chunk_size = 10000 # 문자 기준
chunks = [text[i:i+chunk_size] for i in range(0, len(text), chunk_size)]
# 각 청크의 토큰 수 계산
total_tokens = 0
for chunk in chunks:
total_tokens += self._count_tokens_cached(chunk)
# 필요시 모델별 정확한 계산 적용
if force_accurate:
adjustment_factor = self._get_model_adjustment_factor()
total_tokens = int(total_tokens * adjustment_factor)
return total_tokens
def _get_model_adjustment_factor(self) -> float:
"""모델별 토큰 조정 계수를 반환합니다."""
model = get_settings().config.model.lower()
# 모델별 조정 계수
adjustment_factors = {
'gpt-4': 1.0,
'gpt-3.5-turbo': 1.0,
# 다른 모델들...
}
# 기본 조정 계수
default_factor = 1 + get_settings().get('config.model_token_count_estimate_factor', 0)
return adjustment_factors.get(model, default_factor)
이러한 개선으로 토큰 계산의 효율성과 정확성을 높일 수 있습니다. 특히 큰 파일을 처리할 때 성능 향상이 기대됩니다.
현재 문제점:
개선 방안:
# 기존 코드 (git_patch_processing.py)
def decode_if_bytes(original_file_str):
if isinstance(original_file_str, (bytes, bytearray)):
try:
return original_file_str.decode('utf-8')
except UnicodeDecodeError:
encodings_to_try = ['iso-8859-1', 'latin-1', 'ascii', 'utf-16']
for encoding in encodings_to_try:
try:
return original_file_str.decode(encoding)
except UnicodeDecodeError:
continue
return ""
return original_file_str
# 개선된 코드 예시
class EncodingHelper:
"""파일 인코딩을 처리하는 헬퍼 클래스"""
# 자주 사용되는 인코딩 목록
COMMON_ENCODINGS = ['utf-8', 'iso-8859-1', 'latin-1', 'utf-16', 'cp1252', 'ascii']
@staticmethod
def decode_bytes(content: Union[bytes, bytearray, str],
filename: Optional[str] = None) -> Tuple[str, Optional[str]]:
"""바이트 데이터를 문자열로, 사용된 인코딩을 반환합니다."""
if not isinstance(content, (bytes, bytearray)):
return content, None
# 파일 확장자에 따른 우선 인코딩
preferred_encoding = EncodingHelper._get_preferred_encoding(filename)
encodings_to_try = [preferred_encoding] + [e for e in EncodingHelper.COMMON_ENCODINGS
if e != preferred_encoding]
# 인코딩 시도
for encoding in encodings_to_try:
try:
decoded = content.decode(encoding)
return decoded, encoding
except (UnicodeDecodeError, LookupError):
continue
# 마지막 수단으로 오류 무시 모드 시도
try:
decoded = content.decode('utf-8', errors='ignore')
get_logger().warning(f"Decoded with utf-8 ignoring errors for {filename}")
return decoded, 'utf-8-ignore'
except Exception as e:
get_logger().error(f"Failed to decode content for {filename}: {e}")
return "", None
@staticmethod
def _get_preferred_encoding(filename: Optional[str]) -> str:
"""파일 유형에 따른 선호 인코딩을 반환합니다."""
if not filename:
return 'utf-8'
# 확장자별 일반적인 인코딩
if filename.endswith(('.js', '.py', '.java', '.kt', '.ts', '.html', '.css', '.json')):
return 'utf-8'
if filename.endswith(('.bat', '.cmd')):
return 'cp1252'
return 'utf-8' # 기본 인코딩
이 개선된 EncodingHelper
클래스는 인코딩 처리를 중앙화하고, 파일 유형에 따른 최적의 인코딩을 선택합니다. 또한 인코딩 결과를 더 자세히 로깅하여 문제 해결에 도움을 줍니다.
위의 개선 사항을 기반으로 다음과 같은 단계로 리팩토링을 진행할 수 있습니다:
1단계: 오류 처리 체계 구축
2단계: 모듈 분리
3단계: 모델 호환성 개선
4단계: 인증 메커니즘 강화
5단계: 성능 최적화
PR-Agent의 algo
폴더는 프로젝트의 핵심 알고리즘을 담고 있으며, 여러 개선 포인트가 있습니다. 오류 처리 체계 구축, 코드 구조 개선, 모델 호환성 강화, 인증 메커니즘 개선, 성능 최적화 등의 작업을 통해 코드의 품질과 유지보수성을 크게 향상시킬 수 있습니다.