Skip to content

Commit 6768293

Browse files
authored
Merge pull request #9 from DaleStudy/6-개인화된-솔루션-제공
개인화된 학습 현황 댓글 자동 작성
2 parents c11c641 + 2f00187 commit 6768293

5 files changed

Lines changed: 674 additions & 0 deletions

File tree

handlers/learning-status.js

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* 학습 현황 오케스트레이션 핸들러
3+
*
4+
* PR 제출 파일들을 AI로 분석하여 사용자의 누적 학습 현황을
5+
* PR 이슈 댓글로 게시하거나 업데이트한다.
6+
*/
7+
8+
import {
9+
fetchProblemCategories,
10+
fetchUserSolutions,
11+
fetchPRSubmissions,
12+
} from "../utils/learningData.js";
13+
import { generateApproachAnalysis } from "../utils/openai.js";
14+
import {
15+
formatLearningStatusComment,
16+
upsertLearningStatusComment,
17+
} from "../utils/learningComment.js";
18+
19+
const MAX_FILE_SIZE = 15000; // 15K 문자 제한 (OpenAI 토큰 안전장치)
20+
21+
/**
22+
* 카테고리별로 누적 풀이 진행도를 계산한다.
23+
*
24+
* @param {object} categories - problem-categories.json 전체 오브젝트
25+
* @param {string[]} solvedProblems - 사용자가 풀이한 문제 이름 배열
26+
* @returns {Array<{ category: string, solved: number, total: number, difficulties: string }>}
27+
*/
28+
function buildCategoryProgress(categories, solvedProblems) {
29+
const solvedSet = new Set(solvedProblems);
30+
const categoryMap = new Map();
31+
32+
for (const [problemName, info] of Object.entries(categories)) {
33+
for (const cat of info.categories) {
34+
if (!categoryMap.has(cat)) {
35+
categoryMap.set(cat, { total: 0, solved: 0, solvedDifficulties: [] });
36+
}
37+
const entry = categoryMap.get(cat);
38+
entry.total++;
39+
if (solvedSet.has(problemName)) {
40+
entry.solved++;
41+
entry.solvedDifficulties.push(info.difficulty);
42+
}
43+
}
44+
}
45+
46+
return [...categoryMap.entries()]
47+
.sort((a, b) => {
48+
const ratioA = a[1].total > 0 ? a[1].solved / a[1].total : 0;
49+
const ratioB = b[1].total > 0 ? b[1].solved / b[1].total : 0;
50+
if (ratioB !== ratioA) return ratioB - ratioA;
51+
return a[0].localeCompare(b[0]);
52+
})
53+
.map(([cat, data]) => {
54+
const diffCounts = {};
55+
for (const d of data.solvedDifficulties) {
56+
diffCounts[d] = (diffCounts[d] || 0) + 1;
57+
}
58+
const difficulties = Object.entries(diffCounts)
59+
.map(([d, c]) => `${d} ${c}`)
60+
.join(", ");
61+
return { category: cat, solved: data.solved, total: data.total, difficulties };
62+
});
63+
}
64+
65+
/**
66+
* 학습 현황 기능 전체 흐름 오케스트레이션
67+
*
68+
* @param {string} repoOwner
69+
* @param {string} repoName
70+
* @param {number} prNumber
71+
* @param {string} username
72+
* @param {string} appToken - GitHub App installation token
73+
* @param {string} openaiApiKey
74+
* @returns {Promise<{ skipped: string }|{ analyzed: number, matched: number }>}
75+
*/
76+
export async function postLearningStatus(
77+
repoOwner,
78+
repoName,
79+
prNumber,
80+
username,
81+
appToken,
82+
openaiApiKey
83+
) {
84+
// 1. 문제 카테고리 메타데이터 조회
85+
const categories = await fetchProblemCategories(repoOwner, repoName, appToken);
86+
if (!categories) {
87+
console.log(`[learningStatus] PR #${prNumber}: problem-categories.json not found, skipping`);
88+
return { skipped: "no-categories-file" };
89+
}
90+
91+
// 2. 사용자의 누적 풀이 목록 조회
92+
const solvedProblems = await fetchUserSolutions(repoOwner, repoName, username, appToken);
93+
console.log(
94+
`[learningStatus] PR #${prNumber}: ${username} has ${solvedProblems.length} cumulative solutions`
95+
);
96+
97+
// 3. 이번 PR 제출 파일 목록 조회
98+
const submissions = await fetchPRSubmissions(repoOwner, repoName, prNumber, username, appToken);
99+
if (submissions.length === 0) {
100+
console.log(`[learningStatus] PR #${prNumber}: no solution files found for ${username}, skipping`);
101+
return { skipped: "no-solution-files" };
102+
}
103+
104+
console.log(
105+
`[learningStatus] PR #${prNumber}: analyzing ${submissions.length} submission(s) for ${username}`
106+
);
107+
108+
// 4. 제출 파일별 AI 분석
109+
const submissionResults = [];
110+
for (const submission of submissions) {
111+
const problemInfo = categories[submission.problemName];
112+
113+
if (!problemInfo) {
114+
console.log(
115+
`[learningStatus] No category info for "${submission.problemName}", skipping AI analysis`
116+
);
117+
submissionResults.push({
118+
problemName: submission.problemName,
119+
difficulty: null,
120+
matches: null,
121+
explanation: "",
122+
});
123+
continue;
124+
}
125+
126+
try {
127+
// 파일 원본 내용 가져오기
128+
const rawResponse = await fetch(submission.rawUrl);
129+
if (!rawResponse.ok) {
130+
throw new Error(`Failed to fetch raw file: ${rawResponse.status} ${rawResponse.statusText}`);
131+
}
132+
133+
let fileContent = await rawResponse.text();
134+
if (fileContent.length > MAX_FILE_SIZE) {
135+
fileContent = fileContent.slice(0, MAX_FILE_SIZE);
136+
console.log(
137+
`[learningStatus] Truncated "${submission.problemName}" to ${MAX_FILE_SIZE} chars`
138+
);
139+
}
140+
141+
const analysis = await generateApproachAnalysis(
142+
fileContent,
143+
submission.problemName,
144+
problemInfo,
145+
openaiApiKey
146+
);
147+
148+
submissionResults.push({
149+
problemName: submission.problemName,
150+
difficulty: problemInfo.difficulty,
151+
matches: analysis.matches,
152+
explanation: analysis.explanation,
153+
});
154+
} catch (error) {
155+
console.error(
156+
`[learningStatus] Failed to analyze "${submission.problemName}": ${error.message}`
157+
);
158+
submissionResults.push({
159+
problemName: submission.problemName,
160+
difficulty: problemInfo.difficulty,
161+
matches: null,
162+
explanation: "",
163+
});
164+
}
165+
}
166+
167+
// 5. 카테고리별 진행도 계산
168+
const totalProblems = Object.keys(categories).length;
169+
const categoryProgress = buildCategoryProgress(categories, solvedProblems);
170+
171+
// 6. 댓글 본문 포맷
172+
const commentBody = formatLearningStatusComment(
173+
username,
174+
submissionResults,
175+
solvedProblems.length,
176+
totalProblems,
177+
categoryProgress
178+
);
179+
180+
// 7. 댓글 생성 또는 업데이트
181+
await upsertLearningStatusComment(repoOwner, repoName, prNumber, commentBody, appToken);
182+
183+
// 8. 결과 반환
184+
const matchedCount = submissionResults.filter((r) => r.matches === true).length;
185+
return { analyzed: submissionResults.length, matched: matchedCount };
186+
}

handlers/webhooks.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { ALLOWED_REPO } from "../utils/constants.js";
2222
import { performAIReview, addReactionToComment } from "../utils/prReview.js";
2323
import { hasApprovedReview, safeJson } from "../utils/prActions.js";
2424
import { tagPatterns } from "./tag-patterns.js";
25+
import { postLearningStatus } from "./learning-status.js";
2526

2627
/**
2728
* GitHub webhook 이벤트 처리
@@ -270,6 +271,23 @@ async function handlePullRequestEvent(payload, env) {
270271
}
271272
}
272273

274+
// 학습 현황 댓글 (OPENAI_API_KEY 있을 때만)
275+
if (env.OPENAI_API_KEY) {
276+
try {
277+
await postLearningStatus(
278+
repoOwner,
279+
repoName,
280+
prNumber,
281+
pr.user.login,
282+
appToken,
283+
env.OPENAI_API_KEY
284+
);
285+
} catch (error) {
286+
console.error(`[handlePullRequestEvent] learningStatus failed: ${error.message}`);
287+
// 학습 현황 실패는 전체 흐름을 중단시키지 않음
288+
}
289+
}
290+
273291
return corsResponse({
274292
message: "Processed",
275293
pr: prNumber,

0 commit comments

Comments
 (0)