diff --git a/app/core/logging_config.py b/app/core/logging_config.py new file mode 100644 index 0000000..93d20c0 --- /dev/null +++ b/app/core/logging_config.py @@ -0,0 +1,31 @@ +from logging.config import dictConfig + +""" +애플리케이션 전체에서의 로깅 레벨 설정 +""" +def setup_logging(): + dictConfig({ + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "format": "%(asctime)s %(levelname)s [%(name)s] %(message)s", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "default", + }, + }, + "root": { + "level": "INFO", + "handlers": ["console"], + }, + "loggers": { + "uvicorn": {"level": "INFO"}, + "uvicorn.error": {"level": "INFO"}, + "uvicorn.access": {"level": "INFO"}, + "app": {"level": "INFO", "handlers": ["console"], "propagate": False}, + }, + }) \ No newline at end of file diff --git a/app/main.py b/app/main.py index fa3dff5..3e852a0 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,9 @@ from fastapi import FastAPI, Request from app.core.lifespan import lifespan from app.api.endpoints import predictions +from app.core.logging_config import setup_logging +setup_logging() app = FastAPI( title="DearBelly CV API", description="DearBelly CV를 위한 Swagger 입니다.", diff --git a/app/models/models/best_model_0823.pt b/app/models/models/best_model_0823.pt deleted file mode 100644 index c6088d2..0000000 Binary files a/app/models/models/best_model_0823.pt and /dev/null differ diff --git a/app/models/models/best_model_0920.pt b/app/models/models/best_model_0920.pt new file mode 100644 index 0000000..68182b1 Binary files /dev/null and b/app/models/models/best_model_0920.pt differ diff --git a/app/services/openai_service.py b/app/services/openai_service.py index 7455f19..4cd3697 100644 --- a/app/services/openai_service.py +++ b/app/services/openai_service.py @@ -1,6 +1,9 @@ from app.core.config import settings from openai import OpenAI import json +import logging + +logger = logging.getLogger(__name__) client = OpenAI(api_key=settings.OPENAI_KEY) @@ -32,7 +35,7 @@ def ask_chatgpt_about_pregnancy_safety(self, pill_name: str) -> tuple[str, int]: max_tokens=600, response_format={"type": "json_object"} ) - print("GPT Asking 성공...") + logger.info("GPT Asking 성공...") raw = response.choices[0].message.content.strip() try: @@ -45,6 +48,7 @@ def ask_chatgpt_about_pregnancy_safety(self, pill_name: str) -> tuple[str, int]: else: # 디버깅 preview = raw[:200].replace("\n", "\\n") + logger.warning("JSON 파싱 실패") raise ValueError(f"응답이 유효한 JSON이 아닙니다. preview='{preview}'") description = data.get("description") diff --git a/app/services/predictor_service.py b/app/services/predictor_service.py index 175003c..d9ee6cd 100644 --- a/app/services/predictor_service.py +++ b/app/services/predictor_service.py @@ -1,76 +1,152 @@ import torch import torch.nn as nn -import torch.nn.functional as F +import timm from torchvision import transforms from PIL import Image import json from pathlib import Path from io import BytesIO +import logging -class LightCNN(nn.Module): - def __init__(self, num_classes): +logger = logging.getLogger(__name__) + +class EfficientNetBaseline(nn.Module): + def __init__(self, num_classes, pretrained=True, dropout=0.2): super().__init__() - self.conv1 = nn.Conv2d(3, 8, 3, padding=1) - self.conv2 = nn.Conv2d(8, 16, 3, padding=1) - self.pool = nn.MaxPool2d(2, 2) - self.gap = nn.AdaptiveAvgPool2d((4, 4)) - self.fc1 = nn.Linear(16 * 4 * 4, 64) - self.fc2 = nn.Linear(64, num_classes) + logger.info( + f"Initializing EfficientNetBaseline with num_classes={num_classes}, pretrained={pretrained}, dropout={dropout}") + + self.backbone = timm.create_model( + "efficientnet_b3", pretrained=pretrained, num_classes=0, global_pool="avg" + ) + feat_dim = self.backbone.num_features + + self.bn = nn.BatchNorm1d(feat_dim) + self.dp = nn.Dropout(dropout) + self.fc = nn.Linear(feat_dim, num_classes) def forward(self, x): - x = self.pool(F.relu(self.conv1(x))) - x = self.pool(F.relu(self.conv2(x))) - x = self.gap(x) - x = x.view(x.size(0), -1) - x = F.relu(self.fc1(x)) - x = self.fc2(x) - return x + feats = self.backbone(x) + feats = self.bn(feats) + feats = self.dp(feats) + logits = self.fc(feats) + return logits class PredictorService: def __init__(self, model_path: Path, json_path: Path): + + logger.info(f"Initializing PredictorService with model_path={model_path}, json_path={json_path}") + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + # cuda 모델 확인 + logger.info(f"Using device: {self.device}") + self.idx2label = self._load_idx2label(json_path) self.num_classes = len(self.idx2label) + logger.info(f"Loaded {self.num_classes} classes") + self.model = self._load_model(model_path) self.transform = transforms.Compose([ transforms.Resize((64, 64)), transforms.ToTensor(), ]) + logger.info("PredictorService initialized successfully") def _load_idx2label(self, json_path: Path) -> dict: - with open(json_path, "r", encoding="utf-8") as f: - data = json.load(f) - idx2label = data.get("idx2label") - if not idx2label: - unique_labels = sorted(set(sample["label"] for sample in data["samples"])) - idx2label = {str(label): f"K-{label:06d}" for label in unique_labels} - return idx2label - - def _load_model(self, model_path: Path) -> LightCNN: + + # json 제대로 읽었는지 확인 + logger.info(f"Loading idx2label from {json_path}") + + try: + with open(json_path, "r", encoding="utf-8") as f: + data = json.load(f) + idx2label = data.get("idx2label") + if not idx2label: + logger.warning("idx2label not found in JSON, generating from samples") + unique_labels = sorted(set(sample["label"] for sample in data["samples"])) + idx2label = {str(label): f"K-{label:06d}" for label in unique_labels} + return idx2label + + # 예외 사항 추가 + except FileNotFoundError: + logger.error(f"JSON file not found: {json_path}") + raise + except json.JSONDecodeError as e: + logger.error(f"Failed to parse JSON file: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error loading idx2label: {e}", exc_info=True) + raise + + def _load_model(self, model_path: Path) -> EfficientNetBaseline: + # model path 확인하기 + logger.info(f"Loading model from {model_path}") + import __main__ - __main__.LightCNN = LightCNN - model = torch.load(model_path, map_location=self.device, weights_only=False) - model.eval() - return model + __main__.EfficientNetBaseline = EfficientNetBaseline + + try: + object = torch.load(model_path, map_location=self.device, weights_only=False) + + if isinstance(object, nn.Module) : + # 그 자체로 모델일 때 + model = object.to(self.device) + elif isinstance(object, dict) : + # 반환 타입이 state_dict + state_dict = object + for k in ['state_dict', 'model_state_dict', 'model']: + if k in object and isinstance(object[k], dict): + state_dict = object[k] + break + + model = EfficientNetBaseline(self.num_classes).to(self.device) + + missing, unexpected = model.load_state_dict(state_dict, strict=False) + if missing or unexpected: + logger.warning(f"[load_state_dict] missing keys: {missing}, unexpected keys: {unexpected}") + else: + # type 일치하지 않음 + error_msg = f"Unsupported checkpoint type: {type(object)}" + logger.error(error_msg) + raise TypeError(f"Unsupported checkpoint type: {type(object)}") + + # 완료 + model.eval() + logger.info("Model loaded and set to evaluation mode") + return model + + except FileNotFoundError: + logger.error(f"Model file not found: {model_path}") + raise + except Exception as e: + logger.error(f"Failed to load model: {e}", exc_info=True) + raise + def predict(self, stream_file: BytesIO) -> tuple[str, str, float]: - image = Image.open(stream_file).convert('RGB') - input_tensor = self.transform(image).unsqueeze(0).to(self.device) - with torch.no_grad(): - output = self.model(input_tensor) - predicted_idx = torch.argmax(output, dim=1).item() - confidence = torch.softmax(output, dim=1)[0][predicted_idx].item() + try : + image = Image.open(stream_file).convert('RGB') + input_tensor = self.transform(image).unsqueeze(0).to(self.device) + + with torch.no_grad(): + output = self.model(input_tensor) + predicted_idx = torch.argmax(output, dim=1).item() + confidence = torch.softmax(output, dim=1)[0][predicted_idx].item() - label = str(predicted_idx) - pill_name = self.idx2label.get(label, f"Unknown Label: {label}") + label = str(predicted_idx) + pill_name = self.idx2label.get(label, f"Unknown Label: {label}") + logger.info(f"Prediction completed - pill_name: {pill_name}, label: {label}, confidence: {confidence:.4f}") - return pill_name, label, confidence + return pill_name, label, confidence + except Exception as e: + logger.error(f"Prediction failed: {e}", exc_info=True) + raise HERE = Path(__file__).resolve().parent.parent -MODEL_PATH = HERE / "models" / "models" / "best_model_0823.pt" +MODEL_PATH = HERE / "models" / "models" / "best_model_0920.pt" JSON_PATH = HERE / "models" / "models" / "matched_all.json" predictor_service = PredictorService(MODEL_PATH, JSON_PATH) diff --git a/app/worker/tasks.py b/app/worker/tasks.py index 63dfdc5..1284b8a 100644 --- a/app/worker/tasks.py +++ b/app/worker/tasks.py @@ -8,13 +8,16 @@ from app.services.openai_service import checker from app.services.predictor_service import predictor_service from app.services.s3_service import s3_service +import logging + +logger = logging.getLogger(__name__) """ 이미지를 다운 -> 다운 한 것에 대하여 모델 분석 요청 """ async def process_image_scan(job: ImageJob, redis_client: redis.Redis): correlationId = job.correlationId - print(f"[task] Start image scan for job_id={correlationId}") + logging.info(f"[task] Start image scan for job_id={correlationId}") try: stream_file = await asyncio.to_thread( @@ -28,7 +31,7 @@ async def process_image_scan(job: ImageJob, redis_client: redis.Redis): predictor_service.predict, stream_file ) - print(f"[task] Start Asking GPT for job_id={correlationId}") + logging.info(f"[task] Start Asking GPT for job_id={correlationId}") description, isSafe = checker.ask_chatgpt_about_pregnancy_safety(pillName) finishedAt = datetime.utcnow().isoformat() @@ -50,9 +53,9 @@ async def process_image_scan(job: ImageJob, redis_client: redis.Redis): approximate=True, ) - print(f"[task] Image scan successfully finished for job_id={correlationId}") + logging.info(f"[task] Image scan successfully finished for job_id={correlationId}") except Exception as e: - print(f"[task] Failed to process job_id={correlationId}: {e}") + logging.warning(f"[task] Failed to process job_id={correlationId}: {e}") finally: - print(f"[task] Image scan finished for job_id={correlationId}") + logging.info(f"[task] Image scan finished for job_id={correlationId}") diff --git a/app/worker/worker.py b/app/worker/worker.py index 751455a..9310a99 100644 --- a/app/worker/worker.py +++ b/app/worker/worker.py @@ -4,6 +4,9 @@ from app.core.config import settings from app.schemas.job import ImageJob from app.worker.tasks import process_image_scan +import logging + +logger = logging.getLogger(__name__) """ Redis Stream에 정의한 유효한 형식 메시지를 위한 전처리 함수 @@ -49,7 +52,7 @@ def __init__(self, redis_client: redis_client): self.redis_client = redis_client async def run(self): - print(f"[worker] start consumer={settings.CONSUMER_NAME} group={settings.GROUP_NAME} stream={settings.STREAM_JOB}") + logging.info(f"[worker] start consumer={settings.CONSUMER_NAME} group={settings.GROUP_NAME} stream={settings.STREAM_JOB}") reclaim_every_sec = 30 last_reclaim = 0.0 @@ -82,12 +85,12 @@ async def run(self): # 최종 반환 data data = json.loads(payload_str) - print(f"Job received id={msg_id} correlationId={correlation_id} payload={data}") + logging.info(f"Job received id={msg_id} correlationId={correlation_id} payload={data}") job = ImageJob.model_validate(data) # XADD까지 호출 task = asyncio.create_task(process_image_scan(job, redis_client)) - print(f"[worker] {task} 발행 성공") + logging.info(f"[worker] {task} 발행 성공") # 처리 성공 시에만 ack 후 del task.add_done_callback(lambda t: asyncio.create_task( @@ -132,7 +135,7 @@ async def run(self): job = ImageJob.model_validate_json(payload) task = asyncio.create_task(process_image_scan(job, self.redis_client)) - print(f"[worker] {task} 발행 성공") + logging.info(f"[worker] {task} 발행 성공") def _on_done(t: asyncio.Task, *, msg_id=msg_id, fields=fields): async def _ack_or_dlq(): @@ -155,8 +158,8 @@ async def _ack_or_dlq(): await self.redis_client.xadd(f"{settings.STREAM_JOB}:DLQ", clean) except asyncio.CancelledError: - print("[worker] cancelled; bye") + logging.warning("[worker] cancelled; bye") break except Exception as e: - print(f"[worker] error: {e}") + logging.warning(f"[worker] error: {e}") await asyncio.sleep(1) diff --git a/deployment/generate_review.py b/deployment/generate_review.py index e8c9008..0b9d233 100644 --- a/deployment/generate_review.py +++ b/deployment/generate_review.py @@ -22,7 +22,7 @@ def send_prompt(): raise Exception("환경 변수(GEMINI_API_KEY, GITHUB_TOKEN)가 설정되지 않았습니다.") genai.configure(api_key=gemini_api_key) - model = genai.GenerativeModel('gemini-1.5-pro-latest') + model = genai.GenerativeModel(model_name="gemini-2.5-pro") g = Github(github_token) # GitHub Actions 컨텍스트에서 PR 정보 가져오기 @@ -59,48 +59,50 @@ def send_prompt(): # Gemini에 전달할 프롬프트 구성 prompt = f""" - 당신은 현재 시니어 개발자 역할을 맡고 있으며, 제출된 Pull Request(PR)에 대해 동료 개발자에게 건설적이고 상세한 코드 리뷰를 제공해야 합니다. - - - 요구사항: - 1. 분석 대상: 아래에 제공된 PR 제목, 본문, 그리고 변경된 파일 목록을 바탕으로 PR의 의도와 변경 내용을 종합적으로 이해하십시오. - 2. 페르소나: 공격적이거나 비판적인 어조가 아닌, 팀의 성장과 코드 품질 향상을 돕는 긍정적이고 건설적인 피드백을 제공하십시오. - 3. 피드백 형식: 다음 5가지 핵심 항목에 대해 구체적인 피드백을 작성해 주세요. 각 항목별로 제목을 달아 명확하게 구분해 주세요. - - - PR 정보: + 당신은 시니어 개발자입니다. 제출된 Pull Request(PR)에 대해 동료 개발자에게 건설적이고 상세한 코드 리뷰를 제공합니다. + 리뷰는 반드시 '우선순위 레벨(P1~P5)'로 분류해 주세요. + + [우선순위 정의] + - P1: 꼭 반영해주세요 (Request changes) — 기능 오동작, 보안 취약점, 데이터 손실 위험, 계약 위반, 테스트 실패 가능성이 높은 핵심 문제 + - P2: 적극적으로 고려해주세요 (Request changes) — 유지보수/확장성/성능 저하 가능성이 큰 구조적 개선 필요 + - P3: 웬만하면 반영해 주세요 (Comment) — 가독성/일관성/경계 조건/에러 처리 보완 등 중간 수준 개선 + - P4: 반영해도 좋고 넘어가도 좋습니다 (Approve) — 선택 사항. 팀 컨벤션/취향 차이 영역 + - P5: 그냥 사소한 의견입니다 (Approve) — 미세 스타일, 주석 표현, 네이밍 제안 등 + + [리뷰 페르소나] + - 공격적이거나 비판적 어조는 피하고, 팀의 성장과 코드 품질 향상을 돕는 긍정적이고 구체적인 피드백을 주세요. + - 문제 지적 시에는 “왜 문제인지(근거) → 영향 → 구체적 해결책/코드 스니펫” 순서로 작성해 주세요. + + [PR 정보] PR 제목: {pr_title} PR 본문: {pr_body} 변경된 파일 목록: {changed_files} --- - - 실제 코드 변경(diff): + [실제 코드 변경(diff)] {diff_output} --- - - 코드 리뷰 내용: - - ### 1. 주요 변경 사항 요약 및 의도 파악 - 제공된 정보를 바탕으로 이 PR의 핵심적인 변경 내용이 무엇이며, 어떤 문제를 해결하거나 어떤 기능을 구현하려 하는지 개발자의 의도를 존중하며 간결하게 요약해 주세요. - - ### 2. 코드 품질 및 가독성 - * 코드 스타일: PEP 8, Clean Code 원칙 등 팀의 컨벤션을 준수했는지 검토해 주세요. - * 변수/함수명: 변수, 함수, 클래스 이름이 명확하고 의도를 잘 드러내는지 평가해 주세요. - * 주석/문서화: 복잡한 로직이나 비즈니스 규칙에 대한 설명이 충분히 포함되어 있는지 확인해 주세요. - * 중복 코드: 유사하거나 반복되는 로직이 있는지 파악하고, 재사용 가능한 함수나 클래스로 분리할 것을 제안해 주세요. - - ### 3. 잠재적 버그 및 엣지 케이스 - * 논리적 오류: 코드가 예상치 못한 상황(예: 입력 값 없음, null 값, 0으로 나누기)에서 오류를 일으킬 수 있는지 검토해 주세요. - * 경쟁 상태 (Race Condition): 멀티쓰레드/비동기 환경에서 발생할 수 있는 잠재적인 문제를 지적해 주세요. - * 에러 핸들링: 예외 처리가 적절하게 구현되었는지, 사용자에게 의미 있는 에러 메시지를 제공하는지 확인해 주세요. - - ### 4. 성능 및 효율성 - * 시간 복잡도: 현재 구현된 알고리즘이 대규모 데이터에 대해 비효율적이지 않은지 검토해 주세요. - * 자원 사용: 메모리 누수나 불필요한 I/O, 과도한 DB 쿼리 등이 발생할 가능성은 없는지 확인해 주세요. - * 최적화 제안: 더 효율적인 자료 구조나 알고리즘을 사용하여 성능을 개선할 수 있는 부분을 구체적으로 제안해 주세요. - - ### 5. 보안 및 아키텍처 - * 보안 취약점: SQL Injection, XSS, 불충분한 입력 값 검증 등과 같은 잠재적인 보안 위험이 있는지 검토해 주세요. - * 아키텍처 적합성: 이 PR의 변경 사항이 기존 시스템 아키텍처의 설계 원칙과 잘 부합하는지 평가해 주세요. - * 확장성: 향후 기능 확장 시 이 코드가 유연하게 대처할 수 있도록 설계되었는지 의견을 제시해 주세요. + [출력 형식] + 아래 섹션과 형식을 반드시 지켜서 출력하세요. + + ## 1) PR 의도 요약 + - 이 PR의 목표/맥락/핵심 변경 사항을 3~5줄로 간결히 요약해라. + + ## 2) 전반적 평가 (품질/가독성/테스트/아키텍처) + - 컨벤션(PEP8/Clean Code) 준수 여부 + - 네이밍/모듈 경계/의존성 방향 + - 테스트 전략(단위/통합) 적절성 한 줄 평가 + + ## 3) 우선순위별 피드백 목록 + - 아래 형식을 반복하여, 파일·라인 기준으로 구체적으로 작성해라. + - 최소한 P1/P2는 근거와 수정 제안을 포함해야 하며, 가능하면 코드 패치 예시를 함께 제시해야한다. + + ### [P레벨] 제목 한 줄 요약 + - 파일/위치: : + - 근거(왜 문제인지/왜 개선인지): + - 영향(버그/보안/성능/유지보수 등): + - 제안(구체적 조치, 대안, 참고 링크는 선택): """ try: diff --git a/requirements.txt b/requirements.txt index d06a886..339d3b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,6 @@ torch==2.8.0 torchvision==0.23.0 Pillow==11.3.0 dotenv -openai \ No newline at end of file +openai +timm +logging \ No newline at end of file