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:
g1nation
2026-05-25 09:59:32 +09:00
parent 4153f640c2
commit 0a97324f1b
149 changed files with 14628 additions and 6927 deletions
+35
View File
@@ -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:0002:30]** 도입부에서 다룬 내용 한 문장 요약 (mm:ssmm:ss)
- **[02:3005:00]** 본론 첫 부분… (mm:ssmm: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