diff --git a/src/ai/openAiPredictionClient.ts b/src/ai/openAiPredictionClient.ts index 471705b..084ea66 100644 --- a/src/ai/openAiPredictionClient.ts +++ b/src/ai/openAiPredictionClient.ts @@ -1,6 +1,7 @@ import type { AiPredictionClient, - AiPredictionPrompt + AiPredictionPrompt, + AiPredictionPromptResponseShape } from "./types.js" // workflow log가 과도하게 커지지 않도록 OpenAI 실패 응답 본문을 제한 @@ -96,6 +97,7 @@ function openAiRequestBodyFor( model: string ): Record { const responseShape = prompt.responseShape ?? "prediction" + const responseFormat = responseFormatFor(responseShape) return { model, @@ -112,18 +114,45 @@ function openAiRequestBodyFor( text: { format: { type: "json_schema", - name: responseShape === "predictionBatch" - ? "ai_prediction_batch" - : "ai_prediction", + name: responseFormat.name, strict: true, - schema: responseShape === "predictionBatch" - ? aiPredictionBatchSchema() - : aiPredictionSchema() + schema: responseFormat.schema } } } } +// prompt가 요청한 응답 형태를 OpenAI schema 이름과 구조에 매핑 +function responseFormatFor( + responseShape: AiPredictionPromptResponseShape +): { + name: string + schema: Record +} { + switch (responseShape) { + case "predictionBatch": + return { + name: "ai_prediction_batch", + schema: aiPredictionBatchSchema() + } + case "predictionPairConfirmedConflict": + return { + name: "ai_prediction_pair_confirmed_conflict", + schema: aiConfirmedConflictSchema() + } + case "predictionPairCleanOverlap": + return { + name: "ai_prediction_pair_clean_overlap", + schema: aiCleanOverlapSchema() + } + default: + return { + name: "ai_prediction", + schema: aiPredictionSchema() + } + } +} + // OpenAI API가 반환한 HTTP 실패 원문을 보존해 workflow log에서 원인을 확인 async function openAiRequestErrorMessageFor(response: Response): Promise { const detail = await openAiErrorDetailFor(response) @@ -222,6 +251,154 @@ function aiPredictionSchema(): Record { } } +// 확정 conflict 해결 응답을 위한 OpenAI structured output schema +function aiConfirmedConflictSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + kind: { + type: "string", + enum: ["confirmed_conflict"] + }, + pair: branchPairSchema(), + conflictCause: causeSchema(), + integrationOrder: integrationOrderSchema(), + patches: { + type: "array", + minItems: 1, + items: patchSchema() + } + }, + required: [ + "kind", + "pair", + "conflictCause", + "integrationOrder", + "patches" + ] + } +} + +// clean overlap 예방 응답을 위한 OpenAI structured output schema +function aiCleanOverlapSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + kind: { + type: "string", + enum: ["clean_overlap"] + }, + pair: branchPairSchema(), + overlapCause: causeSchema(), + integrationOrder: integrationOrderSchema(), + preventiveActions: { + type: "array", + minItems: 1, + items: preventiveActionSchema() + } + }, + required: [ + "kind", + "pair", + "overlapCause", + "integrationOrder", + "preventiveActions" + ] + } +} + +// 좌우 순서를 보존하는 branch 조합 식별 schema +function branchPairSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + leftBranchName: { type: "string" }, + rightBranchName: { type: "string" } + }, + required: ["leftBranchName", "rightBranchName"] + } +} + +// conflict 또는 overlap 원인과 관련 파일 schema +function causeSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + summary: { type: "string" }, + files: stringArraySchema() + }, + required: ["summary", "files"] + } +} + +// merge 또는 rebase 작업 순서 schema +function integrationOrderSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + strategy: { + type: "string", + enum: ["merge", "rebase"] + }, + firstBranchName: { type: "string" }, + secondBranchName: { type: "string" }, + reason: { type: "string" }, + steps: { + ...stringArraySchema(), + minItems: 1 + } + }, + required: [ + "strategy", + "firstBranchName", + "secondBranchName", + "reason", + "steps" + ] + } +} + +// 확정 conflict의 파일별 patch schema +function patchSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + filePath: { type: "string" }, + patch: { type: "string" }, + reason: { type: "string" } + }, + required: ["filePath", "patch", "reason"] + } +} + +// clean overlap의 예방 조치 schema +function preventiveActionSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + title: { type: "string" }, + description: { type: "string" }, + files: stringArraySchema() + }, + required: ["title", "description", "files"] + } +} + +// 문자열 목록 field의 공통 schema +function stringArraySchema(): Record { + return { + type: "array", + items: { type: "string" } + } +} + // AI recommended action 하나의 JSON 구조를 OpenAI structured output schema로 표현 function recommendedActionSchema(): Record { return { diff --git a/src/ai/predictionPairPromptBuilder.ts b/src/ai/predictionPairPromptBuilder.ts new file mode 100644 index 0000000..0901310 --- /dev/null +++ b/src/ai/predictionPairPromptBuilder.ts @@ -0,0 +1,27 @@ +import { + DEFAULT_AI_CLEAN_OVERLAP_SYSTEM_PROMPT, + DEFAULT_AI_CONFIRMED_CONFLICT_SYSTEM_PROMPT +} from "./predictionPairPromptTemplates.js" +import type { + AiPredictionPairEvidencePayload, + AiPredictionPrompt, + AiPredictionPromptBuildOptions +} from "./types.js" + +// branch 조합의 deterministic 상태에 맞는 전용 prompt 구성 +export function build( + payload: AiPredictionPairEvidencePayload, + options: AiPredictionPromptBuildOptions = {} +): AiPredictionPrompt { + const confirmedConflict = payload.targetStatus === "confirmed_conflict" + + return { + systemPrompt: options.systemPrompt ?? (confirmedConflict + ? DEFAULT_AI_CONFIRMED_CONFLICT_SYSTEM_PROMPT + : DEFAULT_AI_CLEAN_OVERLAP_SYSTEM_PROMPT), + userPrompt: JSON.stringify(payload, null, 2), + responseShape: confirmedConflict + ? "predictionPairConfirmedConflict" + : "predictionPairCleanOverlap" + } +} diff --git a/src/ai/predictionPairPromptTemplates.ts b/src/ai/predictionPairPromptTemplates.ts new file mode 100644 index 0000000..d5386df --- /dev/null +++ b/src/ai/predictionPairPromptTemplates.ts @@ -0,0 +1,76 @@ +// 확정 conflict의 원인, 반영 순서, 구체적인 patch를 요청하는 system prompt +export const DEFAULT_AI_CONFIRMED_CONFLICT_SYSTEM_PROMPT = [ + "You are Watcher's confirmed merge conflict resolution assistant.", + "Use only the provided branch pair evidence and code context.", + "Preserve the exact 'leftBranchName' and 'rightBranchName' from the input in the 'pair' object without swapping them.", + "Do not recalculate or overwrite the deterministic merge status or graph reasons.", + "Explain the confirmed conflict cause from the provided code only.", + "Recommend a merge or rebase order using only the two provided branch names.", + "Provide at least one concrete patch for files present in the evidence.", + "Do not invent APIs, files, behavior, or code outside the provided context.", + "Do not apply any patch or describe a patch as already applied.", + "Write summaries, reasons, steps, and patch reasons in Korean.", + "Return only JSON with this shape:", + "{", + " \"kind\": \"confirmed_conflict\",", + " \"pair\": {", + " \"leftBranchName\": string,", + " \"rightBranchName\": string", + " },", + " \"conflictCause\": {", + " \"summary\": string,", + " \"files\": string[]", + " },", + " \"integrationOrder\": {", + " \"strategy\": \"merge\" | \"rebase\",", + " \"firstBranchName\": string,", + " \"secondBranchName\": string,", + " \"reason\": string,", + " \"steps\": string[]", + " },", + " \"patches\": [{", + " \"filePath\": string,", + " \"patch\": string,", + " \"reason\": string", + " }]", + "}" +].join("\n") + +// clean overlap을 conflict로 단정하지 않고 예방 조치를 요청하는 system prompt +export const DEFAULT_AI_CLEAN_OVERLAP_SYSTEM_PROMPT = [ + "You are Watcher's clean merge overlap prevention assistant.", + "Use only the provided branch pair evidence and code context.", + "Preserve the exact 'leftBranchName' and 'rightBranchName' from the input in the 'pair' object without swapping them.", + "The provided merge status is clean and is not a confirmed conflict.", + "Do not recalculate or overwrite the deterministic merge status or graph reasons.", + "Do not claim that a conflict exists or will occur.", + "Explain only the potential interaction visible in the provided code.", + "Recommend a merge or rebase order using only the two provided branch names.", + "Provide at least one preventive action and do not provide patches.", + "Do not invent APIs, files, behavior, or code outside the provided context.", + "Write summaries, reasons, steps, titles, and descriptions in Korean.", + "Return only JSON with this shape:", + "{", + " \"kind\": \"clean_overlap\",", + " \"pair\": {", + " \"leftBranchName\": string,", + " \"rightBranchName\": string", + " },", + " \"overlapCause\": {", + " \"summary\": string,", + " \"files\": string[]", + " },", + " \"integrationOrder\": {", + " \"strategy\": \"merge\" | \"rebase\",", + " \"firstBranchName\": string,", + " \"secondBranchName\": string,", + " \"reason\": string,", + " \"steps\": string[]", + " },", + " \"preventiveActions\": [{", + " \"title\": string,", + " \"description\": string,", + " \"files\": string[]", + " }]", + "}" +].join("\n") diff --git a/src/ai/predictionPairResponseValidator.ts b/src/ai/predictionPairResponseValidator.ts new file mode 100644 index 0000000..350ca7d --- /dev/null +++ b/src/ai/predictionPairResponseValidator.ts @@ -0,0 +1,403 @@ +import type { BranchComparisonPair } from "../branches/types.js" +import type { + AiCleanOverlapResponse, + AiConfirmedConflictResponse, + AiPredictionPairEvidencePayload, + AiPredictionPairIntegrationOrder, + AiPredictionPairPatch, + AiPredictionPairPreventiveAction, + AiPredictionPairResponse +} from "./types.js" + +// unknown provider 응답을 branch 조합 상태에 맞는 결과로 검증 +export function validate( + response: unknown, + payload: AiPredictionPairEvidencePayload +): AiPredictionPairResponse { + return payload.targetStatus === "confirmed_conflict" + ? confirmedConflictResponseFor(response, payload) + : cleanOverlapResponseFor(response, payload) +} + +// 확정 conflict 응답의 구조와 evidence 참조 범위 검증 +function confirmedConflictResponseFor( + response: unknown, + payload: AiPredictionPairEvidencePayload +): AiConfirmedConflictResponse { + const value = objectFor(response, "response") + literalFor(value.kind, "response.kind", "confirmed_conflict") + assertOnlyKeys(value, "response", [ + "kind", + "pair", + "conflictCause", + "integrationOrder", + "patches" + ]) + const pair = pairFor(value.pair, "response.pair") + assertMatchingPair(pair, payload.pair) + const conflictCause = causeFor( + value.conflictCause, + "response.conflictCause", + evidenceFilesFor(payload) + ) + const integrationOrder = integrationOrderFor( + value.integrationOrder, + "response.integrationOrder", + payload.pair + ) + const patchFiles = patchFilesFor(payload) + const patches = nonEmptyArrayFor(value.patches, "response.patches") + .map((patch, index) => patchFor( + patch, + `response.patches[${index}]`, + patchFiles + )) + + return { + kind: "confirmed_conflict", + pair, + conflictCause, + integrationOrder, + patches + } +} + +// clean overlap 응답의 구조와 evidence 참조 범위 검증 +function cleanOverlapResponseFor( + response: unknown, + payload: AiPredictionPairEvidencePayload +): AiCleanOverlapResponse { + const value = objectFor(response, "response") + literalFor(value.kind, "response.kind", "clean_overlap") + assertOnlyKeys(value, "response", [ + "kind", + "pair", + "overlapCause", + "integrationOrder", + "preventiveActions" + ]) + const pair = pairFor(value.pair, "response.pair") + assertMatchingPair(pair, payload.pair) + const files = evidenceFilesFor(payload) + const overlapCause = causeFor( + value.overlapCause, + "response.overlapCause", + files + ) + const integrationOrder = integrationOrderFor( + value.integrationOrder, + "response.integrationOrder", + payload.pair + ) + const preventiveActions = nonEmptyArrayFor( + value.preventiveActions, + "response.preventiveActions" + ).map((action, index) => preventiveActionFor( + action, + `response.preventiveActions[${index}]`, + files + )) + + return { + kind: "clean_overlap", + pair, + overlapCause, + integrationOrder, + preventiveActions + } +} + +// 응답의 ordered pair가 요청한 branch 조합과 같은지 검증 +function pairFor( + response: unknown, + path: string +): BranchComparisonPair { + const value = objectFor(response, path) + assertOnlyKeys(value, path, ["leftBranchName", "rightBranchName"]) + + return { + leftBranchName: trimmedStringFor( + value.leftBranchName, + `${path}.leftBranchName` + ), + rightBranchName: trimmedStringFor( + value.rightBranchName, + `${path}.rightBranchName` + ) + } +} + +// conflict 또는 overlap 원인과 관련 파일 목록 검증 +function causeFor( + response: unknown, + path: string, + evidenceFiles: ReadonlySet +): { + summary: string + files: string[] +} { + const value = objectFor(response, path) + assertOnlyKeys(value, path, ["summary", "files"]) + const files = nonEmptyTrimmedStringArrayFor(value.files, `${path}.files`) + assertEvidenceFiles(files, `${path}.files`, evidenceFiles) + + return { + summary: stringFor(value.summary, `${path}.summary`), + files + } +} + +// merge 또는 rebase 순서가 대상 branch 두 개만 사용하는지 검증 +function integrationOrderFor( + response: unknown, + path: string, + pair: BranchComparisonPair +): AiPredictionPairIntegrationOrder { + const value = objectFor(response, path) + assertOnlyKeys(value, path, [ + "strategy", + "firstBranchName", + "secondBranchName", + "reason", + "steps" + ]) + const strategy = strategyFor(value.strategy, `${path}.strategy`) + const firstBranchName = trimmedStringFor( + value.firstBranchName, + `${path}.firstBranchName` + ) + const secondBranchName = trimmedStringFor( + value.secondBranchName, + `${path}.secondBranchName` + ) + assertIntegrationBranches(firstBranchName, secondBranchName, pair) + + return { + strategy, + firstBranchName, + secondBranchName, + reason: stringFor(value.reason, `${path}.reason`), + steps: nonEmptyStringArrayFor(value.steps, `${path}.steps`) + } +} + +// 확정 conflict의 patch가 제공된 파일만 대상으로 하는지 검증 +function patchFor( + response: unknown, + path: string, + evidenceFiles: ReadonlySet +): AiPredictionPairPatch { + const value = objectFor(response, path) + assertOnlyKeys(value, path, ["filePath", "patch", "reason"]) + const filePath = trimmedStringFor(value.filePath, `${path}.filePath`) + assertEvidenceFiles([filePath], `${path}.filePath`, evidenceFiles) + + return { + filePath, + patch: stringFor(value.patch, `${path}.patch`), + reason: stringFor(value.reason, `${path}.reason`) + } +} + +// clean overlap 예방 조치가 제공된 파일만 참조하는지 검증 +function preventiveActionFor( + response: unknown, + path: string, + evidenceFiles: ReadonlySet +): AiPredictionPairPreventiveAction { + const value = objectFor(response, path) + assertOnlyKeys(value, path, ["title", "description", "files"]) + const files = nonEmptyTrimmedStringArrayFor(value.files, `${path}.files`) + assertEvidenceFiles(files, `${path}.files`, evidenceFiles) + + return { + title: stringFor(value.title, `${path}.title`), + description: stringFor(value.description, `${path}.description`), + files + } +} + +// 응답 pair의 좌우 이름과 순서가 요청과 같은지 검증 +function assertMatchingPair( + pair: BranchComparisonPair, + expected: BranchComparisonPair +): void { + if ( + pair.leftBranchName !== expected.leftBranchName || + pair.rightBranchName !== expected.rightBranchName + ) { + throw new Error( + "AI prediction pair response must use matching ordered branch pair" + ) + } +} + +// 작업 순서가 대상 branch 두 개를 중복 없이 포함하는지 검증 +function assertIntegrationBranches( + firstBranchName: string, + secondBranchName: string, + pair: BranchComparisonPair +): void { + const branchNames = new Set([ + pair.leftBranchName, + pair.rightBranchName + ]) + + if ( + firstBranchName === secondBranchName || + !branchNames.has(firstBranchName) || + !branchNames.has(secondBranchName) + ) { + throw new Error( + "AI prediction pair response integration order must use both target branches" + ) + } +} + +// 응답에 포함된 파일이 제공된 evidence 범위를 벗어나지 않는지 검증 +function assertEvidenceFiles( + files: string[], + path: string, + evidenceFiles: ReadonlySet +): void { + if (files.some(file => !evidenceFiles.has(file))) { + throw new Error( + `AI prediction pair response ${path} must use provided evidence files` + ) + } +} + +// 원인과 예방 조치가 참조할 수 있는 모든 evidence 파일 구성 +function evidenceFilesFor( + payload: AiPredictionPairEvidencePayload +): ReadonlySet { + return new Set([ + ...payload.reasons.flatMap(reason => reason.files ?? []), + ...payload.merge.conflictFiles, + ...payload.merge.conflicts.flatMap(conflict => conflict.paths), + ...payload.codeContext.overlapFiles, + ...payload.codeContext.evidence.map(evidence => evidence.filePath) + ]) +} + +// patch가 참조할 수 있는 conflict와 코드 문맥 파일 구성 +function patchFilesFor( + payload: AiPredictionPairEvidencePayload +): ReadonlySet { + return new Set([ + ...payload.merge.conflictFiles, + ...payload.merge.conflicts.flatMap(conflict => conflict.paths), + ...payload.codeContext.overlapFiles, + ...payload.codeContext.evidence.map(evidence => evidence.filePath) + ]) +} + +// 응답 object가 상태별 schema key만 포함하는지 검증 +function assertOnlyKeys( + value: Record, + path: string, + allowedKeys: string[] +): void { + const allowed = new Set(allowedKeys) + + if (Object.keys(value).some(key => !allowed.has(key))) { + throw new Error( + `AI prediction pair response ${path} contains unsupported fields` + ) + } +} + +// unknown 값이 key 접근 가능한 object인지 검증 +function objectFor( + value: unknown, + path: string +): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`AI prediction pair response ${path} must be an object`) + } + + return value as Record +} + +// unknown 배열이 한 개 이상의 항목을 포함하는지 검증 +function nonEmptyArrayFor( + value: unknown, + path: string +): unknown[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error( + `AI prediction pair response ${path} must be a non-empty array` + ) + } + + return value +} + +// unknown 배열이 비어 있지 않은 문자열만 포함하는지 검증 +function nonEmptyStringArrayFor( + value: unknown, + path: string +): string[] { + return nonEmptyArrayFor(value, path) + .map((item, index) => stringFor(item, `${path}[${index}]`)) +} + +// branch 이름과 evidence 파일 경로의 불필요한 양끝 공백 제거 +function nonEmptyTrimmedStringArrayFor( + value: unknown, + path: string +): string[] { + return nonEmptyArrayFor(value, path) + .map((item, index) => trimmedStringFor(item, `${path}[${index}]`)) +} + +// unknown 값이 비어 있지 않은 문자열인지 검증 +function stringFor( + value: unknown, + path: string +): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error( + `AI prediction pair response ${path} must be a non-empty string` + ) + } + + return value +} + +// 식별자 비교 전에 provider가 추가한 양끝 공백 제거 +function trimmedStringFor( + value: unknown, + path: string +): string { + return stringFor(value, path).trim() +} + +// 응답 상태 문자열이 요청한 상태와 같은지 검증 +function literalFor( + value: unknown, + path: string, + expected: T +): T { + if (value !== expected) { + throw new Error( + `AI prediction pair response ${path} must be ${expected}` + ) + } + + return expected +} + +// 작업 방식이 merge 또는 rebase인지 검증 +function strategyFor( + value: unknown, + path: string +): "merge" | "rebase" { + if (value !== "merge" && value !== "rebase") { + throw new Error( + `AI prediction pair response ${path} must be merge or rebase` + ) + } + + return value +} diff --git a/src/ai/predictionPairRunner.ts b/src/ai/predictionPairRunner.ts new file mode 100644 index 0000000..f310f96 --- /dev/null +++ b/src/ai/predictionPairRunner.ts @@ -0,0 +1,52 @@ +import { build as buildPrompt } from "./predictionPairPromptBuilder.js" +import { validate as validateResponse } from "./predictionPairResponseValidator.js" +import type { + AiPredictionClient, + AiPredictionPairEvidencePayload, + AiPredictionPairResult, + AiPredictionPromptBuildOptions +} from "./types.js" + +// branch 조합을 입력 순서대로 실행하고 provider와 validation 실패를 조합별로 격리 +export async function predict( + payloads: AiPredictionPairEvidencePayload[], + client: AiPredictionClient, + options: AiPredictionPromptBuildOptions = {} +): Promise { + const results: AiPredictionPairResult[] = [] + + for (const payload of payloads) { + results.push(await resultFor(payload, client, options)) + } + + return results +} + +// branch 조합 하나의 prompt 생성, provider 호출, 응답 검증 실행 +async function resultFor( + payload: AiPredictionPairEvidencePayload, + client: AiPredictionClient, + options: AiPredictionPromptBuildOptions +): Promise { + try { + const prompt = buildPrompt(payload, options) + const response = await client.predict(prompt) + + return { + status: "predicted", + pair: payload.pair, + response: validateResponse(response, payload) + } + } catch (error) { + return { + status: "failed", + pair: payload.pair, + errorMessage: errorMessageFor(error) + } + } +} + +// unknown provider 또는 validation 오류를 report 가능한 문자열로 변환 +function errorMessageFor(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/ai/types.ts b/src/ai/types.ts index 705de17..6309ba2 100644 --- a/src/ai/types.ts +++ b/src/ai/types.ts @@ -79,6 +79,75 @@ export type AiPredictionPairEvidencePayload = { codeContext: AiPredictionPairCodeContext } +// branch 조합을 반영할 merge 또는 rebase 작업 순서 +export type AiPredictionPairIntegrationOrder = { + strategy: "merge" | "rebase" + firstBranchName: string + secondBranchName: string + reason: string + steps: string[] +} + +// 확정 conflict를 해결하기 위한 파일별 patch 제안 +export type AiPredictionPairPatch = { + filePath: string + patch: string + reason: string +} + +// clean overlap 이후 동작 회귀를 예방하기 위한 확인 항목 +export type AiPredictionPairPreventiveAction = { + title: string + description: string + files: string[] +} + +// 확정 conflict 원인과 구체적인 해결 patch +export type AiConfirmedConflictResponse = { + kind: "confirmed_conflict" + pair: BranchComparisonPair + conflictCause: { + summary: string + files: string[] + } + integrationOrder: AiPredictionPairIntegrationOrder + patches: AiPredictionPairPatch[] +} + +// 현재 merge 가능한 overlap의 예방 조치와 반영 순서 +export type AiCleanOverlapResponse = { + kind: "clean_overlap" + pair: BranchComparisonPair + overlapCause: { + summary: string + files: string[] + } + integrationOrder: AiPredictionPairIntegrationOrder + preventiveActions: AiPredictionPairPreventiveAction[] +} + +// branch 조합의 deterministic merge 상태에 맞는 AI 응답 +export type AiPredictionPairResponse = + | AiConfirmedConflictResponse + | AiCleanOverlapResponse + +// branch 조합 하나에 대한 AI 실행 결과 +export type AiPredictionPairResult = + | AiPredictionPairPredictedResult + | AiPredictionPairFailedResult + +export type AiPredictionPairPredictedResult = { + status: "predicted" + pair: BranchComparisonPair + response: AiPredictionPairResponse +} + +export type AiPredictionPairFailedResult = { + status: "failed" + pair: BranchComparisonPair + errorMessage: string +} + // AI provider에 전달할 system/user prompt 묶음 export type AiPredictionPrompt = { systemPrompt: string @@ -90,6 +159,8 @@ export type AiPredictionPrompt = { export type AiPredictionPromptResponseShape = | "prediction" | "predictionBatch" + | "predictionPairConfirmedConflict" + | "predictionPairCleanOverlap" // prompt 호출자가 provider나 실행 환경에 맞게 system prompt를 교체하기 위한 설정 export type AiPredictionPromptBuildOptions = { diff --git a/src/index.ts b/src/index.ts index b49ae51..7210fcc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,11 +18,19 @@ export { build as buildAiPredictionPrompt, buildBatch as buildAiPredictionBatchPrompt } from "./ai/predictionPromptBuilder.js" +export { + build as buildAiPredictionPairPrompt +} from "./ai/predictionPairPromptBuilder.js" export { DEFAULT_AI_PREDICTION_BATCH_SYSTEM_PROMPT, DEFAULT_AI_PREDICTION_SYSTEM_PROMPT } from "./ai/promptTemplates.js" +export { + DEFAULT_AI_CLEAN_OVERLAP_SYSTEM_PROMPT, + DEFAULT_AI_CONFIRMED_CONFLICT_SYSTEM_PROMPT +} from "./ai/predictionPairPromptTemplates.js" export { predict as predictMergeRisksWithAi } from "./ai/predictionRunner.js" +export { predict as predictBranchPairsWithAi } from "./ai/predictionPairRunner.js" export { DEFAULT_AI_PREDICTION_TARGET_STATUS, select as selectAiPredictionTargets @@ -31,6 +39,9 @@ export { validate as validateAiPredictionResponse, validateBatch as validateAiPredictionBatchResponse } from "./ai/predictionResponseValidator.js" +export { + validate as validateAiPredictionPairResponse +} from "./ai/predictionPairResponseValidator.js" export { collectGitMergeSignal } from "./git/gitMergeSignalCollector.js" export { send as sendMergeRiskReport } from "./reportChannels/reportChannel.js" export { analyze as analyzeBranchMergeRisks } from "./risks/riskAnalyzer.js" @@ -52,6 +63,19 @@ export type { AiPredictionEvidencePayload, AiPredictionFailedResult, AiPredictionFailureDebugEvent, + AiPredictionPairBranchMetadata, + AiPredictionPairCodeContext, + AiPredictionPairCodeContextStatus, + AiPredictionPairEvidencePayload, + AiPredictionPairFailedResult, + AiPredictionPairIntegrationOrder, + AiPredictionPairMergeStatus, + AiPredictionPairPatch, + AiPredictionPairPredictedResult, + AiPredictionPairPreventiveAction, + AiPredictionPairResponse, + AiPredictionPairResult, + AiPredictionPairTargetStatus, AiPredictionPrompt, AiPredictionPromptBuildOptions, AiPredictionPromptDebugEvent, @@ -62,10 +86,13 @@ export type { AiPredictionRunOptions, AiPredictionSkippedResult, AiRecommendedAction, - AiRecommendedActionPriority + AiRecommendedActionPriority, + AiCleanOverlapResponse, + AiConfirmedConflictResponse } from "./ai/types.js" export type { + BranchComparisonPair, BranchContext, BranchCheckMetadata, BranchPullRequestMetadata, diff --git a/tests/ai/openAiPredictionClient.test.ts b/tests/ai/openAiPredictionClient.test.ts index b47ee2b..9914b59 100644 --- a/tests/ai/openAiPredictionClient.test.ts +++ b/tests/ai/openAiPredictionClient.test.ts @@ -120,6 +120,50 @@ test("sends batch response schema to OpenAI", async () => { assert.equal(predictionSchema?.properties?.falsePositiveNotes, undefined) }) +// 확정 conflict 요청이 patch 전용 schema를 사용하는지 확인 +test("sends confirmed conflict pair response schema to OpenAI", async () => { + const spy = fetchSpy(validOpenAiResponse()) + const client = new OpenAiPredictionClient({ + apiKey: "openai-key", + fetch: spy + }) + + await client.predict(confirmedConflictPairPrompt()) + + const body = JSON.parse(String(spy.requests[0]?.init.body)) as PairRequestBody + const schema = body.text.format.schema + + assert.equal(body.text.format.name, "ai_prediction_pair_confirmed_conflict") + assert.equal(schema.additionalProperties, false) + assert.notEqual(schema.properties.conflictCause, undefined) + assert.notEqual(schema.properties.patches, undefined) + assert.equal(schema.properties.overlapCause, undefined) + assert.equal(schema.properties.preventiveActions, undefined) + assert.equal(schema.properties.patches?.minItems, 1) +}) + +// clean overlap 요청이 예방 조치 전용 schema를 사용하는지 확인 +test("sends clean overlap pair response schema to OpenAI", async () => { + const spy = fetchSpy(validOpenAiResponse()) + const client = new OpenAiPredictionClient({ + apiKey: "openai-key", + fetch: spy + }) + + await client.predict(cleanOverlapPairPrompt()) + + const body = JSON.parse(String(spy.requests[0]?.init.body)) as PairRequestBody + const schema = body.text.format.schema + + assert.equal(body.text.format.name, "ai_prediction_pair_clean_overlap") + assert.equal(schema.additionalProperties, false) + assert.notEqual(schema.properties.overlapCause, undefined) + assert.notEqual(schema.properties.preventiveActions, undefined) + assert.equal(schema.properties.conflictCause, undefined) + assert.equal(schema.properties.patches, undefined) + assert.equal(schema.properties.preventiveActions?.minItems, 1) +}) + // OpenAI HTTP 실패가 branch 단위 failed result로 격리될 수 있도록 Error로 노출되는지 확인 test("throws when OpenAI request fails", async () => { const client = new OpenAiPredictionClient({ @@ -185,6 +229,20 @@ type FetchSpy = typeof fetch & { }> } +type PairRequestBody = { + text: { + format: { + name: string + schema: { + additionalProperties: boolean + properties: Record + } + } + } +} + // network 호출 없이 OpenAI response와 HTTP 상태를 주입하는 fetch 대역 function fetchSpy( body: unknown, @@ -235,6 +293,22 @@ function batchPrompt(): AiPredictionPrompt { } } +function confirmedConflictPairPrompt(): AiPredictionPrompt { + return { + systemPrompt: "Return confirmed conflict JSON only.", + userPrompt: "{\"pair\":{\"leftBranchName\":\"feature/a\",\"rightBranchName\":\"feature/b\"}}", + responseShape: "predictionPairConfirmedConflict" + } +} + +function cleanOverlapPairPrompt(): AiPredictionPrompt { + return { + systemPrompt: "Return clean overlap JSON only.", + userPrompt: "{\"pair\":{\"leftBranchName\":\"feature/a\",\"rightBranchName\":\"feature/b\"}}", + responseShape: "predictionPairCleanOverlap" + } +} + // OpenAI Responses API가 반환하는 JSON text 응답 fixture function validOpenAiResponse(): unknown { return { diff --git a/tests/ai/predictionPairPromptBuilder.test.ts b/tests/ai/predictionPairPromptBuilder.test.ts new file mode 100644 index 0000000..231a9b9 --- /dev/null +++ b/tests/ai/predictionPairPromptBuilder.test.ts @@ -0,0 +1,87 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { build } from "../../src/ai/predictionPairPromptBuilder.js" +import { + DEFAULT_AI_CLEAN_OVERLAP_SYSTEM_PROMPT, + DEFAULT_AI_CONFIRMED_CONFLICT_SYSTEM_PROMPT +} from "../../src/ai/predictionPairPromptTemplates.js" +import type { + AiPredictionPairEvidencePayload, + AiPredictionPairTargetStatus +} from "../../src/ai/types.js" + +// 확정 conflict evidence를 patch 전용 prompt로 구성 +test("builds confirmed conflict pair prompt", () => { + const input = payload("confirmed_conflict") + const prompt = build(input) + + assert.equal(prompt.systemPrompt, DEFAULT_AI_CONFIRMED_CONFLICT_SYSTEM_PROMPT) + assert.equal(prompt.responseShape, "predictionPairConfirmedConflict") + assert.deepEqual(JSON.parse(prompt.userPrompt), input) +}) + +// clean overlap evidence를 예방 조치 전용 prompt로 구성 +test("builds clean overlap pair prompt", () => { + const input = payload("potential_overlap") + const prompt = build(input) + + assert.equal(prompt.systemPrompt, DEFAULT_AI_CLEAN_OVERLAP_SYSTEM_PROMPT) + assert.equal(prompt.responseShape, "predictionPairCleanOverlap") + assert.match(prompt.systemPrompt, /not a confirmed conflict/) + assert.deepEqual(JSON.parse(prompt.userPrompt), input) +}) + +// 호출자가 상태별 기본 prompt를 교체할 수 있도록 기존 option 유지 +test("uses caller provided pair system prompt", () => { + const prompt = build(payload("confirmed_conflict"), { + systemPrompt: "Return compact JSON." + }) + + assert.equal(prompt.systemPrompt, "Return compact JSON.") + assert.equal(prompt.responseShape, "predictionPairConfirmedConflict") +}) + +function payload( + targetStatus: AiPredictionPairTargetStatus +): AiPredictionPairEvidencePayload { + const confirmedConflict = targetStatus === "confirmed_conflict" + + return { + pair: { + leftBranchName: "feature/a", + rightBranchName: "feature/b" + }, + targetStatus, + reasons: [{ + code: confirmedConflict ? "confirmed_conflict" : "same_hunk_overlap", + files: ["src/shared.ts"] + }], + branches: { + left: { + name: "feature/a", + commitOid: "a".repeat(40) + }, + right: { + name: "feature/b", + commitOid: "b".repeat(40) + } + }, + merge: { + status: confirmedConflict ? "confirmed_conflict" : "clean", + mergedTreeOid: "c".repeat(40), + conflictFiles: confirmedConflict ? ["src/shared.ts"] : [], + conflicts: confirmedConflict + ? [{ paths: ["src/shared.ts"], type: "content" }] + : [] + }, + codeContext: { + status: "available", + overlapFiles: ["src/shared.ts"], + evidence: [], + includedFileCount: 0, + includedHunkCount: 0, + omittedFileCount: 0, + omittedHunkCount: 0 + } + } +} diff --git a/tests/ai/predictionPairResponseValidator.test.ts b/tests/ai/predictionPairResponseValidator.test.ts new file mode 100644 index 0000000..f9b3c4d --- /dev/null +++ b/tests/ai/predictionPairResponseValidator.test.ts @@ -0,0 +1,198 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { validate } from "../../src/ai/predictionPairResponseValidator.js" +import type { + AiCleanOverlapResponse, + AiConfirmedConflictResponse, + AiPredictionPairEvidencePayload, + AiPredictionPairTargetStatus +} from "../../src/ai/types.js" + +// 확정 conflict 응답을 요청 evidence와 대조해 검증 +test("validates confirmed conflict pair response", () => { + const input = payload("confirmed_conflict") + const response = confirmedConflictResponse() + + assert.deepEqual(validate(response, input), response) +}) + +// clean overlap 응답을 요청 evidence와 대조해 검증 +test("validates clean overlap pair response", () => { + const input = payload("potential_overlap") + const response = cleanOverlapResponse() + + assert.deepEqual(validate(response, input), response) +}) + +// 응답 상태와 ordered pair가 요청과 다르면 거부 +test("rejects mismatched pair response kind and ordered pair", () => { + assert.throws(() => validate( + cleanOverlapResponse(), + payload("confirmed_conflict") + ), /must be confirmed_conflict/) + + assert.throws(() => validate({ + ...confirmedConflictResponse(), + pair: { + leftBranchName: "feature/b", + rightBranchName: "feature/a" + } + }, payload("confirmed_conflict")), /matching ordered branch pair/) +}) + +// 작업 순서에 대상 밖 branch나 같은 branch가 들어가면 거부 +test("rejects invalid pair integration order", () => { + assert.throws(() => validate({ + ...confirmedConflictResponse(), + integrationOrder: { + ...confirmedConflictResponse().integrationOrder, + secondBranchName: "feature/c" + } + }, payload("confirmed_conflict")), /must use both target branches/) + + assert.throws(() => validate({ + ...cleanOverlapResponse(), + integrationOrder: { + ...cleanOverlapResponse().integrationOrder, + secondBranchName: "feature/a" + } + }, payload("potential_overlap")), /must use both target branches/) +}) + +// 제공된 evidence에 없는 원인, patch, 예방 조치 파일을 거부 +test("rejects files outside pair evidence", () => { + assert.throws(() => validate({ + ...confirmedConflictResponse(), + patches: [{ + filePath: "src/unknown.ts", + patch: "@@ -1 +1 @@", + reason: "제공되지 않은 파일" + }] + }, payload("confirmed_conflict")), /must use provided evidence files/) + + assert.throws(() => validate({ + ...cleanOverlapResponse(), + overlapCause: { + summary: "제공되지 않은 파일", + files: ["src/unknown.ts"] + } + }, payload("potential_overlap")), /must use provided evidence files/) +}) + +// 상태별 해결 제안 배열이 비어 있으면 거부 +test("rejects empty pair resolution suggestions", () => { + assert.throws(() => validate({ + ...confirmedConflictResponse(), + patches: [] + }, payload("confirmed_conflict")), /must be a non-empty array/) + + assert.throws(() => validate({ + ...cleanOverlapResponse(), + preventiveActions: [] + }, payload("potential_overlap")), /must be a non-empty array/) +}) + +// 다른 상태의 field가 섞인 응답을 거부 +test("rejects fields outside pair response schema", () => { + assert.throws(() => validate({ + ...cleanOverlapResponse(), + patches: [] + }, payload("potential_overlap")), /contains unsupported fields/) +}) + +function confirmedConflictResponse(): AiConfirmedConflictResponse { + return { + kind: "confirmed_conflict", + pair: { + leftBranchName: "feature/a", + rightBranchName: "feature/b" + }, + conflictCause: { + summary: "두 branch가 같은 조건문을 다르게 수정함", + files: ["src/shared.ts"] + }, + integrationOrder: { + strategy: "rebase", + firstBranchName: "feature/a", + secondBranchName: "feature/b", + reason: "구조 변경을 먼저 반영해야 함", + steps: ["feature/a 반영", "feature/b rebase"] + }, + patches: [{ + filePath: "src/shared.ts", + patch: "@@ -1 +1 @@\n-old\n+new", + reason: "두 변경 의도를 함께 보존함" + }] + } +} + +function cleanOverlapResponse(): AiCleanOverlapResponse { + return { + kind: "clean_overlap", + pair: { + leftBranchName: "feature/a", + rightBranchName: "feature/b" + }, + overlapCause: { + summary: "같은 함수의 인접한 조건을 수정함", + files: ["src/shared.ts"] + }, + integrationOrder: { + strategy: "merge", + firstBranchName: "feature/a", + secondBranchName: "feature/b", + reason: "첫 변경 이후 통합 동작을 확인함", + steps: ["feature/a merge", "feature/b 갱신"] + }, + preventiveActions: [{ + title: "통합 동작 테스트", + description: "두 조건이 함께 실행되는 경우를 확인함", + files: ["src/shared.ts"] + }] + } +} + +function payload( + targetStatus: AiPredictionPairTargetStatus +): AiPredictionPairEvidencePayload { + const confirmedConflict = targetStatus === "confirmed_conflict" + + return { + pair: { + leftBranchName: "feature/a", + rightBranchName: "feature/b" + }, + targetStatus, + reasons: [{ + code: confirmedConflict ? "confirmed_conflict" : "same_hunk_overlap", + files: ["src/shared.ts"] + }], + branches: { + left: { + name: "feature/a", + commitOid: "a".repeat(40) + }, + right: { + name: "feature/b", + commitOid: "b".repeat(40) + } + }, + merge: { + status: confirmedConflict ? "confirmed_conflict" : "clean", + mergedTreeOid: "c".repeat(40), + conflictFiles: confirmedConflict ? ["src/shared.ts"] : [], + conflicts: confirmedConflict + ? [{ paths: ["src/shared.ts"], type: "content" }] + : [] + }, + codeContext: { + status: "available", + overlapFiles: ["src/shared.ts"], + evidence: [], + includedFileCount: 0, + includedHunkCount: 0, + omittedFileCount: 0, + omittedHunkCount: 0 + } + } +} diff --git a/tests/ai/predictionPairRunner.test.ts b/tests/ai/predictionPairRunner.test.ts new file mode 100644 index 0000000..7447287 --- /dev/null +++ b/tests/ai/predictionPairRunner.test.ts @@ -0,0 +1,214 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { + predictBranchPairsWithAi, + type AiCleanOverlapResponse, + type AiConfirmedConflictResponse, + type AiPredictionClient, + type AiPredictionPairEvidencePayload, + type AiPredictionPairTargetStatus, + type AiPredictionPrompt, + type BranchComparisonPair +} from "../../src/index.js" + +// branch 조합마다 provider를 한 번 호출하고 입력 순서로 결과 반환 +test("predicts branch pairs sequentially in input order", async () => { + const inputs = [ + payload("feature/a", "feature/b", "confirmed_conflict"), + payload("feature/c", "feature/d", "potential_overlap") + ] + const snapshot = JSON.stringify(inputs) + const client = new AiPredictionClientSpy() + + const results = await predictBranchPairsWithAi(inputs, client) + + assert.deepEqual(results.map(result => result.status), ["predicted", "predicted"]) + assert.deepEqual(results.map(result => result.pair), inputs.map(input => input.pair)) + assert.deepEqual(client.prompts.map(prompt => prompt.responseShape), [ + "predictionPairConfirmedConflict", + "predictionPairCleanOverlap" + ]) + assert.equal(JSON.stringify(inputs), snapshot) +}) + +// 한 조합의 provider 실패 후 다음 조합을 계속 처리 +test("isolates provider failure to one branch pair", async () => { + const inputs = [ + payload("feature/a", "feature/b", "confirmed_conflict"), + payload("feature/c", "feature/d", "potential_overlap") + ] + const client = new AiPredictionClientSpy([ + new Error("provider failed"), + cleanOverlapResponse(inputs[1]!.pair) + ]) + + const results = await predictBranchPairsWithAi(inputs, client) + + assert.deepEqual(results.map(result => result.status), ["failed", "predicted"]) + assert.match( + results[0]?.status === "failed" ? results[0].errorMessage : "", + /provider failed/ + ) + assert.equal(client.prompts.length, 2) +}) + +// 한 조합의 validation 실패 후 유효한 다음 응답 유지 +test("isolates validation failure to one branch pair", async () => { + const inputs = [ + payload("feature/a", "feature/b", "confirmed_conflict"), + payload("feature/c", "feature/d", "potential_overlap") + ] + const client = new AiPredictionClientSpy([ + cleanOverlapResponse(inputs[0]!.pair), + cleanOverlapResponse(inputs[1]!.pair) + ]) + + const results = await predictBranchPairsWithAi(inputs, client) + + assert.deepEqual(results.map(result => result.status), ["failed", "predicted"]) + assert.match( + results[0]?.status === "failed" ? results[0].errorMessage : "", + /must be confirmed_conflict/ + ) +}) + +// 모든 provider 호출이 실패해도 조합별 failed 결과를 반환 +test("returns failed result for every pair when provider is unavailable", async () => { + const inputs = [ + payload("feature/a", "feature/b", "confirmed_conflict"), + payload("feature/c", "feature/d", "potential_overlap") + ] + const client = new AiPredictionClientSpy([ + new Error("provider unavailable"), + new Error("provider unavailable") + ]) + + const results = await predictBranchPairsWithAi(inputs, client) + + assert.deepEqual(results.map(result => result.status), ["failed", "failed"]) + assert.equal(client.prompts.length, 2) +}) + +class AiPredictionClientSpy implements AiPredictionClient { + prompts: AiPredictionPrompt[] = [] + private responseIndex = 0 + + constructor(private readonly responses: Array = []) {} + + async predict(prompt: AiPredictionPrompt): Promise { + this.prompts.push(prompt) + const configured = this.responses[this.responseIndex] + this.responseIndex += 1 + + if (configured instanceof Error) { + throw configured + } + + return configured ?? responseFor(prompt) + } +} + +function responseFor(prompt: AiPredictionPrompt): unknown { + const input = JSON.parse(prompt.userPrompt) as AiPredictionPairEvidencePayload + + return prompt.responseShape === "predictionPairConfirmedConflict" + ? confirmedConflictResponse(input.pair) + : cleanOverlapResponse(input.pair) +} + +function confirmedConflictResponse( + pair: BranchComparisonPair +): AiConfirmedConflictResponse { + return { + kind: "confirmed_conflict", + pair, + conflictCause: { + summary: "두 branch가 같은 조건문을 다르게 수정함", + files: ["src/shared.ts"] + }, + integrationOrder: { + strategy: "rebase", + firstBranchName: pair.leftBranchName, + secondBranchName: pair.rightBranchName, + reason: "구조 변경을 먼저 반영해야 함", + steps: ["첫 branch 반영", "두 번째 branch rebase"] + }, + patches: [{ + filePath: "src/shared.ts", + patch: "@@ -1 +1 @@\n-old\n+new", + reason: "두 변경 의도를 함께 보존함" + }] + } +} + +function cleanOverlapResponse( + pair: BranchComparisonPair +): AiCleanOverlapResponse { + return { + kind: "clean_overlap", + pair, + overlapCause: { + summary: "같은 함수의 인접한 조건을 수정함", + files: ["src/shared.ts"] + }, + integrationOrder: { + strategy: "merge", + firstBranchName: pair.leftBranchName, + secondBranchName: pair.rightBranchName, + reason: "첫 변경 이후 통합 동작을 확인함", + steps: ["첫 branch merge", "두 번째 branch 갱신"] + }, + preventiveActions: [{ + title: "통합 동작 테스트", + description: "두 조건이 함께 실행되는 경우를 확인함", + files: ["src/shared.ts"] + }] + } +} + +function payload( + leftBranchName: string, + rightBranchName: string, + targetStatus: AiPredictionPairTargetStatus +): AiPredictionPairEvidencePayload { + const confirmedConflict = targetStatus === "confirmed_conflict" + + return { + pair: { + leftBranchName, + rightBranchName + }, + targetStatus, + reasons: [{ + code: confirmedConflict ? "confirmed_conflict" : "same_hunk_overlap", + files: ["src/shared.ts"] + }], + branches: { + left: { + name: leftBranchName, + commitOid: "a".repeat(40) + }, + right: { + name: rightBranchName, + commitOid: "b".repeat(40) + } + }, + merge: { + status: confirmedConflict ? "confirmed_conflict" : "clean", + mergedTreeOid: "c".repeat(40), + conflictFiles: confirmedConflict ? ["src/shared.ts"] : [], + conflicts: confirmedConflict + ? [{ paths: ["src/shared.ts"], type: "content" }] + : [] + }, + codeContext: { + status: "available", + overlapFiles: ["src/shared.ts"], + evidence: [], + includedFileCount: 0, + includedHunkCount: 0, + omittedFileCount: 0, + omittedHunkCount: 0 + } + } +}