Skip to content
Merged
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
18 changes: 17 additions & 1 deletion handlers/learning-status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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;
Expand Down
106 changes: 105 additions & 1 deletion utils/learningComment.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,95 @@ import { getGitHubHeaders } from "./github.js";
*/
const COMMENT_MARKER = "<!-- dalestudy-learning-status -->";

/**
* Hidden marker for embedding cumulative usage data in the comment.
* Format: <!-- usage-data: {"prompt":N,"completion":N,"requests":N} -->
*/
const USAGE_DATA_RE = /<!-- usage-data: ({.*?}) -->/;

/** 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("<details>");
lines.push("<summary>🔢 API 사용량 (gpt-4.1-nano)</summary>");
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("</details>");

// Embed history for future parsing
lines.push(`<!-- usage-data: ${JSON.stringify(history)} -->`);

return lines.join("\n");
}

/**
* Returns true when `comment` is a Bot comment containing our marker.
*
Expand Down Expand Up @@ -129,19 +218,24 @@ 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<void>}
*/
export async function upsertLearningStatusComment(
repoOwner,
repoName,
prNumber,
commentBody,
appToken
appToken,
currentUsage = null
) {
const baseUrl = `https://api.github.com/repos/${repoOwner}/${repoName}`;

Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions utils/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,5 +254,6 @@ ${truncatedContent}
return {
matches: parsed.matches === true,
explanation: typeof parsed.explanation === "string" ? parsed.explanation : "",
usage: data.usage ?? null,
};
}