feat: v2.2.92 → v2.2.158 — god-file 분해 + Stocks feature + 대화 연속성
R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules) · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등) · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks) · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등) R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리) Stocks feature 신규: /stocks slash command (v2.2.152~158) · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신 · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR) · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴 · LLM Top 5 매력도 분석 + Telegram 자동 보고서 · KST 09:00/15:00 watcher 자동 모니터링 대화 연속성 (v2.2.150~157): · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor · thin follow-up 분류 → boilerplate 헤더 suppression · slash 명령 결과 chatHistory mirror (capture wrapper) · echo/parrot 금지 system prompt rule 기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,41 @@ export function getBridgeBaseUrl(): string {
|
||||
return url.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Datacollect Bridge API endpoints — 한 곳에서 관리.
|
||||
*
|
||||
* 이전엔 슬래시 명령마다 endpoint 문자열이 hardcoded 였음 → bridge API 버전이
|
||||
* 바뀌면 5+ 곳을 동시 수정. 이 객체로 모아 한 곳만 바꾸면 됨.
|
||||
*
|
||||
* 카테고리:
|
||||
* - research: NotebookLM Deep Research 워크플로
|
||||
* - youtube: yt-dlp + youtube-transcript-api 기반 영상 분석
|
||||
* - web: Playwright 기반 웹 페이지 추출·벤치마크
|
||||
* - wiki: 생성된 위키 문서 디스크 저장
|
||||
* - lm: 사용자의 LM Studio / Ollama 로 LLM 호출 프록시
|
||||
*/
|
||||
export const BRIDGE_API = {
|
||||
research: {
|
||||
start: '/api/research/start',
|
||||
status: '/api/research/status',
|
||||
import: '/api/research/import',
|
||||
synthesize: '/api/research/synthesize',
|
||||
},
|
||||
youtube: {
|
||||
extract: '/api/youtube/extract',
|
||||
},
|
||||
web: {
|
||||
benchmarkScan: '/api/web-benchmark/scan',
|
||||
extract: '/api/web-extract',
|
||||
},
|
||||
wiki: {
|
||||
save: '/api/wiki/save',
|
||||
},
|
||||
lm: {
|
||||
proxy: '/api/lm',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface BridgeFetchOptions {
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 회의 녹취 텍스트 → 사실 기반 구조화 회의록(Actionable Minutes) LLM 프롬프트.
|
||||
* 사용자 정의 규칙: Fact/Discussion/Decision/Risk/Action 분류, 메타데이터 우선.
|
||||
*/
|
||||
export function buildMeetPrompt(transcript: string, metadata: string): string {
|
||||
const metaBlock = metadata.trim()
|
||||
|| '(메타데이터 미입력 — 녹취록 내용에서 추론하거나 "확인 불가"로 표기)';
|
||||
return `# 임무 (Objective)
|
||||
제공된 회의 녹취 텍스트와 메타데이터를 기반으로, 외부 지식 없이 사실 기반의
|
||||
구조화된 회의록(Actionable Minutes)을 생성한다.
|
||||
|
||||
# 역할 (Role)
|
||||
- Fact Extractor: 사실만 추출
|
||||
- Decision Tracker: 결정 여부 구분
|
||||
- Action Organizer: 실행 항목 구조화
|
||||
- Context Filter: 불필요한 발언(잡담) 제거
|
||||
|
||||
# 데이터 우선순위 (Data Priority)
|
||||
1순위: 메타데이터 / 2순위: 녹취록 내용. 충돌 시 반드시 메타데이터를 사용한다.
|
||||
|
||||
# 처리 절차 (Processing Flow)
|
||||
1. Deconstruction — 잡담 제거, 의미 단위로 분해, 발언자 ID는 무시한다.
|
||||
2. Classification — 모든 내용을 Fact / Discussion / Decision / Risk / Action 으로 분류한다.
|
||||
3. Decision Logic
|
||||
- 명확한 합의 표현 → Decision
|
||||
- 실행 주체 + 행동 → Action
|
||||
- 제안/의견 → Discussion
|
||||
- 조건 부족 → Open Issue
|
||||
4. Structuring — 중복 제거, 핵심만 유지, 짧고 명확한 문장을 사용한다.
|
||||
|
||||
# 출력 검증 (Validation)
|
||||
출력 전 내부적으로 점검한다: Decision은 실제 합의인가 / Action은 실행 가능한가.
|
||||
단, 검증 과정·체크 로그는 출력하지 말 것. 최종 회의록만 출력한다.
|
||||
|
||||
[메타데이터]
|
||||
${metaBlock}
|
||||
|
||||
[회의 녹취록]
|
||||
\`\`\`
|
||||
${transcript}
|
||||
\`\`\`
|
||||
|
||||
# 출력 형식 (Output Format — 정확히 이 구조를 유지)
|
||||
|
||||
# [회의 제목]
|
||||
|
||||
- **날짜**: [YYYY년 MM월 DD일 | 확인 불가]
|
||||
- **참석자**: [메타데이터 기준 | 없을 경우: 논의 참여 주체]
|
||||
- **주제 요약**: [한 문장 요약]
|
||||
|
||||
## 🔹 요약 보고
|
||||
핵심 논의 요약 3~5개를 글머리표로 작성.
|
||||
|
||||
## 1. 주요 논의 사항
|
||||
각 안건마다 아래 구조로:
|
||||
### [안건 제목]
|
||||
- **현황**:
|
||||
- **핵심 논의**:
|
||||
- **결론**: [결정됨 / 논의 중 / 보류]
|
||||
|
||||
## 2. 리스크 및 이슈
|
||||
|
||||
## 3. 결정 사항
|
||||
|
||||
## 4. 오픈 이슈
|
||||
|
||||
## 5. 액션 아이템
|
||||
| 담당 | 작업 내용 | 기한 |
|
||||
| --- | --- | --- |
|
||||
|
||||
위 형식을 정확히 따르고, 모든 내용은 한국어로 작성하시오.`;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/** /benchmark 보고서의 3 파트 분할 — 1: 4-렌즈 / 2: IA + 토큰 / 3: 재구축 명세. */
|
||||
export type SynthesisPart = 1 | 2 | 3;
|
||||
|
||||
/**
|
||||
* scan JSON → 4-렌즈 분석 LLM 프롬프트. Datacollect 웹앱(WebBenchmarkPanel)의
|
||||
* buildSynthesisPrompt를 그대로 이식 — /benchmark 결과가 웹앱과 동등하게 나오도록.
|
||||
*/
|
||||
export 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}`;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 추출된 웹사이트 본문 → Datacollect Research(P-Reinforce v3.0)와 동일한 위키 문서
|
||||
* 프롬프트. Bridge의 /api/research/synthesize 템플릿을 웹 본문 소스용으로 이식.
|
||||
*/
|
||||
export 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} 본문에서 초안 생성.`;
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* `/youtube` slash command 의 LLM 입력 빌더 + 자막 변환 헬퍼.
|
||||
* - formatHms / fullScriptFromSegments / bucketSegments — segment list 가공
|
||||
* - YoutubeAnalysisMode — info/benchmark/both 라우팅 enum (slashRouter 가 사용)
|
||||
* - buildInfoExtractionPrompt — *영상 내용(지식)* 카드 추출 프롬프트
|
||||
* - build4LensPrompt — 영상 *제작 기법* (훅/구조/제작/CTR) 4-렌즈 분석 프롬프트
|
||||
*
|
||||
* 옛 코드: slashRouter.ts 의 320줄짜리 inline 블록. 분리해 (a) 두 프롬프트가 같은
|
||||
* segment 변환 helper 를 자연스럽게 공유, (b) 새 모드 추가 시 한 파일만 수정,
|
||||
* (c) 단위 테스트로 prompt 회귀 확인 가능.
|
||||
*/
|
||||
|
||||
export 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가 잘게 끊겨 그대로 나열하면 가독성이 나쁘므로 묶는다.
|
||||
*/
|
||||
export 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 토큰을 아낀다.
|
||||
*/
|
||||
export 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),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Astra `/youtube` 의 분석 모드. 사용자 입력 `mode:info|benchmark|both`. */
|
||||
export type YoutubeAnalysisMode = 'info' | 'benchmark' | 'both';
|
||||
|
||||
/**
|
||||
* 정보 추출(info) 모드 LLM 프롬프트 — 영상의 *내용·지식* 자체를 다룬다.
|
||||
*
|
||||
* 의도: build4LensPrompt 가 "이 영상을 어떻게 베껴 만들지" 의 벤치마킹 톤이라
|
||||
* 튜토리얼·강의·뉴스·인터뷰·리뷰 같은 정보형 영상에서는 가치가 낮다. 이 함수는
|
||||
* 정반대 방향 — 영상이 *말한 것* 을 사실·주장·근거 단위로 추출해서, 사용자가
|
||||
* 영상을 안 다시 봐도 의사결정·학습·인용에 바로 쓸 수 있는 지식 카드로 정리한다.
|
||||
*
|
||||
* 출력 규칙은 build4LensPrompt 와 일관 (마크다운, 한국어, 자막에 있는 것만 인용).
|
||||
*/
|
||||
export function buildInfoExtractionPrompt(video: any, userContent: string): string {
|
||||
const meta = video.metadata || {};
|
||||
const segments = video.segments || [];
|
||||
|
||||
// 자막 본문 — info 모드는 *전체* 본문을 보여줘야 사실 추출이 정확. 단,
|
||||
// LLM 컨텍스트 한도 고려해 너무 길면 trim. 12000자 = 가벼운 강의 60분 분량 정도.
|
||||
const fullText = segments.map((s: any) => String(s.text || '').trim()).join(' ').replace(/\s+/g, ' ');
|
||||
const trimmed = fullText.length > 12000 ? fullText.slice(0, 12000) + ' …[자막 일부 잘림]' : fullText;
|
||||
|
||||
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,
|
||||
uploadDate: meta.upload_date,
|
||||
viewCount: meta.view_count,
|
||||
likeCount: meta.like_count,
|
||||
tags: (meta.tags || []).slice(0, 8),
|
||||
categories: meta.categories,
|
||||
chapters: meta.chapters,
|
||||
descriptionPreview: (meta.description || '').slice(0, 600),
|
||||
};
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const userBlock = userContent.trim()
|
||||
? `\n\n[사용자 컨텍스트 — 사용자가 어떤 관점에서 이 영상을 활용하려는지]\n${userContent.trim()}`
|
||||
: '';
|
||||
|
||||
return `당신은 영상 콘텐츠를 *지식 카드*로 변환하는 정보 큐레이터입니다. 사장님이
|
||||
이 영상을 다시 보지 않고도 핵심 정보를 그대로 활용할 수 있도록, 영상이 *말한 것*
|
||||
(주장·사실·근거·결론)을 구조화해서 정리하세요.
|
||||
|
||||
[분석 원칙 — 모두 반드시 준수]
|
||||
1. **출처 분리** — 영상 본문(자막)에 *명시된 것* 만 핵심 섹션에 넣음. 정리자의 추론·외부
|
||||
지식·자기 해석은 별도 \`## 🧩 정리자 노트\` 섹션에만. 두 줄 섞지 말 것.
|
||||
2. **빈 곳 채우지 말 것** — 자막에 없는 사실은 "본문에 명시되지 않음" 또는 "해당 사례 없음".
|
||||
3. **신뢰도 라벨 필수** — 모든 핵심 주장 앞에 다음 중 하나:
|
||||
- \`[근거 명시]\` 구체 출처·수치·인용이 본문에 있음
|
||||
- \`[화자 주장]\` 출처 없는 단정 (디노가 그렇게 말함)
|
||||
- \`[가정]\` 조건부·"~인 것 같다" 표현
|
||||
- \`[정리자 추론]\` 본문에 없지만 정리자가 추가 (이건 정리자 노트 섹션 전용)
|
||||
4. **타임스탬프 필수** — 본문 인용·구간 요약·발언 따옴표는 끝에 \`(mm:ss)\` 무조건 붙임.
|
||||
이걸 빠뜨리면 fail. "(시점 미상)" 도 허용 안 함 — 모르면 인용 자체 빼기.
|
||||
5. **화자 한 줄 비유 보존 + 방향 보존** — 영상에 비유·은유·"X 는 Y 같은 것" 식 압축 표현이
|
||||
있으면 반드시 별도 섹션 \`## 💡 화자 한 줄 비유\` 에 보존. 영상의 결정적 요약이 거기
|
||||
들어 있을 가능성 큼. 없으면 "본문에 명시된 한 줄 비유 없음" 명시.
|
||||
⚠️ **비유는 방향이 뒤집히기 쉬움** — 화자가 "Hugging Face = 자료실, Reddit = 공부방"
|
||||
이라 했으면 정확히 그 짝(어느 쪽이 자료실이고 어느 쪽이 공부방인지)을 그대로 따옴표
|
||||
인용으로 보존. 정리자가 단어 위치를 바꾸거나 뜻을 의역하면 안 됨. 고유명사·수치·
|
||||
대응 관계도 마찬가지 — 본문 그대로.
|
||||
6. **순서·단계 발명 금지** — 화자가 "A → B → C 순서로" 라고 명시하지 *않았으면* "단계적
|
||||
학습 순서" 같은 흐름을 정리자가 만들지 말 것. 굳이 필요하면 정리자 노트로.
|
||||
7. 한국어 마크다운. 표·불릿 자유롭게.
|
||||
|
||||
[영상 메타데이터]
|
||||
\`\`\`json
|
||||
${JSON.stringify(slim, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
[자막 본문]
|
||||
${trimmed}${userBlock}
|
||||
|
||||
[필수 출력 형식 — 정확히 이 구조. 아래 8개 섹션 외 추가 금지]
|
||||
|
||||
# ${slim.title || video.title} — 정보 추출 카드
|
||||
|
||||
> **영상 URL**: ${slim.url} · **분석 일자**: ${today} · **길이**: ${slim.durationHms || (slim.durationSec ? formatHms(slim.durationSec) : '?')} · **채널**: ${slim.channel || '?'}
|
||||
|
||||
## 🎯 한 줄 요약 (TL;DR)
|
||||
(영상의 핵심 메시지 한 문장. "무엇이 누구에게 왜 중요한가" 를 압축. 제목 그대로 베끼지
|
||||
말고 본문 기준으로 다시 쓸 것. 정리자의 해석은 금지 — 화자의 말 그대로 압축)
|
||||
|
||||
## 💡 화자 한 줄 비유 (Anchor Metaphor)
|
||||
영상에서 화자가 *전체 메시지를 한 줄로 압축한 비유·은유* 가 있으면 그대로 따옴표로
|
||||
보존. 영상 마무리부에 자주 등장. 예: "Hugging Face = 자료실, Reddit = 공부방,
|
||||
유튜브 = 복습실" 같은 식. 없으면 "본문에 명시된 한 줄 비유 없음".
|
||||
|
||||
## 📌 핵심 주장 3~5개
|
||||
영상이 *명시한* 주요 결론·주장만. 정리자 추론은 여기 들어오면 안 됨 (그건 🧩 섹션).
|
||||
각 항목 한 줄 + 신뢰도 라벨 + 본문 인용 (mm:ss).
|
||||
- **[근거 명시]** "주장 한 줄" — 본문 인용 (mm:ss)
|
||||
- **[화자 주장]** "주장 한 줄" — 본문 인용 (mm:ss)
|
||||
- …
|
||||
|
||||
## 📊 사실·데이터·인용
|
||||
영상에 등장한 *구체적 수치·날짜·출처·고유명사·전문 용어 정의*. 가공 없이 그대로.
|
||||
표로 정리:
|
||||
|
||||
| 항목 | 값 / 정의 | 출처 (영상 내) | 타임스탬프 |
|
||||
| --- | --- | --- | --- |
|
||||
| … | … | 화자/자료 화면/외부 출처 | mm:ss |
|
||||
|
||||
데이터가 없는 영상이면 "본문에 명시된 구체 수치·출처 없음" 한 줄.
|
||||
|
||||
## 🧭 구조 요약 (Sectioned Summary)
|
||||
영상을 chapters (메타데이터에 있으면 그것 사용) 또는 30초 버킷으로 구간 나눠 각 구간의
|
||||
*내용 요약*. 1~2문장씩. 각 항목 끝에 타임스탬프 범위 필수.
|
||||
- **[00:00–02:30]** 도입부에서 다룬 내용 한 문장 요약 (mm:ss–mm:ss)
|
||||
- **[02:30–05:00]** 본론 첫 부분… (mm:ss–mm:ss)
|
||||
- …
|
||||
|
||||
## 🔗 인용용 한 줄 카드 (Citation Snippets)
|
||||
영상의 *결정적 발언* 을 그대로 따옴표로 보존. 사장님이 글·발표·메모에 인용할 때 복붙용.
|
||||
3~5개. 길이는 한 문장. 타임스탬프 필수.
|
||||
- "직접 인용 한 문장" — ${slim.title || video.title}, ${slim.channel || '?'} (mm:ss)
|
||||
- …
|
||||
|
||||
## ❓ 더 파고들 질문 (Open Questions)
|
||||
영상이 답하지 않았거나 추가 검증 필요한 사항 2~4개. 사장님이 다음 자료를 찾을 때
|
||||
바로 검색어로 쓸 수 있게 구체적으로.
|
||||
- "본문에서 X 가 Y 라고 했지만 Z 데이터 출처는 명시 안 됨 — 원 데이터 찾아볼 것"
|
||||
- …
|
||||
|
||||
## 🧩 정리자 노트 (원본 보강) — 선택
|
||||
*본문에 없지만* 정리자가 추가로 짚고 싶은 맥락·해석·연결·경고. 위 6개 핵심 섹션과
|
||||
구조적으로 격리되어, 독자가 "이건 화자가 말한 게 아니라 LLM 이 추론한 거" 라고
|
||||
명확히 인지하도록. 모든 항목은 \`[정리자 추론]\` 라벨로 시작.
|
||||
- **[정리자 추론]** 화자가 "여러 채널을 동시 시청" 하라 했지만, 입문자 페이스를 고려하면
|
||||
먼저 한 채널을 깊게 따라가는 것도 한 가지 시작점이 될 수 있음.
|
||||
- …
|
||||
|
||||
특별히 보강할 게 없으면 이 섹션 통째로 "정리자 추가 노트 없음 — 본문 그대로가 명확함" 한 줄.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* extract된 영상 → 유튜브 4-렌즈(훅/구조/제작/CTR) 분석 LLM 프롬프트.
|
||||
* Datacollect 웹앱(YoutubePanel)의 build4LensPrompt를 그대로 이식.
|
||||
*/
|
||||
export 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]
|
||||
…
|
||||
\`\`\`
|
||||
|
||||
> ⚠️ 본 분석은 스크립트의 언어·구조 패턴 학습용입니다. 대사·자료는 직접 창작/라이선스 확보.`;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* `/meet` 슬래시 명령의 후처리 — 회의록에서 action items 를 뽑아 캘린더 task 일정을
|
||||
* 계산하는 stateless helpers. slashRouter 의 inline 블록을 분리.
|
||||
*
|
||||
* - addBusinessDays(base, n) — 토·일 제외 영업일 n 일 후 날짜
|
||||
* - toYmd(d) — Date → 'YYYY-MM-DD'
|
||||
* - extractMeetingDate(report, fallback) — 회의록에서 회의 일자 추출 (없으면 fallback)
|
||||
* - resolveTaskDate(due, meetingDate, today) — 'D+3' / 'EOW' 같은 due 문구를 절대 날짜로 변환
|
||||
* - parseActionItems(report) — 회의록 마크다운 표에서 action items 파싱
|
||||
*/
|
||||
|
||||
// ─── /meet 캘린더 등록 헬퍼 ───
|
||||
|
||||
/** 토·일을 제외하고 영업일 n일을 더한 날짜 (공휴일은 고려하지 않음). */
|
||||
export function addBusinessDays(base: Date, n: number): Date {
|
||||
const r = new Date(base);
|
||||
let added = 0;
|
||||
while (added < n) {
|
||||
r.setDate(r.getDate() + 1);
|
||||
const day = r.getDay();
|
||||
if (day !== 0 && day !== 6) added++;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Date → 'YYYY-MM-DD' (로컬 기준). */
|
||||
export function toYmd(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** 회의록 본문의 "**날짜**: 2026년 05월 08일"에서 회의 날짜 추출. 없으면 fallback. */
|
||||
export function extractMeetingDate(report: string, fallback: Date): Date {
|
||||
const m = report.match(/날짜\*{0,2}\s*[::]\s*\*{0,2}\s*(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/);
|
||||
if (m) {
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 아이템 '기한' 텍스트 → 캘린더 등록 날짜. 사용자 정의 규칙:
|
||||
* - 명시 날짜(YYYY-MM-DD / YYYY년 M월 D일) → 그 날짜
|
||||
* - "차주 / 다음 주 / 내주" → 회의일 +6일
|
||||
* - "즉시 / 당일 / 금일 / 바로 / 오늘" → 등록일(오늘)
|
||||
* - 변환 불가 / 빈 값 → 등록일 +영업일 5일, tentative=true (제목에 "(미확정)")
|
||||
*/
|
||||
export function resolveTaskDate(due: string, meetingDate: Date, today: Date): { date: string; tentative: boolean } {
|
||||
const t = (due || '').trim();
|
||||
const iso = t.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
|
||||
if (iso) {
|
||||
return { date: `${iso[1]}-${iso[2].padStart(2, '0')}-${iso[3].padStart(2, '0')}`, tentative: false };
|
||||
}
|
||||
const kor = t.match(/(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/);
|
||||
if (kor) {
|
||||
return { date: toYmd(new Date(Number(kor[1]), Number(kor[2]) - 1, Number(kor[3]))), tentative: false };
|
||||
}
|
||||
if (/차주|다음\s*주|내주/.test(t)) {
|
||||
const d = new Date(meetingDate);
|
||||
d.setDate(d.getDate() + 6);
|
||||
return { date: toYmd(d), tentative: false };
|
||||
}
|
||||
if (/즉시|당일|금일|바로|오늘/.test(t)) {
|
||||
return { date: toYmd(today), tentative: false };
|
||||
}
|
||||
// 변환 불가 — 등록일 + 영업일 5일, "(미확정)" 꼬리표.
|
||||
return { date: toYmd(addBusinessDays(today, 5)), tentative: true };
|
||||
}
|
||||
|
||||
/** 회의록 본문의 "## 5. 액션 아이템" 마크다운 표에서 행을 파싱. */
|
||||
export function parseActionItems(report: string): { owner: string; work: string; due: string }[] {
|
||||
const rows: { owner: string; work: string; due: string }[] = [];
|
||||
let inSection = false;
|
||||
for (const line of report.split('\n')) {
|
||||
if (/^#{1,6}\s*5\.\s*액션\s*아이템/.test(line)) { inSection = true; continue; }
|
||||
if (!inSection) continue;
|
||||
if (/^#{1,6}\s/.test(line)) break; // 다음 섹션 시작 → 종료
|
||||
if (!/^\s*\|/.test(line)) continue;
|
||||
const cells = line.split('|').slice(1, -1).map((c) => c.trim());
|
||||
if (cells.length < 3) continue;
|
||||
if (/^:?-+:?$/.test(cells[0])) continue; // 표 구분선
|
||||
if (cells[0] === '담당' || cells[1] === '작업 내용') continue; // 헤더
|
||||
rows.push({ owner: cells[0], work: cells[1], due: cells[2] });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user