eeb527c242
- /youtube: 4-렌즈 분석 → 대본(스크립트) 역기획서 포맷으로 개편, 보고서 앞에 영상 전체 스크립트(Full Script) 출력, 명령어 보조 컨텍스트 지원 - /wikify: 신규 슬래시 명령 — 웹사이트 본문(/api/web-extract)을 P-Reinforce v3.0 위키 문서로 합성. 여러 링크 순차 배치 처리, 명세 문서 완전성 규칙, 위키링크 자동 교정 - Self-Reflector Phase A 기본 비활성화 — [Self-Reflector Check] 내부 검증 로그가 사용자 답변에 노출되지 않도록 - 슬래시 합성·일반 채팅 시스템 프롬프트에 출력 위생 규칙 추가 — 한·영 토큰 깨짐 정제, 내부 검증 로그 출력 금지 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1086 lines
56 KiB
TypeScript
1086 lines
56 KiB
TypeScript
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> | 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<boolean> {
|
||
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<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 }>(
|
||
'/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<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();
|
||
// temperature는 설정값(g1nation.datacollectSynthesisTemperature). 낮을수록 환각·
|
||
// 깨진 문자가 줄어든다. 0~2 범위로 클램프, 기본 0.1.
|
||
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>('/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<boolean> {
|
||
// 인자 파싱: 첫 비-옵션 토큰=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 <url> [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<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⏳ 브라우저 기동 · 페이지 크롤링 · 디자인 토큰 추출 중…`);
|
||
|
||
// 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<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');
|
||
|
||
// 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<string>('datacollectSavePath', '') || '').trim();
|
||
const body: Record<string, unknown> = { 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<number, string[]>();
|
||
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<number, string[]>();
|
||
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<boolean> {
|
||
// URL 토큰만 추출, 나머지는 보조 컨텍스트(우리 채널/콘텐츠 설명).
|
||
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
||
const url = tokens[0] || '';
|
||
const userContent = tokens.slice(1).join(' ');
|
||
if (!url) {
|
||
chunk(view, `사용법: \`/youtube <url> [우리 채널/콘텐츠 설명]\`\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<string>('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<string>('datacollectSavePath', '') || '').trim();
|
||
const body: Record<string, unknown> = { 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<boolean> {
|
||
// 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<boolean> {
|
||
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<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 응답이 비어 있습니다.');
|
||
// 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<string>('datacollectSavePath', '') || '').trim();
|
||
const body: Record<string, unknown> = { 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<boolean> {
|
||
// 토큰을 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 <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`);
|
||
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;
|
||
}
|