diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11f7d11..86243dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,9 @@ jobs: runs-on: ubuntu-latest defaults: run: - working-directory: . + # ๐Ÿ”ฅ ํ”„๋ก ํŠธ ํด๋” ์œ„์น˜(๋ ˆํฌ ๊ธฐ์ค€ ๊ฒฝ๋กœ) + working-directory: ./apps/web + steps: - uses: actions/checkout@v4 diff --git a/apps/review-service/main.py b/apps/review-service/main.py index fd856cd..1e9d47c 100644 --- a/apps/review-service/main.py +++ b/apps/review-service/main.py @@ -1,70 +1,177 @@ import os +import logging +from typing import Optional + import google.generativeai as genai +from google.generativeai.types import GenerationConfig from fastapi import FastAPI, Form, HTTPException from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel -from typing import Optional -app = FastAPI() +# ---------------------------------------------------- +# ๋กœ๊ทธ ์„ค์ • +# ---------------------------------------------------- +logging.basicConfig(level=logging.INFO) +log = logging.getLogger("review-service") # ---------------------------------------------------- -# CORS ์„ค์ • (web/vite:3000 ํ—ˆ์šฉ) +# FastAPI ์•ฑ & CORS # ---------------------------------------------------- +app = FastAPI() + app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:3000"], + # allow_origins๋Š” ์‹ค์ œ ๋ฐฐํฌ ํ™˜๊ฒฝ์— ๋งž๊ฒŒ ์ˆ˜์ •ํ•˜์„ธ์š”. + allow_origins=["http://localhost", "http://localhost:3000", "*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ---------------------------------------------------- -# Gemini AI ์„ค์ • +# Gemini ์„ค์ • # ---------------------------------------------------- +MODEL_NAME = os.environ.get("GEMINI_MODEL", "gemini-1.5-flash-latest") # ์ตœ์‹  ๋ชจ๋ธ ๊ถŒ์žฅ + try: - genai.configure(api_key=os.environ.get("GEMINI_API_KEY")) - model = genai.GenerativeModel('gemini-1.5-flash') + api_key = os.environ.get("GEMINI_API_KEY") + if not api_key: + raise RuntimeError("GEMINI_API_KEY ํ™˜๊ฒฝ ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.") + + genai.configure(api_key=api_key) + model = genai.GenerativeModel(MODEL_NAME) + log.info(f"Gemini ๋ชจ๋ธ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค: {MODEL_NAME}") except Exception as e: - print(f"Error configuring Gemini: {e}") + log.error(f"Gemini ์„ค์ • ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {e}") model = None # ---------------------------------------------------- -# ๐Ÿš€ (์‹ ๊ทœ) AI ์ฝ”๋“œ ๋ฆฌ๋ทฐ์–ด ์—ญํ•  ๋ถ€์—ฌ (System Prompt) +# ์ฝ”๋“œ ๋ฆฌ๋ทฐ์šฉ ๋ฒ ์ด์Šค ํ”„๋กฌํ”„ํŠธ (ํ•œ๊ตญ์–ด + ๊ฐ„๊ฒฐ ์š”์•ฝ) # ---------------------------------------------------- REVIEWER_PROMPT = """ -You are an expert Senior Software Engineer acting as a code reviewer. -Your task is to provide a constructive, professional code review for the user's code snippet. - -Follow these steps: -1. **Overall Assessment:** Start with a brief, one-sentence summary of the code's quality (e.g., "This is a clean implementation," "This works, but has some areas for improvement"). -2. **Positive Feedback:** (Optional) Briefly mention one thing that is done well. -3. **Constructive Criticism:** Identify 2-3 key areas for improvement. For each area, provide: - * **Issue:** Clearly state the problem (e.g., "Potential N+1 query," "Variable name is unclear," "Inefficient algorithm"). - * **Suggestion:** Provide a concrete example of how to fix it or a better approach. -4. **Conclusion:** End with an encouraging summary. - -Format your response using Markdown. Use **bold** text for headings (like **Issue:** and **Suggestion:**) and `code snippets` for code. Do not use Markdown headings (#, ##). +๋‹น์‹ ์€ ์‹œ๋‹ˆ์–ด ์†Œํ”„ํŠธ์›จ์–ด ์—”์ง€๋‹ˆ์–ด์ด์ž ์ฝ”๋“œ ๋ฆฌ๋ทฐ์–ด์ž…๋‹ˆ๋‹ค. +๋‹ค์Œ ์š”๊ตฌ์‚ฌํ•ญ์„ ์ง€์ผœ **ํ•œ๊ตญ์–ด๋กœ ์•„์ฃผ ๊ฐ„๊ฒฐํ•˜๊ฒŒ** ์ฝ”๋“œ ๋ฆฌ๋ทฐ๋ฅผ ์ž‘์„ฑํ•˜์„ธ์š”. + +- ์ „์ฒด ๋ถ„๋Ÿ‰์€ 15์ค„ ์ด๋‚ด๋กœ ์š”์•ฝํ•ฉ๋‹ˆ๋‹ค. +- ์•„๋ž˜ 4๊ฐ€์ง€ ๊ด€์ ์œผ๋กœ๋งŒ ํ‰๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. + 1) ๋ชฉ์  ๋ถ€ํ•ฉ์„ฑ ๋ฐ ์„ค๊ณ„ + 2) ์ •ํ™•์„ฑ ๋ฐ ๊ฒฌ๊ณ ์„ฑ + 3) ์„ฑ๋Šฅ ๋ฐ ํšจ์œจ์„ฑ + 4) ์œ ์ง€๋ณด์ˆ˜์„ฑ ๋ฐ ๊ฐ€๋…์„ฑ +- ๊ฐ ๊ด€์ ๋งˆ๋‹ค **Issue 0~2๊ฐœ, Suggestion 0~2๊ฐœ**๋งŒ ์ ๊ณ , ๊ฐ€์žฅ ์ค‘์š”ํ•œ ๊ฒƒ๋ถ€ํ„ฐ ์จ์ฃผ์„ธ์š”. +- ๋ถˆํ•„์š”ํ•œ ์žฅ๋ฌธ ์„ค๋ช…, ์žฅํ™ฉํ•œ ์˜ˆ์‹œ๋Š” ๋„ฃ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. +- ์ฝ”๋“œ ์ „์ฒด๋ฅผ ๋‹ค์‹œ ๋ถ™์—ฌ์“ฐ์ง€ ๋ง๊ณ , ํ•„์š”ํ•œ ๊ฒฝ์šฐ์—๋งŒ ํ•œ๋‘ ์ค„ ์ •๋„์˜ ์˜ˆ์‹œ๋งŒ ์‚ฌ์šฉํ•˜์„ธ์š”. +- ๋งํˆฌ๋Š” โ€œ~์ž…๋‹ˆ๋‹ค / ~ํ•ด ์ฃผ์„ธ์š”โ€ ํ˜•ํƒœ๋กœ ์ •์ค‘ํ•˜๊ณ  ๋‹ด๋ฐฑํ•˜๊ฒŒ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. + +์ถœ๋ ฅ ํ˜•์‹: + +[์š”์•ฝ] +- ํ•œ ์ค„๋กœ ์ „์ฒด ํ‰๊ฐ€ + +[1. ๋ชฉ์  ๋ถ€ํ•ฉ์„ฑ ๋ฐ ์„ค๊ณ„] +- Issue: ... +- Suggestion: ... + +[2. ์ •ํ™•์„ฑ ๋ฐ ๊ฒฌ๊ณ ์„ฑ] +- Issue: ... +- Suggestion: ... + +[3. ์„ฑ๋Šฅ ๋ฐ ํšจ์œจ์„ฑ] +- Issue: ... +- Suggestion: ... + +[4. ์œ ์ง€๋ณด์ˆ˜์„ฑ ๋ฐ ๊ฐ€๋…์„ฑ] +- Issue: ... +- Suggestion: ... """ # ---------------------------------------------------- -# API ์—”๋“œํฌ์ธํŠธ ์ •์˜ +# ํ—ฌ์Šค ์ฒดํฌ +# ---------------------------------------------------- +@app.get("/") +def read_root(): + return {"status": "Review Service is running.", "model": MODEL_NAME} + + +# ---------------------------------------------------- +# ์ฝ”๋“œ ๋ฆฌ๋ทฐ ์—”๋“œํฌ์ธํŠธ # ---------------------------------------------------- @app.post("/api/review/") -async def handle_code_review(code: str = Form(...)): # ๐Ÿ‘ˆ Review.jsx์˜ FormData("code")๋ฅผ ๋ฐ›์Œ +async def handle_code_review( + code: str = Form(...), + comment: Optional[str] = Form(None), + repo_url: Optional[str] = Form(None), +): if not model: - raise HTTPException(status_code=503, detail="Gemini AI model is not configured.") + raise HTTPException( + status_code=503, + detail="Gemini AI ๋ชจ๋ธ์ด ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์„œ๋ฒ„ ๋กœ๊ทธ๋ฅผ ํ™•์ธํ•˜์„ธ์š”.", + ) + + if not code.strip(): + raise HTTPException( + status_code=400, + detail="๋ฆฌ๋ทฐํ•  ์ฝ”๋“œ๊ฐ€ ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.", + ) + # ์ถ”๊ฐ€ ์ปจํ…์ŠคํŠธ ์ •๋ฆฌ + extra_context_parts = [] + if comment: + extra_context_parts.append( + "์‚ฌ์šฉ์ž๊ฐ€ ์ค‘์ ์ ์œผ๋กœ ๋ณด๊ณ  ์‹ถ์€ ๋ถ€๋ถ„ / ์š”๊ตฌ์‚ฌํ•ญ:\n" + f"{comment.strip()}" + ) + if repo_url: + extra_context_parts.append( + "์ฐธ๊ณ ์šฉ GitHub Repository URL:\n" + f"{repo_url.strip()}" + ) + + extra_context = ("\n\n".join(extra_context_parts) + if extra_context_parts + else "๋ณ„๋„ ์š”๊ตฌ์‚ฌํ•ญ ์—†์Œ") + + # ํ”„๋กฌํ”„ํŠธ ๊ตฌ์„ฑ + full_prompt = f"""{REVIEWER_PROMPT} + +[ํ”„๋กœ์ ํŠธ/์ฝ”๋“œ ๋งฅ๋ฝ] +{extra_context} + +[๋ฆฌ๋ทฐ ๋Œ€์ƒ ์ฝ”๋“œ] +```text +{code} +``` +""" + # --- โ˜…โ˜…โ˜… ์—ฌ๊ธฐ๊ฐ€ ์ˆ˜์ •๋œ ์ง€์ ์ž…๋‹ˆ๋‹ค โ˜…โ˜…โ˜… --- + # 1. full_prompt ๋ณ€์ˆ˜๊ฐ€ `"""`๋กœ ์œ„์—์„œ ์™„์ „ํžˆ ๋๋‚ฌ์Šต๋‹ˆ๋‹ค. + # 2. `try` ๋ธ”๋ก์ด `handle_code_review` ํ•จ์ˆ˜ ๋‚ด๋ถ€์— + # ์˜ฌ๋ฐ”๋ฅด๊ฒŒ '๋“ค์—ฌ์“ฐ๊ธฐ' ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. + try: - # 1. ์‹œ์Šคํ…œ ํ”„๋กฌํ”„ํŠธ์™€ ์‚ฌ์šฉ์ž ์ฝ”๋“œ๋ฅผ ๊ฒฐํ•ฉํ•˜์—ฌ API ํ˜ธ์ถœ - full_prompt = f"{REVIEWER_PROMPT}\n\nHere is the code to review:\n```\n{code}\n```" - response = model.generate_content(full_prompt) + response = await model.generate_content_async( # ๋น„๋™๊ธฐ(async) ํ˜ธ์ถœ + full_prompt, + generation_config=GenerationConfig( + temperature=0.4, + max_output_tokens=2048, # ์•ฝ๊ฐ„ ์—ฌ์œ  ์žˆ๊ฒŒ ๋Š˜๋ฆผ + ), + ) - # 2. AI์˜ ๋ฆฌ๋ทฐ ํ…์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ (Review.jsx์˜ data.review์— ํ•ด๋‹น) - return {"review": response.text} + # response.text๊ฐ€ ๋น„์–ด์žˆ๊ฑฐ๋‚˜ ์—†๋Š” ๊ฒฝ์šฐ๋ฅผ ๋” ์•ˆ์ „ํ•˜๊ฒŒ ์ฒ˜๋ฆฌ + review_text = (response.text or "").strip() + + if not review_text: + log.warning("Gemini์—์„œ ๋นˆ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ–ˆ์Šต๋‹ˆ๋‹ค.") + # prompt๊ฐ€ ์ฐจ๋‹จ๋˜์—ˆ์„ ์ˆ˜ ์žˆ์Œ + if response.prompt_feedback and response.prompt_feedback.block_reason: + log.error(f"ํ”„๋กฌํ”„ํŠธ๊ฐ€ ์ฐจ๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: {response.prompt_feedback.block_reason}") + raise HTTPException(status_code=400, detail=f"ํ”„๋กฌํ”„ํŠธ๊ฐ€ ์ฐจ๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: {response.prompt_feedback.block_reason}") + raise RuntimeError("Gemini์—์„œ ๋นˆ ์‘๋‹ต์„ ๋ฐ›์•˜์Šต๋‹ˆ๋‹ค.") + + return {"review": review_text} except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to get review: {str(e)}") - -@app.get("/") -def read_root(): - return {"status": "Review Service is running."} \ No newline at end of file + log.error(f"๋ฆฌ๋ทฐ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {e}") + # API ํ‚ค ์˜ค๋ฅ˜ ๋“ฑ ๊ตฌ์ฒด์ ์ธ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด ๋” ์ข‹์Šต๋‹ˆ๋‹ค. + raise HTTPException( + status_code=500, + detail=f"๋ฆฌ๋ทฐ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}", + ) \ No newline at end of file diff --git a/apps/web/src/api/reviewService.js b/apps/web/src/api/reviewService.js index 17adab0..ada5e6c 100644 --- a/apps/web/src/api/reviewService.js +++ b/apps/web/src/api/reviewService.js @@ -1,16 +1,18 @@ -// src/api/reviewService.js - const BASE_URL = "/api"; /** * AI ์ฝ”๋“œ ๋ฆฌ๋ทฐ ์š”์ฒญ - * @param {string} code - ๋ฆฌ๋ทฐํ•  ์ฝ”๋“œ ๋ฌธ์ž์—ด - * @returns {Promise} - { review: "..."} ํ˜•ํƒœ + * @param {string} code + * @param {string} [comment] + * @param {string} [repoUrl] */ -export const fetchCodeReview = async (code) => { +export const fetchCodeReview = async (code, comment, repoUrl) => { const formData = new FormData(); formData.append("code", code); + if (comment) formData.append("comment", comment); + if (repoUrl) formData.append("repo_url", repoUrl); + const res = await fetch(`${BASE_URL}/review/`, { method: "POST", body: formData, @@ -18,7 +20,11 @@ export const fetchCodeReview = async (code) => { if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error(err.error || `AI ๋ฆฌ๋ทฐ ์š”์ฒญ ์‹คํŒจ: ${res.statusText || res.status}`); + throw new Error( + err.detail || + err.error || + `AI ๋ฆฌ๋ทฐ ์š”์ฒญ ์‹คํŒจ: ${res.statusText || res.status}`, + ); } return await res.json(); diff --git a/apps/web/src/pages/Review.jsx b/apps/web/src/pages/Review.jsx index 7d79a97..96dea53 100644 --- a/apps/web/src/pages/Review.jsx +++ b/apps/web/src/pages/Review.jsx @@ -1,7 +1,11 @@ import { useState } from "react"; import { Link } from "react-router-dom"; import { - IconArrowLeft, IconSparkles, IconLoader2, IconAlertTriangle, IconCopy + IconArrowLeft, + IconSparkles, + IconLoader2, + IconAlertTriangle, + IconCopy, } from "@tabler/icons-react"; import Particles from "@tsparticles/react"; import { fetchCodeReview } from "../api/reviewService"; @@ -9,7 +13,11 @@ import { fetchCodeReview } from "../api/reviewService"; const particlesOptions = { background: { color: { value: "transparent" } }, fpsLimit: 60, - interactivity: { events: { resize: true } }, + interactivity: { + events: { + resize: true, + }, + }, particles: { color: { value: "#8eb5ff" }, links: { enable: true, opacity: 0.22, width: 1 }, @@ -22,6 +30,8 @@ const particlesOptions = { export default function Review() { const [code, setCode] = useState(""); + const [userComment, setUserComment] = useState(""); + const [repoUrl, setRepoUrl] = useState(""); const [review, setReview] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -30,13 +40,19 @@ export default function Review() { const handleSubmit = async (e) => { e.preventDefault(); if (!code.trim()) return; + setIsLoading(true); setError(null); setReview(""); + try { - const data = await fetchCodeReview(code); - if (data?.review) setReview(data.review); - else throw new Error(data?.error || "Unknown error"); + // reviewService.js์—์„œ fetchCodeReview๋ฅผ (code, comment, repoUrl) ๋ฐ›๋„๋ก ๋งž์ถฐ์ฃผ๋ฉด ๋จ + const data = await fetchCodeReview(code, userComment, repoUrl); + if (data?.review) { + setReview(data.review); + } else { + throw new Error(data?.error || "Unknown error"); + } } catch (err) { setError(err.message || "Failed to fetch review"); } finally { @@ -49,130 +65,175 @@ export default function Review() { await navigator.clipboard.writeText(review || ""); setCopied(true); setTimeout(() => setCopied(false), 1200); - } catch {} + } catch { + /* noop */ + } }; return ( -
- {/* ๋ฐฐ๊ฒฝ ์ž…์ž: ๋ฐฐ๊ฒฝ ๋ ˆ์ด์–ด(z-0)๋กœ */} +
+ {/* ๋ฐฐ๊ฒฝ ์ž…์ž */} - {/* Header */} -
- - Home - - -

- AI Code Review -

- -
-
- - {/* Main */} -
- {/* LEFT: Code input */} -
-
-
-
- Paste Your Code -
- -