import * as vscode from 'vscode'; import { logInfo } from '../../utils'; import { bridgeFetch, getBridgeBaseUrl } from './bridgeClient'; /** * Datacollect "라디오" slash 명령 라우터. * * 사용자가 Astra 채팅에서 `/research <주제>` 같은 입력을 보내면 chatHandlers에서 * 이 모듈로 위임. 새 UI 버튼 없이 채팅 단일 경로만으로 Datacollect bridge의 * 무거운 기능(NotebookLM Deep Research, Playwright 웹 벤치마크, YouTube 4-렌즈 * 분석, Blog Pipeline)을 호출한다. * * 진행 상황과 최종 결과는 모두 webview에 `streamChunk` 메시지로 흘려, 일반 * LLM 응답과 같은 자리에 자연스럽게 표시된다. * * 명령이 처리되면 true 반환 → chatHandlers가 일반 LLM 흐름으로 안 내려가게. */ const COMMANDS = ['/research', '/benchmark', '/youtube', '/blog', '/wikify'] as const; type SlashCommand = typeof COMMANDS[number]; export function isSlashCommand(input: string): boolean { const trimmed = input.trim(); if (!trimmed.startsWith('/')) return false; const head = trimmed.split(/\s+/, 1)[0].toLowerCase(); return (COMMANDS as readonly string[]).includes(head); } interface Webview { postMessage(msg: any): Thenable | boolean; } function chunk(view: Webview | undefined, value: string) { view?.postMessage({ type: 'streamChunk', value }); } /** * 슬래시 명령을 라우팅. chatHandlers에서 `userPrompt.startsWith('/')` 체크 후 호출. * 처리 성공/실패 모두 true 반환 (사용자가 명령을 의도했음을 명확히 표현했으므로 * LLM fallback로 흘리지 않음). * * **반드시 finally에서 `streamEnd`를 보낸다** — Astra webview의 채팅 input은 * `streamEnd` 메시지를 받아야 잠금이 풀린다(sidebarProvider.ts 참조). 일반 LLM * 흐름은 streamer가 자동으로 보내지만, 우리는 LLM을 우회해 bridge를 직접 호출 * 하므로 명시적으로 보내야 한다. 안 보내면 timeout/에러/성공 어떤 경우에도 * input이 영원히 잠긴 채 사용자가 무한 로딩 상태로 보게 됨. */ export async function handleSlashCommand( input: string, view: Webview | undefined, ): Promise { const trimmed = input.trim(); const spaceIdx = trimmed.indexOf(' '); const head = (spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)).toLowerCase() as SlashCommand; const arg = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim(); console.error(`[ASTRA-DEBUG] slashRouter handleSlashCommand head=${head} arg=${arg.slice(0, 40)}`); logInfo(`[SLASH] handleSlashCommand start head=${head} arg="${arg.slice(0, 60)}" bridge=${getBridgeBaseUrl()}`); void vscode.window.showInformationMessage(`📻 Datacollect Radio: ${head} 진입`); void vscode.window.setStatusBarMessage(`📻 Datacollect Radio: ${head} 처리 중…`, 5000); // streamEnd(finally)와 대칭 — webview는 streamStart를 받아야 assistant 메시지 // 버블(streamBody)을 만든다(sidebar.js 참조). 이게 없으면 이후 chunk가 보내는 // streamChunk가 streamBody 부재로 전부 silently drop돼 사용자는 아무 결과도 // 못 본다. 일반 LLM 경로는 streamer가 streamStart를 보내주지만, slash 경로는 // LLM을 우회하므로 streamEnd처럼 streamStart도 명시적으로 보내야 한다. view?.postMessage({ type: 'streamStart' }); chunk(view, `\n\n**📻 Datacollect Radio** · \`${head}\` · bridge=\`${getBridgeBaseUrl()}\`\n\n`); try { switch (head) { case '/research': return await runResearch(arg, view); case '/benchmark': return await runBenchmark(arg, view); case '/youtube': return await runYoutube(arg, view); case '/blog': return await runBlog(arg, view); case '/wikify': return await runWikify(arg, view); } return true; } catch (e: any) { logInfo(`[SLASH] handleSlashCommand error head=${head}: ${e?.message || String(e)}`); chunk(view, `\n\n> ❌ **에러**: ${e?.message || String(e)}\n`); return true; } finally { // input 잠금 해제 — slashRouter 진입했으면 어떤 경로든 반드시 통과. view?.postMessage({ type: 'streamEnd' }); logInfo(`[SLASH] handleSlashCommand finished head=${head} streamEnd posted`); } } // ───────────────────────────── /research ───────────────────────────── async function runResearch(topic: string, view: Webview | undefined): Promise { 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 }>( '/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`); // Deep research는 보통 1~5분. 5초 polling, 최대 120회(10분). const deadline = Date.now() + 10 * 60_000; let lastStatus = ''; while (Date.now() < deadline) { await new Promise(r => setTimeout(r, 5_000)); // status 한 번 호출이 30s를 넘는 사례(stale MCP 자식)가 보고돼 60s로 완화. const st = await bridgeFetch<{ success: boolean; result: any }>( `/api/research/status?notebookId=${encodeURIComponent(start.notebookId)}&taskId=${encodeURIComponent(start.taskId)}`, { method: 'GET' }, { timeoutMs: 60_000 }, ); const status = String(st.result?.status || st.result || '').toLowerCase(); if (status && status !== lastStatus) { chunk(view, ` · ${status}\n`); lastStatus = status; } if (status === 'completed' || status === 'done' || status === 'success' || status === 'finished') break; if (status === 'failed' || status === 'error') { chunk(view, `\n❌ Research 실패: ${JSON.stringify(st.result).slice(0, 400)}\n`); return true; } } chunk(view, `\n📥 import…\n`); // import는 deep research 결과를 노트북 소스로 옮기는 단계. 큰 리포트는 2~5분 // 걸리는 경우가 흔해 120s에서 TRANSIENT_TIMEOUT으로 떨어지는 사례 보고됨. 300s로 늘림. await bridgeFetch('/api/research/import', { method: 'POST', body: JSON.stringify({ notebookId: start.notebookId, taskId: start.taskId }), }, { timeoutMs: 300_000 }); chunk(view, `🧪 synthesize…\n\n`); // synthesize는 LLM이 노트북 전체를 합성 — 큰 노트북은 5~10분. 600s로 cap. const synth = await bridgeFetch<{ success: boolean; markdown?: string; result?: string }>( '/api/research/synthesize', { method: 'POST', body: JSON.stringify({ notebookId: start.notebookId, topic, rootTopic: topic, includeKnowledgeConnections: true }), }, { timeoutMs: 600_000 }, ); const md = synth.markdown || synth.result || '(빈 응답)'; chunk(view, `---\n\n${md}\n`); return true; } // ───────────────────────────── /benchmark ───────────────────────────── type SynthesisPart = 1 | 2 | 3; /** * scan JSON → 4-렌즈 분석 LLM 프롬프트. Datacollect 웹앱(WebBenchmarkPanel)의 * buildSynthesisPrompt를 그대로 이식 — /benchmark 결과가 웹앱과 동등하게 나오도록. */ function buildSynthesisPrompt(scan: any, userContent: string, part: SynthesisPart): string { // 4-렌즈 분석에 필요한 핵심 데이터만 추려 LLM 입력을 가볍게. const slim = { url: scan?.url, title: scan?.meta?.title, description: scan?.meta?.description, lang: scan?.meta?.lang, // §1. 비주얼 아이덴티티 — 컬러 비율 + 다크모드 + 타이포 위계 colors: { palette: scan?.design?.colors?.palette?.slice(0, 8), composition: scan?.design?.colors?.composition, background: scan?.design?.colors?.background, primaryText: scan?.design?.colors?.primaryText, linkColor: scan?.design?.colors?.linkColor, buttonBackground: scan?.design?.colors?.buttonBackground, buttonText: scan?.design?.colors?.buttonText, darkModeHints: scan?.design?.colors?.darkModeHints, }, typography: { primaryFont: scan?.design?.typography?.primaryFont, fontStack: scan?.design?.typography?.fontStack?.slice(0, 3), topFontSizes: scan?.design?.typography?.topFontSizes?.slice(0, 6), topFontWeights: scan?.design?.typography?.topFontWeights?.slice(0, 5), body: scan?.design?.typography?.body, h1: scan?.design?.typography?.h1, h2: scan?.design?.typography?.h2, h3: scan?.design?.typography?.h3, button: scan?.design?.typography?.button, }, // §2. 레이아웃 & 공간감 — 여백 / 그리드 layout: { viewport: { w: scan?.design?.layout?.viewportWidth, h: scan?.design?.layout?.viewportHeight }, bodyMaxWidth: scan?.design?.layout?.bodyMaxWidth, sectionSpacing: scan?.design?.layout?.sectionSpacing, cardSpacing: scan?.design?.layout?.cardSpacing, borderRadiusScale: scan?.design?.layout?.borderRadiusScale, grids: scan?.design?.layout?.grids, containerSystem: scan?.design?.layout?.containerSystem, responsiveHints: scan?.design?.layout?.responsiveHints, layering: scan?.design?.layout?.layering, }, components: scan?.design?.components, mediaTreatment: scan?.design?.mediaTreatment, surfaceTreatment: scan?.design?.surfaceTreatment, // §3. 마이크로 인터랙션 — Hover / Transition interactions: { hoverRules: scan?.interactions?.hoverRules?.slice(0, 6), focusRules: scan?.interactions?.focusRules?.slice(0, 3), transitionDistribution: scan?.interactions?.transitionDistribution, cssVars: scan?.interactions?.cssVars, }, // §4. 라이팅 톤앤매너 — 마이크로카피 microcopy: { headline: scan?.microcopy?.headline, subheadline: scan?.microcopy?.subheadline, subheadlines: scan?.microcopy?.subheadlines?.slice(0, 6), ctaSamples: scan?.microcopy?.ctaSamples?.slice(0, 10), placeholders: scan?.microcopy?.placeholders, stateMessages: scan?.microcopy?.stateMessages, ariaLabels: scan?.microcopy?.ariaLabels?.slice(0, 6), bodySample: scan?.microcopy?.bodySample, voiceSignals: scan?.microcopy?.voiceSignals, }, // 구조 요약 — 역기획서 매칭을 위한 보조 정보 structure: { sections: scan?.structure?.sections?.slice(0, 10).map((s: any) => ({ role: s.role, depth: s.depth, text: s.textPreview?.slice(0, 100), btns: s.buttonCount, links: s.linkCount, imgs: s.imgCount, })), h1: scan?.structure?.h1, h2List: scan?.structure?.h2List?.slice(0, 6), navigationLinks: scan?.structure?.navigationLinks?.slice(0, 10), }, iconography: scan?.design?.iconography, // §5. 사이트맵 — 준비할 리소스 추정의 근거 (페이지별 역할 + 자산 수). sitemap: scan?.sitemap ? { totalPages: scan.sitemap.totalPages, crawlDepth: scan.sitemap.crawlDepth, asciiTree: scan.sitemap.ascii, pages: scan.sitemap.pages?.map((p: any) => ({ url: p.url, role: p.role, title: p.title?.slice(0, 80), h1: p.h1?.slice(0, 80), h2List: p.h2List?.slice(0, 5), contentType: p.primaryContentType, imageCount: p.imageCount, videoCount: p.videoCount, formFields: p.formFields?.slice(0, 6).map((f: any) => ({ name: f.name || f.label, type: f.type, required: f.required, })), ctas: p.ctaSamples?.slice(0, 4), sections: p.sectionRoles?.slice(0, 8).map((s: any) => ({ tag: s.tag, hint: s.hint, preview: (s.preview || '').slice(0, 50), })), error: p.error, })), } : null, }; const today = new Date().toISOString().slice(0, 10); const title = slim.title || 'Reference Site'; const userBlock = userContent.trim() ? userContent.trim() : '(미입력 — 원본 사이트 자체의 재현에만 집중)'; const sharedRules = ` [분석 원칙] 1. 이 보고서의 미션은 "원본 레퍼런스 사이트를 가능한 한 닮은 사이트를 처음부터 다시 만들기 위한 명세"를 작성하는 것이다. 2. 추측이나 일반론은 금지. 모든 진술은 제공된 JSON 스캔 데이터의 구체적 수치/문자열을 근거로 인용한다. 3. JSON에 없는 정보를 지어내지 말 것. 데이터에 없는 항목은 "스캔 데이터 부족"이라고 명시한다. 4. 한국어로 작성한다. 5. 모든 색상/폰트/여백/Radius는 정확한 값(rgb/px)을 그대로 인용한다.`; const commonHeader = ` # ${title} 레퍼런스 사이트 재구축 명세 > **레퍼런스 URL**: ${slim.url} > **분석 일자**: ${today} > **분석 관점**: 4-렌즈 (Visual / Layout / Interaction / Voice) + IA 및 페이지 템플릿 + 재구축 명세 > **스캔된 페이지**: ${slim.sitemap?.totalPages ?? 1}개 (crawlDepth: ${slim.sitemap?.crawlDepth ?? 0})`; const partTemplate = part === 1 ? ` ${commonHeader} ## 한 줄 요약 (One-line Impression) ## 1. 시각적 정체성 (Visual Identity) ### 1-1. 컬러 팔레트 (Color Palette) ### 1-2. 타이포그래피 (Typography) ## 2. 레이아웃 및 여백 (Layout & Whitespace) ### 2-1. 그리드 시스템 (Grid System) ### 2-2. 섹션 간 여백 (Section Spacing) ### 2-3. 카드/카드 그리드 (Card Spacing) ### 2-4. Border Radius / 컨테이너 ## 3. 마이크로 인터랙션 (Micro Interaction) ### 3-1. Hover / Focus 효과 ### 3-2. Transition 패턴 ### 3-3. 레이어링 (z-index / position) ## 4. 라이팅 톤앤매너 (Microcopy & Voice) ### 4-1. 헤드라인 / 서브헤드라인 / CTA 카피 ### 4-2. Placeholder 및 보이스 신호` : part === 2 ? ` ## 5. 정보 구조 / 사이트 맵 (Information Architecture) ### 5-1. 사이트 트리 다이어그램 (Page Tree) - \`sitemap.asciiTree\`를 코드블록으로 그대로 옮겨 적을 것. ### 5-2. 페이지 목록 (Flat View) ### 5-3. 페이지별 구성 요약 (Page Composition) ### 5-4. IA 특징 정리 ### 5-5. 재구축용 컴포넌트 명세 (Component Reconstruction Spec) ### 5-6. 미디어 처리 (Media Treatment) ## 6. 준비해야 할 리소스 (Resources You Need to Prepare) ### 6-1. 페이지별 이미지/비디오 수 ### 6-2. 카피라이팅 분량 ### 6-3. 폼/입력 필드 목록 ## 7. 디자인 토큰 (Design Tokens) - Color / Typography / Spacing / Radius / Border / Shadow / Motion 각각 표로 정리. ## 8. 페이지 템플릿 맵 (Page Template Map) 스캔된 페이지들의 \`primaryContentType\` + \`sectionRoles\` + \`h2List\`를 묶어 **반복되는 템플릿 유형**을 도출하고, 반드시 아래 표 형식으로 작성하라. **반드시 마크다운 표 문법을 쓸 것** (글머리표·산문 금지). 템플릿은 최소 3개 이상 도출하되, 페이지가 모두 다르면 그만큼만 작성. | 템플릿 ID | 적용 URL | 공통 블록 순서 (위 → 아래) | 페이지별 차이점 | 재사용 컴포넌트 | |---|---|---|---|---| | T1: Gallery Landing | / | Header → Hero(작품 1장) → 작품 그리드(3열) → Footer | (없음 / 단독 페이지) | Header, ImageCard, Footer | | T2: Category List | /shop, /paintings | Header → 카테고리 타이틀(h1) → 작품 그리드(2열) → Pagination → Footer | 카테고리명·작품 수 다름 | Header, ImageCard, Footer, Pagination | | T3: Detail | /shop/oil-painting/limited-editions | Header → Breadcrumb → 작품 이미지(좌) + 메타·CTA(우) → 관련 작품 → Footer | 상품별 이미지·가격·CTA 문구 다름 | Header, BreadcrumbBar, BuyButton, RelatedGrid, Footer | 작성 규칙: - **템플릿 ID**: \`T1: <역할>\` 형식. 역할은 한국어 또는 영어 모두 OK. - **적용 URL**: 해당 템플릿을 쓰는 페이지의 URL을 콤마로 모두 나열. 1개면 1개만. - **공통 블록 순서**: \`sectionRoles\`의 tag 순서를 기반으로 \`Header → Hero → ... → Footer\` 식으로 위→아래 흐름을 화살표(\`→\`)로 표기. - **페이지별 차이점**: 같은 템플릿을 쓰는 페이지들 사이의 변하는 부분(타이틀/이미지 수/CTA 문구 등). 단독 페이지면 \`(없음 / 단독 페이지)\`. - **재사용 컴포넌트**: 5-5에서 정의한 컴포넌트 이름을 콤마로 나열. 표 아래에 각 템플릿을 ATag/CSS 명세 수준으로 풀어 쓰는 짧은 단락을 덧붙여도 좋다 (선택).` : ` ## 9. 원본 사이트 재구축 명세 (Rebuild Spec — Same Site, Built From Scratch) > **⚠️ 이 단계의 미션 (절대 이탈 금지)** > - 이 섹션은 **원본 레퍼런스 사이트와 가능한 한 같은 사이트를 처음부터 다시 만들기 위한 개발 명세**다. > - 다른 서비스(대시보드, 분석 툴, SaaS 등)로 **재해석·확장·전환하지 말 것**. 사용자 컨텍스트가 원본과 다른 도메인이면 part 9에서는 무시한다. > - "개선 / 재설계 / 모던화 / 데이터 시각화 추가" 같은 변형 제안 금지. **원본 그대로 복원**이 유일한 목적. > - 모든 결정값(색상·폰트·여백·Radius·전환 속도)은 part 1~7에서 추출한 토큰을 그대로 인용한다. ### 9-1. 디자인 토큰 정의 (원본 값 그대로) - part 7에서 도출한 토큰을 CSS 변수 또는 Tailwind config 형식으로 코드블록에 옮긴다. 값은 절대 임의로 바꾸지 말 것. ### 9-2. 컴포넌트 명세 (원본 사이트의 카드/버튼/네비 등) - part 5-5의 컴포넌트별 props·치수·padding·radius·border·shadow를 코드블록 형태로 명세. ### 9-3. 페이지별 레이아웃 마크업 가이드 - part 8 페이지 템플릿 맵의 각 템플릿(T1, T2, ...)에 대해 HTML 골격(섹션 → 자식 컴포넌트)을 의사 JSX/HTML로 1개씩 제시. ### 9-4. 인터랙션 재현 명세 - part 3의 hover/focus/transition 값을 어느 컴포넌트에 어떻게 적용할지 명시 (예: \`.btn:hover { background: ...; transition: 0.2s ease; }\`). ### 9-5. 콘텐츠 및 자산 준비 목록 - part 6의 페이지별 이미지/비디오 수, 카피 분량, 폼 필드를 체크리스트로 정리. 사장님이 준비해야 할 자산 목록. ### 9-6. 개발 티켓 (원본 복원 기준) - 위 9-1 ~ 9-5를 구현 가능한 단위로 쪼개 \`[FE] / [BE] / [Asset]\` 태그를 붙여 티켓 형태로 나열. 모든 티켓은 "원본 사이트와 같게 만들기" 범위 안에 있어야 한다. 신규 기능 제안 금지. ## 🔍 복원 시 추정이 필요한 영역 (Buildability Gaps) - 스캔으로는 잡히지 않는 영역(다이나믹 데이터·CMS 구조·실제 폰트 라이선스·결제 연동 등)을 나열. 추측이 필요한 부분만 적고, 임의로 결정하지 말 것. > **주의**: 이 단계는 새로운 서비스 기획이 아니라 **원본 사이트 그 자체를 다시 짓기 위한 시방서**다. 9-1 ~ 9-6의 모든 값은 part 1~8에서 인용한 수치여야 한다.`; const partGoal = part === 1 ? '1/3단계: 원본 사이트의 시각·인터랙션·카피 톤을 4-렌즈로 분석한다.' : part === 2 ? '2/3단계: 원본의 IA, 페이지 템플릿, 디자인 토큰, 준비 리소스를 정리한다. 8단계 Page Template Map은 반드시 표 형식으로 작성한다.' : '3/3단계: 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 개발 명세를 작성한다. **사용자 컨텍스트는 무시하고, 다른 서비스로 재해석하지 않는다.**'; return `당신은 시니어 UX/UI 분석가 겸 프론트엔드 아키텍트다. ${sharedRules} [이번 단계 목표] ${partGoal} [레퍼런스 사이트 스캔 데이터 (JSON)] \`\`\`json ${JSON.stringify(slim, null, 2)} \`\`\` [사용자 보조 컨텍스트 — part 1·2의 톤 추정에만 참고. part 3에서는 무시할 것.] ${userBlock} [작성할 보고서 섹션 (이 구조를 그대로 따를 것)] ${partTemplate}`; } /** * Bridge `/api/lm` 프록시로 OpenAI 호환 chat completion 1회 호출. * LLM 서버/모델은 Astra 설정(g1nation.ollamaUrl / defaultModel)을 사용한다. */ async function callLmSynthesis(prompt: string, systemPrompt?: string): Promise { const cfg = vscode.workspace.getConfiguration('g1nation'); const lmUrl = (cfg.get('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434').replace(/\/$/, ''); const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); // temperature는 설정값(g1nation.datacollectSynthesisTemperature). 낮을수록 환각· // 깨진 문자가 줄어든다. 0~2 범위로 클램프, 기본 0.1. const temperature = Math.max(0, Math.min(2, cfg.get('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('/api/lm', { method: 'POST', body: JSON.stringify({ url: `${lmUrl}/v1/chat/completions`, payload: { model, messages: [ { role: 'system', content: sys }, { role: 'user', content: prompt }, ], temperature, }, }), }, { timeoutMs: 120_000 }); const content = res?.choices?.[0]?.message?.content ?? res?.choices?.[0]?.text ?? res?.answer ?? res?.response ?? ''; // 안전망 — 모델이 그래도 내부 검증 로그를 붙이면 잘라낸다. [Self-Reflector Check] // 블록은 항상 답변 맨 끝에 오므로 그 지점부터 끝까지 제거한다. return String(content) .replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '') .trim(); } async function runBenchmark(arg: string, view: Webview | undefined): Promise { // 인자 파싱: 첫 비-옵션 토큰=URL, `depth=N`·`pages=N`=크롤 옵션, 나머지=보조 컨텍스트. // 예) `/benchmark caliverse.io depth=2 pages=12 우리 랜딩 참고용` // URL 토큰만 떼어내므로 "분석해줘" 같은 자연어가 섞여도 안전하다. const tokens = arg.trim().split(/\s+/).filter(Boolean); let url = ''; let depthArg: number | undefined; let pagesArg: number | undefined; const restParts: string[] = []; for (const t of tokens) { const m = /^(depth|pages)=(\d+)$/i.exec(t); if (m) { if (m[1].toLowerCase() === 'depth') depthArg = Number(m[2]); else pagesArg = Number(m[2]); } else if (!url) { url = t; } else { restParts.push(t); } } if (!url) { chunk(view, `사용법: \`/benchmark [depth=N] [pages=N] [보조 설명]\`\n예: \`/benchmark https://example.com depth=2 pages=12\`\n`); return true; } const userContent = restParts.join(' '); // 크롤 옵션 우선순위: 명령 인자 > Settings 설정값 > 기본값(depth 1 / 8페이지). const cfg = vscode.workspace.getConfiguration('g1nation'); const crawlDepth = depthArg ?? (cfg.get('datacollectCrawlDepth', 1) ?? 1); const maxPages = pagesArg ?? (cfg.get('datacollectMaxPages', 8) ?? 8); chunk(view, `🔍 **Web Benchmark 스캔**: ${url}\n(crawlDepth ${crawlDepth} · 최대 ${maxPages}페이지 · Playwright)\n\n⏳ 브라우저 기동 · 페이지 크롤링 · 디자인 토큰 추출 중…`); // 1) scan — 진행 중 멈춘 줄로 오해하지 않도록 4초마다 경과 시간을 누적 표시. 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 }>( '/api/web-benchmark/scan', { 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`); // 스캔이 에러 없이 끝나도(잘못된 URL·봇 차단·JS 렌더링 등) 알맹이가 빌 수 있다. const looksEmpty = !s?.meta?.title && !(s?.design?.colors?.palette?.length) && !s?.microcopy?.headline; if (looksEmpty) { chunk(view, `⚠️ **스캔 결과가 비어 있습니다.** URL이 정확한지, 사이트가 접근 가능한지(봇 차단·JS 전용 렌더링 포함) 확인하세요. 스캔한 URL: \`${url}\`\n\n`); } // raw 스캔 요약 — LLM 합성이 실패하거나 스캔이 비었을 때의 fallback 본문. 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'); // 2) synthesize — LLM 4-렌즈 합성 3단계 (Datacollect 웹앱과 동일한 리포트). // 스캔이 비었거나 합성이 실패하면 raw 요약으로 fallback. let finalReport: string; if (looksEmpty) { chunk(view, `(스캔이 비어 LLM 합성을 건너뜁니다.)\n\n`); finalReport = rawReport; } else { const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); chunk(view, `🧪 **LLM 4-렌즈 합성** (3단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`); try { const parts: string[] = []; for (const part of [1, 2, 3] as const) { chunk(view, `\n · 합성 ${part}/3 진행 중…`); const partT0 = Date.now(); const out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part)); if (!out) throw new Error(`LLM ${part}/3 응답이 비어 있습니다.`); parts.push(out); chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`); } finalReport = parts.join('\n\n---\n\n'); chunk(view, `\n\n`); } catch (e: any) { chunk(view, `\n\n⚠️ LLM 합성 실패: ${e?.message || String(e)}\n원시 스캔 요약만 표시·저장합니다. (LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`); finalReport = rawReport; } } chunk(view, finalReport + '\n\n'); // 3) save — 저장 위치 우선순위: g1nation.datacollectSavePath > Bridge WIKI_RAW_PATH. // 어느 쪽이든 Astra 코드에는 절대경로가 하드코딩되지 않는다. try { const today = new Date().toISOString().slice(0, 10); let host = url; try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ } const title = `웹벤치마크 ${host} ${today}`; const fileMarkdown = [ `# ${title}`, ``, `- **원본 URL**: ${url}`, `- **스캔 시각**: ${new Date().toISOString()}`, `- **크롤 옵션**: depth ${crawlDepth} · 최대 ${maxPages}페이지`, `- **생성**: Astra /benchmark · Datacollect web-benchmark`, ``, finalReport, ``, ].join('\n'); const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); const body: Record = { title, content: fileMarkdown }; if (savePath) body.saveDir = savePath; const saved = await bridgeFetch<{ success: boolean; path?: string }>( '/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 formatHms(totalSec: number): string { if (!isFinite(totalSec) || totalSec <= 0) return '00:00'; const s = Math.floor(totalSec); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : `${m}:${String(sec).padStart(2, '0')}`; } /** * 영상 전체 자막을 30초 버킷으로 묶어 `[mm:ss] 문장…` 형태의 읽기 좋은 full script로 변환. * YouTube 자동자막은 segment가 잘게 끊겨 그대로 나열하면 가독성이 나쁘므로 묶는다. */ function fullScriptFromSegments(segments: any[] | undefined): string { if (!segments || segments.length === 0) return '(자막 없음 — 자동 자막이 없는 영상일 수 있습니다.)'; const buckets = new Map(); for (const seg of segments) { const b = Math.floor((seg.start || 0) / 30); const arr = buckets.get(b) || []; arr.push(String(seg.text || '').trim()); buckets.set(b, arr); } return Array.from(buckets.entries()) .sort((a, b) => a[0] - b[0]) .map(([b, texts]) => `**[${formatHms(b * 30)}]** ${texts.join(' ').replace(/\s+/g, ' ').trim()}`) .join('\n\n'); } /** * timestamped segments → 분 단위 버킷으로 묶은 "타임라인 뼈대" 텍스트. * §2 구조 분석에서 LLM 토큰을 아낀다. */ function bucketSegments(segments: any[] | undefined, bucketSec = 30): { time: string; text: string }[] { if (!segments || segments.length === 0) return []; const buckets = new Map(); for (const seg of segments) { const bucket = Math.floor(seg.start / bucketSec); const arr = buckets.get(bucket) || []; arr.push(String(seg.text || '').trim()); buckets.set(bucket, arr); } return Array.from(buckets.entries()) .sort((a, b) => a[0] - b[0]) .map(([bucket, texts]) => ({ time: formatHms(bucket * bucketSec), text: texts.join(' ').replace(/\s+/g, ' ').trim().slice(0, 240), })); } /** * extract된 영상 → 유튜브 4-렌즈(훅/구조/제작/CTR) 분석 LLM 프롬프트. * Datacollect 웹앱(YoutubePanel)의 build4LensPrompt를 그대로 이식. */ function build4LensPrompt(video: any, userContent: string): string { const meta = video.metadata || {}; const segments = video.segments || []; // 초반 30초 / 60초 텍스트 — §1 훅 분석용. const first30s = segments.filter((s: any) => s.start < 30).map((s: any) => String(s.text || '').trim()).join(' ').slice(0, 600); const first60s = segments.filter((s: any) => s.start < 60).map((s: any) => String(s.text || '').trim()).join(' ').slice(0, 1200); // 타임라인 버킷 (30초 단위) — §2 구조 분석용. const timelineBuckets = bucketSegments(segments, 30); const timelinePreview = timelineBuckets.slice(0, 24).map(b => `[${b.time}] ${b.text}`).join('\n'); // 인게이지먼트 키워드 매치 — §2 보조. const engagementHits = segments .filter((s: any) => /구독|좋아요|알림|댓글|공유|subscribe|like|comment/i.test(String(s.text || ''))) .slice(0, 5) .map((s: any) => ({ t: formatHms(s.start), text: String(s.text || '').trim().slice(0, 100) })); const slim = { url: meta.webpage_url || `https://www.youtube.com/watch?v=${video.video_id}`, title: meta.title || video.title, channel: meta.channel, durationSec: meta.duration, durationHms: meta.duration_string, viewCount: meta.view_count, likeCount: meta.like_count, commentCount: meta.comment_count, uploadDate: meta.upload_date, thumbnail: meta.thumbnail, tags: (meta.tags || []).slice(0, 12), categories: meta.categories, chapters: meta.chapters, descriptionPreview: (meta.description || '').slice(0, 600), opening30s: first30s, opening60s: first60s, engagementMoments: engagementHits, segmentCount: segments.length, timelinePreview, }; const today = new Date().toISOString().slice(0, 10); const userBlock = userContent.trim() ? userContent.trim() : '(미입력 — 일반 콘텐츠 제작자 컨텍스트로 작성)'; return `당신은 유튜브 '대본(스크립트)' 분석 전문가이자 콘텐츠 작가입니다. 사장님이 이 영상과 비슷한 콘텐츠의 **대본을 직접 쓰려** 합니다. 영상 연출이 아니라 오직 스크립트(텍스트)와 언어 구조만 분석해, 읽자마자 자기 대본에 복붙하듯 써먹을 수 있는 '유저 친화적 역기획서'를 작성하세요. [분석 원칙] 1. BGM·자막·컷 전환·썸네일 등 대본만으로 알 수 없는 '영상 연출' 항목은 과감히 생략한다. 오직 스크립트(텍스트)와 언어 구조에만 집중한다. 2. 대사를 단순 인용하지 말고, 그 대사가 시청자 심리를 어떻게 건드렸는지 '언어적 장치'를 태그로 라벨링한다. 아래 태그 어휘에서만 골라 일관되게 사용한다: #FOMO #권위부여 #호기심갭 #사회적증명 #페르소나 #약속Promise #공감후킹 #반전 #숫자강조 #문제고발 #브릿지멘트 #쉬운비유 3. 전문 용어가 나오면, 화자가 그것을 어떤 '쉬운 비유'나 일상어로 풀어 말했는지 그 구어체 '말의 맛'을 반드시 분석에 포함한다. 4. 한국어. 자막(text)·chapters·메타데이터에 있는 것만 인용(추측 금지). 타임스탬프는 mm:ss. [영상 데이터] \`\`\`json ${JSON.stringify(slim, null, 2)} \`\`\` [우리가 만들고 싶은 콘텐츠 / 채널 컨텍스트] ${userBlock} [필수 출력 형식 — 정확히 이 구조. 아래 5개 섹션 외 추가 금지] # ${slim.title || video.title} — 대본 역기획서 > **영상 URL**: ${slim.url} · **분석 일자**: ${today} · **길이**: ${slim.durationHms || (slim.durationSec ? formatHms(slim.durationSec) : '?')} · **채널**: ${slim.channel || '?'} ## 🎬 한 줄 인상 (One-line Read) (이 영상 스크립트의 핵심 성격과 설득 전략을 한 줄로. 예: "전문 지식을 친구에게 설명하듯 풀어내고, 호기심 갭으로 끝까지 끌고 가는 정보형 대본") ## 1. 스크립트 뼈대 구조도 (Script Architecture) 구간별 마크다운 표 1개. '레퍼런스 실제 대사'는 자막에서 1문장 이내로 짧게 따옴표 인용. '스크립트 기능'에는 위 태그 어휘를 1~2개 붙인다. 비중 %는 durationSec 기준, chapters가 있으면 그것을, 없으면 timelinePreview로 구간을 추정. | 구간 (비중) | 스크립트 기능 (태그) | 레퍼런스 실제 대사 | 벤치마킹 핵심 기술 | | --- | --- | --- | --- | | 오프닝 Hook (0:00~?, ?%) | #호기심갭 #약속Promise | "첫 대사…" | 결과를 미리 흘려 이탈 차단 | | 도입부 (?~?, ?%) | … | … | … | | 본론 (?~?, ?%) | … | … | … | | 아웃트로·CTA (?~?, ?%) | … | … | … | ## 2. 말의 맛 & 톤앤매너 (Tone & Manner) - **문장 길이 특징**: 단문/장문, 호흡, 리듬 — 실제 자막 예시 1개를 따옴표로. - **어조 페르소나**: 예) 친근한 전문가체 / 단정적 신뢰체 — 근거 대사 1개. - **핵심 대사 장치**: 시청자 중간 이탈을 막으려 대본 사이에 심은 미끼 문장·브릿지 멘트를 타임스탬프와 함께 2~3개 추출, 각각 태그 라벨을 붙인다. - **전문용어 → 쉬운 비유**: 어려운 개념을 화자가 어떤 비유·일상어로 풀었는지 \`용어 → "화자의 실제 표현"\` 형태로 2~3개. 사례가 없으면 "해당 사례 없음"이라 명시. ## 3. 내 대본에 바로 쓰는 액션 체크리스트 (Action Items) 다음 대본을 쓸 때 무조건 적용할 행동 지침 3~4개. 반드시 체크박스로, 구체적 수치를 포함. - [ ] (예: 오프닝 15초 안에 '내가 누구인지' 페르소나 한 문장 박기) - [ ] … - [ ] … ## ✂️ 빈칸 채우기식 대본 템플릿 (Fill-in-the-Blank) 레퍼런스의 말하기 구조·접속사·리듬은 그대로 살리고, 내 콘텐츠 내용만 [ ]에 채우면 대본이 완성되는 형태. 각 [ ] 안에는 무엇을 넣을지 짧은 힌트를 적는다. \`\`\` [오프닝 — Hook] "여러분, 혹시 [시청자의 흔한 고민]… 해보신 적 있으세요? 오늘은 [이 영상이 줄 핵심 결과]를 [숫자]분 만에 끝내 드릴게요." [도입부 — 공감 + 권위] … [본론 — 단계별 설명] … [아웃트로 — CTA] … \`\`\` > ⚠️ 본 분석은 스크립트의 언어·구조 패턴 학습용입니다. 대사·자료는 직접 창작/라이선스 확보.`; } async function runYoutube(arg: string, view: Webview | undefined): Promise { // URL 토큰만 추출, 나머지는 보조 컨텍스트(우리 채널/콘텐츠 설명). const tokens = arg.trim().split(/\s+/).filter(Boolean); const url = tokens[0] || ''; const userContent = tokens.slice(1).join(' '); if (!url) { chunk(view, `사용법: \`/youtube [우리 채널/콘텐츠 설명]\`\n예: \`/youtube https://youtu.be/xxxx\`\n`); return true; } chunk(view, `🎬 **YouTube 추출**: ${url}\n(자막 + 메타데이터)\n\n⏳ Python 추출기 기동 · 자막/메타 추출 중…`); // 1) extract — Bridge는 `source` 필드를 기대한다(`url`이 아님). const t0 = Date.now(); const heartbeat = setInterval(() => { chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`); }, 4000); const data = await bridgeFetch<{ success: boolean; videos?: any[]; totalVideos?: number }>( '/api/youtube/extract', { method: 'POST', body: JSON.stringify({ source: url, withMetadata: true, limit: 5 }) }, { timeoutMs: 5 * 60_000 }, ).finally(() => clearInterval(heartbeat)); const okVideos = (data.videos || []).filter((v: any) => v?.status === 'ok'); chunk(view, `\n✅ **추출 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${okVideos.length}/${data.totalVideos ?? (data.videos || []).length}개 영상)\n\n`); if (okVideos.length === 0) { chunk(view, `⚠️ 자막 추출에 성공한 영상이 없습니다. 자막이 없거나 비공개 영상일 수 있습니다.\n`); return true; } const cfg = vscode.workspace.getConfiguration('g1nation'); const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); const ytSystem = '당신은 유튜브 콘텐츠 시니어 PD입니다. 데이터에 근거한 제작 가이드만 제공하세요.'; // 2) 영상마다 LLM 4-렌즈 분석 (보통 1건; 채널/플레이리스트면 순차). for (const video of okVideos) { const vTitle = video?.metadata?.title || video?.title || video?.video_id || '(제목 없음)'; // 보고서 앞에 영상 전체 스크립트를 먼저 출력 — 분석과 원문 대본을 함께 보도록. const script = fullScriptFromSegments(video?.segments); chunk(view, `## 📜 전체 스크립트 (Full Script)\n\n${script}\n\n---\n\n`); chunk(view, `🧪 **LLM 4-렌즈 분석**: ${vTitle} (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`); let report: string; try { const partT0 = Date.now(); report = await callLmSynthesis(build4LensPrompt(video, userContent), ytSystem); if (!report) throw new Error('LLM 응답이 비어 있습니다.'); chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)\n\n`); } catch (e: any) { chunk(view, `\n\n⚠️ LLM 분석 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`); continue; } chunk(view, report + '\n\n'); // 3) save — benchmark와 동일하게 /api/wiki/save (datacollectSavePath > WIKI_RAW_PATH). 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 title = `유튜브분석 ${vTitle} ${today}`; const fileMarkdown = [ `# ${title}`, ``, `- **영상 URL**: ${videoUrl}`, `- **분석 시각**: ${new Date().toISOString()}`, `- **생성**: Astra /youtube · Datacollect youtube insight`, ``, `## 📜 전체 스크립트 (Full Script)`, ``, script, ``, `---`, ``, report, ``, ].join('\n'); const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); const body: Record = { title, content: fileMarkdown }; if (savePath) body.saveDir = savePath; const saved = await bridgeFetch<{ success: boolean; path?: string }>( '/api/wiki/save', { method: 'POST', body: JSON.stringify(body) }, { timeoutMs: 30_000 }, ); chunk(view, `💾 **결과물 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n\n`); } catch (e: any) { chunk(view, `⚠️ 결과물 저장 실패: ${e?.message || String(e)}\n\n`); } } return true; } // ───────────────────────────── /blog ───────────────────────────── async function runBlog(keyword: string, view: Webview | undefined): Promise { // Blog Pipeline은 Datacollect의 별도 흐름(blog/app.js + local_platform_server 8787)으로 // 실행된다. Bridge 3002에는 대응 endpoint가 없어 Astra가 직접 호출할 경로가 없음. // MVP에서는 사용자가 그쪽 UI로 빠르게 갈 수 있도록 안내만. 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 ───────────────────────────── /** * 추출된 웹사이트 본문 → Datacollect Research(P-Reinforce v3.0)와 동일한 위키 문서 * 프롬프트. Bridge의 /api/research/synthesize 템플릿을 웹 본문 소스용으로 이식. */ function buildWikifyPrompt(extracted: any, userContent: string): string { const today = new Date().toISOString().slice(0, 10); const topic = (userContent.trim() || extracted?.title || extracted?.url || '웹사이트 지식').trim(); const url = extracted?.url || ''; const idSlug = (topic.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9가-힣-]/g, '').slice(0, 80)) || 'web-wiki'; const headings = Array.isArray(extracted?.headings) ? extracted.headings.slice(0, 40) : []; const body = String(extracted?.text || '').slice(0, 30000); return `임무: 아래 웹사이트에서 추출한 본문을 근거로 P-Reinforce v3.0 규격에 맞춰 고밀도 지식 문서를 작성하시오. 주제: '${topic}' [필수 규칙] 1. 반드시 아래 [웹사이트 본문]의 내용만을 근거로 작성하시오. 외부 지식을 절대 섞지 마시오. 2. 반드시 아래 Markdown 템플릿과 Frontmatter 형식을 정확히 따르시오. 섹션 이름과 구조를 변경하지 마시오. 3. 본문에 없는 정보는 지어내지 말고, 해당 섹션에 "본문에서 확인되지 않음"이라고 명시하시오. 4. 한국어로 작성하시오. 관련 개념·고유명사는 [[대괄호 두 개]]로 감싸 위키 링크로 만드시오. 위키 링크는 반드시 \`[[\` 로 열고 \`]]\` 로 닫으시오 (닫는 대괄호를 빠뜨리지 마시오). 5. 원문이 JSON Schema·API 명세·기술 스펙·설정 레퍼런스인 경우, '📖 세부 내용'에 등장하는 모든 필드·속성·파라미터를 **누락 없이** 마크다운 표로 정리하시오. 표 컬럼은 [필드 | 타입 | 필수/선택 | 제약·설명]. 원문의 \`required\` 배열에 있는 항목만 '필수'로 표기하고, 그 목록을 임의로 바꾸거나 추가하지 마시오. \`additionalProperties\`·\`const\`·\`enum\` 같은 제약과 중첩 객체 구조도 원문 그대로 반영하시오. 최상위 필드를 하나라도 빠뜨리지 마시오. [웹사이트 메타] - URL: ${url} - 제목: ${extracted?.title || '(없음)'} - 설명: ${extracted?.description || '(없음)'} - 주요 헤딩: ${headings.join(' / ') || '(없음)'} [웹사이트 본문] \`\`\` ${body} \`\`\` [출력 템플릿 - 이 형식을 정확히 따르시오] --- id: ${idSlug} title: "${topic}" category: "10_Wiki/Topics" status: "draft" verification_status: "conceptual" canonical_id: "" aliases: [] duplicate_of: "" source_trust_level: "B" confidence_score: 0.8 created_at: ${today} updated_at: ${today} review_reason: "" merge_history: [] tags: ["web", "wikify"] raw_sources: ["${url}"] applied_in: [] github_commit: "" --- # [[${topic}]] ## 🎯 한 줄 통찰 (One-line insight) (이 웹사이트/주제의 핵심 가치를 관통하는 강력한 한 줄 정의) ## 🧠 핵심 개념 (Core concepts) (본문을 구성하는 가장 중요한 3-5가지 핵심 개념/기둥) ## 🧩 추출된 패턴 (Extracted patterns) (본문에서 발견된 반복되는 구조, 전략, 주장 또는 접근법) ## 📖 세부 내용 (Details) (본문에서 합성된 상세하고 전문적인 설명. 논리적 단락이나 글머리 기호로 구분하시오. 원문이 명세·스키마·API 레퍼런스라면 위 규칙 5에 따라 모든 필드를 표로 빠짐없이 정리하시오.) ## ⚖️ 모순 및 업데이트 (Contradictions & updates) (본문 내에서 상충되는 정보나 주목할 최신 정보가 있다면 서술. 없으면 "본문에서 확인되지 않음".) ## 🛠️ 적용 사례 (Applied in summary) (본문에 구체적 사례·수치·제품·프로젝트·의사결정이 있으면 요약하여 기술. 없으면 "본문에서 확인되지 않음".) ## ✅ 검증 상태 및 신뢰도 - **상태:** draft - **검증 단계:** conceptual - **출처 신뢰도:** B (Primary Source — 웹사이트 본문 직접 추출) - **중복 검사 결과:** 신규 생성 (New discovery) ## 🔗 관련 문서 링크 (Related document links) (이 주제와 직접 연결되는 핵심 개념 3-7개를 [[위키링크]]로 제시하고, 각 링크마다 연결 이유를 한 줄로 적으시오. 본문에 등장한 개념을 우선 사용.) ## 📝 변경 이력 (Change history) - ${today}: Astra /wikify 로 ${url} 본문에서 초안 생성.`; } /** * URL 한 개를 위키화 — 본문 추출 → P-Reinforce v3.0 합성 → 저장. * 다중 링크 배치 시 runWikify가 이 함수를 순차 반복 호출한다. * @returns 위키 문서 생성·저장까지 성공하면 true. 본문 빈약/합성 실패는 false. * (extract 자체 실패는 throw — 호출자가 잡는다.) */ async function wikifyOne(url: string, userContent: string, view: Webview | undefined): Promise { const cfg = vscode.workspace.getConfiguration('g1nation'); // 1) extract — Bridge가 Playwright로 main/article 본문 텍스트를 추출. 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 }>( '/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) { chunk(view, `⚠️ 추출된 본문이 거의 없어 건너뜁니다. (JS 전용 렌더링이거나 콘텐츠가 빈약한 페이지)\n`); return false; } // 2) synthesize — P-Reinforce v3.0 위키 문서로 LLM 합성. const model = (cfg.get('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 응답이 비어 있습니다.'); // LLM이 위키링크 [[ ]]의 닫는 대괄호를 하나 빠뜨리는 깨짐을 자동 교정. report = report.replace(/\[\[([^\[\]]+?)\](?!\])/g, '[[$1]]'); chunk(view, ` ✓ (${Math.round((Date.now() - synthT0) / 1000)}s)\n\n`); } catch (e: any) { chunk(view, `\n⚠️ 위키 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`); return false; } chunk(view, report + '\n\n'); // 3) save — /api/wiki/save (datacollectSavePath > WIKI_RAW_PATH). // report 자체가 frontmatter 포함 완결 위키 문서이므로 그대로 저장한다. try { const today = new Date().toISOString().slice(0, 10); let host = url; try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ } const title = `위키 ${(userContent.trim() || data.title || host)} ${today}`; const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); const body: Record = { title, content: report }; if (savePath) body.saveDir = savePath; const saved = await bridgeFetch<{ success: boolean; path?: string }>( '/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; } async function runWikify(arg: string, view: Webview | undefined): Promise { // 토큰을 URL과 비-URL로 분류. URL처럼 생긴 토큰은 모두 처리 대상, 나머지는 공통 주제명. // 여러 링크를 공백으로 구분해 넣으면 1개씩 순차적으로 위키화한다. const isUrl = (t: string) => /^https?:\/\//i.test(t) || /^[a-z0-9-]+(\.[a-z0-9-]+)+/i.test(t); const tokens = arg.trim().split(/\s+/).filter(Boolean); const urls = tokens.filter(isUrl); const userContent = tokens.filter((t) => !isUrl(t)).join(' '); if (urls.length === 0) { chunk(view, `사용법: \`/wikify [url2 url3 …] [주제명]\`\n예: \`/wikify https://example.com\`\n여러 링크를 공백으로 구분해 한 번에 넣으면 1개씩 순차 위키화합니다.\n`); return true; } // 단일 링크 — 그대로 처리. if (urls.length === 1) { chunk(view, `📚 **위키화**: ${urls[0]}\n(본문 추출 → P-Reinforce v3.0 위키 문서 합성)\n\n`); await wikifyOne(urls[0], userContent, view); return true; } // 다중 링크 — 1개씩 순차 처리. 한 건이 실패해도 나머지는 계속 진행. chunk(view, `📚 **위키화 배치**: 총 ${urls.length}개 링크를 순차 처리합니다.\n`); const batchT0 = Date.now(); let okCount = 0; for (let i = 0; i < urls.length; i++) { chunk(view, `\n---\n\n### [${i + 1}/${urls.length}] ${urls[i]}\n\n`); try { if (await wikifyOne(urls[i], userContent, view)) okCount++; } catch (e: any) { chunk(view, `❌ [${i + 1}/${urls.length}] 처리 실패: ${e?.message || String(e)}\n`); } } chunk(view, `\n---\n\n🏁 **배치 완료**: ${okCount}/${urls.length}개 성공 (${Math.round((Date.now() - batchT0) / 1000)}s 소요)\n`); return true; }