/** * 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 { 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 [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('datacollectCrawlDepth', 1) ?? 1); const maxPages = pagesArg ?? (cfg.get('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('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('datacollectSavePath', '') || '').trim(); const body: Record = { 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 { 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('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
{ 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('datacollectSavePath', '') || '').trim(); const body: Record = { 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 { 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 { 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('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(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('datacollectSavePath', '') || '').trim(); const body: Record = { 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 { 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 [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 { 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 [참석자·날짜 등 메타데이터]\`\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('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 => { 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('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('datacollectSavePath', '') || '').trim(); const body: Record = { 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('meetUsesTasks', true); const meetUsesCalendar = gCfg.get('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 { 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 { 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('datacollectSavePath', '') || '').trim(); const body: Record = { 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 { // 글자 예산으로 배치 묶기 — 한 블록이 예산보다 커도 단독 배치로 허용. 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 });