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
2 changes: 0 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,9 @@ jobs:
packageManager: bun
secrets: |
PRIVATE_KEY
AI_GATEWAY_TOKEN
INTERNAL_SECRET
WEBHOOK_SECRET
env:
PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
AI_GATEWAY_TOKEN: ${{ secrets.AI_GATEWAY_TOKEN }}
INTERNAL_SECRET: ${{ secrets.INTERNAL_SECRET }}
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,15 @@ GitHub webhook
| ---------------------- | ----------------------------------------------------------------------------------- |
| `APP_ID`, `WORKER_URL` | `wrangler.jsonc` 의 `vars` (공개 값) |
| `PRIVATE_KEY` | GitHub org secret `APP_PRIVATE_KEY` |
| `AI_GATEWAY_TOKEN` | GitHub org secret `AI_GATEWAY_TOKEN` |
| `AI` | `wrangler.jsonc` 의 AI 바인딩. 같은 계정의 AI Gateway `dalestudy` 를 토큰 없이 부른다 |
| `INTERNAL_SECRET` | GitHub 저장소 secret `INTERNAL_SECRET` |
| `WEBHOOK_SECRET` | GitHub 저장소 secret `WEBHOOK_SECRET`. GitHub App 웹훅 설정의 secret 과 같은 값이다 |

GitHub secret 은 배포할 때마다 Worker secret 으로 들어가므로 `wrangler secret put` 으로 따로 바꾸지 않는다. 다음 배포 때 GitHub 값으로 덮인다.
로컬에서는 `.dev.vars` 에 같은 이름으로 넣는다. `.dev.vars`, `*.pem` 은 커밋하지 않는다.

OpenAI 키는 Worker 에 두지 않는다. AI Gateway `dalestudy` 의 Provider Keys(BYOK)에 저장돼 있고, 게이트웨이는 BYOK only 라 키가 없으면 Unified Billing 으로 넘어가지 않고 실패한다. AI 바인딩은 `wrangler dev` 에서도 원격 게이트웨이를 부르므로 `wrangler login` 이 dalestudy 계정에 되어 있어야 한다.

## GitHub App 설정

https://github.com/organizations/DaleStudy/settings/apps/dalestudy
Expand Down
33 changes: 11 additions & 22 deletions handlers/complexity-analysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* - LLM : actualTime / actualSpace / feedback / suggestion / headerLine 만 책임.
*/

import { aiGatewayHeaders, OPENAI_CHAT_COMPLETIONS_URL } from "../utils/constants.js";
import { createChatCompletion } from "../utils/openai.js";

// ── 상수 ──────────────────────────────────────────

Expand Down Expand Up @@ -242,35 +242,24 @@ export function composeSolution(modelSol, originalContent) {
};
}

export async function callComplexityAnalysis(fileEntries, gatewayToken) {
export async function callComplexityAnalysis(fileEntries, ai) {
const userPrompt = fileEntries
.map(
(f) =>
`${FILE_DELIMITER} ${f.problemName} ${FILE_DELIMITER}\n\`\`\`\n${addLineNumbers(stripComplexityComments(f.content))}\n\`\`\``
)
.join("\n\n");

const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, {
method: "POST",
headers: aiGatewayHeaders(gatewayToken),
body: JSON.stringify({
model: "gpt-5-nano",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
],
response_format: { type: "json_object" },
max_completion_tokens: 5000,
reasoning_effort: "minimal",
}),
const data = await createChatCompletion(ai, {
model: "gpt-5-nano",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
],
response_format: { type: "json_object" },
max_completion_tokens: 5000,
reasoning_effort: "minimal",
});

if (!response.ok) {
const error = await response.text();
throw new Error(`OpenAI API error: ${error}`);
}

const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) throw new Error("Empty response from OpenAI");

Expand Down
46 changes: 28 additions & 18 deletions handlers/complexity-analysis.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
callComplexityAnalysis,
renderComplexitySection,
} from "./complexity-analysis.js";
import { createFakeAi } from "../tests/fakeAi.js";

const PLAIN_SOURCE = "function solution() { return 0; }";

Expand Down Expand Up @@ -452,6 +453,8 @@ function solution() {}`;
// ── callComplexityAnalysis ────────────────────────

describe("callComplexityAnalysis", () => {
const ai = createFakeAi();

beforeEach(() => {
vi.clearAllMocks();
});
Expand All @@ -471,7 +474,7 @@ describe("callComplexityAnalysis", () => {
{ problemName: "valid-parentheses", content: PLAIN_SOURCE },
];

const results = await callComplexityAnalysis(fileEntries, "fake-key");
const results = await callComplexityAnalysis(fileEntries, ai);

expect(results).toHaveLength(2);
expect(results[0].problemName).toBe("two-sum");
Expand All @@ -485,7 +488,7 @@ describe("callComplexityAnalysis", () => {
await expect(
callComplexityAnalysis(
[{ problemName: "two-sum", content: PLAIN_SOURCE }],
"fake-key"
ai
)
).rejects.toThrow("OpenAI API error");
});
Expand All @@ -498,7 +501,7 @@ describe("callComplexityAnalysis", () => {
await expect(
callComplexityAnalysis(
[{ problemName: "two-sum", content: PLAIN_SOURCE }],
"fake-key"
ai
)
).rejects.toThrow("Empty response from OpenAI");
});
Expand All @@ -513,7 +516,7 @@ describe("callComplexityAnalysis", () => {
await expect(
callComplexityAnalysis(
[{ problemName: "two-sum", content: PLAIN_SOURCE }],
"fake-key"
ai
)
).rejects.toThrow("OpenAI returned invalid JSON");
});
Expand All @@ -530,7 +533,7 @@ describe("callComplexityAnalysis", () => {
{ problemName: "longest-prefix", content: PLAIN_SOURCE },
];

const results = await callComplexityAnalysis(fileEntries, "fake-key");
const results = await callComplexityAnalysis(fileEntries, ai);

expect(results).toHaveLength(1);
expect(results[0].problemName).toBe("longest-prefix");
Expand All @@ -550,32 +553,39 @@ describe("callComplexityAnalysis", () => {
function solution() { return 0; }`;
await callComplexityAnalysis(
[{ problemName: "two-sum", content: source }],
"fake-key"
ai
);

expect(captured).toContain("L1: \nL2: \nL3: function solution()");
expect(captured).not.toContain("TC: O(n)");
expect(captured).not.toContain("SC: O(1)");
});

it("AI Gateway 인증 헤더와 재시도 헤더를 함께 보낸다", async () => {
let captured = null;
globalThis.fetch = vi.fn().mockImplementation(async (url, opts) => {
captured = opts.headers;
return await makeOpenAIResponse([makeSingleSolutionAnalysis("two-sum")]);
});
it("dalestudy 게이트웨이의 OpenAI chat/completions 로 재시도 헤더와 함께 보낸다", async () => {
globalThis.fetch = vi
.fn()
.mockResolvedValueOnce(
await makeOpenAIResponse([makeSingleSolutionAnalysis("two-sum")])
);

await callComplexityAnalysis(
[{ problemName: "two-sum", content: PLAIN_SOURCE }],
"fake-key"
ai
);

expect(captured).toMatchObject({
"cf-aig-authorization": "Bearer fake-key",
"cf-aig-max-attempts": "3",
"cf-aig-retry-delay": "1000",
"cf-aig-backoff": "exponential",
expect(ai.gateway).toHaveBeenCalledWith("dalestudy");
const request = ai.run.mock.calls[0][0];
expect(request).toMatchObject({
provider: "openai",
endpoint: "chat/completions",
headers: {
"cf-aig-max-attempts": 3,
"cf-aig-retry-delay": 1000,
"cf-aig-backoff": "exponential",
},
});
// 인증은 바인딩이 하므로 게이트웨이 토큰을 싣지 않는다
expect(request.headers).not.toHaveProperty("cf-aig-authorization");
});
});

Expand Down
4 changes: 2 additions & 2 deletions handlers/internal-dispatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async function handleTagPatterns(payload, appToken, env) {
headSha,
prData,
appToken,
env.AI_GATEWAY_TOKEN
env.AI
);
return corsResponse({ handler: "tag-patterns", result });
}
Expand All @@ -85,7 +85,7 @@ async function handleLearningStatus(payload, appToken, env) {
prNumber,
username,
appToken,
env.AI_GATEWAY_TOKEN
env.AI
);
return corsResponse({ handler: "learning-status", result });
}
10 changes: 6 additions & 4 deletions handlers/internal-dispatch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { tagPatterns } from "./tag-patterns.js";
import { postLearningStatus } from "./learning-status.js";
import { generateGitHubAppToken } from "../utils/github.js";

const FAKE_AI = { gateway: () => {} };

const VALID_SECRET = "test-secret-123";

function makeRequest(pathname, { secret, body } = {}) {
Expand Down Expand Up @@ -88,7 +90,7 @@ describe("handleInternalDispatch — 인증", () => {
});

describe("handleInternalDispatch — 라우팅", () => {
const env = { INTERNAL_SECRET: VALID_SECRET, AI_GATEWAY_TOKEN: "fake-gateway-token" };
const env = { INTERNAL_SECRET: VALID_SECRET, AI: FAKE_AI };

beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -123,7 +125,7 @@ describe("handleInternalDispatch — 라우팅", () => {
"abc123",
prData,
"fake-token",
"fake-gateway-token"
FAKE_AI
);
expect(postLearningStatus).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -154,7 +156,7 @@ describe("handleInternalDispatch — 라우팅", () => {
42,
"testuser",
"fake-token",
"fake-gateway-token"
FAKE_AI
);
expect(tagPatterns).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -200,7 +202,7 @@ describe("handleInternalDispatch — 라우팅", () => {
});

describe("handleInternalDispatch — 에러 처리", () => {
const env = { INTERNAL_SECRET: VALID_SECRET, AI_GATEWAY_TOKEN: "fake-gateway-token" };
const env = { INTERNAL_SECRET: VALID_SECRET, AI: FAKE_AI };

beforeEach(() => {
vi.clearAllMocks();
Expand Down
6 changes: 3 additions & 3 deletions handlers/learning-status.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ function buildCategoryProgress(categories, solvedProblems) {
* @param {number} prNumber
* @param {string} username
* @param {string} appToken - GitHub App installation token
* @param {string} openaiApiKey
* @param {Ai} ai - Worker 의 AI 바인딩 (env.AI)
* @returns {Promise<{ skipped: string }|{ analyzed: number, matched: number }>}
*/
export async function postLearningStatus(
Expand All @@ -102,7 +102,7 @@ export async function postLearningStatus(
prNumber,
username,
appToken,
openaiApiKey
ai
) {
// 1. 문제 카테고리 메타데이터 조회
const categories = await fetchProblemCategories(repoOwner, repoName, appToken);
Expand Down Expand Up @@ -167,7 +167,7 @@ export async function postLearningStatus(
fileContent,
submission.problemName,
problemInfo,
openaiApiKey
ai
);

if (analysis.usage) {
Expand Down
8 changes: 4 additions & 4 deletions handlers/tag-patterns.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const MAX_CHECK_RUN_OUTPUT_LENGTH = 65535;
* @param {string} headSha - PR head commit SHA
* @param {object} prData - PR 객체 (draft, labels 포함)
* @param {string} appToken - GitHub App installation token
* @param {string} openaiApiKey
* @param {Ai} ai - Worker 의 AI 바인딩 (env.AI)
*/
export async function tagPatterns(
repoOwner,
Expand All @@ -44,7 +44,7 @@ export async function tagPatterns(
headSha,
prData,
appToken,
openaiApiKey
ai
) {
// 2-1. Skip 조건
if (prData.draft === true) {
Expand Down Expand Up @@ -84,7 +84,7 @@ export async function tagPatterns(
const fileEntries = await downloadFileEntries(solutionFiles);

// 2-4. 복잡도 분석은 1콜이므로 패턴 루프와 병렬 진행. 실패해도 패턴 분석은 게시.
const complexityPromise = callComplexityAnalysis(fileEntries, openaiApiKey)
const complexityPromise = callComplexityAnalysis(fileEntries, ai)
.catch((err) => {
console.error(`[tagPatterns] complexity analysis failed: ${err.message}`);
return [];
Expand All @@ -97,7 +97,7 @@ export async function tagPatterns(
const analysis = await generatePatternAnalysis(
fe.content,
fe.problemName,
openaiApiKey
ai
);
analyses.push({ fileEntry: fe, analysis });
} catch (error) {
Expand Down
Loading
Loading