feat(datacollect): /youtube 개편·/wikify 신규·출력 위생 (v2.2.48)

- /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>
This commit is contained in:
2026-05-20 18:34:07 +09:00
parent 9e7c7fe605
commit eeb527c242
6 changed files with 554 additions and 34 deletions
+3
View File
@@ -9,3 +9,6 @@ tsconfig.json
*.vsix
assets/icon.svg
preview.html
_*.json
_yt_*
_*
+77
View File
@@ -1,5 +1,82 @@
# Astra Patch Notes
## v2.2.48 (2026-05-20)
### 🧹 출력 품질 — 내부 체크 로그 차단 + 한영 토큰 깨짐 정제
- **`[Self-Reflector Check]` 내부 검증 로그 노출 차단.** Self-Reflector Phase A를 기본 비활성화(`g1nation.selfReflector.enabled` 기본값 `true``false`). 답변 끝에 `Consistency/Completeness/Accuracy` 내부 체크 블록이 더 이상 붙지 않는다 — 일반 채팅·회사 모드 모두 적용. 기능 자체는 남아 설정에서 켤 수 있음.
- **한영 토큰 깨짐 정제.** 슬래시 합성(`callLmSynthesis`)·일반 채팅(`getSystemPrompt`) 시스템 프롬프트에 출력 위생 규칙 추가 — 한 단어 안에 한글·영문 알파벳 혼용 금지(`결ently`·`인orp` 같은 깨진 합성 표기 차단), 외래어는 완전 한글 또는 완전 영문으로 일관되게.
- **안전망.** 슬래시 합성 결과에 내부 검증 로그가 새어 나오면 후처리 정규식으로 자동 제거.
- **신규 패키징:** `astra-2.2.48.vsix`.
---
## v2.2.47 (2026-05-20)
### 🔗 /wikify 다중 링크 배치 처리
- `/wikify`**여러 링크를 공백으로 구분해 한 번에** 넣으면 1개씩 순차 위키화한다. 예: `/wikify url1 url2 … url10` → 10개 위키 문서 생성.
- 진행 표시 `[i/N]`, 한 건이 실패해도 나머지는 계속 진행, 완료 시 `N/M개 성공` 요약.
- URL과 주제명 자동 분류 — URL 패턴 토큰은 모두 처리 대상, 비-URL 토큰은 공통 주제명으로.
- 단일 링크 입력은 기존과 동일하게 동작.
- **신규 패키징:** `astra-2.2.47.vsix`.
---
## v2.2.46 (2026-05-20)
### 🔧 /wikify 정확도 개선 — 명세 문서 완전성 + 위키링크 교정
- **명세/스키마 문서 완전성 강화.** `buildWikifyPrompt`에 규칙 추가 — 원문이 JSON Schema·API 명세·기술 스펙이면 `📖 세부 내용`에 모든 필드·속성을 누락 없이 마크다운 표(`[필드|타입|필수/선택|제약]`)로 정리하고, 원문 `required` 배열을 임의 변경 금지, `additionalProperties`·`enum`·중첩 구조도 원문 그대로 반영. (이전엔 LLM이 `extra`·`models` 등 최상위 필드를 누락하던 문제 — 추출은 정확했으나 합성 단계 손실)
- **위키링크 `[[ ]]` 자동 교정.** LLM이 닫는 대괄호를 하나 빠뜨리는 깨짐(`[[rfcs repo]`)을 후처리 정규식으로 자동 보정.
- **신규 패키징:** `astra-2.2.46.vsix`.
---
## v2.2.45 (2026-05-20)
### 📚 신규 /wikify — 웹사이트 본문을 P-Reinforce v3.0 위키 문서로
- **신규 슬래시 명령 `/wikify <url> [주제명]`.** 사이트 본문 텍스트를 추출해 Datacollect Research(`/research`)와 동일한 **P-Reinforce v3.0 규격 위키 문서**로 LLM 합성·저장한다 — YAML frontmatter + `🎯 한 줄 통찰 / 🧠 핵심 개념 / 🧩 추출된 패턴 / 📖 세부 내용 / ⚖️ 모순 / 🛠️ 적용 사례 / ✅ 검증 상태 / 🔗 관련 문서 링크([[위키링크]]) / 📝 변경 이력`.
- **Bridge에 본문 추출 엔드포인트 `/api/web-extract` 신규** — Playwright readability 방식으로 `main`/`article` 본문 텍스트만 추출(nav·header·footer 등 노이즈 제거), 본문 32000자 상한.
- `/benchmark`(디자인 벤치마킹)와 달리 `/wikify`는 사이트 **콘텐츠를 지식 문서화**한다. 결과물은 `datacollectSavePath`(또는 Bridge `WIKI_RAW_PATH`)에 저장.
- **신규 패키징:** `astra-2.2.45.vsix`.
---
## v2.2.44 (2026-05-20)
### 📜 /youtube — 보고서 앞에 영상 전체 스크립트 출력
- `/youtube` 결과물 맨 앞에 영상 전체 자막(Full Script) 섹션을 추가. 30초 버킷으로 묶어 `[mm:ss] 문장…` 형태로 정리 — 잘게 끊긴 자동자막을 가독성 있게 합쳐, 분석 보고서와 원문 대본을 한 문서에서 함께 본다.
- 화면 출력·저장 markdown 양쪽 모두 `📜 전체 스크립트``---``대본 역기획서` 순서로 적용.
- **신규 패키징:** `astra-2.2.44.vsix`.
---
## v2.2.43 (2026-05-20)
### ✍️ /youtube 출력 포맷 개편 — "대본 역기획서"
- `/youtube` 분석 리포트를 영상 제작 가이드(4-렌즈)에서 **대본(스크립트) 역기획서**로 전면 개편. BGM·자막·컷 전환 등 영상 연출 항목을 걷어내고 스크립트(텍스트)·언어 구조에만 집중한다.
- 새 레이아웃 5종: 🎬 한 줄 인상 / 1. 스크립트 뼈대 구조도(표) / 2. 말의 맛 & 톤앤매너 / 3. 내 대본에 바로 쓰는 액션 체크리스트 / ✂️ 빈칸 채우기식 대본 템플릿.
- 언어적 장치를 고정 태그 어휘(`#FOMO #권위부여 #호기심갭 #브릿지멘트` 등)로 라벨링. '전문용어 → 쉬운 비유' 분석 항목 신설 — 화자의 구어체 '말의 맛'을 명시적으로 추출한다.
- **신규 패키징:** `astra-2.2.43.vsix`.
---
## v2.2.42 (2026-05-20)
### 🎬 /youtube — Datacollect youtube insight 4-렌즈 분석 이식
- **`/youtube`가 이제 LLM 4-렌즈 콘텐츠 제작 가이드를 생성한다.** 그동안 transcript/메타데이터 덤프만 했으나, 이제 Datacollect 웹앱(YoutubePanel)의 `build4LensPrompt`를 그대로 이식 — 10초 훅 / 스크립트 구조(기승전결 타임라인) / 제작 리소스·편집 스타일 / 썸네일·제목 CTR 4-렌즈 분석 + 역기획서 + 대본 템플릿을 생성한다.
- **extract 필드 버그 수정.** Bridge `/api/youtube/extract``source` 필드를 요구하는데 ASTRA가 `url`을 보내 "source URL이 필요합니다" 에러가 나던 문제. 이제 `{ source, withMetadata, limit }` 로 올바르게 호출한다.
- **결과물 자동 저장.** 분석 markdown을 `/benchmark`와 동일하게 raw 폴더(`datacollectSavePath` > Bridge `WIKI_RAW_PATH`)에 저장.
- **명령어 보조 컨텍스트.** `/youtube <url> <우리 채널 설명>` 형태로 URL 뒤 자연어는 "우리가 만들 콘텐츠" 컨텍스트로 분석에 반영된다.
- **신규 패키징:** `astra-2.2.42.vsix`.
---
## v2.2.41 (2026-05-20)
### 🎛️ /benchmark 합성 Temperature 설정 추가
- **`g1nation.datacollectSynthesisTemperature` 신설** (기본 0.1). `/benchmark` LLM 4-렌즈 합성의 temperature를 Astra Settings 패널 'Datacollect' 섹션에서 조절 가능 — 그동안 코드에 `0.3`으로 하드코딩돼 있었다. 낮출수록(0.1) 한국어 생성 중 섞이는 깨진 문자·환각이 줄고 결과가 결정적이다. 0~2 범위로 클램프.
+3 -3
View File
@@ -2,7 +2,7 @@
"name": "astra",
"displayName": "Astra",
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
"version": "2.2.41",
"version": "2.2.48",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
@@ -503,8 +503,8 @@
},
"g1nation.selfReflector.enabled": {
"type": "boolean",
"default": true,
"description": "Self-Reflector Phase A — append a [Self-Reflector Check] block at the end of every substantive LLM answer (Consistency / Completeness / Accuracy, plus References / Paths for code answers). Zero extra LLM calls — the rule lives in the system prompt and the model self-imposes the checklist. Turns response quality up by making the verification step explicit. Disable for purely casual / chat-only usage."
"default": false,
"description": "Self-Reflector Phase A — append a [Self-Reflector Check] block at the end of every substantive LLM answer (Consistency / Completeness / Accuracy, plus References / Paths for code answers). Zero extra LLM calls — the rule lives in the system prompt and the model self-imposes the checklist. OFF by default: the check block is an internal verification log that leaks into the user-facing answer and reads as unpolished. Enable only if you want that transparency block visible."
},
"g1nation.selfReflector.externalVerification": {
"type": "boolean",
+1 -1
View File
@@ -235,7 +235,7 @@ export function getConfig(): IAgentConfig {
return v === 'off' || v === 'strict' ? v : 'smart';
})(),
companyIntentAlignmentMaxRounds: Math.max(1, Math.min(5, cfg.get<number>('company.intentAlignmentMaxRounds', 3))),
selfReflectorEnabled: cfg.get<boolean>('selfReflector.enabled', true),
selfReflectorEnabled: cfg.get<boolean>('selfReflector.enabled', false),
selfReflectorExternalEnabled: cfg.get<boolean>('selfReflector.externalVerification', false),
selfReflectorExecutionEnabled: cfg.get<boolean>('selfReflector.executionVerification', false),
companyPixelOfficeEnabled: cfg.get<boolean>('company.pixelOffice.enabled', true),
+470 -30
View File
@@ -16,7 +16,7 @@ import { bridgeFetch, getBridgeBaseUrl } from './bridgeClient';
* 명령이 처리되면 true 반환 → chatHandlers가 일반 LLM 흐름으로 안 내려가게.
*/
const COMMANDS = ['/research', '/benchmark', '/youtube', '/blog'] as const;
const COMMANDS = ['/research', '/benchmark', '/youtube', '/blog', '/wikify'] as const;
type SlashCommand = typeof COMMANDS[number];
export function isSlashCommand(input: string): boolean {
@@ -72,6 +72,7 @@ export async function handleSlashCommand(
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) {
@@ -410,13 +411,20 @@ ${partTemplate}`;
* Bridge `/api/lm` 프록시로 OpenAI 호환 chat completion 1회 호출.
* LLM 서버/모델은 Astra 설정(g1nation.ollamaUrl / defaultModel)을 사용한다.
*/
async function callLmSynthesis(prompt: string): Promise<string> {
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({
@@ -424,7 +432,7 @@ async function callLmSynthesis(prompt: string): Promise<string> {
payload: {
model,
messages: [
{ role: 'system', content: '당신은 시니어 UX/UI 분석가이자 프론트엔드 아키텍트다. 모든 보고서는 한국어로, 제공된 JSON 스캔 데이터의 구체적 수치를 인용해 작성한다. 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 명세를 작성하는 것이 미션이며, 다른 서비스로 재해석·확장하지 않는다.' },
{ role: 'system', content: sys },
{ role: 'user', content: prompt },
],
temperature,
@@ -436,7 +444,11 @@ async function callLmSynthesis(prompt: string): Promise<string> {
?? res?.answer
?? res?.response
?? '';
return String(content).trim();
// 안전망 — 모델이 그래도 내부 검증 로그를 붙이면 잘라낸다. [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> {
@@ -580,41 +592,276 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise<boo
// ───────────────────────────── /youtube ─────────────────────────────
async function runYoutube(url: string, view: Webview | undefined): Promise<boolean> {
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`);
chunk(view, `사용법: \`/youtube <url> [우리 채널/콘텐츠 설명]\`\n예: \`/youtube https://youtu.be/xxxx\`\n`);
return true;
}
chunk(view, `🎬 **YouTube 추출**: ${url}\n(transcript + metadata)\n\n`);
const data = await bridgeFetch<{ success: boolean; metadata?: any; segments?: any[]; plainTranscript?: string; outputDir?: string }>(
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({ url }) },
{ 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 },
);
const m = data.metadata || {};
chunk(view, `### 메타데이터\n`);
chunk(view, `- **title**: ${m.title || '(없음)'}\n`);
chunk(view, `- **channel**: ${m.channel || m.uploader || '(없음)'}\n`);
chunk(view, `- **duration**: ${m.duration || '(없음)'}\n`);
chunk(view, `- **views**: ${m.view_count ?? '(없음)'}\n`);
chunk(view, `- **upload_date**: ${m.upload_date || '(없음)'}\n`);
if (Array.isArray(m.tags) && m.tags.length) {
chunk(view, `- **tags**: ${m.tags.slice(0, 10).join(', ')}\n`);
}
if (Array.isArray(m.chapters) && m.chapters.length) {
chunk(view, `\n### 챕터 (${m.chapters.length})\n`);
for (const c of m.chapters.slice(0, 12)) {
chunk(view, `- ${c.start_time}s — ${c.title}\n`);
chunk(view, `💾 **결과물 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n\n`);
} catch (e: any) {
chunk(view, `⚠️ 결과물 저장 실패: ${e?.message || String(e)}\n\n`);
}
}
const transcript = data.plainTranscript || '';
chunk(view, `\n### Transcript (${transcript.length.toLocaleString()}자, 처음 4000자만 미리보기)\n\n`);
chunk(view, `\`\`\`\n${transcript.slice(0, 4000)}${transcript.length > 4000 ? '\n... (생략)' : ''}\n\`\`\`\n`);
chunk(view, `\n> 💡 Hook/Structure/Production/CTR 4-렌즈 분석을 원하면 위 transcript를 인용해 Astra에 추가 질문하세요.\n`);
return true;
}
@@ -643,3 +890,196 @@ async function runBlog(keyword: string, view: Webview | undefined): Promise<bool
} 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;
}
+1 -1
View File
@@ -340,7 +340,7 @@ export function getSystemPrompt(): string {
const now = new Date();
const dateTimeStr = now.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long', hour: '2-digit', minute: '2-digit' });
const isoDate = now.toISOString().split('T')[0];
const base = `${BASE_SYSTEM_PROMPT}\n\n[CURRENT DATE/TIME]\nToday: ${isoDate} (${dateTimeStr})\nUse this date as the absolute reference for any date-related calculations (e.g., "this week", "today", "yesterday").`;
const base = `${BASE_SYSTEM_PROMPT}\n\n[CURRENT DATE/TIME]\nToday: ${isoDate} (${dateTimeStr})\nUse this date as the absolute reference for any date-related calculations (e.g., "this week", "today", "yesterday").\n\n[출력 위생 규칙 — 반드시 준수]\n- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n- 내부 검증·체크 로그(Consistency/Completeness/Accuracy 등) 블록을 사용자 출력에 포함하지 마시오.`;
// Self-Reflector Phase A — 사용자 설정이 켜져 있으면 답변 끝에 자기검증
// 블록을 강제하는 룰을 prepend. require로 동적 로드해 순환 import 회피.
try {