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
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,16 @@ AI prediction은 deterministic possibility score를 대체하지 않습니다. W

기본 AI provider는 Gemini API입니다. consumer repository에는 `GEMINI_API_KEY` secret을 설정해야 합니다.

AI prediction 대상은 기본적으로 `medium` 이상 possibility입니다. 현재 기준으로 score가 25점 이상인 branch만 AI prediction 대상으로 선택됩니다. 낮은 score의 branch는 AI 호출을 생략하고 `skipped` 상태로 report에 표시됩니다.
AI prediction 대상은 기본적으로 `critical` possibility입니다. 이미 virtual merge에서 conflict가 확정된 branch는 AI 호출 없이 deterministic report만 사용합니다. 그 외 낮은 status의 branch는 AI 호출을 생략하고 `skipped` 상태로 report에 표시됩니다.

Gemini free tier rate limit을 줄이기 위해 선택된 branch prediction은 순차 실행하며 기본적으로 호출 사이에 60초를 대기합니다.

AI prediction 결과는 다음 상태 중 하나입니다.

| status | 의미 |
| --- | --- |
| `predicted` | Gemini API 응답을 검증했고 prediction과 recommended actions를 report에 포함함 |
| `skipped` | deterministic possibility score가 threshold보다 낮아 AI 호출을 생략함 |
| `skipped` | AI prediction 대상이 아니어서 provider 호출을 생략함 |
| `failed` | provider 호출이나 응답 검증에 실패해 branch 단위 실패로 격리함 |

AI provider가 실패해도 deterministic possibility report는 유지됩니다. 실패한 branch는 `failed` 상태와 error message를 report에 포함합니다.
Expand Down Expand Up @@ -187,7 +189,7 @@ npm test

5. stdout report가 정상일 때만 consumer repository secret에 `DISCORD_WEBHOOK_URL`을 추가하고 같은 workflow를 다시 수동 실행합니다.

Discord 메시지가 정상적으로 도착하면 consumer repository의 예시 workflow에 `schedule`과 `pull_request` trigger를 유지해 실서비스 실행으로 전환합니다.
Discord 메시지가 정상적으로 도착하면 consumer repository의 예시 workflow에 `schedule` trigger를 유지해 실서비스 실행으로 전환합니다.

## Local test와 scheduled run 차이

Expand All @@ -199,12 +201,11 @@ scheduled run은 consumer repository의 실제 remote branch를 fetch하고, `ba

| 증상 | 확인할 항목 |
| --- | --- |
| workflow가 시작되지 않음 | consumer workflow가 `pull_request`, `schedule`, `workflow_dispatch` 중 필요한 trigger를 가지고 있는지 확인 |
| fork PR에서 실행되지 않음 | 예시 workflow의 `github.event.pull_request.head.repo.fork == false` 조건 확인 |
| workflow가 시작되지 않음 | consumer workflow가 `schedule`, `workflow_dispatch` 중 필요한 trigger를 가지고 있는지 확인 |
| checkout 또는 fetch 실패 | `WATCHER_GITHUB_TOKEN` 권한과 `contents: read` permission 확인 |
| PR metadata가 비어 있음 | `pull-requests: read` permission과 commit에 연결된 PR 존재 여부 확인 |
| check metadata가 비어 있음 | `checks: read` permission과 해당 branch head SHA의 check run 존재 여부 확인 |
| AI prediction이 `skipped`로 표시됨 | deterministic possibility score가 기본 threshold인 25점 미만인지 확인 |
| AI prediction이 `failed`로 표시됨 | `GEMINI_API_KEY` secret과 Gemini API 응답 형식 확인 |
| AI prediction이 `skipped`로 표시됨 | deterministic possibility status가 `critical`인지와 `confirmed_conflict`가 아닌지 확인 |
| AI prediction이 `failed`로 표시됨 | `GEMINI_API_KEY` secret, Gemini API 응답 형식, rate limit 상태 확인 |
| Discord 전송이 되지 않음 | `DISCORD_WEBHOOK_URL` secret과 webhook channel 권한 확인 |
| merge된 branch가 계속 감시됨 | GitHub `Automatically delete head branches` 설정과 원격 branch 삭제 상태 확인 |
172 changes: 157 additions & 15 deletions src/ai/geminiPredictionClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,37 @@ import type {
// workflow log가 과도하게 커지지 않도록 Gemini 실패 응답 본문을 제한
const MAX_GEMINI_ERROR_DETAIL_LENGTH = 1000

// Gemini 서버 capacity나 일시적 내부 오류는 backoff 후 재시도 가능
const RETRYABLE_GEMINI_STATUS_CODES = new Set([429, 500, 502, 503, 504])

// Gemini 일시 실패가 장기 장애인지 판단하기 전까지 허용할 총 요청 횟수
const DEFAULT_GEMINI_MAX_ATTEMPTS = 5

// 503 계열 일시 실패의 exponential backoff 시작 대기 시간
const DEFAULT_GEMINI_RETRY_BASE_DELAY_MS = 2000

// 429 rate limit은 서버 capacity와 별도로 더 길게 대기
const DEFAULT_GEMINI_RATE_LIMIT_DELAY_MS = 60000

// 같은 시점에 retry가 몰리지 않도록 추가할 최대 jitter
const DEFAULT_GEMINI_RETRY_JITTER_MS = 1000

// Watcher가 Gemini prediction에 기본으로 사용할 model 이름
export const DEFAULT_GEMINI_PREDICTION_MODEL = "gemini-3.5-flash"

// consumer repository에서 Gemini API key를 주입할 environment variable 이름
export const GEMINI_API_KEY_ENV_NAME = "GEMINI_API_KEY"

// 테스트와 운영 환경에서 Gemini 연결값을 바꿔 끼우기 위한 설정
// 호출 환경에 맞게 Gemini 연결값을 바꿔 끼우기 위한 설정
export type GeminiPredictionClientOptions = {
apiKey?: string
model?: string
endpoint?: string
fetch?: typeof fetch
maxAttempts?: number
retryBaseDelayMs?: number
rateLimitDelayMs?: number
retryJitterMs?: number
}

// Watcher가 사용하는 Gemini generateContent 응답 중 JSON text 추출에 필요한 최소 shape
Expand All @@ -39,12 +58,24 @@ export class GeminiPredictionClient implements AiPredictionClient {
// generateContent endpoint에 포함할 Gemini model 이름
private readonly model: string

// 테스트와 API version 교체를 위해 분리한 Gemini REST base URL
// API version 교체와 proxy 구성을 위해 분리한 Gemini REST base URL
private readonly endpoint: string

// 테스트에서 network 없이 검증할 수 있도록 주입 가능한 fetch 구현
// 실행 환경별 HTTP 호출 구현을 바꿔 끼우기 위한 fetch 구현
private readonly fetcher: typeof fetch

// 일시 실패가 지속될 때 최대 몇 번까지 요청할지 결정
private readonly maxAttempts: number

// 503 계열 일시 실패의 exponential backoff 시작 대기 시간
private readonly retryBaseDelayMs: number

// 429 rate limit 응답 후 다음 요청까지 기다릴 시간
private readonly rateLimitDelayMs: number

// retry 요청이 동시에 몰리는 것을 피하기 위한 최대 jitter
private readonly retryJitterMs: number

// 명시 options 또는 GEMINI_API_KEY 환경 변수로 Gemini client를 구성
constructor(options: GeminiPredictionClientOptions = {}) {
const apiKey = options.apiKey ?? process.env[GEMINI_API_KEY_ENV_NAME]
Expand All @@ -57,31 +88,68 @@ export class GeminiPredictionClient implements AiPredictionClient {
this.model = options.model ?? DEFAULT_GEMINI_PREDICTION_MODEL
this.endpoint = options.endpoint ?? "https://generativelanguage.googleapis.com/v1beta"
this.fetcher = options.fetch ?? fetch
this.maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_GEMINI_MAX_ATTEMPTS)
this.retryBaseDelayMs = options.retryBaseDelayMs ?? DEFAULT_GEMINI_RETRY_BASE_DELAY_MS
this.rateLimitDelayMs = options.rateLimitDelayMs ?? DEFAULT_GEMINI_RATE_LIMIT_DELAY_MS
this.retryJitterMs = options.retryJitterMs ?? DEFAULT_GEMINI_RETRY_JITTER_MS
}

// system/user prompt를 Gemini JSON 응답 요청으로 변환하고 parsed JSON을 반환
async predict(prompt: AiPredictionPrompt): Promise<unknown> {
const response = await this.fetcher(this.url(), {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": this.apiKey
},
body: JSON.stringify(geminiRequestBodyFor(prompt))
})
const body = JSON.stringify(geminiRequestBodyFor(prompt))
let lastResponse: Response | undefined

for (let attempt = 0; attempt < this.maxAttempts; attempt += 1) {
const response = await this.fetcher(this.url(), {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": this.apiKey
},
body
})

if (response.ok) {
const value = await response.json() as GeminiGenerateContentResponse
return JSON.parse(geminiTextFor(value))
}

lastResponse = response

if (!response.ok) {
throw new Error(await geminiRequestErrorMessageFor(response))
if (!this.shouldRetry(response.status, attempt)) {
break
}

await delay(this.retryDelayFor(response.status, attempt))
}

if (!lastResponse) {
throw new Error("Gemini prediction request failed without response")
}

const value = await response.json() as GeminiGenerateContentResponse
return JSON.parse(geminiTextFor(value))
throw new Error(await geminiRequestErrorMessageFor(lastResponse))
}

// Gemini model별 generateContent 호출 URL을 구성
private url(): string {
return `${this.endpoint}/models/${this.model}:generateContent`
}

// retry 가능한 status이고 다음 시도 횟수가 남아 있는지 확인
private shouldRetry(status: number, attempt: number): boolean {
return attempt + 1 < this.maxAttempts && RETRYABLE_GEMINI_STATUS_CODES.has(status)
}

// 429는 더 길게 대기하고 503 계열은 exponential backoff로 간격을 늘림
private retryDelayFor(status: number, attempt: number): number {
const jitter = Math.floor(Math.random() * this.retryJitterMs)

if (status === 429) {
return this.rateLimitDelayMs + jitter
}

return (2 ** attempt) * this.retryBaseDelayMs + jitter
}
}

// Watcher 기본 AI provider를 Gemini client로 조립
Expand Down Expand Up @@ -112,6 +180,15 @@ function geminiRequestBodyFor(prompt: AiPredictionPrompt): Record<string, unknow
}
}

// 운영 환경에서는 retry 사이에 실제로 대기
function delay(delayMs: number): Promise<void> {
if (delayMs <= 0) {
return Promise.resolve()
}

return new Promise(resolve => setTimeout(resolve, delayMs))
}
Comment thread
opficdev marked this conversation as resolved.

// Gemini API가 반환한 HTTP 실패 원문을 보존해 workflow log에서 원인을 확인
async function geminiRequestErrorMessageFor(response: Response): Promise<string> {
const detail = await geminiErrorDetailFor(response)
Expand All @@ -125,6 +202,13 @@ async function geminiErrorDetailFor(response: Response): Promise<string | undefi
try {
const text = await response.text()
const trimmed = text.trim()
const rateLimitDetail = response.status === 429
? geminiRateLimitDetailFor(trimmed)
: undefined

if (rateLimitDetail) {
return rateLimitDetail
}

if (MAX_GEMINI_ERROR_DETAIL_LENGTH < trimmed.length) {
return trimmed.slice(0, MAX_GEMINI_ERROR_DETAIL_LENGTH) + "... (1000자 제한)"
Expand All @@ -136,6 +220,64 @@ async function geminiErrorDetailFor(response: Response): Promise<string | undefi
}
}

// Gemini 429 quota 응답은 report에 긴 원문 대신 핵심 metric과 retry 시간만 표시
function geminiRateLimitDetailFor(text: string): string | undefined {
const message = geminiErrorMessageFor(text) ?? text
const metric = valueAfter(message, "Quota exceeded for metric:")
const limit = valueAfter(message, "limit:")
const model = valueAfter(message, "model:")
const retry = retryDelayTextFor(message)

if (!metric && !retry) {
return message || undefined
}

return [
"Gemini quota exceeded",
metric ? `metric: ${metric}` : undefined,
limit ? `limit: ${limit}` : undefined,
model ? `model: ${model}` : undefined,
retry ? `retry after: ${retry}` : undefined
].filter((value): value is string => value !== undefined).join(", ")
}

// JSON error body에서 Gemini가 제공한 message만 추출
function geminiErrorMessageFor(text: string): string | undefined {
try {
const value = JSON.parse(text) as {
error?: {
message?: unknown
}
}

return typeof value.error?.message === "string" ? value.error.message : undefined
} catch {
return undefined
}
}

// "key: value" 형태의 Gemini quota message에서 한 항목만 추출
function valueAfter(message: string, key: string): string | undefined {
const index = message.indexOf(key)

if (index === -1) {
return undefined
}

const value = message.slice(index + key.length).trim()
const end = value.search(/[,\n]/)
const trimmed = (end === -1 ? value : value.slice(0, end)).trim()

return trimmed || undefined
}

// Gemini quota message의 retry 안내 시간을 추출
function retryDelayTextFor(message: string): string | undefined {
const match = message.match(/Please retry in ([0-9]+(?:\.[0-9]+)?s)/)

return match?.[1]
}

// Gemini 응답 part가 여러 개로 나뉘어도 하나의 JSON 문자열로 합침
function geminiTextFor(response: GeminiGenerateContentResponse): string {
const text = response.candidates?.[0]?.content?.parts
Expand Down
12 changes: 9 additions & 3 deletions src/ai/predictionResponseValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,16 @@ function recommendedActionFor(
}
}

// confidence는 report에서 비교할 수 있도록 0-100 범위의 숫자로 제한
// confidence는 report에서 비교할 수 있도록 0-100 범위의 정수로 제한
function confidenceFor(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || 100 < value) {
throw new Error("AI prediction response confidence must be a number between 0 and 100")
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
!Number.isInteger(value) ||
value < 0 ||
100 < value
) {
Comment thread
opficdev marked this conversation as resolved.
throw new Error("AI prediction response confidence must be an integer between 0 and 100")
}

return value
Expand Down
42 changes: 35 additions & 7 deletions src/ai/predictionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,38 @@ import type {
AiPredictionRunOptions
} from "./types.js"

// Gemini free tier rate limit을 줄이기 위해 선택된 AI 호출 사이에 둘 내부 대기 시간
const DEFAULT_AI_PREDICTION_DELAY_MS = 60000

// 대상 선택, prompt 생성, provider 호출, 응답 검증을 branch별 AI prediction 결과로 연결
export async function predict(
payloads: AiPredictionEvidencePayload[],
client: AiPredictionClient,
options: AiPredictionRunOptions = {}
): Promise<AiPredictionResult[]> {
const targets = new Set(selectTargets(payloads, options))
const targets = new Set(selectTargets(payloads))
const results: AiPredictionResult[] = []

return Promise.all(payloads.map(async (payload): Promise<AiPredictionResult> => {
for (const payload of payloads) {
if (!targets.has(payload)) {
return {
results.push({
status: "skipped",
branchName: payload.branch.name,
baseBranch: payload.branch.baseBranch,
reason: "below_threshold"
}
reason: skippedReasonFor(payload)
})
continue
}

results.push(await predictedResultFor(payload, client, options))
targets.delete(payload)

if (0 < targets.size) {
await delay(DEFAULT_AI_PREDICTION_DELAY_MS)
}
}

return predictedResultFor(payload, client, options)
}))
return results
}

// provider 호출과 schema 검증 실패를 branch 단위 failed 결과로 격리
Expand Down Expand Up @@ -61,3 +73,19 @@ async function predictedResultFor(
function errorMessageFor(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}

// AI prediction 대상이 아닌 branch의 생략 사유를 report 가능한 값으로 변환
function skippedReasonFor(payload: AiPredictionEvidencePayload): "not_target" | "confirmed_conflict" {
return payload.possibility.reasons.some(reason => reason.code === "confirmed_conflict")
? "confirmed_conflict"
: "not_target"
}

// 선택된 AI provider 호출 사이에 간격을 두어 rate limit 진입 가능성을 낮춤
function delay(delayMs: number): Promise<void> {
if (delayMs <= 0) {
return Promise.resolve()
}

return new Promise(resolve => setTimeout(resolve, delayMs))
}
20 changes: 10 additions & 10 deletions src/ai/predictionTargetSelector.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import type {
AiPredictionEvidencePayload,
AiPredictionTargetSelectionOptions
AiPredictionEvidencePayload
} from "./types.js"
import { BranchRiskStatus } from "../risks/types.js"

// medium 이상 possibility를 기본 AI prediction 대상으로 삼기 위한 최소 score
export const DEFAULT_AI_PREDICTION_MINIMUM_SCORE = 25
// Gemini 호출량을 줄이기 위해 기본 AI prediction 대상은 critical possibility로 제한
export const DEFAULT_AI_PREDICTION_TARGET_STATUS = BranchRiskStatus.Critical

// deterministic possibility score 기준으로 AI prediction 대상 evidence만 선택
// deterministic possibility status 기준으로 AI prediction 대상 evidence만 선택
export function select(
payloads: AiPredictionEvidencePayload[],
options: AiPredictionTargetSelectionOptions = {}
payloads: AiPredictionEvidencePayload[]
): AiPredictionEvidencePayload[] {
const minimumScore = options.minimumScore ?? DEFAULT_AI_PREDICTION_MINIMUM_SCORE

return payloads.filter(payload => minimumScore <= payload.possibility.score)
return payloads.filter(payload =>
payload.possibility.status === DEFAULT_AI_PREDICTION_TARGET_STATUS &&
!payload.possibility.reasons.some(reason => reason.code === "confirmed_conflict")
)
}
Loading
Loading