diff --git a/handlers/complexity-analysis.js b/handlers/complexity-analysis.js index 7e39083..1da761d 100644 --- a/handlers/complexity-analysis.js +++ b/handlers/complexity-analysis.js @@ -47,11 +47,6 @@ export function stripComplexityComments(content) { .join("\n"); } -export function extractBigO(line) { - const m = line.match(BIG_O_LITERAL_PATTERN); - return m ? m[0] : null; -} - function isCommentLine(line) { const trimmed = line.trim(); if (trimmed === "") return false; @@ -62,6 +57,31 @@ function isClassWrapperLine(line) { return typeof line === "string" && CLASS_WRAPPER_PATTERN.test(line); } +/** + * 키워드에서 가장 가까운 Big-O 리터럴을 고른다. + * "Time O(n), Space O(1)" 처럼 한 줄에 둘 다 적혀 있어도 각 키워드에 맞는 값을 얻는다. + */ +export function extractBigONear(line, keywordPattern) { + const keyword = line.match(keywordPattern); + if (!keyword) return null; + const keywordStart = keyword.index; + const keywordEnd = keywordStart + keyword[0].length; + const distance = (literal) => + literal.index >= keywordEnd + ? literal.index - keywordEnd + : Math.max(0, keywordStart - (literal.index + literal[0].length)); + + let nearest = null; + for (const literal of line.matchAll( + new RegExp(BIG_O_LITERAL_PATTERN.source, "g") + )) { + if (nearest === null || distance(literal) < distance(nearest)) { + nearest = literal; + } + } + return nearest ? nearest[0] : null; +} + /** * 헤더 라인 위쪽으로 빈 줄 또는 비-주석 라인을 만나기 전까지의 연속 주석 블록에서 * 시간/공간 복잡도 주석을 추출한다. @@ -69,14 +89,25 @@ function isClassWrapperLine(line) { * 단, 외곽 클래스/struct/impl 선언 라인(예: `class Solution:`)은 투명하게 통과한다. * Python LeetCode 풀이처럼 사용자 주석이 클래스 선언 위에 있고, 모델이 메서드 라인을 * headerLine 으로 반환하는 흔한 패턴을 지원하기 위함. + * + * 프롬프트가 클래스 라인도 headerLine 으로 허용하므로, headerLine 이 클래스 선언이면 + * 클래스 바로 안쪽(메서드 위) 주석 블록도 본다. */ export function extractUserAnnotations(content, headerLine) { - if (!Number.isInteger(headerLine) || headerLine <= 1) { - return { userTime: null, userSpace: null }; - } + const found = { userTime: null, userSpace: null }; + if (!Number.isInteger(headerLine) || headerLine < 1) return found; + const lines = content.split("\n"); - let userTime = null; - let userSpace = null; + const collect = (line) => { + if (found.userTime === null) { + found.userTime = extractBigONear(line, TIME_KEYWORD_PATTERN); + } + if (found.userSpace === null) { + found.userSpace = extractBigONear(line, SPACE_KEYWORD_PATTERN); + } + return found.userTime !== null && found.userSpace !== null; + }; + for (let i = headerLine - 2; i >= 0; i--) { const line = lines[i]; if (line === undefined) break; @@ -87,18 +118,15 @@ export function extractUserAnnotations(content, headerLine) { } if (!isCommentLine(line)) break; + if (collect(line)) return found; + } - if (userTime === null && TIME_KEYWORD_PATTERN.test(line)) { - const bigO = extractBigO(line); - if (bigO) userTime = bigO; - } - if (userSpace === null && SPACE_KEYWORD_PATTERN.test(line)) { - const bigO = extractBigO(line); - if (bigO) userSpace = bigO; + if (isClassWrapperLine(lines[headerLine - 1])) { + for (let i = headerLine; i < lines.length && isCommentLine(lines[i]); i++) { + if (collect(lines[i])) break; } - if (userTime !== null && userSpace !== null) break; } - return { userTime, userSpace }; + return found; } /** diff --git a/handlers/complexity-analysis.test.js b/handlers/complexity-analysis.test.js index c96537b..8213caa 100644 --- a/handlers/complexity-analysis.test.js +++ b/handlers/complexity-analysis.test.js @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { isComplexityCommentLine, stripComplexityComments, - extractBigO, + extractBigONear, extractUserAnnotations, bigOEquals, cleanBigO, @@ -124,21 +124,37 @@ function solution() {}`); }); }); -describe("extractBigO", () => { +describe("extractBigONear", () => { + const TIME = /\b(?:TC|tc|Time|time)\b|시간\s*복잡도/; + const SPACE = /\b(?:SC|sc|Space|space)\b|공간\s*복잡도/; + it("기본 형태 인식", () => { - expect(extractBigO("// TC: O(n)")).toBe("O(n)"); - expect(extractBigO("// SC: O(n^2)")).toBe("O(n^2)"); - expect(extractBigO("# 시간 복잡도: O(n log n)")).toBe("O(n log n)"); + expect(extractBigONear("// TC: O(n)", TIME)).toBe("O(n)"); + expect(extractBigONear("// SC: O(n^2)", SPACE)).toBe("O(n^2)"); + expect(extractBigONear("# 시간 복잡도: O(n log n)", TIME)).toBe("O(n log n)"); }); it("괄호 안 파라미터 형태도 인식", () => { - expect(extractBigO("// O(n*m)")).toBe("O(n*m)"); - expect(extractBigO("// O(V + E)")).toBe("O(V + E)"); - expect(extractBigO("// O(2^n)")).toBe("O(2^n)"); + expect(extractBigONear("// TC: O(n*m)", TIME)).toBe("O(n*m)"); + expect(extractBigONear("// TC: O(V + E)", TIME)).toBe("O(V + E)"); + expect(extractBigONear("// TC: O(2^n)", TIME)).toBe("O(2^n)"); + }); + + it("한 줄에 시간과 공간이 함께 있으면 각 키워드에 가까운 값을 고른다", () => { + const line = "# Time O(n), Space O(1)"; + expect(extractBigONear(line, TIME)).toBe("O(n)"); + expect(extractBigONear(line, SPACE)).toBe("O(1)"); + }); + + it("Big-O 가 키워드 앞에 있어도 가까운 값을 고른다", () => { + const line = "// O(n) time, O(1) space"; + expect(extractBigONear(line, TIME)).toBe("O(n)"); + expect(extractBigONear(line, SPACE)).toBe("O(1)"); }); - it("Big-O 가 없으면 null", () => { - expect(extractBigO("// 알고리즘 설명")).toBe(null); + it("키워드나 Big-O 가 없으면 null", () => { + expect(extractBigONear("// 알고리즘 설명", TIME)).toBe(null); + expect(extractBigONear("// TC: 선형", TIME)).toBe(null); }); }); @@ -301,6 +317,28 @@ impl Solution { }); }); + it("headerLine 이 클래스 선언이면 클래스 안쪽 주석에서 추출한다", () => { + const src = `class Solution: + # Python 문자열은 immutable이라 대문자만 선택적으로 수정 불가 + # lower()를 쓰면 한번 순회하므로 가장 간단 Time O(n), Space O(1) + def toLowerCase(self, s: str) -> str: + return s.lower()`; + expect(extractUserAnnotations(src, 1)).toEqual({ + userTime: "O(n)", + userSpace: "O(1)", + }); + }); + + it("headerLine 이 클래스 선언이 아니면 아래쪽 주석을 보지 않는다", () => { + const src = `function solution() { + // TC: O(n) +}`; + expect(extractUserAnnotations(src, 1)).toEqual({ + userTime: null, + userSpace: null, + }); + }); + it("class wrapper 위쪽이 빈 줄이면 거기서 중단", () => { const src = `// 다른 풀이의 주석 diff --git a/handlers/webhooks.js b/handlers/webhooks.js index f5c3b82..c75c7d0 100644 --- a/handlers/webhooks.js +++ b/handlers/webhooks.js @@ -211,16 +211,18 @@ async function handleProjectsV2ItemEvent(payload, env) { /** * Pull Request 이벤트 처리 * - opened/reopened: Week 설정 체크(제목이 WEEK NN 형태인 풀이 PR만) + AI 풀이 분석 - * - synchronize: AI 풀이 분석만 (Week 체크 스킵) + * - synchronize, ready_for_review: AI 풀이 분석만 (Week 체크 스킵) * * AI 풀이 분석과 학습 현황은 제목과 상관없이 돌린다. Onboarding PR 처럼 WEEK 가 없는 * 제목에도 풀이 파일이 있을 수 있고, 풀이 파일이 없으면 두 핸들러가 AI 를 부르지 않고 건너뛴다. + * draft PR 은 AI 풀이 분석이 건너뛰므로, draft 가 풀리는 ready_for_review 에서 분석한다. */ async function handlePullRequestEvent(payload, env, ctx) { const action = payload.action; - // opened, reopened, synchronize 액션만 처리 - if (!["opened", "reopened", "synchronize"].includes(action)) { + if ( + !["opened", "reopened", "synchronize", "ready_for_review"].includes(action) + ) { console.log(`Ignoring pull_request action: ${action}`); return corsResponse({ message: `Ignored: ${action}` }); } diff --git a/handlers/webhooks.test.js b/handlers/webhooks.test.js index f9bb013..91093fe 100644 --- a/handlers/webhooks.test.js +++ b/handlers/webhooks.test.js @@ -311,6 +311,28 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { expect(aiReview).not.toHaveBeenCalled(); }); + it("draft 에서 ready_for_review 로 바뀌면 AI 리뷰를 디스패치한다", async () => { + const ctx = makeCtx(); + const env = { + AI: FAKE_AI, + INTERNAL_SECRET: "fake-secret", + WORKER_URL: "https://worker.test", + }; + + const response = await handleWebhook( + makeRequest("pull_request", { + ...basePRPayload, + action: "ready_for_review", + }), + env, + ctx + ); + + expect(response.status).toBe(200); + const fetchedUrls = globalThis.fetch.mock.calls.map(([url]) => url); + expect(fetchedUrls).toEqual(["https://worker.test/internal/ai-review"]); + }); + it("INTERNAL_SECRET 이 없으면 in-process 핸들러 호출로 폴백한다", async () => { const ctx = makeCtx(); const env = { diff --git a/tests/ai-review.test.js b/tests/ai-review.test.js index ac3d424..04006c2 100644 --- a/tests/ai-review.test.js +++ b/tests/ai-review.test.js @@ -649,6 +649,30 @@ describe("aiReview — 의도한 접근과 학습 현황", () => { ); }); + it("패턴 목록을 통째로 돌려주면 감지된 패턴 없음으로 표시한다", async () => { + const { fetchMock, requests } = makeFetchMock({ + solutionFiles: [makeSolutionFile("to-lower-case")], + patternResponse: { + patterns: [ + "Bit Manipulation", + "Greedy", + "Hash Map / Hash Set", + "Two Pointers", + "Dynamic Programming", + "Dynamic Programming", + ], + description: "알고리즘 패턴으로 분류하기 어렵다", + }, + }); + globalThis.fetch = fetchMock; + + await runAiReview(); + + expect(findCheckRun(requests).output.summary).toContain( + "| `to-lower-case/testuser.js` | - | 감지된 패턴 없음 | - |" + ); + }); + it("problem-categories.json 이 없으면 학습 현황 없이 게시한다", async () => { const requests = setupFetch({ solutionFiles: [makeSolutionFile("two-sum")], diff --git a/utils/openai.js b/utils/openai.js index cc5ce64..bdb5dc0 100644 --- a/utils/openai.js +++ b/utils/openai.js @@ -109,6 +109,39 @@ ${prDiff} return data.choices[0]?.message?.content || "Failed to generate review"; } +const ALGORITHM_PATTERNS = [ + "Two Pointers", + "Sliding Window", + "Fast & Slow Pointers", + "BFS", + "DFS", + "Backtracking", + "Dynamic Programming", + "Binary Search", + "Monotonic Stack", + "Heap / Priority Queue", + "Hash Map / Hash Set", + "Greedy", + "Divide and Conquer", + "Union Find", + "Trie", + "Bit Manipulation", +]; + +const MAX_PATTERNS = 3; + +/** + * 모델이 돌려준 패턴에서 목록에 없는 이름과 중복을 걸러낸다. + * 상한을 넘으면 패턴 목록을 그대로 베낀 응답이므로 하나도 믿지 않는다. + */ +export function sanitizePatterns(patterns) { + if (!Array.isArray(patterns)) return []; + const known = [...new Set(patterns)].filter((p) => + ALGORITHM_PATTERNS.includes(p) + ); + return known.length > MAX_PATTERNS ? [] : known; +} + /** * 솔루션 파일의 알고리즘 패턴 분석 * @@ -125,22 +158,7 @@ export async function generatePatternAnalysis(fileContent, problemName, problemI 주어진 소스 코드를 분석해서, 다음 패턴 목록 중 해당되는 것만 골라주세요. 감지 대상 패턴: -- Two Pointers -- Sliding Window -- Fast & Slow Pointers -- BFS -- DFS -- Backtracking -- Dynamic Programming -- Binary Search -- Monotonic Stack -- Heap / Priority Queue -- Hash Map / Hash Set -- Greedy -- Divide and Conquer -- Union Find -- Trie -- Bit Manipulation +${ALGORITHM_PATTERNS.map((p) => `- ${p}`).join("\n")} 반드시 JSON 객체로만 응답하세요. 형식: { @@ -152,11 +170,12 @@ export async function generatePatternAnalysis(fileContent, problemName, problemI 규칙: - patterns는 위 목록의 정확한 이름만 사용 -- 해당 패턴이 없으면 빈 배열 -- 일반적으로 1-3개 패턴이면 충분 +- 코드의 핵심 아이디어인 패턴만 최대 ${MAX_PATTERNS}개. 목록을 훑어 조금이라도 관련 있는 것을 모두 고르지 마세요 +- 단순 순회, 내장 함수 호출, 문자열 변환처럼 목록의 어느 패턴에도 해당하지 않으면 빈 배열 - description은 150자 이내 - 의도된 접근법이 주어지지 않으면 matchesIntendedApproach, approachExplanation 은 생략 - 핵심 자료구조나 알고리즘이 의도된 접근법과 같으면 matchesIntendedApproach=true (완전히 같을 필요는 없음) +- 의도된 접근법이 내장 함수 사용이나 단순 순회여서 패턴이 없어도, 코드가 그렇게 풀었으면 matchesIntendedApproach=true - 최적화된 접근법이 의도됐는데 브루트 포스로 풀었으면 matchesIntendedApproach=false - approachExplanation은 80자 이내`; @@ -193,7 +212,7 @@ ${fileContent} const parsed = JSON.parse(content); return { - patterns: Array.isArray(parsed.patterns) ? parsed.patterns : [], + patterns: sanitizePatterns(parsed.patterns), description: typeof parsed.description === "string" ? parsed.description : "", approach: problemInfo ? { diff --git a/utils/openai.test.js b/utils/openai.test.js new file mode 100644 index 0000000..88a4b73 --- /dev/null +++ b/utils/openai.test.js @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { sanitizePatterns } from "./openai.js"; + +describe("sanitizePatterns", () => { + it("목록에 있는 패턴은 그대로 둔다", () => { + expect(sanitizePatterns(["Two Pointers", "Greedy"])).toEqual([ + "Two Pointers", + "Greedy", + ]); + }); + + it("중복과 목록에 없는 이름을 걸러낸다", () => { + expect( + sanitizePatterns(["Two Pointers", "Two Pointers", "String Manipulation"]) + ).toEqual(["Two Pointers"]); + }); + + it("상한을 넘으면 빈 배열을 돌려준다", () => { + expect( + sanitizePatterns(["Two Pointers", "Greedy", "BFS", "DFS"]) + ).toEqual([]); + }); + + it("배열이 아니면 빈 배열을 돌려준다", () => { + expect(sanitizePatterns(undefined)).toEqual([]); + }); +});