feat: 템플릿(6종) + 강도 조절(3단계) + 커스텀 프롬프트

회의록 외 다양한 용도로 쓸 수 있도록 출력 구조와 요약 깊이를 분리.

템플릿:
- 🗂️ 회의록 (표준, 라이브 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)
This commit is contained in:
2026-04-21 19:38:24 +09:00
parent 6e104cbca1
commit 0101e4b0d2
9 changed files with 675 additions and 64 deletions
+9 -2
View File
@@ -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
+7 -1
View File
@@ -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') {
+161 -14
View File
@@ -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<SummaryDepth, string> = {
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<SummaryMode>('gemini')
const [template, setTemplate] = useState<TemplateId>(DEFAULT_TEMPLATE_ID)
const [depth, setDepth] = useState<SummaryDepth>(DEFAULT_DEPTH)
const [customPrompt, setCustomPrompt] = useState('')
const [result, setResult] = useState<MinutesResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [uploadedFile, setUploadedFile] = useState<File | null>(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 (
<main className="flex-1 bg-gradient-to-b from-neutral-50 to-white">
<div className="mx-auto max-w-3xl px-6 py-12">
@@ -94,13 +154,13 @@ export default function HomePage() {
Meeting Minutes
</h1>
<p className="mt-2 text-neutral-500">
,
, 릿
</p>
</header>
<section className="mb-8">
<label className="block text-sm font-medium text-neutral-600 mb-2">
</label>
<input
type="text"
@@ -111,10 +171,10 @@ export default function HomePage() {
/>
</section>
<section className="mb-8">
<div className="flex items-center gap-4 mb-6">
<label className="text-sm font-medium text-neutral-600">
:
<section className="mb-8 space-y-5">
<div className="flex items-center gap-4">
<label className="text-sm font-medium text-neutral-600 min-w-20">
</label>
<div className="flex gap-2">
<button
@@ -139,10 +199,88 @@ export default function HomePage() {
</button>
</div>
</div>
{summaryMode === 'gemini' && tab === 'record' && (
<p className="text-xs text-purple-600">
💡 30 , .
</p>
{summaryMode === 'gemini' && (
<>
<div>
<label className="block text-sm font-medium text-neutral-600 mb-2">
릿
</label>
<div className="flex flex-wrap gap-2">
{templateList.map((tpl) => (
<button
key={tpl.id}
onClick={() => handleTemplateChange(tpl.id)}
title={tpl.description}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-all ${
template === tpl.id
? 'border-purple-400 bg-purple-50 text-purple-700'
: 'border-neutral-200 bg-white text-neutral-600 hover:border-neutral-300 hover:bg-neutral-50'
}`}
>
<span className="mr-1">{tpl.icon}</span>
{tpl.name}
</button>
))}
</div>
<p className="mt-2 text-xs text-neutral-500">
{template === 'custom'
? '직접 프롬프트 작성'
: TEMPLATES[template]?.description}
</p>
</div>
{activeTemplateMeta.adjustable && (
<div className="flex items-center gap-4">
<label className="text-sm font-medium text-neutral-600 min-w-20">
</label>
<div className="flex gap-2">
{(['concise', 'standard', 'detailed'] as const).map(
(d) => (
<button
key={d}
onClick={() => setDepth(d)}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
depth === d
? 'bg-purple-100 text-purple-700 ring-1 ring-purple-300'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
}`}
>
{DEPTH_LABELS[d]}
</button>
),
)}
</div>
</div>
)}
{template === 'custom' && (
<div>
<label className="block text-sm font-medium text-neutral-600 mb-2">
</label>
<textarea
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="예: 당신은 기술 문서 작성자입니다. 아래 내용을 개발자 가이드 형식으로..."
rows={4}
className="w-full rounded-xl border border-neutral-300 px-4 py-3 text-sm text-neutral-700 placeholder:text-neutral-400 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all resize-y"
/>
<p className="mt-1 text-xs text-neutral-500">
릿 .
</p>
</div>
)}
{tab === 'record' && (
<p className="text-xs text-purple-600">
{liveSummaryActive
? '💡 녹음 중 30초마다 중간 정리가 자동 갱신되고, 종료 시 최종 문서가 생성됩니다.'
: '💡 이 템플릿은 녹음 종료 시 한 번만 생성됩니다.'}
</p>
)}
</>
)}
</section>
@@ -173,7 +311,10 @@ export default function HomePage() {
{tab === 'record' ? (
<LiveRecorder
onTranscriptReady={handleTranscriptReady}
liveSummaryEnabled={summaryMode === 'gemini'}
liveSummaryEnabled={liveSummaryActive}
template={template}
depth={depth}
customPrompt={template === 'custom' ? customPrompt : undefined}
/>
) : (
<AudioUploader onFileSelected={handleUpload} />
@@ -208,7 +349,7 @@ export default function HomePage() {
disabled={loading || !transcript.trim()}
className="w-full rounded-xl bg-neutral-900 px-6 py-3.5 text-white font-medium shadow-lg shadow-neutral-900/10 hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-all mb-8"
>
{loading ? '생성 중...' : result ? '회의록 재생성' : '회의록 생성'}
{loading ? '생성 중...' : result ? '재생성' : '생성'}
</button>
{error && (
@@ -217,6 +358,12 @@ export default function HomePage() {
</div>
)}
{result?.warning && (
<div className="mb-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
{result.warning}
</div>
)}
{result && (
<MinutesViewer
markdown={result.markdown}