Skip to content

Commit 03e9d9d

Browse files
soobingsounmindclaude
committed
perf: GraphQL 배치 조회 + OpenAI 배치 분석으로 subrequest 절감
Cloudflare Workers의 50 subrequest 한도를 초과하는 문제를 해결한다. 1. fetchCohortUserSolutions: PR별 REST 파일 조회를 제거하고 GraphQL에 files(first:100)를 포함시켜 한 번에 조회 (기존 ~20회 → ~5회) 2. postLearningStatus: 파일별 OpenAI 개별 호출을 generateBatchApproachAnalysis로 일괄 처리 (기존 N회 → 1회) 전체 subrequest: 18+3N → 18+2N (N=5 기준 33→28회) Closes #10 Co-Authored-By: sounmind <37020415+sounmind@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d51353a commit 03e9d9d

3 files changed

Lines changed: 167 additions & 45 deletions

File tree

handlers/learning-status.js

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
fetchCohortUserSolutions,
1111
fetchPRSubmissions,
1212
} from "../utils/learningData.js";
13-
import { generateApproachAnalysis } from "../utils/openai.js";
13+
import { generateBatchApproachAnalysis } from "../utils/openai.js";
1414
import {
1515
formatLearningStatusComment,
1616
upsertLearningStatusComment,
@@ -105,9 +105,10 @@ export async function postLearningStatus(
105105
`[learningStatus] PR #${prNumber}: analyzing ${submissions.length} submission(s) for ${username}`
106106
);
107107

108-
// 4. 제출 파일별 AI 분석
108+
// 4. 제출 파일 코드 다운로드 (N회 fetch)
109109
const submissionResults = [];
110-
const totalUsage = { prompt_tokens: 0, completion_tokens: 0 };
110+
const batchItems = [];
111+
const batchIndices = [];
111112

112113
for (const submission of submissions) {
113114
const problemInfo = categories[submission.problemName];
@@ -126,7 +127,6 @@ export async function postLearningStatus(
126127
}
127128

128129
try {
129-
// 파일 원본 내용 가져오기
130130
const rawResponse = await fetch(submission.rawUrl);
131131
if (!rawResponse.ok) {
132132
throw new Error(`Failed to fetch raw file: ${rawResponse.status} ${rawResponse.statusText}`);
@@ -140,27 +140,18 @@ export async function postLearningStatus(
140140
);
141141
}
142142

143-
const analysis = await generateApproachAnalysis(
144-
fileContent,
145-
submission.problemName,
146-
problemInfo,
147-
openaiApiKey
148-
);
149-
150-
if (analysis.usage) {
151-
totalUsage.prompt_tokens += analysis.usage.prompt_tokens ?? 0;
152-
totalUsage.completion_tokens += analysis.usage.completion_tokens ?? 0;
153-
}
154-
143+
const idx = submissionResults.length;
155144
submissionResults.push({
156145
problemName: submission.problemName,
157146
difficulty: problemInfo.difficulty,
158-
matches: analysis.matches,
159-
explanation: analysis.explanation,
147+
matches: null,
148+
explanation: "",
160149
});
150+
batchItems.push({ problemName: submission.problemName, fileContent, problemInfo });
151+
batchIndices.push(idx);
161152
} catch (error) {
162153
console.error(
163-
`[learningStatus] Failed to analyze "${submission.problemName}": ${error.message}`
154+
`[learningStatus] Failed to fetch "${submission.problemName}": ${error.message}`
164155
);
165156
submissionResults.push({
166157
problemName: submission.problemName,
@@ -171,7 +162,27 @@ export async function postLearningStatus(
171162
}
172163
}
173164

174-
const hasUsage = totalUsage.prompt_tokens > 0 || totalUsage.completion_tokens > 0;
165+
// 5. AI 일괄 분석 (1회 OpenAI 호출로 모든 제출 파일 분석)
166+
let totalUsage = null;
167+
if (batchItems.length > 0) {
168+
try {
169+
console.log(
170+
`[learningStatus] PR #${prNumber}: batch analyzing ${batchItems.length} file(s) via OpenAI`
171+
);
172+
const { results: batchResults, usage } = await generateBatchApproachAnalysis(batchItems, openaiApiKey);
173+
totalUsage = usage;
174+
for (let i = 0; i < batchResults.length; i++) {
175+
submissionResults[batchIndices[i]].matches = batchResults[i].matches;
176+
submissionResults[batchIndices[i]].explanation = batchResults[i].explanation;
177+
}
178+
} catch (error) {
179+
console.error(
180+
`[learningStatus] Batch analysis failed: ${error.message}`
181+
);
182+
}
183+
}
184+
185+
const hasUsage = totalUsage != null;
175186

176187
// 5. 카테고리별 진행도 계산
177188
const totalProblems = Object.keys(categories).length;

utils/learningData.js

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -80,16 +80,21 @@ async function fetchActiveCohortProjectId(repoOwner, repoName, appToken) {
8080
}
8181

8282
/**
83-
* 기수 프로젝트에서 해당 유저가 머지한 PR 번호 목록을 반환한다.
84-
* 프로젝트 아이템을 페이지네이션하며 author.login으로 필터링한다.
83+
* 기수 프로젝트의 아이템을 페이지네이션하며 해당 유저가 머지한 PR의
84+
* 파일 경로를 GraphQL로 한 번에 조회하여 풀이한 문제 이름 목록을 반환한다.
85+
*
86+
* PR별 REST 호출 없이 GraphQL 응답에 files를 포함시켜 subrequest를 절약한다.
8587
*
8688
* @param {string} projectId
8789
* @param {string} username
8890
* @param {string} appToken
89-
* @returns {Promise<number[]>}
91+
* @returns {Promise<string[]>}
9092
*/
91-
async function fetchUserMergedPRsInProject(projectId, username, appToken) {
92-
const prNumbers = [];
93+
async function fetchCohortSolvedFromProject(projectId, username, appToken) {
94+
const usernamePattern = new RegExp(
95+
`^([^/]+)/${escapeRegExp(username)}\\.[^/]+$`
96+
);
97+
const problemNames = new Set();
9398
let cursor = null;
9499

95100
while (true) {
@@ -103,9 +108,11 @@ async function fetchUserMergedPRsInProject(projectId, username, appToken) {
103108
nodes {
104109
content {
105110
... on PullRequest {
106-
number
107111
state
108112
author { login }
113+
files(first: 100) {
114+
nodes { path }
115+
}
109116
}
110117
}
111118
}
@@ -124,15 +131,20 @@ async function fetchUserMergedPRsInProject(projectId, username, appToken) {
124131
pr?.state === "MERGED" &&
125132
pr?.author?.login?.toLowerCase() === username.toLowerCase()
126133
) {
127-
prNumbers.push(pr.number);
134+
for (const file of pr.files?.nodes || []) {
135+
const match = file.path.match(usernamePattern);
136+
if (match) {
137+
problemNames.add(match[1]);
138+
}
139+
}
128140
}
129141
}
130142

131143
if (!pageInfo.hasNextPage) break;
132144
cursor = pageInfo.endCursor;
133145
}
134146

135-
return prNumbers;
147+
return Array.from(problemNames);
136148
}
137149

138150
/**
@@ -165,31 +177,17 @@ export async function fetchCohortUserSolutions(
165177
return fetchUserSolutions(repoOwner, repoName, username, appToken);
166178
}
167179

168-
const prNumbers = await fetchUserMergedPRsInProject(
180+
const problems = await fetchCohortSolvedFromProject(
169181
projectId,
170182
username,
171183
appToken
172184
);
173185

174186
console.log(
175-
`[fetchCohortUserSolutions] ${username} has ${prNumbers.length} merged PRs in current cohort`
187+
`[fetchCohortUserSolutions] ${username} solved ${problems.length} problems in current cohort`
176188
);
177189

178-
const problemNames = new Set();
179-
for (const prNumber of prNumbers) {
180-
const submissions = await fetchPRSubmissions(
181-
repoOwner,
182-
repoName,
183-
prNumber,
184-
username,
185-
appToken
186-
);
187-
for (const { problemName } of submissions) {
188-
problemNames.add(problemName);
189-
}
190-
}
191-
192-
return Array.from(problemNames);
190+
return problems;
193191
}
194192

195193
/**

utils/openai.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,119 @@ ${truncatedContent}
258258
};
259259
}
260260

261+
/**
262+
* 여러 솔루션 파일의 접근법 일치 여부를 한 번의 API 호출로 일괄 분석.
263+
* subrequest 수를 줄이기 위해 파일당 개별 호출 대신 배치로 처리한다.
264+
*
265+
* @param {Array<{problemName: string, fileContent: string, problemInfo: object}>} items
266+
* @param {string} apiKey - OpenAI API 키
267+
* @returns {Promise<{results: Array<{matches: boolean, explanation: string}>, usage: object|null}>}
268+
*/
269+
export async function generateBatchApproachAnalysis(items, apiKey) {
270+
if (items.length === 0) return { results: [], usage: null };
271+
272+
// 단건이면 기존 함수 위임
273+
if (items.length === 1) {
274+
const { fileContent, problemName, problemInfo } = items[0];
275+
const result = await generateApproachAnalysis(fileContent, problemName, problemInfo, apiKey);
276+
return {
277+
results: [{ matches: result.matches, explanation: result.explanation }],
278+
usage: result.usage,
279+
};
280+
}
281+
282+
const systemPrompt = `You are an algorithm analysis expert. You will receive multiple problems. For each one, determine if the submitted code matches the intended approach.
283+
284+
Respond with a JSON object containing a "results" array with exactly ${items.length} entries, in the same order as the input:
285+
{
286+
"results": [
287+
{ "matches": true, "explanation": "한국어 1문장, 80자 이내" },
288+
...
289+
]
290+
}
291+
292+
Rules:
293+
- matches=true if the core data structure or algorithm matches the intended approach
294+
- matches=false if brute force was used when an optimized approach was intended
295+
- Keep each explanation to 1 sentence in Korean, 80 characters or fewer
296+
- You MUST return exactly ${items.length} results`;
297+
298+
const MAX_BATCH_FILE_SIZE = 5000;
299+
300+
const problemSections = items.map(({ problemName, fileContent, problemInfo }, i) => {
301+
const truncated = fileContent.slice(0, MAX_BATCH_FILE_SIZE);
302+
return `## 문제 ${i + 1}: ${problemName}
303+
- 난이도: ${problemInfo.difficulty}
304+
- 카테고리: ${(problemInfo.categories || []).join(", ")}
305+
- 의도된 접근법: ${problemInfo.intended_approach}
306+
307+
\`\`\`
308+
${truncated}
309+
\`\`\``;
310+
});
311+
312+
const userPrompt = problemSections.join("\n\n") +
313+
`\n\n위 ${items.length}개 코드가 각각 의도된 접근법과 일치하는지 분석해주세요.`;
314+
315+
const response = await fetch("https://api.openai.com/v1/chat/completions", {
316+
method: "POST",
317+
headers: {
318+
Authorization: `Bearer ${apiKey}`,
319+
"Content-Type": "application/json",
320+
},
321+
body: JSON.stringify({
322+
model: "gpt-4.1-nano",
323+
messages: [
324+
{ role: "system", content: systemPrompt },
325+
{ role: "user", content: userPrompt },
326+
],
327+
response_format: { type: "json_object" },
328+
max_tokens: 200 * items.length,
329+
temperature: 0.2,
330+
}),
331+
});
332+
333+
if (!response.ok) {
334+
const error = await response.text();
335+
throw new Error(`OpenAI batch API error: ${error}`);
336+
}
337+
338+
const data = await response.json();
339+
const content = data.choices[0]?.message?.content;
340+
341+
if (!content) {
342+
throw new Error("Empty response from OpenAI batch analysis");
343+
}
344+
345+
let parsed;
346+
try {
347+
parsed = JSON.parse(content);
348+
} catch {
349+
throw new Error(`OpenAI returned invalid JSON: ${content.slice(0, 200)}`);
350+
}
351+
352+
const rawResults = parsed.results;
353+
if (!Array.isArray(rawResults)) {
354+
throw new Error(`OpenAI did not return a results array`);
355+
}
356+
357+
if (rawResults.length !== items.length) {
358+
console.warn(
359+
`[generateBatchApproachAnalysis] Expected ${items.length} results, got ${rawResults.length}`
360+
);
361+
}
362+
363+
const results = items.map((_, i) => {
364+
const r = rawResults[i];
365+
return {
366+
matches: r?.matches === true,
367+
explanation: typeof r?.explanation === "string" ? r.explanation : "",
368+
};
369+
});
370+
371+
return { results, usage: data.usage ?? null };
372+
}
373+
261374
/**
262375
* 솔루션의 시간/공간 복잡도 분석.
263376
* 사용자가 코드 어딘가에 자유 포맷으로 남긴 TC/SC 주석을 함께 추출하여

0 commit comments

Comments
 (0)