From 0101e4b0d293b487e43a2ce0f6c6d4ac0980d3e9 Mon Sep 17 00:00:00 2001 From: nad4-su Date: Tue, 21 Apr 2026 19:38:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=85=9C=ED=94=8C=EB=A6=BF(6=EC=A2=85)?= =?UTF-8?q?=20+=20=EA=B0=95=EB=8F=84=20=EC=A1=B0=EC=A0=88(3=EB=8B=A8?= =?UTF-8?q?=EA=B3=84)=20+=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=ED=94=84?= =?UTF-8?q?=EB=A1=AC=ED=94=84=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회의록 외 다양한 용도로 쓸 수 있도록 출력 구조와 요약 깊이를 분리. 템플릿: - 🗂️ 회의록 (표준, 라이브 O) - 🎓 강의·세미나 노트 (상세, 라이브 O) - 🤝 1:1 미팅 (표준, 라이브 O) - 💡 브레인스토밍 (상세, 라이브 O) - 🎤 인터뷰 (표준, 라이브 X — Q&A 포맷) - 📝 원문 정리 (상세 고정, 라이브 X — 요약 없이 문단화만) - ⚙️ 커스텀 (자유 프롬프트) 강도: 간결 / 표준 / 상세 — 템플릿별 기본값 프리셋, 언제든 override. 변경: - src/lib/templates.ts 신규 — 프리셋 + 프롬프트 빌더 - minutes-generator / live-summary 가 buildPrompt 공유 - /api/summarize{-live} 가 template/depth/customPrompt 파라미터 수용 - page.tsx UI: 템플릿 픽커, 강도 라디오, 커스텀 textarea - LiveRecorder 가 설정을 useLiveSummary 로 전달 (ref 기반 최신값) - 단순 변환 모드는 기존 동작 유지 (템플릿 미적용) - 프롬프트 빌더 단위 테스트 19개 (총 57 tests) --- src/__tests__/templates.test.ts | 166 ++++++++++++++ src/app/api/summarize-live/route.ts | 11 +- src/app/api/summarize/route.ts | 8 +- src/app/page.tsx | 175 +++++++++++++-- src/components/recorder/LiveRecorder.tsx | 10 + src/hooks/useLiveSummary.ts | 19 +- src/lib/live-summary.ts | 48 ++--- src/lib/minutes-generator.ts | 40 ++-- src/lib/templates.ts | 262 +++++++++++++++++++++++ 9 files changed, 675 insertions(+), 64 deletions(-) create mode 100644 src/__tests__/templates.test.ts create mode 100644 src/lib/templates.ts diff --git a/src/__tests__/templates.test.ts b/src/__tests__/templates.test.ts new file mode 100644 index 0000000..5303994 --- /dev/null +++ b/src/__tests__/templates.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from 'vitest' +import { + TEMPLATES, + buildPrompt, + resolveDepth, + liveEnabledFor, + depthAdjustableFor, + DEFAULT_TEMPLATE_ID, + DEFAULT_DEPTH, +} from '@/lib/templates' + +describe('TEMPLATES registry', () => { + it('6개의 프리셋 템플릿을 제공한다', () => { + expect(Object.keys(TEMPLATES)).toHaveLength(6) + expect(TEMPLATES).toHaveProperty('meeting') + expect(TEMPLATES).toHaveProperty('lecture') + expect(TEMPLATES).toHaveProperty('one_on_one') + expect(TEMPLATES).toHaveProperty('brainstorm') + expect(TEMPLATES).toHaveProperty('interview') + expect(TEMPLATES).toHaveProperty('raw') + }) + + it('기본 템플릿은 meeting, 기본 강도는 standard', () => { + expect(DEFAULT_TEMPLATE_ID).toBe('meeting') + expect(DEFAULT_DEPTH).toBe('standard') + }) + + it('raw 템플릿은 라이브 요약과 강도 조절이 불가', () => { + expect(TEMPLATES.raw.liveSupported).toBe(false) + expect(TEMPLATES.raw.depthAdjustable).toBe(false) + }) + + it('interview 템플릿은 라이브 요약 불가', () => { + expect(TEMPLATES.interview.liveSupported).toBe(false) + }) + + it('lecture는 detailed, meeting은 standard 기본 강도', () => { + expect(TEMPLATES.lecture.defaultDepth).toBe('detailed') + expect(TEMPLATES.meeting.defaultDepth).toBe('standard') + }) +}) + +describe('buildPrompt', () => { + const transcript = '안녕하세요 테스트입니다' + + it('meeting + standard + non-live 조합의 프롬프트를 생성한다', () => { + const prompt = buildPrompt({ + templateId: 'meeting', + depth: 'standard', + transcript, + }) + expect(prompt).toContain('회의록 작성 전문가') + expect(prompt).toContain('액션 아이템') + expect(prompt).toContain('표준') + expect(prompt).toContain(transcript) + expect(prompt).not.toContain('회의가 아직 진행 중') + }) + + it('live=true이면 진행 중 모디파이어를 포함한다', () => { + const prompt = buildPrompt({ + templateId: 'meeting', + depth: 'standard', + transcript, + live: true, + }) + expect(prompt).toContain('회의가 아직 진행 중') + expect(prompt).toContain('논의 중') + }) + + it('depthAdjustable=false인 raw 템플릿은 강도 모디파이어를 적용하지 않는다', () => { + const prompt = buildPrompt({ + templateId: 'raw', + depth: 'concise', + transcript, + }) + expect(prompt).toContain('편집자') + expect(prompt).not.toContain('작성 강도') + }) + + it('liveSupported=false 템플릿은 live=true여도 라이브 모디파이어를 넣지 않는다', () => { + const prompt = buildPrompt({ + templateId: 'interview', + depth: 'standard', + transcript, + live: true, + }) + expect(prompt).not.toContain('회의가 아직 진행 중') + }) + + it('강도별로 다른 모디파이어가 포함된다', () => { + const concise = buildPrompt({ + templateId: 'meeting', + depth: 'concise', + transcript, + }) + const detailed = buildPrompt({ + templateId: 'meeting', + depth: 'detailed', + transcript, + }) + expect(concise).toContain('간결') + expect(detailed).toContain('상세') + expect(concise).not.toContain('상세') + }) + + it('custom 템플릿은 customPrompt를 그대로 사용한다', () => { + const prompt = buildPrompt({ + templateId: 'custom', + depth: 'standard', + transcript, + customPrompt: '당신은 시인입니다. 시 형식으로 정리하세요.', + }) + expect(prompt).toContain('당신은 시인입니다') + expect(prompt).toContain(transcript) + expect(prompt).not.toContain('액션 아이템') + }) + + it('custom인데 customPrompt가 비어있으면 meeting 기본 프롬프트로 폴백', () => { + const prompt = buildPrompt({ + templateId: 'custom', + depth: 'standard', + transcript, + customPrompt: ' ', + }) + expect(prompt).toContain('회의록 작성 전문가') + }) +}) + +describe('resolveDepth', () => { + it('depth 미지정 시 템플릿 기본값을 반환한다', () => { + expect(resolveDepth('lecture')).toBe('detailed') + expect(resolveDepth('meeting')).toBe('standard') + }) + + it('depthAdjustable=false 템플릿은 전달된 depth를 무시', () => { + expect(resolveDepth('raw', 'concise')).toBe('detailed') + }) + + it('지정된 depth가 있고 조절 가능하면 그대로 반환', () => { + expect(resolveDepth('meeting', 'concise')).toBe('concise') + }) +}) + +describe('liveEnabledFor / depthAdjustableFor', () => { + it('raw는 라이브/강도 둘 다 false', () => { + expect(liveEnabledFor('raw')).toBe(false) + expect(depthAdjustableFor('raw')).toBe(false) + }) + + it('interview는 라이브 false, 강도 true', () => { + expect(liveEnabledFor('interview')).toBe(false) + expect(depthAdjustableFor('interview')).toBe(true) + }) + + it('meeting / lecture / brainstorm / one_on_one은 둘 다 true', () => { + expect(liveEnabledFor('meeting')).toBe(true) + expect(liveEnabledFor('lecture')).toBe(true) + expect(liveEnabledFor('brainstorm')).toBe(true) + expect(liveEnabledFor('one_on_one')).toBe(true) + }) + + it('custom은 라이브 가능, 강도 조절은 불가(사용자 프롬프트가 강도까지 지정)', () => { + expect(liveEnabledFor('custom')).toBe(true) + expect(depthAdjustableFor('custom')).toBe(false) + }) +}) diff --git a/src/app/api/summarize-live/route.ts b/src/app/api/summarize-live/route.ts index 08696da..81de5a1 100644 --- a/src/app/api/summarize-live/route.ts +++ b/src/app/api/summarize-live/route.ts @@ -1,12 +1,13 @@ import { NextRequest } from 'next/server' import { generateLiveSummary } from '@/lib/live-summary' +import type { SummaryDepth, TemplateId } from '@/lib/templates' const MAX_TRANSCRIPT_CHARS = 40_000 export async function POST(request: NextRequest) { try { const body = await request.json() - const { transcript } = body + const { transcript, template, depth, customPrompt } = body if (!transcript || typeof transcript !== 'string') { return Response.json( @@ -28,7 +29,13 @@ export async function POST(request: NextRequest) { ? transcript.slice(-MAX_TRANSCRIPT_CHARS) : transcript - const result = await generateLiveSummary(truncated, { apiKey }) + const result = await generateLiveSummary(truncated, { + apiKey, + template: (template as TemplateId | undefined) ?? 'meeting', + depth: depth as SummaryDepth | undefined, + customPrompt: + typeof customPrompt === 'string' ? customPrompt : undefined, + }) if (!result.success) { const status = result.rateLimited ? 429 : 502 diff --git a/src/app/api/summarize/route.ts b/src/app/api/summarize/route.ts index 796da64..8808f9c 100644 --- a/src/app/api/summarize/route.ts +++ b/src/app/api/summarize/route.ts @@ -3,11 +3,13 @@ import { generateGeminiMinutes, generateSimpleMinutes, } from '@/lib/minutes-generator' +import type { SummaryDepth, TemplateId } from '@/lib/templates' export async function POST(request: NextRequest) { try { const body = await request.json() - const { title, transcript, mode, date } = body + const { title, transcript, mode, date, template, depth, customPrompt } = + body if (!transcript || typeof transcript !== 'string') { return Response.json( @@ -20,6 +22,10 @@ export async function POST(request: NextRequest) { title: title || '무제 회의', transcript, date: date ? new Date(date) : new Date(), + template: (template as TemplateId | undefined) ?? 'meeting', + depth: depth as SummaryDepth | undefined, + customPrompt: + typeof customPrompt === 'string' ? customPrompt : undefined, } if (mode === 'gemini') { diff --git a/src/app/page.tsx b/src/app/page.tsx index 1ad39c3..17bf647 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,18 @@ 'use client' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { AudioUploader } from '@/components/upload/AudioUploader' import { LiveRecorder } from '@/components/recorder/LiveRecorder' import { MinutesViewer } from '@/components/minutes/MinutesViewer' +import { + TEMPLATES, + DEFAULT_TEMPLATE_ID, + DEFAULT_DEPTH, + depthAdjustableFor, + liveEnabledFor, + type SummaryDepth, + type TemplateId, +} from '@/lib/templates' type Tab = 'upload' | 'record' type SummaryMode = 'simple' | 'gemini' @@ -11,6 +20,13 @@ type SummaryMode = 'simple' | 'gemini' interface MinutesResult { markdown: string mode: SummaryMode + warning?: string +} + +const DEPTH_LABELS: Record = { + concise: '간결', + standard: '표준', + detailed: '상세', } export default function HomePage() { @@ -18,11 +34,45 @@ export default function HomePage() { const [title, setTitle] = useState('') const [transcript, setTranscript] = useState('') const [summaryMode, setSummaryMode] = useState('gemini') + const [template, setTemplate] = useState(DEFAULT_TEMPLATE_ID) + const [depth, setDepth] = useState(DEFAULT_DEPTH) + const [customPrompt, setCustomPrompt] = useState('') const [result, setResult] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [uploadedFile, setUploadedFile] = useState(null) + const templateList = useMemo( + () => [ + ...Object.values(TEMPLATES), + { + id: 'custom' as const, + name: '커스텀', + icon: '⚙️', + description: '직접 프롬프트 작성', + }, + ], + [], + ) + + const activeTemplateMeta = useMemo(() => { + if (template === 'custom') { + return { adjustable: false, live: true } + } + return { + adjustable: depthAdjustableFor(template), + live: liveEnabledFor(template), + } + }, [template]) + + function handleTemplateChange(id: TemplateId) { + setTemplate(id) + if (id !== 'custom') { + const tpl = TEMPLATES[id] + setDepth(tpl.defaultDepth) + } + } + async function handleUpload(file: File) { setUploadedFile(file) setError(null) @@ -64,6 +114,9 @@ export default function HomePage() { title: title || '무제 회의', transcript: text, mode, + template, + depth, + customPrompt: template === 'custom' ? customPrompt : undefined, }), }) const data = await res.json() @@ -73,7 +126,11 @@ export default function HomePage() { return } - setResult({ markdown: data.markdown, mode: data.mode }) + setResult({ + markdown: data.markdown, + mode: data.mode, + warning: data.warning, + }) } catch { setError('회의록 생성에 실패했습니다.') } finally { @@ -86,6 +143,9 @@ export default function HomePage() { generateMinutes(text, summaryMode) } + const liveSummaryActive = + summaryMode === 'gemini' && activeTemplateMeta.live && tab === 'record' + return (
@@ -94,13 +154,13 @@ export default function HomePage() { Meeting Minutes

- 음성을 텍스트로, 텍스트를 회의록으로 + 음성을 텍스트로, 용도에 맞는 템플릿으로 정리

-
-
-