Files
connectai/src/features/datacollect/handlers.ts
T
koriwebandClaude Fable 5 47b3b9f93a v2.2.299~307: 아키텍처 수렴 + 채팅 정리 + 벤치마킹 강화 + Claude 구독 엔진
- 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>
2026-07-11 21:02:15 +09:00

1244 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Datacollect handlers — /benchmark · /youtube · /blog · /wikify · /meet.
* (/research(NotebookLM)는 v2.2.205 에서 제거 — 로컬 Datacollect 앱 전용으로 분리)
*
* v2.2.201 에서 slashRouter.ts 에서 분리. Datacollect bridge (port 3002) 통합
* 슬래시 명령 클러스터 — NotebookLM Deep Research · Playwright 웹 스캔 ·
* yt-dlp YouTube 자막 추출 · 본문 wikify · 회의록 합성.
*
* callLmSynthesis / repairKoreanGlitches 는 slashRouter 에 남음 (communication
* 핸들러도 사용하는 일반 LLM 호출 인프라). 본 파일은 *bridge 호출 시퀀스* 만 담당.
*/
import * as vscode from 'vscode';
import { promises as fsp } from 'fs';
import * as path from 'path';
import { registerSlashCommand, chunk, type Webview } from './slashRouter';
import { callLmSynthesis } from './llm';
import { 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 });