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:
@@ -9,3 +9,6 @@ tsconfig.json
|
||||
*.vsix
|
||||
assets/icon.svg
|
||||
preview.html
|
||||
_*.json
|
||||
_yt_*
|
||||
_*
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user