From eeb527c242a38c8d2450828410b09ce4c2745569 Mon Sep 17 00:00:00 2001 From: g1nation Date: Wed, 20 May 2026 18:34:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(datacollect):=20/youtube=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8=C2=B7/wikify=20=EC=8B=A0=EA=B7=9C=C2=B7=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20=EC=9C=84=EC=83=9D=20(v2.2.48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- .vscodeignore | 3 + PATCHNOTES.md | 77 ++++ package.json | 6 +- src/config.ts | 2 +- src/features/datacollect/slashRouter.ts | 498 ++++++++++++++++++++++-- src/utils.ts | 2 +- 6 files changed, 554 insertions(+), 34 deletions(-) diff --git a/.vscodeignore b/.vscodeignore index 4639bd4..22e14ba 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -9,3 +9,6 @@ tsconfig.json *.vsix assets/icon.svg preview.html +_*.json +_yt_* +_* diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 156a67c..806a281 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -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 [주제명]`.** 사이트 본문 텍스트를 추출해 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 뒤 자연어는 "우리가 만들 콘텐츠" 컨텍스트로 분석에 반영된다. +- **신규 패키징:** `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 범위로 클램프. diff --git a/package.json b/package.json index 9335338..c6de19c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/config.ts b/src/config.ts index f7d82ac..a9a2f3a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -235,7 +235,7 @@ export function getConfig(): IAgentConfig { return v === 'off' || v === 'strict' ? v : 'smart'; })(), companyIntentAlignmentMaxRounds: Math.max(1, Math.min(5, cfg.get('company.intentAlignmentMaxRounds', 3))), - selfReflectorEnabled: cfg.get('selfReflector.enabled', true), + selfReflectorEnabled: cfg.get('selfReflector.enabled', false), selfReflectorExternalEnabled: cfg.get('selfReflector.externalVerification', false), selfReflectorExecutionEnabled: cfg.get('selfReflector.executionVerification', false), companyPixelOfficeEnabled: cfg.get('company.pixelOffice.enabled', true), diff --git a/src/features/datacollect/slashRouter.ts b/src/features/datacollect/slashRouter.ts index a9181ed..3f20e07 100644 --- a/src/features/datacollect/slashRouter.ts +++ b/src/features/datacollect/slashRouter.ts @@ -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 { +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({ @@ -424,7 +432,7 @@ async function callLmSynthesis(prompt: string): Promise { 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 { ?? 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 { @@ -580,41 +592,276 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise { +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`); + chunk(view, `사용법: \`/youtube [우리 채널/콘텐츠 설명]\`\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 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`); + 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; } - 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`); + + 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`); } } - - 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 { + 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; +} diff --git a/src/utils.ts b/src/utils.ts index b09234e..a420517 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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 {