- v2.2.299 아키텍처 수렴: coreChat 통일(엔진 휴리스틱 3벌 제거), 기업 모드 검색 오케스트레이터 승격, lib/execUtil(실행 래퍼 6곳·Python 탐지 3벌 단일화), lib/kstSchedule(워처 4개 nowInKst 통합), estimateTokens 통합, 설정 접근 규칙 명문화 - v2.2.300 채팅 화면 정리: LiveReasoningFilter(스트리밍 중 <think>/Harmony 추론 토큰 단위 차단), 확신도·검토요청 footer 기본 숨김(계산·Reflection 은 유지) - v2.2.301 문맥·의도 이해: [답변 전 이해 원칙] 상시 주입, 워크플로우 의도 브리핑, Report QA 루프(규칙 레지스트리+실측치+회귀 게이트, 블로그_v3 개념 이식) - v2.2.302 /benchmark 비즈니스 렌즈(가격·수익·운영)+빌드 프롬프트 모드+QA 연계 - v2.2.303 handoff 모드(측정치 무손실 인수인계 문서)+/claude(Claude Code 터미널 위임) - v2.2.304 이식성: 지식 경로 두뇌-상대 규약(pickWikiDir 상대 해석), 이사 체크리스트 - v2.2.305 Claude 구독 엔진: claude: 프로바이더(CLI 위임, 모델 드롭다운 자동 노출, coreChat 지원 — 워크플로우·QA도 구독 모델 가능) - v2.2.306 Tone Guard: AI 상투어 금지 레지스트리(상담사 화법 실사례 8종+대조 예시) - v2.2.307 /benchmark 레이아웃 골격(sectionRoles 결정론 분석, 롤링 배너 즉답), 파트별 실패 격리, 합성 타임아웃 120→300초 검증: tsc 무오류 + jest 888 통과 + esbuild 정상 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1244 lines
72 KiB
TypeScript
1244 lines
72 KiB
TypeScript
/**
|
||
* 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 { getConfig } from '../../config';
|
||
import { logInfo } from '../../utils';
|
||
import { runReportQaLoop } from '../../agents/reportQa';
|
||
import { bridgeFetch, BRIDGE_API } from './bridgeClient';
|
||
import { collectSourceFiles } from './reviewFiles';
|
||
import { buildReviewFilePrompt, buildReviewReducePrompt } from './prompts/reviewPrompt';
|
||
import { type SynthesisPart, buildSynthesisPrompt } from './prompts/synthesisPrompt';
|
||
import { buildHandoffDoc } from './prompts/handoffDoc';
|
||
import { buildLayoutSkeleton } from './prompts/layoutSkeleton';
|
||
import {
|
||
type YoutubeAnalysisMode,
|
||
formatHms,
|
||
fullScriptFromSegments,
|
||
buildInfoExtractionPrompt,
|
||
build4LensPrompt,
|
||
} from './prompts/youtubePrompts';
|
||
import { buildWikifyPrompt } from './prompts/wikifyPrompt';
|
||
import { buildMeetPrompt, buildMeetExtractPrompt, buildMeetReducePrompt, buildMeetVerifyPrompt, buildMeetRevisePrompt, buildMeetEditorPrompt, buildTaskDetailPrompt } from './prompts/meetPrompt';
|
||
import { persistWikiDoc, describeSave } from './wikiSave';
|
||
import { createCalendarEvent, createTask, readCalendarConfig } from '../calendar';
|
||
import {
|
||
transcriptHash, taskKey, loadRegisteredKeys, markRegistered,
|
||
savePending, loadPending, classifyAction,
|
||
registerAction, buildNotes, renderPendingQuestion, processConfirmDecisions,
|
||
parseTaskDetails,
|
||
loadGlossaryTerms, updateGlossary, extractGlossaryCandidates, loadTeamRoster,
|
||
type PendingItem, type PendingFile,
|
||
} from './scheduling/meetRegistration';
|
||
import {
|
||
addBusinessDays,
|
||
toYmd,
|
||
extractMeetingDate,
|
||
extractMeetingHeader,
|
||
resolveTaskDate,
|
||
parseActionItems,
|
||
} from './scheduling/calendarHelpers';
|
||
|
||
// ───────────────────────────── /benchmark ─────────────────────────────
|
||
|
||
/**
|
||
* /benchmark 모드 → 실행할 합성 파트 목록.
|
||
* - full(기본): 디자인 3파트 + 비즈니스 렌즈 (1~4)
|
||
* - design: 기존 3파트만 (원본 복원 시방서)
|
||
* - biz: 비즈니스 렌즈만 (빠름 — 포지셔닝·가격·운영)
|
||
* - build: 풀코스 + 스텝별 빌드 프롬프트 (1~5)
|
||
*/
|
||
export type BenchmarkMode = 'full' | 'design' | 'biz' | 'build' | 'handoff';
|
||
export const BENCHMARK_MODE_PARTS: Record<BenchmarkMode, ReadonlyArray<1 | 2 | 3 | 4 | 5>> = {
|
||
full: [1, 2, 3, 4],
|
||
design: [1, 2, 3],
|
||
biz: [4],
|
||
build: [1, 2, 3, 4, 5],
|
||
handoff: [], // LLM 합성 없음 — 스캔 원시 데이터로 인수인계 문서를 결정론 생성
|
||
};
|
||
|
||
/** /benchmark 인자 파싱 — 순수 함수 (테스트 대상). */
|
||
export function parseBenchmarkArgs(arg: string): {
|
||
url: string; depth?: number; pages?: number; mode: BenchmarkMode; userContent: string;
|
||
} {
|
||
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
||
let url = '';
|
||
let depth: number | undefined;
|
||
let pages: number | undefined;
|
||
let mode: BenchmarkMode = 'full';
|
||
const restParts: string[] = [];
|
||
const BARE_MODES = new Set<BenchmarkMode>(['full', 'design', 'biz', 'build', 'handoff']);
|
||
for (const t of tokens) {
|
||
const m = /^(depth|pages)=(\d+)$/i.exec(t);
|
||
const modeM = /^mode[:=](full|design|biz|build|handoff)$/i.exec(t);
|
||
if (m) {
|
||
if (m[1].toLowerCase() === 'depth') depth = Number(m[2]);
|
||
else pages = Number(m[2]);
|
||
} else if (modeM) {
|
||
mode = modeM[1].toLowerCase() as BenchmarkMode;
|
||
} else if (BARE_MODES.has(t.toLowerCase() as BenchmarkMode)) {
|
||
mode = t.toLowerCase() as BenchmarkMode;
|
||
} else if (!url) {
|
||
url = t;
|
||
} else {
|
||
restParts.push(t);
|
||
}
|
||
}
|
||
return { url, depth, pages, mode, userContent: restParts.join(' ') };
|
||
}
|
||
|
||
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
|
||
const { url, depth: depthArg, pages: pagesArg, mode, userContent } = parseBenchmarkArgs(arg);
|
||
if (!url) {
|
||
chunk(view, [
|
||
'사용법: `/benchmark <url> [mode] [depth=N] [pages=N] [보조 설명]`',
|
||
'- mode: `full`(기본 — 디자인+비즈니스) · `design`(복원 시방서만) · `biz`(사업 분석만) · `build`(풀코스+빌드 프롬프트) · `handoff`(클라우드 개발용 인수인계 문서 — LLM 없이 즉시)',
|
||
'- 가격·수익 분석이 목적이면 `depth=2` 로 pricing 페이지까지 크롤 권장',
|
||
'예: `/benchmark https://interiorai.com build depth=2 pages=12 나는 부동산 쪽으로 변형하고 싶어`',
|
||
'',
|
||
].join('\n'));
|
||
return true;
|
||
}
|
||
|
||
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`);
|
||
}, 10000);
|
||
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})`,
|
||
'```',
|
||
// 큰 사이트맵 트리는 채팅을 벽처럼 밀어내므로 상위 25줄만 보이고 나머지는 요약(전체는 저장 파일에).
|
||
(() => {
|
||
const tree = String(s?.sitemap?.ascii || '').trimEnd();
|
||
if (!tree) return '(없음)';
|
||
const lines = tree.split('\n');
|
||
return lines.length <= 25
|
||
? tree
|
||
: `${lines.slice(0, 25).join('\n')}\n… (외 ${lines.length - 25}줄 · 총 ${s?.sitemap?.totalPages ?? lines.length}페이지 — 전체 트리는 저장된 보고서 파일 참조)`;
|
||
})(),
|
||
'```',
|
||
].join('\n');
|
||
|
||
// ── handoff 모드: LLM 합성을 건너뛰고 스캔 원시 데이터로 인수인계 문서를 즉시 생성.
|
||
// 소형 모델의 요약 병목을 통과시키지 않는다 (측정치 무손실 — handoffDoc.ts 참조).
|
||
if (mode === 'handoff') {
|
||
const handoffDoc = buildHandoffDoc(s, url, { crawlDepth, maxPages, userContent });
|
||
chunk(view, `📦 **개발 인수인계 문서 생성** (LLM 미개입 — 측정치 원본)\n\n`);
|
||
const hToday = new Date().toISOString().slice(0, 10);
|
||
let hHost = url;
|
||
try { hHost = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw */ }
|
||
const hTitle = `웹벤치마크 핸드오프 ${hHost} ${hToday}`;
|
||
const savedH = await persistWikiDoc(hTitle, handoffDoc, { docType: 'benchmark-handoff', sources: [url] });
|
||
chunk(view, describeSave(savedH, '인수인계 문서'));
|
||
if (savedH.ok && savedH.path) {
|
||
chunk(view, [
|
||
'',
|
||
'**다음 단계 — 클라우드 모델로 개발:**',
|
||
`1. 파일을 열어 §1 "내 요구사항" 을 채우세요 (무엇으로 변형할지).`,
|
||
`2. Claude Code(구독 인증)로 바로 개발: \`/claude ${savedH.path} 파일을 읽고 명세대로 개발해줘\``,
|
||
` (또는 Antigravity 에 파일 내용을 붙여넣어도 됩니다)`,
|
||
'',
|
||
].join('\n'));
|
||
}
|
||
return true;
|
||
}
|
||
|
||
let finalReport: string;
|
||
if (looksEmpty) {
|
||
chunk(view, `(스캔이 비어 LLM 합성을 건너뜁니다.)\n\n`);
|
||
finalReport = rawReport;
|
||
} else {
|
||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||
const partList = BENCHMARK_MODE_PARTS[mode];
|
||
const PART_LABELS: Record<number, string> = {
|
||
1: '4-렌즈 디자인', 2: 'IA·토큰', 3: '재구축 명세', 4: '비즈니스 렌즈', 5: '빌드 프롬프트',
|
||
};
|
||
chunk(view, `🧪 **LLM 합성** (mode \`${mode}\` · ${partList.length}단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`);
|
||
try {
|
||
const parts: string[] = [];
|
||
let okParts = 0;
|
||
for (let i = 0; i < partList.length; i++) {
|
||
const part = partList[i];
|
||
chunk(view, `\n · ${i + 1}/${partList.length} ${PART_LABELS[part]} 합성 중…`);
|
||
const partT0 = Date.now();
|
||
// [v2.2.307] 파트별 실패 격리 — 한 파트가 죽어도 완성된 파트를 버리지 않는다
|
||
// (실사례: 파트 2 타임아웃으로 105초 걸린 파트 1까지 폐기). 타임아웃도 300초로 —
|
||
// 입력 JSON 이 큰 파트는 e4b 급에서 120초를 넘긴다.
|
||
let out: string;
|
||
try {
|
||
out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part), undefined, { timeoutMs: 300_000 });
|
||
} catch (partErr: any) {
|
||
chunk(view, ` ✗ (${Math.round((Date.now() - partT0) / 1000)}s — ${partErr?.message || partErr})`);
|
||
parts.push(`> ⚠️ **${PART_LABELS[part]} 파트 합성 실패**: ${partErr?.message || String(partErr)} — 나머지 파트는 계속 진행됨.`);
|
||
continue;
|
||
}
|
||
if (!out) {
|
||
chunk(view, ` ✗ (빈 응답)`);
|
||
parts.push(`> ⚠️ **${PART_LABELS[part]} 파트 합성 실패**: 빈 응답 — 나머지 파트는 계속 진행됨.`);
|
||
continue;
|
||
}
|
||
okParts++;
|
||
// [Report QA] 비즈니스 렌즈는 날조 위험(임의 가격·매출)이 가장 큰 파트 —
|
||
// 채점→피드백 재작성→회귀 게이트를 통과시킨다. prompt 로 합성 프롬프트
|
||
// 자체(스캔 JSON 포함)를 넘겨 재작성이 원본 근거를 보게 한다.
|
||
if (part === 4 && getConfig().reportQaEnabled) {
|
||
chunk(view, ` (품질 검증 중…)`);
|
||
const qa = await runReportQaLoop({
|
||
draft: out,
|
||
prompt: buildSynthesisPrompt(s, userContent, 4),
|
||
intentBrief: null,
|
||
model,
|
||
onProgress: (msg: string) => chunk(view, ` · ${msg}`),
|
||
});
|
||
out = qa.finalDraft;
|
||
logInfo('[Benchmark] 비즈니스 렌즈 QA 완료.', { score: qa.score, revisions: qa.revisions });
|
||
}
|
||
parts.push(out);
|
||
chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`);
|
||
}
|
||
if (okParts === 0) throw new Error('모든 합성 파트가 실패했습니다.');
|
||
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;
|
||
}
|
||
}
|
||
|
||
// [v2.2.307] 레이아웃 골격 (결정론) — 합성 성공/실패와 무관하게 항상 포함.
|
||
// "배너/캐러셀이 있는가, 각 페이지가 어떤 블록 순서로 구성되는가"에 즉답.
|
||
finalReport = `${buildLayoutSkeleton(s)}\n\n---\n\n${finalReport}`;
|
||
|
||
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');
|
||
chunk(view, describeSave(await persistWikiDoc(title, fileMarkdown, { docType: 'benchmark', sources: [url] }), '결과물'));
|
||
} 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`);
|
||
}, 10000);
|
||
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);
|
||
// 전체 스크립트는 아래 저장 파일에 그대로 들어가므로, 채팅엔 길이 + 미리보기만 보여 벽 스크롤을 막는다.
|
||
const scriptPreview = script.length > 600 ? `${script.slice(0, 600).trimEnd()}…` : script;
|
||
chunk(view, `## 📜 전체 스크립트 (${script.length.toLocaleString()}자)\n\n${scriptPreview}\n\n_(전체 스크립트는 저장되는 분석 파일에 포함됩니다.)_\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 saved = await persistWikiDoc(title, fileMarkdown, { docType: 'youtube' });
|
||
if (!saved.ok) throw new Error(saved.error || '저장 실패');
|
||
savedOk++;
|
||
chunk(view, `💾 **결과물 저장 완료**${progressTag} (${saved.via === 'bridge' ? 'NAS Bridge' : saved.locLabel}): \`${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`);
|
||
}, 10000);
|
||
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.
|
||
const { getCanonicalFormat } = await import('./wikiFormat');
|
||
const canonicalFormat = await getCanonicalFormat();
|
||
chunk(view, canonicalFormat
|
||
? `📐 포맷 정본 v${canonicalFormat.version} (브리지)
|
||
`
|
||
: `📐 포맷 내장 사본 사용 (브리지 템플릿 미제공 — 브리지 갱신 시 정본 적용)
|
||
`);
|
||
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}`;
|
||
chunk(view, describeSave(await persistWikiDoc(title, report, { docType: 'wikify', sources: [url] }), '위키 문서'));
|
||
} 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; // 용어집 후보 추출용 — 자동 보강 전 원본 보존
|
||
// [회의 헤더 파싱] STT 본문(첫 "참석자 N mm:ss" 줄) 앞의 헤더 블록(참석자 명단·
|
||
// 일시·장소·녹취 길이)을 메타데이터에 끌어올린다. 헤더는 첫 조각에만 있어 후반
|
||
// 조각이 명단을 못 보던 문제를, 모든 조각 프롬프트에 주입해 해소한다(화자 정규화·메타 추출).
|
||
const headerBlock = extractMeetingHeader(transcript);
|
||
if (headerBlock) {
|
||
metadata = `${headerBlock}\n${metadata ? '\n' + metadata : ''}`.trim();
|
||
}
|
||
// [회사 표준 팀 분류] 화자번호 → 팀/역할 귀속의 통제 어휘집. 모든 조각·병합에 주입.
|
||
metadata = `${metadata ? metadata + '\n' : ''}[회사 표준 팀/역할 분류 — 화자를 이 팀들 중 하나로 귀속할 것] ${loadTeamRoster()}`;
|
||
// [작성일] 회의 개요의 '작성일' 채움용 (모델은 오늘 날짜를 모름).
|
||
metadata = `${metadata}\n[작성일] ${toYmd(new Date())}`;
|
||
// [자동 용어집] 이전 /meet 들에서 누적된 인명·용어를 메타데이터에 보강 —
|
||
// meetPrompt 가 메타데이터를 STT 보정 용어집으로 쓰므로 반복 회의의 표기
|
||
// 일관성이 자동으로 좋아진다. 사용자 입력 메타데이터가 항상 우선(앞에 배치).
|
||
const glossaryTerms = loadGlossaryTerms();
|
||
if (glossaryTerms.length) {
|
||
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();
|
||
// 출력 토큰 상한 — 미지정 시 서버 기본값에 잘려 후반부·긴 표가 통째로 사라지던 "누락"의 주원인.
|
||
// 넉넉히 주고(잘리면 callLmSynthesis 가 이어쓰기까지 수행) 완결성을 확보한다. 긴 회의는 설정으로 상향.
|
||
const MEET_REPORT_TOKENS = Math.max(2048, cfg.get<number>('meetMaxOutputTokens', 8000) || 8000); // 최종 회의록(단일샷/병합)
|
||
const MEET_EXTRACT_TOKENS = 5000; // 세그먼트 추출 노트
|
||
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, { maxTokens: MEET_REPORT_TOKENS });
|
||
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, { maxTokens: MEET_EXTRACT_TOKENS });
|
||
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, { maxTokens: MEET_REPORT_TOKENS });
|
||
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, 기본 ON) ─────────────────────────
|
||
// 검수자: 녹취록(근거 소스) vs 회의록 대조 → 지적. 지적된 부분만 수정하고 재검수를
|
||
// '검증 통과' 또는 최대 라운드까지 반복(전체 재작성 X). 통과 후 편집자가 보고서 형태로 정리.
|
||
if (cfg.get<boolean>('meetVerifyPass', true)) {
|
||
const verifySystem = '당신은 회의록 검증자입니다. 회의록의 각 결정·액션이 근거 소스에 실제로 존재하는지만 판정합니다. 한국어로 출력합니다.';
|
||
const source = groundingNotes || transcript.slice(0, 28000);
|
||
const maxRounds = Math.max(1, Math.min(4, cfg.get<number>('meetVerifyMaxRounds', 2)));
|
||
let passed = false;
|
||
let lastFlags = '';
|
||
for (let round = 1; round <= maxRounds; round++) {
|
||
try {
|
||
chunk(view, `🔍 **검수 ${round}/${maxRounds}** — 녹취록과 회의록 대조 중…`);
|
||
const t2 = Date.now();
|
||
const flagged = await callLmSynthesis(buildMeetVerifyPrompt(report, source), verifySystem);
|
||
chunk(view, ` ✓ (${Math.round((Date.now() - t2) / 1000)}s)\n`);
|
||
if (!flagged || /검증\s*통과/.test(flagged)) {
|
||
passed = true;
|
||
chunk(view, ` ✅ 검수 통과\n\n`);
|
||
break;
|
||
}
|
||
lastFlags = flagged.trim();
|
||
const issueCount = (lastFlags.match(/❗/g) || []).length;
|
||
if (round === maxRounds) {
|
||
// 마지막 라운드에서도 지적이 남으면 더는 수정하지 않고 미해결로 기록.
|
||
chunk(view, ` ⚠️ 지적 ${issueCount || ''}건 미해결 (최대 라운드 도달)\n\n`);
|
||
break;
|
||
}
|
||
chunk(view, ` ✎ 지적 ${issueCount || ''}건 — 해당 부분만 수정 중…`);
|
||
const t3 = Date.now();
|
||
const revised = await callLmSynthesis(
|
||
buildMeetRevisePrompt(report, lastFlags, source), meetSystem, { maxTokens: MEET_REPORT_TOKENS },
|
||
);
|
||
// 수정본이 비정상적으로 짧으면(모델 붕괴) 이전 회의록을 지킨다.
|
||
if (revised && revised.trim().length > report.length * 0.5) {
|
||
report = revised.trim();
|
||
chunk(view, ` ✓ (${Math.round((Date.now() - t3) / 1000)}s)\n\n`);
|
||
} else {
|
||
chunk(view, ` ⚠️ 수정본이 비정상적으로 짧아 이전 회의록 유지 → 검수 중단\n\n`);
|
||
break;
|
||
}
|
||
} catch (e: any) {
|
||
chunk(view, `\n⚠️ 검수 ${round} 실패(회의록은 유지): ${e?.message || String(e)}\n`);
|
||
break;
|
||
}
|
||
}
|
||
if (!passed && lastFlags) {
|
||
report += `\n\n---\n## ⚠️ 검수 미해결 지적 (자동 — 확인 필요)\n${lastFlags}\n`;
|
||
}
|
||
|
||
// ── 편집(Editor): 보고서 형태로 구조 정리 (근거 유지·환각 금지) ──
|
||
if (cfg.get<boolean>('meetEditorPass', true)) {
|
||
try {
|
||
chunk(view, `📐 **편집(Editor)** — 보고서 형태로 구조 정리 중…`);
|
||
const t4 = Date.now();
|
||
const edited = await callLmSynthesis(
|
||
buildMeetEditorPrompt(report, source),
|
||
'당신은 회의록 편집자입니다. 형식과 구조만 다듬고, 근거 소스에 없는 내용을 새로 만들지 않습니다. 한국어로 출력합니다.',
|
||
{ maxTokens: MEET_REPORT_TOKENS },
|
||
);
|
||
if (edited && edited.trim().length > report.length * 0.6) {
|
||
// 편집자가 없던 사실을 넣지 않았는지 1회 재검증(환각 방지 안전망).
|
||
const editFlags = await callLmSynthesis(buildMeetVerifyPrompt(edited.trim(), source), verifySystem);
|
||
if (!editFlags || /검증\s*통과/.test(editFlags)) {
|
||
report = edited.trim();
|
||
chunk(view, ` ✓ (${Math.round((Date.now() - t4) / 1000)}s)\n\n`);
|
||
} else {
|
||
chunk(view, ` ⚠️ 편집본에서 근거 미확인 항목 발견 → 편집 전 회의록 유지\n\n`);
|
||
}
|
||
} else {
|
||
chunk(view, ` ⚠️ 편집본이 비정상적으로 짧아 편집 전 회의록 유지\n\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}`;
|
||
chunk(view, describeSave(await persistWikiDoc(title, report, { docType: 'meeting', sources: [filePath] }), '회의록'));
|
||
} 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(' + ');
|
||
// 기한 없는 확정·기한미정 액션을 작성일(오늘)로 자동 등록 (기본 ON).
|
||
const dateFallbackToday = (gCfg.get<string>('meetTaskDateFallback', 'today') || 'today') === 'today';
|
||
const registeredKeys = loadRegisteredKeys(tHash);
|
||
const holds: PendingItem[] = [];
|
||
let autoOk = 0, autoFail = 0, dupSkipped = 0, pastCount = 0;
|
||
const newKeys: string[] = [];
|
||
|
||
// ── 액션 상세 자동 확장 (task 노트용) — v2.2.264 ──
|
||
// 슬림 표엔 '작업 상세'가 없어 task 노트가 앙상하던 문제. 배치 LLM 1회로
|
||
// 각 액션을 회의 불참자도 이해할 자기완결 설명으로 풀어 씀(근거 기반·환각 금지).
|
||
let detailByKey = new Map<string, string>();
|
||
if (gCfg.get<boolean>('meetTaskDetailExpand', true)) {
|
||
try {
|
||
chunk(view, `\n🧾 **액션 상세 정리** — task 설명 풀어쓰는 중…`);
|
||
const dsrc = groundingNotes || transcript.slice(0, 28000);
|
||
const expanded = await callLmSynthesis(
|
||
buildTaskDetailPrompt(tasks, meetTitle, dsrc),
|
||
'당신은 회의록에서 각 액션의 실행 설명을 작성합니다. 근거에 없는 내용을 지어내지 않습니다. 한국어로 출력합니다.',
|
||
{ maxTokens: MEET_EXTRACT_TOKENS },
|
||
);
|
||
detailByKey = parseTaskDetails(expanded, tasks);
|
||
chunk(view, ` ✓ (${detailByKey.size}/${tasks.length}건)\n`);
|
||
} catch (e: any) {
|
||
chunk(view, ` ⚠️ 생략(${e?.message || String(e)})\n`);
|
||
}
|
||
}
|
||
|
||
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, dateFallbackToday);
|
||
if (cls.route === 'hold') {
|
||
holds.push({
|
||
idx: holds.length + 1,
|
||
owner: task.owner, work: task.work, detail: detailByKey.get(key) || task.detail, deliverable: task.deliverable, due: task.due, source: task.source,
|
||
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: detailByKey.get(key) || task.detail, meetTitle, owner: task.owner, deliverable: task.deliverable,
|
||
dueRaw: task.due, dateLabel: cls.date, source: task.source, 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}`;
|
||
chunk(view, describeSave(await persistWikiDoc(title, report, { docType: 'review' }), '리뷰'));
|
||
} 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: '웹 벤치마크 — 디자인 4-렌즈 + 비즈니스 렌즈(가격·수익·운영) 분석, build 모드는 스텝별 빌드 프롬프트까지', 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 });
|