Files
connectai/src/features/datacollect/handlers.ts
T
koriweb 6adbc2a6fa 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>
2026-06-18 18:18:20 +09:00

1068 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Datacollect handlers — /benchmark · /youtube · /blog · /wikify · /meet.
* (/research(NotebookLM)는 v2.2.205 에서 제거 — 로컬 Datacollect 앱 전용으로 분리)
*
* v2.2.201 에서 slashRouter.ts 에서 분리. Datacollect bridge (port 3002) 통합
* 슬래시 명령 클러스터 — NotebookLM Deep Research · Playwright 웹 스캔 ·
* yt-dlp YouTube 자막 추출 · 본문 wikify · 회의록 합성.
*
* callLmSynthesis / repairKoreanGlitches 는 slashRouter 에 남음 (communication
* 핸들러도 사용하는 일반 LLM 호출 인프라). 본 파일은 *bridge 호출 시퀀스* 만 담당.
*/
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,
formatHms,
fullScriptFromSegments,
buildInfoExtractionPrompt,
build4LensPrompt,
} from './prompts/youtubePrompts';
import { buildWikifyPrompt } from './prompts/wikifyPrompt';
import { buildMeetPrompt, buildMeetExtractPrompt, buildMeetReducePrompt, buildMeetVerifyPrompt } from './prompts/meetPrompt';
import { createCalendarEvent, createTask, readCalendarConfig } from '../calendar';
import {
transcriptHash, taskKey, loadRegisteredKeys, markRegistered,
savePending, loadPending, classifyAction,
registerAction, buildNotes, renderPendingQuestion, processConfirmDecisions,
loadGlossaryTerms, updateGlossary, extractGlossaryCandidates,
type PendingItem, type PendingFile,
} from './scheduling/meetRegistration';
import {
addBusinessDays,
toYmd,
extractMeetingDate,
resolveTaskDate,
parseActionItems,
} from './scheduling/calendarHelpers';
// ───────────────────────────── /benchmark ─────────────────────────────
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
const tokens = arg.trim().split(/\s+/).filter(Boolean);
let url = '';
let depthArg: number | undefined;
let pagesArg: number | undefined;
const restParts: string[] = [];
for (const t of tokens) {
const m = /^(depth|pages)=(\d+)$/i.exec(t);
if (m) {
if (m[1].toLowerCase() === 'depth') depthArg = Number(m[2]);
else pagesArg = Number(m[2]);
} else if (!url) {
url = t;
} else {
restParts.push(t);
}
}
if (!url) {
chunk(view, `사용법: \`/benchmark <url> [depth=N] [pages=N] [보조 설명]\`\n예: \`/benchmark https://example.com depth=2 pages=12\`\n`);
return true;
}
const userContent = restParts.join(' ');
const cfg = vscode.workspace.getConfiguration('g1nation');
const crawlDepth = depthArg ?? (cfg.get<number>('datacollectCrawlDepth', 1) ?? 1);
const maxPages = pagesArg ?? (cfg.get<number>('datacollectMaxPages', 8) ?? 8);
chunk(view, `🔍 **Web Benchmark 스캔**: ${url}\n(crawlDepth ${crawlDepth} · 최대 ${maxPages}페이지 · Playwright)\n\n⏳ 브라우저 기동 · 페이지 크롤링 · 디자인 토큰 추출 중…`);
const t0 = Date.now();
const heartbeat = setInterval(() => {
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
}, 4000);
const scan = await bridgeFetch<{ success: boolean; scan: any; slug?: string }>(
BRIDGE_API.web.benchmarkScan,
{
method: 'POST',
body: JSON.stringify({ url, captureScreenshots: false, maxPages, crawlDepth }),
},
{ timeoutMs: 6 * 60_000 },
).finally(() => clearInterval(heartbeat));
const s = scan.scan;
chunk(view, `\n✅ **스캔 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${s?.sitemap?.totalPages ?? 1}페이지)\n\n`);
const looksEmpty = !s?.meta?.title
&& !(s?.design?.colors?.palette?.length)
&& !s?.microcopy?.headline;
if (looksEmpty) {
chunk(view, `⚠️ **스캔 결과가 비어 있습니다.** URL이 정확한지, 사이트가 접근 가능한지(봇 차단·JS 전용 렌더링 포함) 확인하세요. 스캔한 URL: \`${url}\`\n\n`);
}
const palette = s?.design?.colors?.palette?.slice(0, 5) || [];
const rawReport = [
`### 메타`,
`- **title**: ${s?.meta?.title || '(없음)'}`,
`- **description**: ${s?.meta?.description || '(없음)'}`,
`- **lang**: ${s?.meta?.lang || '(없음)'}`,
``,
`### 디자인 토큰 (상위)`,
`- **컬러 팔레트 Top 5**: ${palette.map((p: any) => `\`${p.value}\` (×${p.count})`).join(', ') || '(없음)'}`,
`- **컬러 비율**: ${s?.design?.colors?.composition?.ratioLabel || '(없음)'}`,
`- **Primary Font**: \`${s?.design?.typography?.primaryFont || '(없음)'}\``,
``,
`### 사이트맵 (${s?.sitemap?.totalPages ?? 1}페이지, depth ${s?.sitemap?.crawlDepth ?? 0})`,
'```',
s?.sitemap?.ascii || '(없음)',
'```',
].join('\n');
let finalReport: string;
if (looksEmpty) {
chunk(view, `(스캔이 비어 LLM 합성을 건너뜁니다.)\n\n`);
finalReport = rawReport;
} else {
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
chunk(view, `🧪 **LLM 4-렌즈 합성** (3단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`);
try {
const parts: string[] = [];
for (const part of [1, 2, 3] as const) {
chunk(view, `\n · 합성 ${part}/3 진행 중…`);
const partT0 = Date.now();
const out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part));
if (!out) throw new Error(`LLM ${part}/3 응답이 비어 있습니다.`);
parts.push(out);
chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`);
}
finalReport = parts.join('\n\n---\n\n');
chunk(view, `\n\n`);
} catch (e: any) {
chunk(view, `\n\n⚠️ LLM 합성 실패: ${e?.message || String(e)}\n원시 스캔 요약만 표시·저장합니다. (LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
finalReport = rawReport;
}
}
chunk(view, finalReport + '\n\n');
try {
const today = new Date().toISOString().slice(0, 10);
let host = url;
try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ }
const title = `웹벤치마크 ${host} ${today}`;
const fileMarkdown = [
`# ${title}`,
``,
`- **원본 URL**: ${url}`,
`- **스캔 시각**: ${new Date().toISOString()}`,
`- **크롤 옵션**: depth ${crawlDepth} · 최대 ${maxPages}페이지`,
`- **생성**: Astra /benchmark · Datacollect web-benchmark`,
``,
finalReport,
``,
].join('\n');
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { title, content: fileMarkdown };
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;
}
// ───────────────────────────── /youtube ─────────────────────────────
function _looksLikeYoutubeChannelUrl(url: string): boolean {
return /youtube\.com\/(channel\/|@|c\/|user\/|playlist\?list=|playlist\/)/i.test(url)
|| /youtube\.com\/[^/?#]+\/(videos|shorts|streams)\b/i.test(url);
}
function _normalizeYoutubeUrl(url: string): string {
try {
const u = new URL(url);
if (!/youtube\.com$|youtube\.com\.|youtu\.be$/i.test(u.hostname)) return url;
const p = u.pathname;
if (/\/(watch|shorts|playlist|videos|streams|featured|community|about)\b/i.test(p)) return url;
if (u.hostname.includes('youtu.be')) return url;
if (/^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)\/?$/i.test(p)) {
u.pathname = p.replace(/\/?$/, '/videos');
return u.toString();
}
return url;
} catch {
return url;
}
}
const YOUTUBE_BATCH_MAX = 50;
async function runYoutube(arg: string, view: Webview | undefined): Promise<boolean> {
const BARE_MODE_KEYWORDS = new Set(['info', 'benchmark', 'both']);
const tokens = arg.trim().split(/\s+/).filter(Boolean);
const url = tokens[0] || '';
let limitOverride: number | null = null;
let mode: YoutubeAnalysisMode = 'both';
const contextTokens: string[] = [];
for (const tok of tokens.slice(1)) {
const nMatch = tok.match(/^n[:=](\d+)$/i);
if (nMatch) {
const n = parseInt(nMatch[1], 10);
if (Number.isFinite(n) && n > 0) limitOverride = Math.min(YOUTUBE_BATCH_MAX, n);
continue;
}
const modeMatch = tok.match(/^mode[:=](info|benchmark|both)$/i);
if (modeMatch) { mode = modeMatch[1].toLowerCase() as YoutubeAnalysisMode; continue; }
const lower = tok.toLowerCase();
if (BARE_MODE_KEYWORDS.has(lower)) { mode = lower as YoutubeAnalysisMode; continue; }
contextTokens.push(tok);
}
const userContent = contextTokens.join(' ');
if (!url) {
chunk(view, [
`사용법:\n`,
`- 단일 영상: \`/youtube <영상URL> [info|benchmark|both] [컨텍스트]\`\n`,
`- 채널/플레이리스트: \`/youtube <채널URL> [n:30] [info|benchmark|both] [컨텍스트]\`\n`,
`\n**분석 모드** (생략 시 \`both\`):\n`,
`- \`info\` — 영상의 *내용*을 지식 카드로 추출 (튜토리얼·강의·뉴스·인터뷰)\n`,
`- \`benchmark\` — 대본 역기획서 4-렌즈 분석 (콘텐츠 제작 벤치마크용)\n`,
`- \`both\` — 둘 다 생성 (영상당 LLM 호출 2회)\n`,
`\n예시:\n`,
`- \`/youtube https://youtu.be/abc info\`\n`,
`- \`/youtube https://youtube.com/@somechannel n:20 info AI 학습 자료\`\n`,
`\n💡 \`mode:info\` / \`mode=info\` 같은 명시형도 그대로 동작 (백워드 호환).\n`,
].join(''));
return true;
}
const isChannel = _looksLikeYoutubeChannelUrl(url);
const limit = limitOverride ?? (isChannel ? 10 : 1);
const normalizedUrl = isChannel ? _normalizeYoutubeUrl(url) : url;
if (normalizedUrl !== url) {
chunk(view, `🔧 채널 URL 정규화: \`${url}\`\`${normalizedUrl}\` (yt-dlp 영상 enumeration 을 위한 \`/videos\` 탭 명시)\n\n`);
}
const modeLabel = mode === 'info' ? '📋 정보 추출 (지식 카드)'
: mode === 'benchmark' ? '🎬 벤치마킹 (4-렌즈 역기획서)'
: '📋 정보 추출 + 🎬 벤치마킹 (둘 다)';
if (isChannel) {
const callsPerVideo = mode === 'both' ? 2 : 1;
chunk(view, `📺 **채널/플레이리스트 감지** → 최신 ${limit}개 영상을 1개씩 순차 분석·wiki화 합니다.\n` +
`분석 모드: **${modeLabel}** (영상당 LLM ${callsPerVideo}회 호출)\n` +
`각 영상은 자막추출 → LLM 분석 → wiki 저장 순으로 처리되며, 영상당 보통 30~${120 * callsPerVideo}초.\n` +
`중간에 멈추려면 Astra 사이드바의 ⏹ Stop 을 누르세요.\n\n`);
} else {
chunk(view, `📊 **분석 모드**: ${modeLabel}\n\n`);
}
chunk(view, `🎬 **YouTube 추출**: ${normalizedUrl}\n(자막 + 메타데이터${limit > 1 ? `, ${limit}개 영상` : ''})\n\n⏳ Python 추출기 기동 · 자막/메타 추출 중…`);
const t0 = Date.now();
const heartbeat = setInterval(() => {
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
}, 4000);
const extractTimeoutMs = Math.max(5 * 60_000, limit * 60_000);
const data = await bridgeFetch<{ success: boolean; videos?: any[]; totalVideos?: number }>(
BRIDGE_API.youtube.extract,
{ method: 'POST', body: JSON.stringify({ source: normalizedUrl, withMetadata: true, limit }) },
{
timeoutMs: extractTimeoutMs,
onHeartbeat: limit > 1
? (elapsedMs) => chunk(view, `\n · 추출 진행 중 (${Math.round(elapsedMs / 1000)}s, ${limit}개 영상)\n`)
: undefined,
},
).finally(() => clearInterval(heartbeat));
const okVideos = (data.videos || []).filter((v: any) => v?.status === 'ok');
chunk(view, `\n✅ **추출 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${okVideos.length}/${data.totalVideos ?? (data.videos || []).length}개 영상)\n\n`);
if (okVideos.length === 0) {
chunk(view, `⚠️ 자막 추출에 성공한 영상이 없습니다. 자막이 없거나 비공개 영상일 수 있습니다.\n`);
return true;
}
const cfg = vscode.workspace.getConfiguration('g1nation');
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
const sysInfo = '당신은 영상 콘텐츠를 지식 카드로 변환하는 정보 큐레이터입니다. 자막에 명시된 사실만 인용하세요.';
const sysBench = '당신은 유튜브 콘텐츠 시니어 PD입니다. 데이터에 근거한 제작 가이드만 제공하세요.';
type Section = { label: string; body: string };
async function runOneAnalysis(video: any, prompt: string, system: string, sectionLabel: string, progressTag: string): Promise<Section | null> {
chunk(view, `🧪 **${sectionLabel}**${progressTag} (모델 \`${model}\`)…`);
try {
const t = Date.now();
const body = await callLmSynthesis(prompt, system);
if (!body) throw new Error('LLM 응답이 비어 있습니다.');
chunk(view, ` ✓ (${Math.round((Date.now() - t) / 1000)}s)\n\n`);
chunk(view, body + '\n\n');
return { label: sectionLabel, body };
} catch (e: any) {
chunk(view, `\n\n⚠️ ${sectionLabel} 실패${progressTag}: ${e?.message || String(e)}\n`);
return null;
}
}
const total = okVideos.length;
let analyzedOk = 0;
let analyzedFail = 0;
let savedOk = 0;
let savedFail = 0;
const batchT0 = Date.now();
for (let i = 0; i < okVideos.length; i++) {
const video = okVideos[i];
const vTitle = video?.metadata?.title || video?.title || video?.video_id || '(제목 없음)';
const progressTag = total > 1 ? ` [${i + 1}/${total}]` : '';
if (total > 1) chunk(view, `\n━━━ **${progressTag.trim()} ${vTitle}** ━━━\n\n`);
const script = fullScriptFromSegments(video?.segments);
chunk(view, `## 📜 전체 스크립트 (Full Script)\n\n${script}\n\n---\n\n`);
const sections: Section[] = [];
if (mode === 'info' || mode === 'both') {
const sec = await runOneAnalysis(video, buildInfoExtractionPrompt(video, userContent), sysInfo, '📋 정보 추출 (지식 카드)', progressTag);
if (sec) sections.push(sec);
}
if (mode === 'benchmark' || mode === 'both') {
const sec = await runOneAnalysis(video, build4LensPrompt(video, userContent), sysBench, '🎬 벤치마킹 (4-렌즈 역기획서)', progressTag);
if (sec) sections.push(sec);
}
if (sections.length === 0) {
analyzedFail++;
chunk(view, `(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
continue;
}
analyzedOk++;
try {
const today = new Date().toISOString().slice(0, 10);
const videoUrl = video?.metadata?.webpage_url || `https://www.youtube.com/watch?v=${video?.video_id}`;
const modeSuffix = mode === 'info' ? ' (정보)' : mode === 'benchmark' ? ' (벤치마크)' : '';
const title = `유튜브분석 ${vTitle}${modeSuffix} ${today}`;
const sectionDivider = sections.length > 1 ? `\n\n---\n\n` : '';
const fileMarkdown = [
`# ${title}`,
``,
`- **영상 URL**: ${videoUrl}`,
`- **분석 시각**: ${new Date().toISOString()}`,
`- **분석 모드**: ${mode}`,
`- **생성**: Astra /youtube · Datacollect youtube insight`,
``,
`## 📜 전체 스크립트 (Full Script)`,
``,
script,
``,
`---`,
``,
sections.map((s) => s.body).join(sectionDivider),
``,
].join('\n');
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { title, content: fileMarkdown };
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 },
);
savedOk++;
chunk(view, `💾 **결과물 저장 완료**${progressTag}: \`${saved?.path || '(경로 미확인)'}\`\n\n`);
} catch (e: any) {
savedFail++;
chunk(view, `⚠️ 결과물 저장 실패${progressTag}: ${e?.message || String(e)}\n\n`);
}
}
if (total > 1) {
const batchSec = Math.round((Date.now() - batchT0) / 1000);
chunk(view, `\n━━━━━━━━━━━━━━━━━━━━\n`
+ `🏁 **배치 완료** (총 ${batchSec}s · ${total}개 영상)\n`
+ `- 분석: ✅ ${analyzedOk} / ❌ ${analyzedFail}\n`
+ `- 저장: 💾 ${savedOk} / ⚠️ ${savedFail}\n`);
}
return true;
}
// ───────────────────────────── /blog ─────────────────────────────
async function runBlog(keyword: string, view: Webview | undefined): Promise<boolean> {
const target = 'http://127.0.0.1:8787/blog/';
chunk(view, `🖋️ **Blog Pipeline**\n\n`);
if (keyword) chunk(view, `요청 키워드: \`${keyword}\`\n\n`);
chunk(view, [
`현재 Blog Pipeline은 Datacollect의 별도 정적 페이지(`,
`[${target}](${target})`,
`)에서 단계별로 실행됩니다. Bridge(3002)에 대응 API가 없어 채팅에서 직접`,
` 트리거할 수 없어, 다음 두 가지 중 하나를 선택해 주세요:\n\n`,
].join(''));
chunk(view, `1. 위 링크에서 키워드/참고 자료/경험담을 입력해 직접 파이프라인 실행\n`);
chunk(view, `2. Bridge에 \`/api/blog/run\` 같은 endpoint를 노출하면 \`/blog\`도 end-to-end 실행 가능 — 원하시면 추가 작업으로 진행\n`);
try { await vscode.env.openExternal(vscode.Uri.parse(target)); } catch { /* best-effort */ }
return true;
}
// ───────────────────────────── /wikify ─────────────────────────────
type WikifyResult = { ok: true } | { ok: false; reason: string };
async function wikifyOne(url: string, userContent: string, view: Webview | undefined): Promise<WikifyResult> {
const cfg = vscode.workspace.getConfiguration('g1nation');
chunk(view, `⏳ 본문 추출 중…`);
const t0 = Date.now();
const heartbeat = setInterval(() => {
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
}, 4000);
const data = await bridgeFetch<{ success: boolean; url: string; title?: string; description?: string; lang?: string; headings?: string[]; text?: string; textLength?: number; truncated?: boolean }>(
BRIDGE_API.web.extract,
{ method: 'POST', body: JSON.stringify({ url }) },
{ timeoutMs: 3 * 60_000 },
).finally(() => clearInterval(heartbeat));
chunk(view, `\n✅ 본문 추출 (${Math.round((Date.now() - t0) / 1000)}s · ${(data.textLength ?? 0).toLocaleString()}${data.truncated ? ', 일부 잘림' : ''})\n\n`);
if (!data.text || data.text.trim().length < 50) {
const reason = `본문 빈약 (${data.textLength ?? 0}자 — JS 전용 렌더링 또는 콘텐츠 없음)`;
chunk(view, `⚠️ 추출된 본문이 거의 없어 건너뜁니다. (${reason})\n`);
return { ok: false, reason };
}
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
const wikiSystem = '당신은 지식 큐레이터입니다. 제공된 웹사이트 본문을 P-Reinforce 규격의 고밀도 위키 문서로 정리합니다. 본문에 없는 내용은 절대 지어내지 않으며, 모든 문서는 한국어로 작성합니다.';
// 포맷 정본을 브리지에서 가져온다 (wiki_format.mjs — research 와 동일 포맷 보장).
// 구버전 브리지(엔드포인트 없음)면 wikifyPrompt 의 내장 사본 fallback.
let canonicalFormat: import('./prompts/wikifyPrompt').CanonicalWikiFormat | null = null;
try {
canonicalFormat = await bridgeFetch<any>(BRIDGE_API.wiki.template, { method: 'GET' }, { timeoutMs: 5000 });
chunk(view, `📐 포맷 정본 v${canonicalFormat?.version ?? '?'} (브리지)\n`);
} catch {
chunk(view, `📐 포맷 내장 사본 사용 (브리지 템플릿 미제공 — 브리지 갱신 시 정본 적용)\n`);
}
chunk(view, `🧪 P-Reinforce 위키 합성 (모델 \`${model}\`)…`);
let report: string;
try {
const synthT0 = Date.now();
report = await callLmSynthesis(buildWikifyPrompt(data, userContent, canonicalFormat), wikiSystem);
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
report = report.replace(/\[\[([^\[\]]+?)\](?!\])/g, '[[$1]]');
// 한·영 깨진 토큰("덩ey" 류) 감지 시 1회 수리 — 위키 문서는 영구 자산이라
// 채팅보다 더 중요. 검증 미통과면 원문 유지.
try {
const { findBrokenHangulTokens, repairBrokenHangul } = await import('../../agent/hangulHygiene');
const broken = findBrokenHangulTokens(report);
if (broken.length > 0) {
chunk(view, ` 🩹 표기 오류 ${broken.length}건 교정…`);
const fixed = await repairBrokenHangul(report, broken, (system, user) => callLmSynthesis(user, system));
if (fixed) report = fixed;
}
} catch { /* 수리 실패 시 원문 유지 */ }
chunk(view, ` ✓ (${Math.round((Date.now() - synthT0) / 1000)}s)\n\n`);
} catch (e: any) {
const reason = `LLM 합성 실패: ${e?.message || String(e)}`;
chunk(view, `\n⚠️ 위키 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
return { ok: false, reason };
}
chunk(view, report + '\n\n');
try {
const today = new Date().toISOString().slice(0, 10);
let host = url;
try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ }
const title = `위키 ${(userContent.trim() || data.title || host)} ${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 { ok: true };
}
async function runWikify(arg: string, view: Webview | undefined): Promise<boolean> {
const isUrl = (t: string) => /^https?:\/\//i.test(t) || /^[a-z0-9-]+(\.[a-z0-9-]+)+/i.test(t);
const tokens = arg.trim().split(/\s+/).filter(Boolean);
const urls = tokens.filter(isUrl);
const userContent = tokens.filter((t) => !isUrl(t)).join(' ');
if (urls.length === 0) {
chunk(view, `사용법: \`/wikify <url> [url2 url3 …] [주제명]\`\n예: \`/wikify https://example.com\`\n여러 링크를 공백으로 구분해 한 번에 넣으면 1개씩 순차 위키화합니다.\n`);
return true;
}
if (urls.length === 1) {
chunk(view, `📚 **위키화**: ${urls[0]}\n(본문 추출 → P-Reinforce v3.0 위키 문서 합성)\n\n`);
const result = await wikifyOne(urls[0], userContent, view);
if (!result.ok) chunk(view, `\n실패 사유: ${result.reason}\n`);
return true;
}
chunk(view, `📚 **위키화 배치**: 총 ${urls.length}개 링크를 순차 처리합니다.\n`);
const batchT0 = Date.now();
let okCount = 0;
const failures: Array<{ url: string; reason: string }> = [];
for (let i = 0; i < urls.length; i++) {
chunk(view, `\n---\n\n### [${i + 1}/${urls.length}] ${urls[i]}\n\n`);
try {
const result = await wikifyOne(urls[i], userContent, view);
if (result.ok) okCount++;
else failures.push({ url: urls[i], reason: result.reason });
} catch (e: any) {
const reason = `처리 오류: ${e?.message || String(e)}`;
chunk(view, `❌ [${i + 1}/${urls.length}] ${reason}\n`);
failures.push({ url: urls[i], reason });
}
}
chunk(view, `\n---\n\n🏁 **배치 완료**: ${okCount}/${urls.length}개 성공 (${Math.round((Date.now() - batchT0) / 1000)}s 소요)\n`);
if (failures.length > 0) {
chunk(view, `\n**실패 ${failures.length}건 사유**:\n`);
for (const f of failures) chunk(view, `- ${f.url}${f.reason}\n`);
}
return true;
}
// ───────────────────────────── /meet ─────────────────────────────
async function runMeet(arg: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise<boolean> {
const trimmed = arg.trim();
// ── 서브커맨드: 보류 항목 확인/답변 (등록 게이트의 후속 흐름) ──────────────
if (/^pending\b/i.test(trimmed)) {
const pend = loadPending();
chunk(view, pend && pend.items.length
? renderPendingQuestion(pend)
: '\nℹ️ 등록 보류 중인 액션 아이템이 없습니다.\n');
return true;
}
if (/^confirm\b/i.test(trimmed)) {
await runMeetConfirm(trimmed.replace(/^confirm\b/i, '').trim(), view, context);
return true;
}
let filePath = '';
let metadata = '';
if (trimmed.startsWith('"')) {
const end = trimmed.indexOf('"', 1);
if (end > 0) {
filePath = trimmed.slice(1, end);
metadata = trimmed.slice(end + 1).trim();
}
}
if (!filePath) {
const sp = trimmed.indexOf(' ');
if (sp === -1) filePath = trimmed;
else { filePath = trimmed.slice(0, sp); metadata = trimmed.slice(sp + 1).trim(); }
}
if (!filePath) {
chunk(view, `사용법: \`/meet <txt 파일 경로> [참석자·날짜 등 메타데이터]\`\n예: \`/meet c:\\doc\\0101.txt\`\n경로에 공백이 있으면 따옴표로 감싸세요: \`/meet "c:\\my docs\\0101.txt"\`\n`);
return true;
}
chunk(view, `📝 **회의록 작성**: ${filePath}\n\n⏳ 녹취 파일 읽는 중…`);
let transcript: string;
try {
transcript = await fsp.readFile(filePath, 'utf-8');
} catch (e: any) {
chunk(view, `\n\n❌ 파일을 읽을 수 없습니다: ${e?.message || String(e)}\n경로가 정확한지 확인하세요.\n`);
return true;
}
if (!transcript || transcript.trim().length < 20) {
chunk(view, `\n\n⚠️ 파일 내용이 거의 비어 있습니다.\n`);
return true;
}
// 중복 방지 키 — 동일 녹취 재실행 시 이미 등록된 액션을 건너뛰기 위한 해시 (원본 전체 기준).
const tHash = transcriptHash(transcript);
const userMetadata = metadata; // 용어집 후보 추출용 — 자동 보강 전 원본 보존
// [자동 용어집] 이전 /meet 들에서 누적된 인명·용어를 메타데이터에 보강 —
// meetPrompt 가 메타데이터를 STT 보정 용어집으로 쓰므로 반복 회의의 표기
// 일관성이 자동으로 좋아진다. 사용자 입력 메타데이터가 항상 우선(앞에 배치).
const glossaryTerms = loadGlossaryTerms();
if (glossaryTerms.length) {
metadata = `${metadata ? metadata + '\n' : ''}[자동 용어집 — 이전 회의에서 누적된 인명·용어 표기] ${glossaryTerms.join(', ')}`;
chunk(view, `📚 자동 용어집 ${glossaryTerms.length}개 용어 주입\n`);
}
// v2.2.211: 60K 하드 자르기 폐지 → 세그먼트 추출(Map) + 병합(Reduce).
// 단일샷 60K 는 로컬 32K 컨텍스트에서 잘리거나 lost-in-the-middle 로 중간
// 안건이 증발하던 원인. 12K 조각별 추출은 입력이 짧아 누락·날조 둘 다 준다.
const SEG_SIZE = 12000; // 조각 크기 (로컬 컨텍스트에 여유)
const SINGLE_SHOT_MAX = 14000; // 이하면 기존 단일샷 경로
const MAX_SEGMENTS = 12; // 런타임 상한 (~144K자 — 기존 60K 의 2.4배 커버)
const segLimit = SEG_SIZE * MAX_SEGMENTS;
const overCap = transcript.length > segLimit;
if (overCap) transcript = transcript.slice(0, segLimit);
chunk(view, `\n✅ 파일 읽기 완료 (${transcript.length.toLocaleString()}${overCap ? `, 상한 ${segLimit.toLocaleString()}자 초과로 일부 잘림` : ''})\n\n`);
const cfg = vscode.workspace.getConfiguration('g1nation');
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.';
let report: string;
let groundingNotes = ''; // 검증 패스용 — 세그먼트 경로에서 추출 노트 보관
try {
if (transcript.length <= SINGLE_SHOT_MAX) {
chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
const t0 = Date.now();
report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem);
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`);
} else {
// ── Map: 줄 경계 기준 조각 분할 → 조각별 사실 추출 ──
const segments: string[] = [];
let buf = '';
for (const line of transcript.split('\n')) {
if (buf.length + line.length + 1 > SEG_SIZE && buf) { segments.push(buf); buf = ''; }
buf += (buf ? '\n' : '') + line;
}
if (buf) segments.push(buf);
chunk(view, `🧩 **긴 녹취록 — 2단계 합성** (조각 ${segments.length}× ~${(SEG_SIZE / 1000) | 0}K자, 모델 \`${model}\`)\n`);
const extractSystem = '당신은 회의 녹취 사실 추출기입니다. 제공된 조각에 명시된 내용만 형식대로 추출하고, 없는 사실을 만들지 않습니다. 모든 출력은 한국어입니다.';
const notes: string[] = [];
let failedSegs = 0;
// 약한 모델이 큰 조각에서 출력 붕괴(반복/깨짐)할 때, 그 조각을 줄 경계로
// 절반씩 쪼개 재귀 재시도한다. 입력을 줄이면 붕괴 확률이 떨어지므로 모델
// 교체 없이도 성공률이 오른다. MIN_SEG 이하인데도 실패하면 그 구간만 포기.
const MIN_SEG = 3500;
const extractSeg = async (seg: string, label: string, idx: number, total: number): Promise<string | null> => {
try {
// callLmSynthesis 가 내부적으로 재시도(반복 억제 강화)까지 수행한다.
const note = await callLmSynthesis(buildMeetExtractPrompt(seg, metadata, idx, total), extractSystem);
if (!note) throw new Error('추출 결과가 비어 있습니다.');
return note.trim();
} catch (segErr: any) {
if (seg.length <= MIN_SEG) {
chunk(view, ` ⚠️ 조각 ${label} 추출 실패(최소 크기 도달, 건너뜀): ${segErr?.message || String(segErr)}\n`);
return null;
}
chunk(view, ` ↩︎ 조각 ${label}(${seg.length.toLocaleString()}자) 출력 붕괴 → 절반으로 쪼개 재시도\n`);
const lines = seg.split('\n');
let cut = 0, acc = 0;
for (; cut < lines.length - 1; cut++) { acc += lines[cut].length + 1; if (acc >= seg.length / 2) break; }
const left = lines.slice(0, cut + 1).join('\n');
const right = lines.slice(cut + 1).join('\n');
if (!left.trim() || !right.trim()) return null; // 더는 못 쪼갬
const parts = [
await extractSeg(left, `${label}a`, idx, total),
await extractSeg(right, `${label}b`, idx, total),
].filter(Boolean) as string[];
return parts.length ? parts.join('\n\n') : null;
}
};
for (let i = 0; i < segments.length; i++) {
chunk(view, ` ⏳ 조각 ${i + 1}/${segments.length} 추출 중…\n`);
const t0 = Date.now();
const note = await extractSeg(segments[i], String(i + 1), i + 1, segments.length);
if (note) {
notes.push(`### ─── 조각 ${i + 1}/${segments.length} 노트 ───\n${note}`);
chunk(view, ` ✓ 조각 ${i + 1} 완료 (${Math.round((Date.now() - t0) / 1000)}s)\n`);
} else {
// 한 조각이 끝내 실패해도 전체 회의록을 포기하지 않는다 — 누락을
// 명시하고 나머지 조각으로 부분 회의록을 만든다.
failedSegs++;
notes.push(`### ─── 조각 ${i + 1}/${segments.length} 노트 ───\n(이 구간은 모델 출력 오류로 추출하지 못했습니다.)`);
chunk(view, ` ⚠️ 조각 ${i + 1} 추출 실패(건너뜀)\n`);
}
}
if (failedSegs === segments.length) {
throw new Error(`모든 조각(${segments.length}개) 추출에 실패했습니다 — 모델 출력이 계속 붕괴합니다.`);
}
if (failedSegs > 0) {
chunk(view, `\n⚠️ ${segments.length}개 중 ${failedSegs}개 구간을 추출하지 못해 **부분 회의록**으로 진행합니다. 더 큰 모델(예: 27B+) 사용을 권장합니다.\n`);
}
groundingNotes = notes.join('\n\n');
// ── Reduce: 노트 병합 → 최종 회의록 ──
chunk(view, ` 🧪 최종 회의록 병합 중…`);
const t1 = Date.now();
report = await callLmSynthesis(buildMeetReducePrompt(groundingNotes, metadata), meetSystem);
if (!report) throw new Error('병합 단계 LLM 응답이 비어 있습니다.');
chunk(view, ` ✓ (${Math.round((Date.now() - t1) / 1000)}s)\n\n`);
}
} catch (e: any) {
const msg = e?.message || String(e);
const degen = /붕괴|반복|깨|parse input/i.test(msg);
chunk(view, `\n\n⚠️ 회의록 합성 실패: ${msg}\n`);
if (degen) {
chunk(view, `\n💡 모델 출력이 반복/깨짐(degeneration)으로 붕괴한 것으로 보입니다. 재시도(반복 억제 강화)에도 풀리지 않았습니다.\n`
+ ` · 현재 모델 \`${model}\` 이 긴 한국어 녹취록에는 약할 수 있습니다 — **더 큰 모델(27B+급)** 로 \`g1nation.defaultModel\` 변경을 권장합니다.\n`
+ ` · 또는 녹취록을 더 짧게 나눠 여러 번 \`/meet\` 하면 성공률이 올라갑니다.\n`);
} else {
chunk(view, `(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n`);
}
chunk(view, '\n');
return true;
}
// ── 검증 패스 (옵션, g1nation.meetVerifyPass) — 결정·액션을 근거 소스와 대조 ──
if (cfg.get<boolean>('meetVerifyPass', false)) {
try {
chunk(view, `🔍 **검증 패스** — 결정·액션 근거 대조 중…`);
const t2 = Date.now();
const source = groundingNotes || transcript.slice(0, 28000);
const flagged = await callLmSynthesis(
buildMeetVerifyPrompt(report, source),
'당신은 회의록 검증자입니다. 회의록의 각 결정·액션이 근거 소스에 실제로 존재하는지만 판정합니다. 한국어로 출력합니다.',
);
chunk(view, ` ✓ (${Math.round((Date.now() - t2) / 1000)}s)\n\n`);
if (flagged && !/검증\s*통과/.test(flagged)) {
report += `\n\n---\n## ⚠️ 검증 결과 (자동)\n${flagged.trim()}\n`;
}
} catch (e: any) {
chunk(view, `\n⚠️ 검증 패스 실패(회의록은 유지): ${e?.message || String(e)}\n`);
}
}
chunk(view, report + '\n\n');
try {
const today = new Date().toISOString().slice(0, 10);
const baseName = filePath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '') || 'meeting';
const title = `회의록 ${baseName} ${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`);
}
if (context) {
try {
const calCfg = readCalendarConfig(context);
if (!calCfg.refreshToken) {
chunk(view, `\nℹ️ 캘린더 자동 등록을 건너뜁니다 — Google Calendar OAuth(쓰기)가 연결되지 않았습니다. (Astra Settings → Google 섹션에서 연결)\n`);
} else {
const tasks = parseActionItems(report);
// [자동 용어집 누적] 이번 회의의 담당자 이름 + 사용자가 입력한 메타데이터
// 용어를 워크스페이스 용어집에 저장 — 다음 /meet 의 STT 보정에 자동 사용.
try {
updateGlossary([...tasks.map(t => t.owner), ...extractGlossaryCandidates(userMetadata)]);
} catch { /* 용어집 실패는 본 흐름에 영향 없음 */ }
if (tasks.length === 0) {
chunk(view, `\nℹ️ 액션 아이템이 없어 캘린더에 등록할 항목이 없습니다.\n`);
} else {
const today = new Date();
const meetingDate = extractMeetingDate(report, today);
const titleMatch = report.match(/^#\s+(.+)$/m);
const meetTitle = titleMatch
? titleMatch[1].replace(/^\[회의 제목\]\s*/, '').trim()
: '회의';
const gCfg = vscode.workspace.getConfiguration('g1nation');
const meetUsesTasks = gCfg.get<boolean>('meetUsesTasks', true);
const meetUsesCalendar = gCfg.get<boolean>('meetUsesCalendar', true);
if (!meetUsesTasks && !meetUsesCalendar) {
chunk(view, `\n️ Google Tasks·Calendar 등록이 모두 꺼져 있어 액션 아이템 자동 등록을 건너뜁니다. (Settings 의 \`g1nation.meetUsesTasks\` / \`g1nation.meetUsesCalendar\` 확인)\n`);
} else {
// ── 확신 게이트 등록 (v2.2.216) ─────────────────────────
// 확정만 자동 등록. 진행미정/기한미정/조건부는 보류→질문→
// `/meet confirm` 답변으로 등록 완결. 반복은 첫 1회만.
// 같은 녹취 재실행은 해시 레지스트리로 이중 등록 차단.
const destLabel = [meetUsesTasks && 'Tasks', meetUsesCalendar && 'Calendar'].filter(Boolean).join(' + ');
const registeredKeys = loadRegisteredKeys(tHash);
const holds: PendingItem[] = [];
let autoOk = 0, autoFail = 0, dupSkipped = 0, pastCount = 0;
const newKeys: string[] = [];
chunk(view, `\n📝 **Google ${destLabel} 등록 (확신 게이트)**: 액션 아이템 ${tasks.length}건 분류 중…\n`);
for (const task of tasks) {
const key = taskKey(task.work);
if (registeredKeys.has(key)) {
dupSkipped++;
chunk(view, ` · ⏭️ 이미 등록됨(같은 녹취) — ${task.work}\n`);
continue;
}
const cls = classifyAction(task, meetingDate, today);
if (cls.route === 'hold') {
holds.push({
idx: holds.length + 1,
owner: task.owner, work: task.work, detail: task.detail, deliverable: task.deliverable, due: task.due,
kind: cls.kind, condition: cls.condition, suggestedDate: cls.suggestedDate,
});
continue;
}
// auto — 과거 날짜 가드: 옛 녹취면 과거 날짜 그대로 + 완료확인 표기.
const past = cls.pastNote;
if (past) pastCount++;
const evTitle = past ? `${task.work} (과거자료·완료확인 필요)` : task.work;
const extra: string[] = [];
if (past) extra.push('⚠️ 과거 자료 기반 등록 — 이미 완료되었는지 확인이 필요합니다.');
if (cls.recurNote) extra.push(`↻ 반복 업무 언급(${cls.recurNote}) — 정책상 첫 1회만 등록합니다.`);
const notes = buildNotes({
detail: task.detail, meetTitle, owner: task.owner, deliverable: task.deliverable,
dueRaw: task.due, dateLabel: cls.date, extra,
});
const r = await registerAction(context, {
title: evTitle, date: cls.date, notes,
useTasks: meetUsesTasks, useCalendar: meetUsesCalendar,
});
if (r.failures.length === 0) {
autoOk++;
newKeys.push(key);
chunk(view, ` · ✅ ${cls.date}${evTitle} (${r.successes.join(' + ')})\n`);
} else {
autoFail++;
if (r.successes.length) newKeys.push(key);
chunk(view, ` · ${cls.date}${evTitle}${r.successes.length ? ` (✅ ${r.successes.join(' + ')})` : ''}\n`);
for (const f of r.failures) chunk(view, ` ⚠️ ${f}\n`);
}
}
if (newKeys.length) markRegistered(tHash, meetTitle, newKeys);
const summary: string[] = [`자동 등록 ${autoOk}`];
if (autoFail) summary.push(`실패 ${autoFail}`);
if (dupSkipped) summary.push(`중복 건너뜀 ${dupSkipped}`);
if (pastCount) summary.push(`과거자료 ${pastCount}건(완료확인 필요)`);
if (holds.length) summary.push(`보류 ${holds.length}건(확인 필요)`);
chunk(view, `\n결과 — ${summary.join(' · ')}\n`);
if (holds.length) {
const pend: PendingFile = {
createdAt: new Date().toISOString(),
meetTitle,
meetingDateYmd: meetingDate.toISOString().slice(0, 10),
transcriptHash: tHash,
items: holds,
};
savePending(pend);
chunk(view, renderPendingQuestion(pend) + '\n');
}
}
}
}
} catch (e: any) {
chunk(view, `\n⚠️ 캘린더 등록 중 오류: ${e?.message || String(e)}\n`);
}
}
return true;
}
/**
* `/meet confirm 1=6/20 2=ok 3=skip` — 보류된 액션 아이템에 대한 사용자 답변을
* 받아 등록을 *완결*한다 (확신 게이트의 후속 흐름).
* - `날짜` → 그 날짜로 등록 (조건부면 '조건 확인일'로 등록)
* - `ok` → 일반 항목: 제안 날짜로 등록 · 조건부: 날짜 없는 Tasks 로 [조건부] 등록
* - `skip` → 등록하지 않고 목록에서 제거
* 처리된 항목은 pending 에서 제거되고, 등록 성공분은 레지스트리에 기록되어
* 같은 녹취 재실행 시 중복 등록되지 않는다.
*/
async function runMeetConfirm(arg: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise<void> {
if (!context) {
chunk(view, '\n⚠️ 확장 컨텍스트를 사용할 수 없어 등록을 진행할 수 없습니다.\n');
return;
}
// 코어는 출력 중립(processConfirmDecisions) — 텔레그램 인바운드와 공용 (P5 HITL).
const { lines } = await processConfirmDecisions(context, arg);
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 은 로컬
// Datacollect 앱 전용으로 분리(Chrome/Google 로그인 의존). ASTRA 백엔드는 NAS 경량
// Bridge 로 운영 가능해야 하므로 brower-auth 가 필요한 명령은 두지 않는다.
registerSlashCommand({ name: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark });
registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube });
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 });