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

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

-
-
-