feat(review): /review 코드 리뷰 map-reduce 청킹 명령 (v2.2.255)

일반 에이전트 채팅이 큰 코드베이스 리뷰를 단일 호출로 처리하다 약한 로컬
모델에서 빈 응답으로 무너지던 문제를, /meet 의 검증된 map-reduce 로 우회.

- /review <디렉터리|파일> [초점] 신설 (코어 채팅 경로 무수정)
- Map: 파일별 독립 리뷰(라인 인용 근거), callLmSynthesis 재시도/붕괴감지 활용,
  한 파일 실패해도 부분 리뷰로 진행
- Reduce: 노트 통합 + hierarchical fold 로 reduce 입력을 약한 모델 한도(16K) 안 유지
- 의존성/빌드 산출물 제외, 파일 30개·400KB 상한, 결과 wiki 저장
- 신규 reviewPrompt.ts / reviewFiles.ts, 테스트 +5건(전체 667 통과)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 18:18:20 +09:00
parent 1efbe2ec0f
commit 6adbc2a6fa
6 changed files with 438 additions and 1 deletions
+196
View File
@@ -12,9 +12,12 @@
import * as vscode from 'vscode';
import { promises as fsp } from 'fs';
import * as path from 'path';
import { registerSlashCommand, chunk, type Webview } from './slashRouter';
import { callLmSynthesis } from './llm';
import { bridgeFetch, BRIDGE_API } from './bridgeClient';
import { collectSourceFiles } from './reviewFiles';
import { buildReviewFilePrompt, buildReviewReducePrompt } from './prompts/reviewPrompt';
import { type SynthesisPart, buildSynthesisPrompt } from './prompts/synthesisPrompt';
import {
type YoutubeAnalysisMode,
@@ -859,6 +862,198 @@ async function runMeetConfirm(arg: string, view: Webview | undefined, context?:
chunk(view, '\n' + lines.map(l => ` ${l}`).join('\n') + '\n');
}
// ───────────────────────────── /review ─────────────────────────────
/**
* `/review <디렉터리|파일 경로> [초점]` — 코드 리뷰 map-reduce.
*
* 일반 에이전트 채팅은 큰 코드베이스 리뷰를 단일 호출로 처리하다 약한 로컬 모델에서
* 빈 응답(첫 토큰 EOS)으로 무너진다. /review 는 /meet 와 같은 map-reduce 로 우회:
* - Map : 파일 하나씩 독립 리뷰 → 파일별 발견사항 노트(callLmSynthesis 가 재시도/붕괴
* 감지까지 내장)
* - Reduce: 노트를 통합 → 우선순위 매겨진 최종 보고서. 노트가 크면 배치로 접어
* (hierarchical fold) reduce 입력도 약한 모델 한도 안에 둔다.
* 한 조각이 끝내 실패해도 전체를 포기하지 않고 부분 리뷰로 진행한다(/meet 와 동일 정책).
*/
const REVIEW_MAX_FILES = 30; // 1회 리뷰 파일 상한 (초과분은 잘림 + 경고)
const REVIEW_MAX_FILE_BYTES = 400_000; // 이보다 큰 파일은 생성물로 보고 제외
const REVIEW_PER_FILE_CHARS = 16_000; // 파일 1개에서 모델에 보낼 본문 상한 (초과 시 앞부분만)
const REVIEW_REDUCE_BUDGET = 16_000; // reduce 1회 입력(노트) 상한 — 약한 모델 안전선
async function runReview(arg: string, view: Webview | undefined, _context?: vscode.ExtensionContext): Promise<boolean> {
const trimmed = arg.trim();
// 경로 파싱 — /meet 와 동일하게 따옴표 감싼 경로 + 뒤따르는 초점 텍스트 지원.
let targetPath = '';
let focus = '';
if (trimmed.startsWith('"')) {
const end = trimmed.indexOf('"', 1);
if (end > 0) { targetPath = trimmed.slice(1, end); focus = trimmed.slice(end + 1).trim(); }
}
if (!targetPath) {
const sp = trimmed.indexOf(' ');
if (sp === -1) targetPath = trimmed;
else { targetPath = trimmed.slice(0, sp); focus = trimmed.slice(sp + 1).trim(); }
}
if (!targetPath) {
chunk(view, '사용법: `/review <디렉터리 또는 파일 경로> [리뷰 초점]`\n예: `/review E:\\Wiki\\astraai`\n경로에 공백이 있으면 따옴표로: `/review "E:\\my proj\\src" 보안 위주로`\n');
return true;
}
// 대상 판별 — 파일 1개 vs 디렉터리.
let stat;
try {
stat = await fsp.stat(targetPath);
} catch (e: any) {
chunk(view, `\n❌ 경로를 찾을 수 없습니다: ${e?.message || String(e)}\n`);
return true;
}
const projectLabel = targetPath.replace(/[\\/]+$/, '').replace(/^.*[\\/]/, '') || targetPath;
chunk(view, `🔍 **코드 리뷰**: ${targetPath}${focus ? `\n초점: ${focus}` : ''}\n\n`);
// ── 대상 파일 수집 ──
interface RFile { absPath: string; relPath: string; }
let files: RFile[] = [];
let truncatedFiles = false;
let totalCandidates = 0;
if (stat.isDirectory()) {
const collected = await collectSourceFiles(targetPath, { maxFiles: REVIEW_MAX_FILES, maxFileBytes: REVIEW_MAX_FILE_BYTES });
files = collected.files.map(f => ({ absPath: f.absPath, relPath: f.relPath }));
truncatedFiles = collected.truncated;
totalCandidates = collected.totalCandidates;
} else {
files = [{ absPath: targetPath, relPath: path.basename(targetPath) }];
totalCandidates = 1;
}
if (files.length === 0) {
chunk(view, `\nℹ️ 리뷰할 소스 파일을 찾지 못했습니다. (의존성·빌드 산출물은 제외됩니다)\n`);
return true;
}
chunk(view, `📂 소스 파일 ${files.length}개 리뷰 대상${truncatedFiles ? ` (후보 ${totalCandidates}개 중 상위 ${files.length}개만 — 상한 ${REVIEW_MAX_FILES}; 범위를 좁혀 다시 실행 권장)` : ''}\n\n`);
const reviewSystem = '당신은 시니어 코드 리뷰어입니다. 제공된 코드만 근거로 사실 기반의 발견사항을 추출·통합하며, 없는 코드·취약점을 지어내지 않습니다. 모든 출력은 한국어입니다.';
// ── Map: 파일별 독립 리뷰 ──
const noteBlocks: string[] = [];
let failed = 0;
for (let i = 0; i < files.length; i++) {
const f = files[i];
chunk(view, ` ⏳ (${i + 1}/${files.length}) ${f.relPath} 리뷰 중…\n`);
let content: string;
try {
content = await fsp.readFile(f.absPath, 'utf-8');
} catch (e: any) {
failed++;
chunk(view, ` ⚠️ 읽기 실패(건너뜀): ${e?.message || String(e)}\n`);
continue;
}
if (!content.trim()) { continue; } // 빈 파일은 조용히 스킵
let truncNote = '';
if (content.length > REVIEW_PER_FILE_CHARS) {
content = content.slice(0, REVIEW_PER_FILE_CHARS);
truncNote = `\n(파일이 커서 앞 ${REVIEW_PER_FILE_CHARS.toLocaleString()}자만 리뷰함)`;
}
try {
const note = await callLmSynthesis(
buildReviewFilePrompt(f.relPath, content, i + 1, files.length, focus),
reviewSystem,
);
if (!note) throw new Error('리뷰 결과가 비어 있습니다.');
noteBlocks.push(`### ─── ${f.relPath} ───\n${note.trim()}${truncNote}`);
chunk(view, ` ✓ 완료\n`);
} catch (e: any) {
failed++;
noteBlocks.push(`### ─── ${f.relPath} ───\n(이 파일은 모델 출력 오류로 리뷰하지 못했습니다: ${e?.message || String(e)})`);
chunk(view, ` ⚠️ 리뷰 실패(건너뜀): ${e?.message || String(e)}\n`);
}
}
if (failed === files.length) {
chunk(view, `\n❌ 모든 파일 리뷰에 실패했습니다 — 모델 출력이 계속 붕괴합니다. 더 큰 모델(활성 7B+) 사용을 권장합니다.\n`);
return true;
}
if (failed > 0) {
chunk(view, `\n⚠️ ${files.length}개 중 ${failed}개 파일을 리뷰하지 못해 **부분 리뷰**로 진행합니다.\n`);
}
// ── Reduce: 노트 통합 (노트가 크면 배치로 접어 약한 모델 한도 안에 유지) ──
chunk(view, `\n 🧪 발견사항 통합 중…\n`);
let report: string;
try {
report = await reduceReviewNotes(noteBlocks, projectLabel, files.length, focus, reviewSystem, view);
if (!report) throw new Error('통합 단계 응답이 비어 있습니다.');
} catch (e: any) {
chunk(view, `\n⚠️ 통합 실패: ${e?.message || String(e)}\n약한 모델일 수 있습니다 — 활성 7B+ 모델 또는 범위를 좁혀 다시 시도하세요.\n`);
return true;
}
chunk(view, '\n' + report + '\n\n');
// ── 저장 (wiki) — /meet 와 동일 경로 ──
try {
const cfg = vscode.workspace.getConfiguration('g1nation');
const today = new Date().toISOString().slice(0, 10);
const title = `코드리뷰 ${projectLabel} ${today}`;
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { title, content: report };
if (savePath) body.saveDir = savePath;
const saved = await bridgeFetch<{ success: boolean; path?: string }>(
BRIDGE_API.wiki.save,
{ method: 'POST', body: JSON.stringify(body) },
{ timeoutMs: 30_000 },
);
chunk(view, `💾 **리뷰 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`);
} catch (e: any) {
chunk(view, `⚠️ 리뷰 저장 실패(보고서는 위에 출력됨): ${e?.message || String(e)}\n`);
}
return true;
}
/**
* 파일별 노트를 최종 보고서로 통합. 노트 합계가 REVIEW_REDUCE_BUDGET 를 넘으면
* 배치로 나눠 각 배치를 reduce 한 뒤(부분 보고서) 그 결과를 다시 reduce 하는 fold
* 로 수렴시킨다 — reduce 입력이 항상 약한 모델 한도 안에 들어오게.
*/
async function reduceReviewNotes(
noteBlocks: string[], projectLabel: string, fileCount: number, focus: string,
reviewSystem: string, view: Webview | undefined,
): Promise<string> {
// 글자 예산으로 배치 묶기 — 한 블록이 예산보다 커도 단독 배치로 허용.
const packBatches = (blocks: string[]): string[][] => {
const batches: string[][] = [];
let cur: string[] = [];
let curLen = 0;
for (const b of blocks) {
if (cur.length && curLen + b.length > REVIEW_REDUCE_BUDGET) { batches.push(cur); cur = []; curLen = 0; }
cur.push(b); curLen += b.length + 2;
}
if (cur.length) batches.push(cur);
return batches;
};
let level = noteBlocks;
let pass = 0;
while (true) {
const batches = packBatches(level);
if (batches.length === 1) {
return await callLmSynthesis(
buildReviewReducePrompt(batches[0].join('\n\n'), projectLabel, fileCount, focus),
reviewSystem,
);
}
pass++;
chunk(view, ` · 통합 ${pass}단계 — ${level.length}개 노트를 ${batches.length}개 배치로 접는 중…\n`);
const partials: string[] = [];
for (let i = 0; i < batches.length; i++) {
const r = await callLmSynthesis(
buildReviewReducePrompt(batches[i].join('\n\n'), projectLabel, fileCount, focus),
reviewSystem,
);
if (r && r.trim()) partials.push(`### ─── 부분 통합 ${pass}-${i + 1} ───\n${r.trim()}`);
}
if (partials.length === 0) throw new Error('배치 통합이 모두 비었습니다.');
level = partials;
}
}
// ─── 등록 ─────────────────────────────────────────────────────────────────
// /research(NotebookLM Deep Research)는 v2.2.205 에서 제거 — NotebookLM 은 로컬
@@ -869,3 +1064,4 @@ registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또
registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog });
registerSlashCommand({ name: '/wikify', description: '웹 URL → P-Reinforce v3.0 위키 합성·저장', handler: runWikify });
registerSlashCommand({ name: '/meet', description: '회의 transcript → 회의록 합성 + 캘린더·task 등록', handler: runMeet });
registerSlashCommand({ name: '/review', description: '코드 리뷰 — 디렉터리/파일을 파일별 리뷰(map) 후 통합(reduce). 약한 모델도 처리', handler: runReview });