From fd6aaa434b2a5a7813591974565c1d3f927a6a13 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Fri, 16 Oct 2026 13:12:19 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20AI=20=EB=A6=AC=EB=B7=B0=20=EB=AF=B8?= =?UTF-8?q?=EC=99=84=EC=84=B1=20=EC=9D=91=EB=8B=B5=20=EC=A7=84=EB=8B=A8=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9E=AC=EC=8B=9C=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ai-review/run.mjs | 45 +++++++++++++++++++++++----------- .github/ai-review/run.test.mjs | 45 +++++++++++++++++++++++++++++++++- docs/ai-review-operations.md | 1 + 3 files changed, 76 insertions(+), 15 deletions(-) diff --git a/.github/ai-review/run.mjs b/.github/ai-review/run.mjs index 40b0d1e..c846f69 100644 --- a/.github/ai-review/run.mjs +++ b/.github/ai-review/run.mjs @@ -16,6 +16,11 @@ import { const API_VERSION = "2022-11-28"; const DEFAULT_MODEL = "gemini-3.6-flash"; const MAX_OPEN_FINDINGS = 20; +const OUTPUT_TOKEN_LIMITS = [8_192, 16_384]; +const FINISH_REASONS = new Set([ + "STOP", "MAX_TOKENS", "SAFETY", "RECITATION", "LANGUAGE", "OTHER", "BLOCKLIST", + "PROHIBITED_CONTENT", "SPII", "MALFORMED_FUNCTION_CALL", "IMAGE_SAFETY", +]); const REVIEW_SCHEMA = { type: "object", @@ -158,23 +163,35 @@ async function callGemini(input) { contents: [{ role: "user", parts: [{ text: JSON.stringify(input) }] }], generationConfig: { temperature: 0.2, - maxOutputTokens: 4096, responseFormat: { text: { mimeType: "APPLICATION_JSON", schema: REVIEW_SCHEMA } }, }, }; - const response = await request(endpoint, { - method: "POST", - headers: { "Content-Type": "application/json", "x-goog-api-key": key }, - body: JSON.stringify(payload), - }, 150_000); - const candidate = response.candidates?.[0]; - if (candidate?.finishReason !== "STOP") throw new Error("AI 응답이 완성되지 않았습니다."); - const text = candidate.content?.parts?.map((part) => part.text ?? "").join(""); - if (!text) throw new Error("AI 응답이 비어 있습니다."); - try { - return JSON.parse(text); - } catch { - throw new Error("AI 응답이 유효한 JSON이 아닙니다."); + return completeReview(async (maxOutputTokens) => { + payload.generationConfig.maxOutputTokens = maxOutputTokens; + return request(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json", "x-goog-api-key": key }, + body: JSON.stringify(payload), + }, 150_000); + }); +} + +export async function completeReview(generate) { + for (const [index, maxOutputTokens] of OUTPUT_TOKEN_LIMITS.entries()) { + const response = await generate(maxOutputTokens); + const candidate = response?.candidates?.[0]; + const reason = candidate + ? (FINISH_REASONS.has(candidate.finishReason) ? candidate.finishReason : "UNKNOWN") + : "NO_CANDIDATE"; + if (reason === "MAX_TOKENS" && index + 1 < OUTPUT_TOKEN_LIMITS.length) continue; + if (reason !== "STOP") throw new Error(`AI 응답이 완성되지 않았습니다 (종료 이유: ${reason}).`); + const text = candidate.content?.parts?.map((part) => part.text ?? "").join(""); + if (!text) throw new Error("AI 응답이 비어 있습니다."); + try { + return JSON.parse(text); + } catch { + throw new Error("AI 응답이 유효한 JSON이 아닙니다."); + } } } diff --git a/.github/ai-review/run.test.mjs b/.github/ai-review/run.test.mjs index e8c7534..9037209 100644 --- a/.github/ai-review/run.test.mjs +++ b/.github/ai-review/run.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { mergeReview, parseState, renderSummary } from "./core.mjs"; -import { feedback, review } from "./run.mjs"; +import { completeReview, feedback, review } from "./run.mjs"; const headSha = "a".repeat(40); const baseSha = "b".repeat(40); @@ -182,3 +182,46 @@ test("권한이 있는 리뷰어의 오탐 판정은 기존 댓글만 갱신한 assert.equal(parseState(patched[0].body.body).findings[0].status, "false-positive"); assert.equal(calls.filter((call) => call.method === "POST").length, 0); }); + +function response(finishReason, text = "") { + return { candidates: [{ finishReason, content: { parts: [{ text }] } }] }; +} + +test("출력 한도에 도달하면 한 번만 더 큰 한도로 재시도한다", async () => { + const limits = []; + const review = await completeReview(async (limit) => { + limits.push(limit); + return limits.length === 1 + ? response("MAX_TOKENS", "미완성 응답") + : response("STOP", '{"summary":"검토 완료","findings":[]}'); + }); + assert.deepEqual(limits, [8_192, 16_384]); + assert.equal(review.summary, "검토 완료"); +}); + +test("출력 한도가 다시 부족하면 응답 본문 없이 종료 이유만 알린다", async () => { + const limits = []; + await assert.rejects( + completeReview(async (limit) => { + limits.push(limit); + return response("MAX_TOKENS", "게시하면 안 되는 응답 본문"); + }), + (error) => error.message.includes("MAX_TOKENS") && !error.message.includes("응답 본문"), + ); + assert.deepEqual(limits, [8_192, 16_384]); +}); + +test("토큰 한도 외 종료는 재시도하지 않고 안전한 이유만 기록한다", async () => { + const reasons = ["SAFETY", "악의적인 임의 이유"]; + for (const reason of reasons) { + let calls = 0; + await assert.rejects( + completeReview(async () => { + calls += 1; + return response(reason, "민감한 원문"); + }), + (error) => !error.message.includes("민감한 원문") && !error.message.includes("악의적인"), + ); + assert.equal(calls, 1); + } +}); diff --git a/docs/ai-review-operations.md b/docs/ai-review-operations.md index 350ed2a..9a52ef1 100644 --- a/docs/ai-review-operations.md +++ b/docs/ai-review-operations.md @@ -21,6 +21,7 @@ Phase 3 구현 범위: GitHub Actions에서 PR diff를 Gemini로 검토하고 PR - 한 번에 최대 60개 파일, diff 60,000자, context 24,000자, finding 20개, PR당 성공한 모델 호출 20회로 제한한다. 제외 파일 수와 호출 수를 댓글에 표시한다. - AI가 반환한 finding은 실제 검토 파일의 추가된 줄만 허용한다. severity, confidence, category, classification, file, line, 원인, 영향, 조치를 검증한다. - API key와 원문 diff는 Actions 로그에 출력하지 않는다. 요청 실패 시 HTTP 상태만 기록한다. +- AI 응답이 출력 토큰 한도에 닿으면 한 번만 더 큰 한도로 재시도한다. 끝까지 완성되지 않으면 응답 본문 없이 허용된 종료 이유만 로그에 기록한다. GitHub의 `pull_request_target` 보안 지침상 [PR head를 checkout하여 실행하는 것은 위험하다](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target). 이 workflow의 checkout은 기준 브랜치의 스크립트만 가져오고, PR patch는 GitHub API의 데이터로만 처리한다. Workflow나 리뷰 스크립트가 바뀌는 PR은 특히 신중히 검토한다.