Files
connectai/src/features/datacollect/handlers.ts
T
koriweb 6b017b0d31 feat: Bridge 타깃 토글 + /research 제거 + 환각·오염 방지 강화 (v2.2.205)
- Datacollect Bridge 로컬/NAS 타깃 토글(Settings 패널) + NAS URL/x-bridge-token.
  기본 local = 현행 동작 유지. (백엔드 NAS 분리 준비)
- /research(NotebookLM) 제거 — 로컬 Datacollect 앱 전용으로 분리.
- 에러로그 오염 차단: STT/스택트레이스/에러덤프를 장기기억 채굴 제외 + 자동
  추출 항목 14일 TTL(참조 시 슬라이딩 연장). 기존·수동 항목 무영향.
- 컨텍스트 [주제] 태깅 + 교차오염 방지 경계 지침.
- "확인 불가" 사실 날조 금지 규칙(R7과 구분).
- /meet STT 오타 보정: 철자 정규화 허용하되 사실 날조는 차단.

타입체크 + 407 테스트 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 16:47:55 +09:00

667 lines
35 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 { registerSlashCommand, chunk, type Webview } from './slashRouter';
import { callLmSynthesis } from './llm';
import { bridgeFetch, BRIDGE_API } from './bridgeClient';
import { type SynthesisPart, buildSynthesisPrompt } from './prompts/synthesisPrompt';
import {
type YoutubeAnalysisMode,
formatHms,
fullScriptFromSegments,
buildInfoExtractionPrompt,
build4LensPrompt,
} from './prompts/youtubePrompts';
import { buildWikifyPrompt } from './prompts/wikifyPrompt';
import { buildMeetPrompt } from './prompts/meetPrompt';
import { createCalendarEvent, createTask, readCalendarConfig } from '../calendar';
import {
addBusinessDays,
toYmd,
extractMeetingDate,
resolveTaskDate,
parseActionItems,
} from './scheduling/calendarHelpers';
// ───────────────────────────── /benchmark ─────────────────────────────
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
const tokens = arg.trim().split(/\s+/).filter(Boolean);
let url = '';
let depthArg: number | undefined;
let pagesArg: number | undefined;
const restParts: string[] = [];
for (const t of tokens) {
const m = /^(depth|pages)=(\d+)$/i.exec(t);
if (m) {
if (m[1].toLowerCase() === 'depth') depthArg = Number(m[2]);
else pagesArg = Number(m[2]);
} else if (!url) {
url = t;
} else {
restParts.push(t);
}
}
if (!url) {
chunk(view, `사용법: \`/benchmark <url> [depth=N] [pages=N] [보조 설명]\`\n예: \`/benchmark https://example.com depth=2 pages=12\`\n`);
return true;
}
const userContent = restParts.join(' ');
const cfg = vscode.workspace.getConfiguration('g1nation');
const crawlDepth = depthArg ?? (cfg.get<number>('datacollectCrawlDepth', 1) ?? 1);
const maxPages = pagesArg ?? (cfg.get<number>('datacollectMaxPages', 8) ?? 8);
chunk(view, `🔍 **Web Benchmark 스캔**: ${url}\n(crawlDepth ${crawlDepth} · 최대 ${maxPages}페이지 · Playwright)\n\n⏳ 브라우저 기동 · 페이지 크롤링 · 디자인 토큰 추출 중…`);
const t0 = Date.now();
const heartbeat = setInterval(() => {
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
}, 4000);
const scan = await bridgeFetch<{ success: boolean; scan: any; slug?: string }>(
BRIDGE_API.web.benchmarkScan,
{
method: 'POST',
body: JSON.stringify({ url, captureScreenshots: false, maxPages, crawlDepth }),
},
{ timeoutMs: 6 * 60_000 },
).finally(() => clearInterval(heartbeat));
const s = scan.scan;
chunk(view, `\n✅ **스캔 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${s?.sitemap?.totalPages ?? 1}페이지)\n\n`);
const looksEmpty = !s?.meta?.title
&& !(s?.design?.colors?.palette?.length)
&& !s?.microcopy?.headline;
if (looksEmpty) {
chunk(view, `⚠️ **스캔 결과가 비어 있습니다.** URL이 정확한지, 사이트가 접근 가능한지(봇 차단·JS 전용 렌더링 포함) 확인하세요. 스캔한 URL: \`${url}\`\n\n`);
}
const palette = s?.design?.colors?.palette?.slice(0, 5) || [];
const rawReport = [
`### 메타`,
`- **title**: ${s?.meta?.title || '(없음)'}`,
`- **description**: ${s?.meta?.description || '(없음)'}`,
`- **lang**: ${s?.meta?.lang || '(없음)'}`,
``,
`### 디자인 토큰 (상위)`,
`- **컬러 팔레트 Top 5**: ${palette.map((p: any) => `\`${p.value}\` (×${p.count})`).join(', ') || '(없음)'}`,
`- **컬러 비율**: ${s?.design?.colors?.composition?.ratioLabel || '(없음)'}`,
`- **Primary Font**: \`${s?.design?.typography?.primaryFont || '(없음)'}\``,
``,
`### 사이트맵 (${s?.sitemap?.totalPages ?? 1}페이지, depth ${s?.sitemap?.crawlDepth ?? 0})`,
'```',
s?.sitemap?.ascii || '(없음)',
'```',
].join('\n');
let finalReport: string;
if (looksEmpty) {
chunk(view, `(스캔이 비어 LLM 합성을 건너뜁니다.)\n\n`);
finalReport = rawReport;
} else {
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
chunk(view, `🧪 **LLM 4-렌즈 합성** (3단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`);
try {
const parts: string[] = [];
for (const part of [1, 2, 3] as const) {
chunk(view, `\n · 합성 ${part}/3 진행 중…`);
const partT0 = Date.now();
const out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part));
if (!out) throw new Error(`LLM ${part}/3 응답이 비어 있습니다.`);
parts.push(out);
chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`);
}
finalReport = parts.join('\n\n---\n\n');
chunk(view, `\n\n`);
} catch (e: any) {
chunk(view, `\n\n⚠️ LLM 합성 실패: ${e?.message || String(e)}\n원시 스캔 요약만 표시·저장합니다. (LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
finalReport = rawReport;
}
}
chunk(view, finalReport + '\n\n');
try {
const today = new Date().toISOString().slice(0, 10);
let host = url;
try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ }
const title = `웹벤치마크 ${host} ${today}`;
const fileMarkdown = [
`# ${title}`,
``,
`- **원본 URL**: ${url}`,
`- **스캔 시각**: ${new Date().toISOString()}`,
`- **크롤 옵션**: depth ${crawlDepth} · 최대 ${maxPages}페이지`,
`- **생성**: Astra /benchmark · Datacollect web-benchmark`,
``,
finalReport,
``,
].join('\n');
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { title, content: fileMarkdown };
if (savePath) body.saveDir = savePath;
const saved = await bridgeFetch<{ success: boolean; path?: string }>(
BRIDGE_API.wiki.save,
{ method: 'POST', body: JSON.stringify(body) },
{ timeoutMs: 30_000 },
);
chunk(view, `💾 **결과물 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`);
} catch (e: any) {
chunk(view, `⚠️ 결과물 저장 실패: ${e?.message || String(e)}\n`);
}
return true;
}
// ───────────────────────────── /youtube ─────────────────────────────
function _looksLikeYoutubeChannelUrl(url: string): boolean {
return /youtube\.com\/(channel\/|@|c\/|user\/|playlist\?list=|playlist\/)/i.test(url)
|| /youtube\.com\/[^/?#]+\/(videos|shorts|streams)\b/i.test(url);
}
function _normalizeYoutubeUrl(url: string): string {
try {
const u = new URL(url);
if (!/youtube\.com$|youtube\.com\.|youtu\.be$/i.test(u.hostname)) return url;
const p = u.pathname;
if (/\/(watch|shorts|playlist|videos|streams|featured|community|about)\b/i.test(p)) return url;
if (u.hostname.includes('youtu.be')) return url;
if (/^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)\/?$/i.test(p)) {
u.pathname = p.replace(/\/?$/, '/videos');
return u.toString();
}
return url;
} catch {
return url;
}
}
const YOUTUBE_BATCH_MAX = 50;
async function runYoutube(arg: string, view: Webview | undefined): Promise<boolean> {
const BARE_MODE_KEYWORDS = new Set(['info', 'benchmark', 'both']);
const tokens = arg.trim().split(/\s+/).filter(Boolean);
const url = tokens[0] || '';
let limitOverride: number | null = null;
let mode: YoutubeAnalysisMode = 'both';
const contextTokens: string[] = [];
for (const tok of tokens.slice(1)) {
const nMatch = tok.match(/^n[:=](\d+)$/i);
if (nMatch) {
const n = parseInt(nMatch[1], 10);
if (Number.isFinite(n) && n > 0) limitOverride = Math.min(YOUTUBE_BATCH_MAX, n);
continue;
}
const modeMatch = tok.match(/^mode[:=](info|benchmark|both)$/i);
if (modeMatch) { mode = modeMatch[1].toLowerCase() as YoutubeAnalysisMode; continue; }
const lower = tok.toLowerCase();
if (BARE_MODE_KEYWORDS.has(lower)) { mode = lower as YoutubeAnalysisMode; continue; }
contextTokens.push(tok);
}
const userContent = contextTokens.join(' ');
if (!url) {
chunk(view, [
`사용법:\n`,
`- 단일 영상: \`/youtube <영상URL> [info|benchmark|both] [컨텍스트]\`\n`,
`- 채널/플레이리스트: \`/youtube <채널URL> [n:30] [info|benchmark|both] [컨텍스트]\`\n`,
`\n**분석 모드** (생략 시 \`both\`):\n`,
`- \`info\` — 영상의 *내용*을 지식 카드로 추출 (튜토리얼·강의·뉴스·인터뷰)\n`,
`- \`benchmark\` — 대본 역기획서 4-렌즈 분석 (콘텐츠 제작 벤치마크용)\n`,
`- \`both\` — 둘 다 생성 (영상당 LLM 호출 2회)\n`,
`\n예시:\n`,
`- \`/youtube https://youtu.be/abc info\`\n`,
`- \`/youtube https://youtube.com/@somechannel n:20 info AI 학습 자료\`\n`,
`\n💡 \`mode:info\` / \`mode=info\` 같은 명시형도 그대로 동작 (백워드 호환).\n`,
].join(''));
return true;
}
const isChannel = _looksLikeYoutubeChannelUrl(url);
const limit = limitOverride ?? (isChannel ? 10 : 1);
const normalizedUrl = isChannel ? _normalizeYoutubeUrl(url) : url;
if (normalizedUrl !== url) {
chunk(view, `🔧 채널 URL 정규화: \`${url}\`\`${normalizedUrl}\` (yt-dlp 영상 enumeration 을 위한 \`/videos\` 탭 명시)\n\n`);
}
const modeLabel = mode === 'info' ? '📋 정보 추출 (지식 카드)'
: mode === 'benchmark' ? '🎬 벤치마킹 (4-렌즈 역기획서)'
: '📋 정보 추출 + 🎬 벤치마킹 (둘 다)';
if (isChannel) {
const callsPerVideo = mode === 'both' ? 2 : 1;
chunk(view, `📺 **채널/플레이리스트 감지** → 최신 ${limit}개 영상을 1개씩 순차 분석·wiki화 합니다.\n` +
`분석 모드: **${modeLabel}** (영상당 LLM ${callsPerVideo}회 호출)\n` +
`각 영상은 자막추출 → LLM 분석 → wiki 저장 순으로 처리되며, 영상당 보통 30~${120 * callsPerVideo}초.\n` +
`중간에 멈추려면 Astra 사이드바의 ⏹ Stop 을 누르세요.\n\n`);
} else {
chunk(view, `📊 **분석 모드**: ${modeLabel}\n\n`);
}
chunk(view, `🎬 **YouTube 추출**: ${normalizedUrl}\n(자막 + 메타데이터${limit > 1 ? `, ${limit}개 영상` : ''})\n\n⏳ Python 추출기 기동 · 자막/메타 추출 중…`);
const t0 = Date.now();
const heartbeat = setInterval(() => {
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
}, 4000);
const extractTimeoutMs = Math.max(5 * 60_000, limit * 60_000);
const data = await bridgeFetch<{ success: boolean; videos?: any[]; totalVideos?: number }>(
BRIDGE_API.youtube.extract,
{ method: 'POST', body: JSON.stringify({ source: normalizedUrl, withMetadata: true, limit }) },
{
timeoutMs: extractTimeoutMs,
onHeartbeat: limit > 1
? (elapsedMs) => chunk(view, `\n · 추출 진행 중 (${Math.round(elapsedMs / 1000)}s, ${limit}개 영상)\n`)
: undefined,
},
).finally(() => clearInterval(heartbeat));
const okVideos = (data.videos || []).filter((v: any) => v?.status === 'ok');
chunk(view, `\n✅ **추출 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${okVideos.length}/${data.totalVideos ?? (data.videos || []).length}개 영상)\n\n`);
if (okVideos.length === 0) {
chunk(view, `⚠️ 자막 추출에 성공한 영상이 없습니다. 자막이 없거나 비공개 영상일 수 있습니다.\n`);
return true;
}
const cfg = vscode.workspace.getConfiguration('g1nation');
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
const sysInfo = '당신은 영상 콘텐츠를 지식 카드로 변환하는 정보 큐레이터입니다. 자막에 명시된 사실만 인용하세요.';
const sysBench = '당신은 유튜브 콘텐츠 시니어 PD입니다. 데이터에 근거한 제작 가이드만 제공하세요.';
type Section = { label: string; body: string };
async function runOneAnalysis(video: any, prompt: string, system: string, sectionLabel: string, progressTag: string): Promise<Section | null> {
chunk(view, `🧪 **${sectionLabel}**${progressTag} (모델 \`${model}\`)…`);
try {
const t = Date.now();
const body = await callLmSynthesis(prompt, system);
if (!body) throw new Error('LLM 응답이 비어 있습니다.');
chunk(view, ` ✓ (${Math.round((Date.now() - t) / 1000)}s)\n\n`);
chunk(view, body + '\n\n');
return { label: sectionLabel, body };
} catch (e: any) {
chunk(view, `\n\n⚠️ ${sectionLabel} 실패${progressTag}: ${e?.message || String(e)}\n`);
return null;
}
}
const total = okVideos.length;
let analyzedOk = 0;
let analyzedFail = 0;
let savedOk = 0;
let savedFail = 0;
const batchT0 = Date.now();
for (let i = 0; i < okVideos.length; i++) {
const video = okVideos[i];
const vTitle = video?.metadata?.title || video?.title || video?.video_id || '(제목 없음)';
const progressTag = total > 1 ? ` [${i + 1}/${total}]` : '';
if (total > 1) chunk(view, `\n━━━ **${progressTag.trim()} ${vTitle}** ━━━\n\n`);
const script = fullScriptFromSegments(video?.segments);
chunk(view, `## 📜 전체 스크립트 (Full Script)\n\n${script}\n\n---\n\n`);
const sections: Section[] = [];
if (mode === 'info' || mode === 'both') {
const sec = await runOneAnalysis(video, buildInfoExtractionPrompt(video, userContent), sysInfo, '📋 정보 추출 (지식 카드)', progressTag);
if (sec) sections.push(sec);
}
if (mode === 'benchmark' || mode === 'both') {
const sec = await runOneAnalysis(video, build4LensPrompt(video, userContent), sysBench, '🎬 벤치마킹 (4-렌즈 역기획서)', progressTag);
if (sec) sections.push(sec);
}
if (sections.length === 0) {
analyzedFail++;
chunk(view, `(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
continue;
}
analyzedOk++;
try {
const today = new Date().toISOString().slice(0, 10);
const videoUrl = video?.metadata?.webpage_url || `https://www.youtube.com/watch?v=${video?.video_id}`;
const modeSuffix = mode === 'info' ? ' (정보)' : mode === 'benchmark' ? ' (벤치마크)' : '';
const title = `유튜브분석 ${vTitle}${modeSuffix} ${today}`;
const sectionDivider = sections.length > 1 ? `\n\n---\n\n` : '';
const fileMarkdown = [
`# ${title}`,
``,
`- **영상 URL**: ${videoUrl}`,
`- **분석 시각**: ${new Date().toISOString()}`,
`- **분석 모드**: ${mode}`,
`- **생성**: Astra /youtube · Datacollect youtube insight`,
``,
`## 📜 전체 스크립트 (Full Script)`,
``,
script,
``,
`---`,
``,
sections.map((s) => s.body).join(sectionDivider),
``,
].join('\n');
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { title, content: fileMarkdown };
if (savePath) body.saveDir = savePath;
const saved = await bridgeFetch<{ success: boolean; path?: string }>(
BRIDGE_API.wiki.save,
{ method: 'POST', body: JSON.stringify(body) },
{ timeoutMs: 30_000 },
);
savedOk++;
chunk(view, `💾 **결과물 저장 완료**${progressTag}: \`${saved?.path || '(경로 미확인)'}\`\n\n`);
} catch (e: any) {
savedFail++;
chunk(view, `⚠️ 결과물 저장 실패${progressTag}: ${e?.message || String(e)}\n\n`);
}
}
if (total > 1) {
const batchSec = Math.round((Date.now() - batchT0) / 1000);
chunk(view, `\n━━━━━━━━━━━━━━━━━━━━\n`
+ `🏁 **배치 완료** (총 ${batchSec}s · ${total}개 영상)\n`
+ `- 분석: ✅ ${analyzedOk} / ❌ ${analyzedFail}\n`
+ `- 저장: 💾 ${savedOk} / ⚠️ ${savedFail}\n`);
}
return true;
}
// ───────────────────────────── /blog ─────────────────────────────
async function runBlog(keyword: string, view: Webview | undefined): Promise<boolean> {
const target = 'http://127.0.0.1:8787/blog/';
chunk(view, `🖋️ **Blog Pipeline**\n\n`);
if (keyword) chunk(view, `요청 키워드: \`${keyword}\`\n\n`);
chunk(view, [
`현재 Blog Pipeline은 Datacollect의 별도 정적 페이지(`,
`[${target}](${target})`,
`)에서 단계별로 실행됩니다. Bridge(3002)에 대응 API가 없어 채팅에서 직접`,
` 트리거할 수 없어, 다음 두 가지 중 하나를 선택해 주세요:\n\n`,
].join(''));
chunk(view, `1. 위 링크에서 키워드/참고 자료/경험담을 입력해 직접 파이프라인 실행\n`);
chunk(view, `2. Bridge에 \`/api/blog/run\` 같은 endpoint를 노출하면 \`/blog\`도 end-to-end 실행 가능 — 원하시면 추가 작업으로 진행\n`);
try { await vscode.env.openExternal(vscode.Uri.parse(target)); } catch { /* best-effort */ }
return true;
}
// ───────────────────────────── /wikify ─────────────────────────────
type WikifyResult = { ok: true } | { ok: false; reason: string };
async function wikifyOne(url: string, userContent: string, view: Webview | undefined): Promise<WikifyResult> {
const cfg = vscode.workspace.getConfiguration('g1nation');
chunk(view, `⏳ 본문 추출 중…`);
const t0 = Date.now();
const heartbeat = setInterval(() => {
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
}, 4000);
const data = await bridgeFetch<{ success: boolean; url: string; title?: string; description?: string; lang?: string; headings?: string[]; text?: string; textLength?: number; truncated?: boolean }>(
BRIDGE_API.web.extract,
{ method: 'POST', body: JSON.stringify({ url }) },
{ timeoutMs: 3 * 60_000 },
).finally(() => clearInterval(heartbeat));
chunk(view, `\n✅ 본문 추출 (${Math.round((Date.now() - t0) / 1000)}s · ${(data.textLength ?? 0).toLocaleString()}${data.truncated ? ', 일부 잘림' : ''})\n\n`);
if (!data.text || data.text.trim().length < 50) {
const reason = `본문 빈약 (${data.textLength ?? 0}자 — JS 전용 렌더링 또는 콘텐츠 없음)`;
chunk(view, `⚠️ 추출된 본문이 거의 없어 건너뜁니다. (${reason})\n`);
return { ok: false, reason };
}
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
const wikiSystem = '당신은 지식 큐레이터입니다. 제공된 웹사이트 본문을 P-Reinforce v3.0 규격의 고밀도 위키 문서로 정리합니다. 본문에 없는 내용은 절대 지어내지 않으며, 모든 문서는 한국어로 작성합니다.';
chunk(view, `🧪 P-Reinforce 위키 합성 (모델 \`${model}\`)…`);
let report: string;
try {
const synthT0 = Date.now();
report = await callLmSynthesis(buildWikifyPrompt(data, userContent), wikiSystem);
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
report = report.replace(/\[\[([^\[\]]+?)\](?!\])/g, '[[$1]]');
chunk(view, ` ✓ (${Math.round((Date.now() - synthT0) / 1000)}s)\n\n`);
} catch (e: any) {
const reason = `LLM 합성 실패: ${e?.message || String(e)}`;
chunk(view, `\n⚠️ 위키 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
return { ok: false, reason };
}
chunk(view, report + '\n\n');
try {
const today = new Date().toISOString().slice(0, 10);
let host = url;
try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ }
const title = `위키 ${(userContent.trim() || data.title || host)} ${today}`;
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { title, content: report };
if (savePath) body.saveDir = savePath;
const saved = await bridgeFetch<{ success: boolean; path?: string }>(
BRIDGE_API.wiki.save,
{ method: 'POST', body: JSON.stringify(body) },
{ timeoutMs: 30_000 },
);
chunk(view, `💾 위키 문서 저장 완료: \`${saved?.path || '(경로 미확인)'}\`\n`);
} catch (e: any) {
chunk(view, `⚠️ 위키 문서 저장 실패: ${e?.message || String(e)}\n`);
}
return { ok: true };
}
async function runWikify(arg: string, view: Webview | undefined): Promise<boolean> {
const isUrl = (t: string) => /^https?:\/\//i.test(t) || /^[a-z0-9-]+(\.[a-z0-9-]+)+/i.test(t);
const tokens = arg.trim().split(/\s+/).filter(Boolean);
const urls = tokens.filter(isUrl);
const userContent = tokens.filter((t) => !isUrl(t)).join(' ');
if (urls.length === 0) {
chunk(view, `사용법: \`/wikify <url> [url2 url3 …] [주제명]\`\n예: \`/wikify https://example.com\`\n여러 링크를 공백으로 구분해 한 번에 넣으면 1개씩 순차 위키화합니다.\n`);
return true;
}
if (urls.length === 1) {
chunk(view, `📚 **위키화**: ${urls[0]}\n(본문 추출 → P-Reinforce v3.0 위키 문서 합성)\n\n`);
const result = await wikifyOne(urls[0], userContent, view);
if (!result.ok) chunk(view, `\n실패 사유: ${result.reason}\n`);
return true;
}
chunk(view, `📚 **위키화 배치**: 총 ${urls.length}개 링크를 순차 처리합니다.\n`);
const batchT0 = Date.now();
let okCount = 0;
const failures: Array<{ url: string; reason: string }> = [];
for (let i = 0; i < urls.length; i++) {
chunk(view, `\n---\n\n### [${i + 1}/${urls.length}] ${urls[i]}\n\n`);
try {
const result = await wikifyOne(urls[i], userContent, view);
if (result.ok) okCount++;
else failures.push({ url: urls[i], reason: result.reason });
} catch (e: any) {
const reason = `처리 오류: ${e?.message || String(e)}`;
chunk(view, `❌ [${i + 1}/${urls.length}] ${reason}\n`);
failures.push({ url: urls[i], reason });
}
}
chunk(view, `\n---\n\n🏁 **배치 완료**: ${okCount}/${urls.length}개 성공 (${Math.round((Date.now() - batchT0) / 1000)}s 소요)\n`);
if (failures.length > 0) {
chunk(view, `\n**실패 ${failures.length}건 사유**:\n`);
for (const f of failures) chunk(view, `- ${f.url}${f.reason}\n`);
}
return true;
}
// ───────────────────────────── /meet ─────────────────────────────
async function runMeet(arg: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise<boolean> {
const trimmed = arg.trim();
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 MAX = 60000;
const truncated = transcript.length > MAX;
if (truncated) transcript = transcript.slice(0, MAX);
chunk(view, `\n✅ 파일 읽기 완료 (${transcript.length.toLocaleString()}${truncated ? ', 상한 초과로 일부 잘림' : ''})\n\n`);
const cfg = vscode.workspace.getConfiguration('g1nation');
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.';
chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
let report: string;
try {
const t0 = Date.now();
report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem);
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`);
} catch (e: any) {
chunk(view, `\n\n⚠️ 회의록 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
return true;
}
chunk(view, report + '\n\n');
try {
const today = new Date().toISOString().slice(0, 10);
const baseName = filePath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '') || 'meeting';
const title = `회의록 ${baseName} ${today}`;
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { title, content: report };
if (savePath) body.saveDir = savePath;
const saved = await bridgeFetch<{ success: boolean; path?: string }>(
BRIDGE_API.wiki.save,
{ method: 'POST', body: JSON.stringify(body) },
{ timeoutMs: 30_000 },
);
chunk(view, `💾 **회의록 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`);
} catch (e: any) {
chunk(view, `⚠️ 회의록 저장 실패: ${e?.message || String(e)}\n`);
}
if (context) {
try {
const calCfg = readCalendarConfig(context);
if (!calCfg.refreshToken) {
chunk(view, `\nℹ️ 캘린더 자동 등록을 건너뜁니다 — Google Calendar OAuth(쓰기)가 연결되지 않았습니다. (Astra Settings → Google 섹션에서 연결)\n`);
} else {
const tasks = parseActionItems(report);
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 {
const destLabel = [meetUsesTasks && 'Tasks', meetUsesCalendar && 'Calendar'].filter(Boolean).join(' + ');
chunk(view, `\n📝 **Google ${destLabel} 등록**: 액션 아이템 ${tasks.length}건…\n`);
let tasksOk = 0;
let calendarOk = 0;
let tentativeCount = 0;
for (const task of tasks) {
const { date, tentative } = resolveTaskDate(task.due, meetingDate, today);
if (tentative) tentativeCount++;
const evTitle = tentative ? `${task.work} (미확정)` : task.work;
const detailLine = task.detail?.trim()
? task.detail.trim()
: '(녹취록에서 작업 상세가 추출되지 않음 — 회의록 본문 참조)';
const notes = [
`■ 작업 상세`,
detailLine,
``,
`■ 맥락`,
`· 회의록: ${meetTitle}`,
`· 담당: ${task.owner || '(미지정)'}`,
`· 기한: ${task.due?.trim() || '(미표기)'}${date}${tentative ? ' (미확정·자동 산정)' : ''}`,
``,
`— Astra /meet 자동 등록`,
].join('\n');
const successes: string[] = [];
const failures: string[] = [];
if (meetUsesTasks) {
const r = await createTask(context, { title: evTitle, due: date, notes });
if (r.ok) { tasksOk++; successes.push('Tasks'); }
else { failures.push(`Tasks: ${r.error}`); }
}
if (meetUsesCalendar) {
const r = await createCalendarEvent(context, {
title: evTitle, start: date, allDay: true, description: notes,
});
if (r.ok) { calendarOk++; successes.push('Calendar'); }
else { failures.push(`Calendar: ${r.error}`); }
}
if (failures.length === 0) {
chunk(view, ` · ${date}${evTitle} (${successes.join(' + ')})\n`);
} else {
chunk(view, ` · ${date}${evTitle}${successes.length ? ` (✅ ${successes.join(' + ')})` : ''}\n`);
for (const f of failures) chunk(view, ` ⚠️ ${f}\n`);
}
}
const summary: string[] = [];
if (meetUsesTasks) summary.push(`Tasks ${tasksOk}/${tasks.length}`);
if (meetUsesCalendar) summary.push(`Calendar ${calendarOk}/${tasks.length}`);
chunk(view, `✅ 등록 완료 — ${summary.join(' · ')}${tentativeCount > 0 ? ` · 미확정 ${tentativeCount}` : ''}\n`);
}
}
}
} catch (e: any) {
chunk(view, `\n⚠️ 캘린더 등록 중 오류: ${e?.message || String(e)}\n`);
}
}
return true;
}
// ─── 등록 ─────────────────────────────────────────────────────────────────
// /research(NotebookLM Deep Research)는 v2.2.205 에서 제거 — NotebookLM 은 로컬
// Datacollect 앱 전용으로 분리(Chrome/Google 로그인 의존). ASTRA 백엔드는 NAS 경량
// Bridge 로 운영 가능해야 하므로 brower-auth 가 필요한 명령은 두지 않는다.
registerSlashCommand({ name: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark });
registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube });
registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog });
registerSlashCommand({ name: '/wikify', description: '웹 URL → P-Reinforce v3.0 위키 합성·저장', handler: runWikify });
registerSlashCommand({ name: '/meet', description: '회의 transcript → 회의록 합성 + 캘린더·task 등록', handler: runMeet });