From cc98312f15aa190cb06d3b6969cd54f10e7e06fc Mon Sep 17 00:00:00 2001 From: soobing Date: Wed, 8 Apr 2026 21:23:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20OpenAI=20API=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EB=9F=89=20=EC=B8=A1=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?PR=20=EB=8C=93=EA=B8=80=EC=97=90=20=ED=91=9C=EC=8B=9C=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - openai.js: generateApproachAnalysis가 usage 필드(prompt_tokens, completion_tokens) 반환하도록 수정 - learning-status.js: 제출 파일별 AI 분석 호출마다 토큰 누적 집계 - learningComment.js: PR 댓글 하단에 요청별 토큰 사용량 이력 테이블 추가 - 요청 횟수(#1, #2, ...)별 입력/출력/합계 토큰 및 비용 표시 - 2회 이상 시 합계 행 표시 - 이력을 hidden HTML 마커로 댓글에 저장해 업데이트 시 누적 Co-Authored-By: sounmind Co-Authored-By: Claude Sonnet 4.6 --- handlers/learning-status.js | 18 +++++- utils/learningComment.js | 106 +++++++++++++++++++++++++++++++++++- utils/openai.js | 1 + 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/handlers/learning-status.js b/handlers/learning-status.js index 87025e9..b3676ae 100644 --- a/handlers/learning-status.js +++ b/handlers/learning-status.js @@ -107,6 +107,8 @@ export async function postLearningStatus( // 4. 제출 파일별 AI 분석 const submissionResults = []; + const totalUsage = { prompt_tokens: 0, completion_tokens: 0 }; + for (const submission of submissions) { const problemInfo = categories[submission.problemName]; @@ -145,6 +147,11 @@ export async function postLearningStatus( openaiApiKey ); + if (analysis.usage) { + totalUsage.prompt_tokens += analysis.usage.prompt_tokens ?? 0; + totalUsage.completion_tokens += analysis.usage.completion_tokens ?? 0; + } + submissionResults.push({ problemName: submission.problemName, difficulty: problemInfo.difficulty, @@ -164,6 +171,8 @@ export async function postLearningStatus( } } + const hasUsage = totalUsage.prompt_tokens > 0 || totalUsage.completion_tokens > 0; + // 5. 카테고리별 진행도 계산 const totalProblems = Object.keys(categories).length; const categoryProgress = buildCategoryProgress(categories, solvedProblems); @@ -178,7 +187,14 @@ export async function postLearningStatus( ); // 7. 댓글 생성 또는 업데이트 - await upsertLearningStatusComment(repoOwner, repoName, prNumber, commentBody, appToken); + await upsertLearningStatusComment( + repoOwner, + repoName, + prNumber, + commentBody, + appToken, + hasUsage ? totalUsage : null + ); // 8. 결과 반환 const matchedCount = submissionResults.filter((r) => r.matches === true).length; diff --git a/utils/learningComment.js b/utils/learningComment.js index 53b77c0..28b34f1 100644 --- a/utils/learningComment.js +++ b/utils/learningComment.js @@ -9,6 +9,95 @@ import { getGitHubHeaders } from "./github.js"; */ const COMMENT_MARKER = ""; +/** + * Hidden marker for embedding cumulative usage data in the comment. + * Format: + */ +const USAGE_DATA_RE = //; + +/** gpt-4.1-nano pricing (USD per token) */ +const INPUT_COST_PER_TOKEN = 0.10 / 1_000_000; +const OUTPUT_COST_PER_TOKEN = 0.40 / 1_000_000; + +/** + * Calculates cost in USD from token counts. + * + * @param {number} promptTokens + * @param {number} completionTokens + * @returns {number} + */ +function calcCost(promptTokens, completionTokens) { + return promptTokens * INPUT_COST_PER_TOKEN + completionTokens * OUTPUT_COST_PER_TOKEN; +} + +/** + * Formats a number with thousand-separating commas. + * + * @param {number} n + * @returns {string} + */ +function fmt(n) { + return n.toLocaleString("en-US"); +} + +/** + * Parses per-request usage history embedded in an existing comment body. + * + * @param {string|undefined} body + * @returns {Array<{ prompt: number, completion: number }>} + */ +function parseUsageFromComment(body) { + if (!body) return []; + const match = body.match(USAGE_DATA_RE); + if (!match) return []; + try { + const parsed = JSON.parse(match[1]); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** + * Builds the usage footer section (per-request history table + hidden data marker). + * + * @param {Array<{ prompt: number, completion: number }>} history - all requests including current (latest last) + * @returns {string} + */ +function formatUsageSection(history) { + const lines = []; + lines.push("
"); + lines.push("🔢 API 사용량 (gpt-4.1-nano)"); + lines.push(""); + lines.push("| 요청 | 입력 토큰 | 출력 토큰 | 합계 | 비용 |"); + lines.push("|---:|---:|---:|---:|---:|"); + + let totalPrompt = 0; + let totalCompletion = 0; + + for (let i = 0; i < history.length; i++) { + const { prompt, completion } = history[i]; + const total = prompt + completion; + const cost = calcCost(prompt, completion); + lines.push(`| #${i + 1} | ${fmt(prompt)} | ${fmt(completion)} | ${fmt(total)} | $${cost.toFixed(6)} |`); + totalPrompt += prompt; + totalCompletion += completion; + } + + if (history.length > 1) { + const totalCost = calcCost(totalPrompt, totalCompletion); + lines.push(`| **합계** | **${fmt(totalPrompt)}** | **${fmt(totalCompletion)}** | **${fmt(totalPrompt + totalCompletion)}** | **$${totalCost.toFixed(6)}** |`); + } + + lines.push(""); + lines.push("
"); + + // Embed history for future parsing + lines.push(``); + + return lines.join("\n"); +} + /** * Returns true when `comment` is a Bot comment containing our marker. * @@ -129,11 +218,15 @@ export function formatLearningStatusComment( * Searches through the first 100 issue comments for an existing Bot comment * that contains COMMENT_MARKER. If found, PATCHes it; otherwise POSTs a new one. * + * When `currentUsage` is provided, appends a collapsible usage/cost section to + * the comment and accumulates it with any previously stored cumulative totals. + * * @param {string} repoOwner * @param {string} repoName * @param {number} prNumber * @param {string} commentBody * @param {string} appToken + * @param {{ prompt_tokens: number, completion_tokens: number }|null} [currentUsage] * @returns {Promise} */ export async function upsertLearningStatusComment( @@ -141,7 +234,8 @@ export async function upsertLearningStatusComment( repoName, prNumber, commentBody, - appToken + appToken, + currentUsage = null ) { const baseUrl = `https://api.github.com/repos/${repoOwner}/${repoName}`; @@ -160,6 +254,16 @@ export async function upsertLearningStatusComment( const comments = await listResponse.json(); const existing = comments.find(isLearningStatusComment); + // Append usage section when token data is available + if (currentUsage) { + const prevHistory = parseUsageFromComment(existing?.body); + const history = [ + ...prevHistory, + { prompt: currentUsage.prompt_tokens, completion: currentUsage.completion_tokens }, + ]; + commentBody = commentBody.trimEnd() + "\n\n" + formatUsageSection(history) + "\n"; + } + if (existing) { // Update existing comment console.log( diff --git a/utils/openai.js b/utils/openai.js index cee3539..bc8f7ee 100644 --- a/utils/openai.js +++ b/utils/openai.js @@ -254,5 +254,6 @@ ${truncatedContent} return { matches: parsed.matches === true, explanation: typeof parsed.explanation === "string" ? parsed.explanation : "", + usage: data.usage ?? null, }; }