Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 47 additions & 19 deletions handlers/complexity-analysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -62,21 +57,57 @@ 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)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정확한 복잡도를 적어도 주석 표기에 따라 판정이 달라지네요. 문자 거리로 가장 가까운 복잡도를 선택하면서 발생하는 문제라 수정이 필요할 거 같습니다.

4feb4dd 기준으로 확인한 테스트 PR #6입니다

주석 기대하는 공간 복잡도 실제 추출값
TC: O(n), SC: O(1) O(1) ❌ O(n)
Time O(n), Space O(1) O(1) ✅ O(1)

nearest = literal;
}
}
return nearest ? nearest[0] : null;
}

/**
* 헤더 라인 위쪽으로 빈 줄 또는 비-주석 라인을 만나기 전까지의 연속 주석 블록에서
* 시간/공간 복잡도 주석을 추출한다.
*
* 단, 외곽 클래스/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;
Expand All @@ -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;
}

/**
Expand Down
58 changes: 48 additions & 10 deletions handlers/complexity-analysis.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import {
isComplexityCommentLine,
stripComplexityComments,
extractBigO,
extractBigONear,
extractUserAnnotations,
bigOEquals,
cleanBigO,
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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 = `// 다른 풀이의 주석

Expand Down
8 changes: 5 additions & 3 deletions handlers/webhooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}` });
}
Expand Down
22 changes: 22 additions & 0 deletions handlers/webhooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
24 changes: 24 additions & 0 deletions tests/ai-review.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
Expand Down
57 changes: 38 additions & 19 deletions utils/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
* 솔루션 파일의 알고리즘 패턴 분석
*
Expand All @@ -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 객체로만 응답하세요. 형식:
{
Expand All @@ -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자 이내`;

Expand Down Expand Up @@ -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
? {
Expand Down
27 changes: 27 additions & 0 deletions utils/openai.test.js
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading