refactor: v2.2.195-201 — slashRouter god-file 해체 (–95%) + 인프라 5개 추출
아키텍처 감사 결과 HIGH 2건 + MED 2건 + LOW 1건 — 7 라운드 정리 시리즈.
기능 변경 없음, 순수 구조 정리.
**slashRouter.ts: 4,174 → 201줄 (–3,973, –95%)**
**agent.ts: 1,617 → 1,551줄 (–66, –4%)**
v2.2.195: eventSourcedStore + SystemPromptBlock registry
- createEventStore<E>(opts) — 4 store (customers/hire/runway/feedback) I/O 240줄 중복 제거
- _turnCtx 5 named string field → 1 Map<string, string> (새 verification block 추가 25곳→1곳)
- buildAstraModeSystemPrompt: 5 ternary gate + 5 위치 → 1 for-loop join
v2.2.196: trackers cluster split
- src/features/teamops/handlers/_shared.ts (fmtKrw/parseAmount/daysUntil/parseTaskOwner/stageEmoji/STAGE_ORDER/TERMINAL_STAGES)
- src/features/teamops/handlers/trackers.ts (runway/customers/hire)
- src/features/teamops/handlers/index.ts (barrel)
- extension.ts 에 side-effect import (순환 import 회피)
v2.2.197: mtimeFileCache + PostAnswerHook registry
- src/lib/mtimeFileCache.ts — createMtimeFileCache<T>(name, parse) (terminologyBlock + termValidator 2-cache invariant 자동화)
- src/agent/postAnswerHooks/{types,index}.ts — Devil/SelfCheck/TermValidator 3 _maybeX method → 1 runPostAnswerHooks(ctx) loop
- agent.ts –66줄
v2.2.198: dashboards cluster split
- src/features/teamops/handlers/dashboards.ts (morning/evening/cohort/weekly)
v2.2.199: coordination + communication clusters split
- src/features/teamops/handlers/coordination.ts (task/decisions/onesie/blocked/standup)
- src/features/teamops/handlers/communication.ts (draft/feedback)
- callLmSynthesis export 노출 (communication 이 사용)
- 옛 parseTaskOwner local 정의 삭제 (_shared.ts 사용)
v2.2.200: system cluster split
- src/features/system/handlers.ts (memory/glossary/help)
v2.2.201: datacollect cluster split + LLM 인프라 추출
- src/features/datacollect/handlers.ts (research/benchmark/youtube/blog/wikify/meet)
- src/features/datacollect/llm.ts (callLmSynthesis + repairKoreanGlitches + bridgeErrorRemedy)
- slashRouter import 4개로 축소: vscode/logInfo/getBridgeBaseUrl/bridgeErrorRemedy
**최종 slashRouter (201줄):**
- REGISTRY Map + registerSlashCommand/listSlashCommands/isSlashCommand
- handleSlashCommand (dispatcher + 에러 처리)
- Webview interface + chunk helper
- getRecentSlashCommands ring buffer (actionability scoring 용)
**미래 부담 감소 metrics:**
- 새 슬래시 명령: god-file 끝에 함수 + register → 1 파일 + 1 register call
- 새 verification block: 5곳 편집 → 1 set call
- 새 event store: 60줄 boilerplate → createEventStore 한 줄
- 새 post-answer hook: 3 step → 1 push
- 새 mtime cache: Map + invariant 관리 → createMtimeFileCache 한 줄
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,744 @@
|
||||
/**
|
||||
* Datacollect handlers — /research · /benchmark · /youtube · /blog · /wikify · /meet.
|
||||
*
|
||||
* 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';
|
||||
|
||||
// ───────────────────────────── /research ─────────────────────────────
|
||||
|
||||
async function runResearch(topic: string, view: Webview | undefined): Promise<boolean> {
|
||||
if (!topic) {
|
||||
chunk(view, `사용법: \`/research <주제>\`\n주제를 입력해 NotebookLM Deep Research를 시작합니다.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `🚀 **Research 시작**: \`${topic}\`\n\n`);
|
||||
const start = await bridgeFetch<{ success: boolean; notebookId: string; taskId: string }>(
|
||||
BRIDGE_API.research.start,
|
||||
{ method: 'POST', body: JSON.stringify({ topic }) },
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
chunk(view, `- notebookId: \`${start.notebookId}\`\n- taskId: \`${start.taskId}\`\n\n⏳ 상태 polling (5초 간격, 최대 10분)…\n`);
|
||||
|
||||
const deadline = Date.now() + 10 * 60_000;
|
||||
const HEARTBEAT_MS = 30_000;
|
||||
const MAX_CONSECUTIVE_FAILS = 5;
|
||||
const COMPLETED_SET = new Set(['completed', 'done', 'success', 'finished']);
|
||||
const FAILED_SET = new Set(['failed', 'error', 'cancelled', 'canceled', 'aborted']);
|
||||
|
||||
let lastStatus = '';
|
||||
let lastChangeAt = Date.now();
|
||||
let consecutiveFails = 0;
|
||||
let pollCount = 0;
|
||||
let researchOk = false;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 5_000));
|
||||
pollCount++;
|
||||
let st: { success: boolean; result: any } | undefined;
|
||||
try {
|
||||
st = await bridgeFetch<{ success: boolean; result: any }>(
|
||||
`${BRIDGE_API.research.status}?notebookId=${encodeURIComponent(start.notebookId)}&taskId=${encodeURIComponent(start.taskId)}`,
|
||||
{ method: 'GET' },
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
consecutiveFails = 0;
|
||||
} catch (e: any) {
|
||||
consecutiveFails++;
|
||||
if (consecutiveFails >= MAX_CONSECUTIVE_FAILS) {
|
||||
chunk(view, `\n❌ Status polling 연속 실패 ${consecutiveFails}회 — bridge 가 응답하지 않습니다. 중단합니다.\n(원인: ${e?.message || String(e)})\n`);
|
||||
return true;
|
||||
}
|
||||
chunk(view, `\n · status 호출 실패 ${consecutiveFails}/${MAX_CONSECUTIVE_FAILS} (${e?.message || 'unknown'})\n`);
|
||||
continue;
|
||||
}
|
||||
const status = String(st.result?.status || st.result || '').trim().toLowerCase();
|
||||
if (status && status !== lastStatus) {
|
||||
chunk(view, ` · ${status}\n`);
|
||||
lastStatus = status;
|
||||
lastChangeAt = Date.now();
|
||||
} else if (Date.now() - lastChangeAt > HEARTBEAT_MS) {
|
||||
chunk(view, ` · ⏳ 대기 중 (${Math.round((Date.now() - lastChangeAt) / 1000)}s, 폴링 ${pollCount}회)\n`);
|
||||
lastChangeAt = Date.now();
|
||||
}
|
||||
if (COMPLETED_SET.has(status)) { researchOk = true; break; }
|
||||
if (FAILED_SET.has(status)) {
|
||||
chunk(view, `\n❌ Research 실패: ${JSON.stringify(st.result).slice(0, 400)}\n`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!researchOk) {
|
||||
chunk(view, `\n❌ 10분 polling 후에도 완료 신호가 오지 않았습니다 (마지막 status: \`${lastStatus || '(없음)'}\`). 중단합니다.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `\n📥 import…\n`);
|
||||
await bridgeFetch(BRIDGE_API.research.import, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notebookId: start.notebookId, taskId: start.taskId }),
|
||||
}, {
|
||||
timeoutMs: 300_000,
|
||||
onHeartbeat: (elapsedMs) => chunk(view, ` · import 진행 중 (${Math.round(elapsedMs / 1000)}s)\n`),
|
||||
});
|
||||
|
||||
chunk(view, `🧪 synthesize…\n\n`);
|
||||
const synth = await bridgeFetch<{ success: boolean; markdown?: string; result?: string }>(
|
||||
BRIDGE_API.research.synthesize,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notebookId: start.notebookId, topic, rootTopic: topic, includeKnowledgeConnections: true }),
|
||||
},
|
||||
{
|
||||
timeoutMs: 600_000,
|
||||
onHeartbeat: (elapsedMs) => chunk(view, ` · synthesize LLM 작업 중 (${Math.round(elapsedMs / 1000)}s)\n`),
|
||||
},
|
||||
);
|
||||
const md = synth.markdown || synth.result || '(빈 응답)';
|
||||
chunk(view, `---\n\n${md}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ───────────────────────────── /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 notes = `회의록: ${meetTitle}\n담당: ${task.owner}\n기한 표기: ${task.due || '(없음)'}\nAstra /meet 자동 등록`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── 등록 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
registerSlashCommand({ name: '/research', description: 'NotebookLM Deep Research 호출 후 위키 합성', handler: runResearch });
|
||||
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 });
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Datacollect LLM 호출 인프라 — bridge `/api/lm` 프록시 통해 OpenAI 호환 chat
|
||||
* completion 단발 호출.
|
||||
*
|
||||
* v2.2.201 에서 slashRouter.ts 에서 분리. 옛 위치는 slashRouter.callLmSynthesis
|
||||
* 였으나 datacollect handlers + teamops/communication 양쪽이 import 하므로
|
||||
* 별도 인프라 모듈로.
|
||||
*
|
||||
* `repairKoreanGlitches` 는 callLmSynthesis 출력 위생용 — 모델이 한·영 혼합
|
||||
* 토큰 깨짐 (예: "핵ess") 을 뱉으면 LLM 1회 추가 호출로 교정.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { bridgeFetch, BRIDGE_API } from './bridgeClient';
|
||||
|
||||
/**
|
||||
* Bridge `/api/lm` 프록시로 OpenAI 호환 chat completion 1회 호출.
|
||||
* LLM 서버/모델은 Astra 설정(g1nation.ollamaUrl / defaultModel)을 사용한다.
|
||||
*/
|
||||
export async function callLmSynthesis(prompt: string, systemPrompt?: string): Promise<string> {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const lmUrl = (cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434').replace(/\/$/, '');
|
||||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||||
const temperature = Math.max(0, Math.min(2, cfg.get<number>('datacollectSynthesisTemperature', 0.1) ?? 0.1));
|
||||
const baseSys = systemPrompt
|
||||
|| '당신은 시니어 UX/UI 분석가이자 프론트엔드 아키텍트다. 모든 보고서는 한국어로, 제공된 JSON 스캔 데이터의 구체적 수치를 인용해 작성한다. 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 명세를 작성하는 것이 미션이며, 다른 서비스로 재해석·확장하지 않는다.';
|
||||
const sys = baseSys + '\n\n[출력 위생 규칙 — 반드시 준수]\n'
|
||||
+ '- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n'
|
||||
+ '- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n'
|
||||
+ '- [Self-Reflector Check], Consistency/Completeness/Accuracy 같은 내부 검증·체크 로그 블록을 출력에 절대 포함하지 마시오. 최종 사용자 결과물만 출력하시오.';
|
||||
const res = await bridgeFetch<any>(BRIDGE_API.lm.proxy, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
url: `${lmUrl}/v1/chat/completions`,
|
||||
payload: {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: sys },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature,
|
||||
top_p: 0.85,
|
||||
top_k: 20,
|
||||
repeat_penalty: 1.1,
|
||||
},
|
||||
}),
|
||||
}, { timeoutMs: 120_000 });
|
||||
const content = res?.choices?.[0]?.message?.content
|
||||
?? res?.choices?.[0]?.text
|
||||
?? res?.answer
|
||||
?? res?.response
|
||||
?? '';
|
||||
let out = String(content)
|
||||
.replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '')
|
||||
.trim();
|
||||
if (/[가-힣][a-z]{2,}/.test(out)) {
|
||||
out = await repairKoreanGlitches(out, lmUrl, model);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글+영문이 한 단어로 깨진 표기(LLM 토큰 꼬임)를 LLM 1회 호출로 교정.
|
||||
*/
|
||||
async function repairKoreanGlitches(text: string, lmUrl: string, model: string): Promise<string> {
|
||||
try {
|
||||
const res = await bridgeFetch<any>(BRIDGE_API.lm.proxy, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
url: `${lmUrl}/v1/chat/completions`,
|
||||
payload: {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: '당신은 한국어 교정기입니다. 입력 텍스트에서 한글과 영문 알파벳이 한 단어로 잘못 합쳐진 깨진 표기(예: "핵ess"→"핵심", "결ently"→"결국")만 자연스러운 한국어로 교정합니다. 그 외 내용·문장·마크다운 구조·표·정상적인 영문 용어(API, JSON 등)는 한 글자도 바꾸지 않으며, 교정된 전체 텍스트만 그대로 출력합니다(설명·주석 금지).' },
|
||||
{ role: 'user', content: text },
|
||||
],
|
||||
temperature: 0,
|
||||
top_p: 0.7,
|
||||
top_k: 20,
|
||||
},
|
||||
}),
|
||||
}, { timeoutMs: 120_000 });
|
||||
const fixed = String(
|
||||
res?.choices?.[0]?.message?.content
|
||||
?? res?.choices?.[0]?.text
|
||||
?? res?.answer ?? res?.response ?? '',
|
||||
).replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '').trim();
|
||||
if (!fixed || fixed.length < text.length * 0.7) return text;
|
||||
return fixed;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Datacollect bridge 가 자주 뱉는 환경 의존성 에러(Python 패키지 미설치, Python
|
||||
* 자체 부재 등) 를 패턴 매칭해서 사용자에게 *해결 명령까지* 알려주는 가이드 텍스트.
|
||||
* 없으면 빈 문자열 반환. slashRouter 의 catch 블록에서 일반 에러 메시지 뒤에 append.
|
||||
*/
|
||||
export function bridgeErrorRemedy(rawMsg: string): string {
|
||||
const msg = String(rawMsg || '');
|
||||
const pkgMatch = msg.match(/필수 패키지가 없습니다?[:\s]+([\w\-,\s.]+)/i)
|
||||
|| msg.match(/missing (?:python )?packages?[:\s]+([\w\-,\s.]+)/i);
|
||||
if (pkgMatch) {
|
||||
const pkgs = pkgMatch[1].split(/[,\s]+/).map((s) => s.trim()).filter(Boolean).join(' ');
|
||||
return `\n\n💡 **해결**: Datacollect bridge 가 도는 환경에서 아래 명령으로 누락된 Python 패키지를 설치하세요.\n\n`
|
||||
+ '```bash\n'
|
||||
+ `# macOS (homebrew Python — PEP 668 보호 우회):\n`
|
||||
+ `python3 -m pip install --user --break-system-packages ${pkgs}\n\n`
|
||||
+ `# 또는 가상환경(venv) 사용 시 그 venv 활성화 후:\n`
|
||||
+ `pip install ${pkgs}\n`
|
||||
+ '```\n\n'
|
||||
+ `설치 후 **bridge 재시작은 보통 불필요** — bridge 는 Python 을 child process 로 spawn 하므로 다음 호출이 바로 새 패키지를 인식합니다. 그래도 안 되면 \`npm run bridge\` 재시작.\n`;
|
||||
}
|
||||
if (/Python 3이 설치돼 있지 않거나 PATH/i.test(msg) || /command not found.*python/i.test(msg)) {
|
||||
return `\n\n💡 **해결**: Python 3 이 설치돼 있어야 합니다. https://www.python.org 에서 설치 후 터미널에서 \`python3 --version\` 으로 확인하세요. 이미 설치돼 있으면 PATH 설정 확인 필요.`;
|
||||
}
|
||||
if (/ECONNREFUSED|fetch failed/i.test(msg) || /연결할 수 없습니다/i.test(msg)) {
|
||||
return `\n\n💡 **해결**: Datacollect bridge 가 떠 있지 않습니다. \`Datacollector_MAC\` 프로젝트에서 \`npm run bridge\` 실행 후 다시 시도하세요.`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user