mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-08-12 22:33:25 +09:00
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:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { generateLiveSummary } from '@/lib/live-summary'
|
import { generateLiveSummary } from '@/lib/live-summary'
|
||||||
|
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||||
|
|
||||||
const MAX_TRANSCRIPT_CHARS = 40_000
|
const MAX_TRANSCRIPT_CHARS = 40_000
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { transcript } = body
|
const { transcript, template, depth, customPrompt } = body
|
||||||
|
|
||||||
if (!transcript || typeof transcript !== 'string') {
|
if (!transcript || typeof transcript !== 'string') {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
@@ -28,7 +29,13 @@ export async function POST(request: NextRequest) {
|
|||||||
? transcript.slice(-MAX_TRANSCRIPT_CHARS)
|
? transcript.slice(-MAX_TRANSCRIPT_CHARS)
|
||||||
: transcript
|
: 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) {
|
if (!result.success) {
|
||||||
const status = result.rateLimited ? 429 : 502
|
const status = result.rateLimited ? 429 : 502
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import {
|
|||||||
generateGeminiMinutes,
|
generateGeminiMinutes,
|
||||||
generateSimpleMinutes,
|
generateSimpleMinutes,
|
||||||
} from '@/lib/minutes-generator'
|
} from '@/lib/minutes-generator'
|
||||||
|
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
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') {
|
if (!transcript || typeof transcript !== 'string') {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
@@ -20,6 +22,10 @@ export async function POST(request: NextRequest) {
|
|||||||
title: title || '무제 회의',
|
title: title || '무제 회의',
|
||||||
transcript,
|
transcript,
|
||||||
date: date ? new Date(date) : new Date(),
|
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') {
|
if (mode === 'gemini') {
|
||||||
|
|||||||
+160
-13
@@ -1,9 +1,18 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { AudioUploader } from '@/components/upload/AudioUploader'
|
import { AudioUploader } from '@/components/upload/AudioUploader'
|
||||||
import { LiveRecorder } from '@/components/recorder/LiveRecorder'
|
import { LiveRecorder } from '@/components/recorder/LiveRecorder'
|
||||||
import { MinutesViewer } from '@/components/minutes/MinutesViewer'
|
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 Tab = 'upload' | 'record'
|
||||||
type SummaryMode = 'simple' | 'gemini'
|
type SummaryMode = 'simple' | 'gemini'
|
||||||
@@ -11,6 +20,13 @@ type SummaryMode = 'simple' | 'gemini'
|
|||||||
interface MinutesResult {
|
interface MinutesResult {
|
||||||
markdown: string
|
markdown: string
|
||||||
mode: SummaryMode
|
mode: SummaryMode
|
||||||
|
warning?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEPTH_LABELS: Record<SummaryDepth, string> = {
|
||||||
|
concise: '간결',
|
||||||
|
standard: '표준',
|
||||||
|
detailed: '상세',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
@@ -18,11 +34,45 @@ export default function HomePage() {
|
|||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [transcript, setTranscript] = useState('')
|
const [transcript, setTranscript] = useState('')
|
||||||
const [summaryMode, setSummaryMode] = useState<SummaryMode>('gemini')
|
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 [result, setResult] = useState<MinutesResult | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [uploadedFile, setUploadedFile] = useState<File | 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) {
|
async function handleUpload(file: File) {
|
||||||
setUploadedFile(file)
|
setUploadedFile(file)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -64,6 +114,9 @@ export default function HomePage() {
|
|||||||
title: title || '무제 회의',
|
title: title || '무제 회의',
|
||||||
transcript: text,
|
transcript: text,
|
||||||
mode,
|
mode,
|
||||||
|
template,
|
||||||
|
depth,
|
||||||
|
customPrompt: template === 'custom' ? customPrompt : undefined,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -73,7 +126,11 @@ export default function HomePage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setResult({ markdown: data.markdown, mode: data.mode })
|
setResult({
|
||||||
|
markdown: data.markdown,
|
||||||
|
mode: data.mode,
|
||||||
|
warning: data.warning,
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setError('회의록 생성에 실패했습니다.')
|
setError('회의록 생성에 실패했습니다.')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -86,6 +143,9 @@ export default function HomePage() {
|
|||||||
generateMinutes(text, summaryMode)
|
generateMinutes(text, summaryMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const liveSummaryActive =
|
||||||
|
summaryMode === 'gemini' && activeTemplateMeta.live && tab === 'record'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 bg-gradient-to-b from-neutral-50 to-white">
|
<main className="flex-1 bg-gradient-to-b from-neutral-50 to-white">
|
||||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||||
@@ -94,13 +154,13 @@ export default function HomePage() {
|
|||||||
Meeting Minutes
|
Meeting Minutes
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-neutral-500">
|
<p className="mt-2 text-neutral-500">
|
||||||
음성을 텍스트로, 텍스트를 회의록으로
|
음성을 텍스트로, 용도에 맞는 템플릿으로 정리
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<label className="block text-sm font-medium text-neutral-600 mb-2">
|
<label className="block text-sm font-medium text-neutral-600 mb-2">
|
||||||
회의 제목
|
제목
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -111,10 +171,10 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8 space-y-5">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4">
|
||||||
<label className="text-sm font-medium text-neutral-600">
|
<label className="text-sm font-medium text-neutral-600 min-w-20">
|
||||||
변환 모드:
|
변환 모드
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -139,10 +199,88 @@ export default function HomePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{summaryMode === 'gemini' && tab === 'record' && (
|
|
||||||
<p className="text-xs text-purple-600">
|
{summaryMode === 'gemini' && (
|
||||||
💡 녹음 중 30초마다 중간 요약이 자동 갱신되고, 종료 시 최종 회의록이 생성됩니다.
|
<>
|
||||||
|
<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>
|
</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>
|
</section>
|
||||||
|
|
||||||
@@ -173,7 +311,10 @@ export default function HomePage() {
|
|||||||
{tab === 'record' ? (
|
{tab === 'record' ? (
|
||||||
<LiveRecorder
|
<LiveRecorder
|
||||||
onTranscriptReady={handleTranscriptReady}
|
onTranscriptReady={handleTranscriptReady}
|
||||||
liveSummaryEnabled={summaryMode === 'gemini'}
|
liveSummaryEnabled={liveSummaryActive}
|
||||||
|
template={template}
|
||||||
|
depth={depth}
|
||||||
|
customPrompt={template === 'custom' ? customPrompt : undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AudioUploader onFileSelected={handleUpload} />
|
<AudioUploader onFileSelected={handleUpload} />
|
||||||
@@ -208,7 +349,7 @@ export default function HomePage() {
|
|||||||
disabled={loading || !transcript.trim()}
|
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"
|
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>
|
</button>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -217,6 +358,12 @@ export default function HomePage() {
|
|||||||
</div>
|
</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 && (
|
{result && (
|
||||||
<MinutesViewer
|
<MinutesViewer
|
||||||
markdown={result.markdown}
|
markdown={result.markdown}
|
||||||
|
|||||||
@@ -4,15 +4,22 @@ import { useEffect, useRef } from 'react'
|
|||||||
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
||||||
import { useLiveSummary } from '@/hooks/useLiveSummary'
|
import { useLiveSummary } from '@/hooks/useLiveSummary'
|
||||||
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
||||||
|
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||||
|
|
||||||
interface LiveRecorderProps {
|
interface LiveRecorderProps {
|
||||||
onTranscriptReady: (transcript: string) => void
|
onTranscriptReady: (transcript: string) => void
|
||||||
liveSummaryEnabled: boolean
|
liveSummaryEnabled: boolean
|
||||||
|
template: TemplateId
|
||||||
|
depth: SummaryDepth
|
||||||
|
customPrompt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LiveRecorder({
|
export function LiveRecorder({
|
||||||
onTranscriptReady,
|
onTranscriptReady,
|
||||||
liveSummaryEnabled,
|
liveSummaryEnabled,
|
||||||
|
template,
|
||||||
|
depth,
|
||||||
|
customPrompt,
|
||||||
}: LiveRecorderProps) {
|
}: LiveRecorderProps) {
|
||||||
const {
|
const {
|
||||||
isListening,
|
isListening,
|
||||||
@@ -36,6 +43,9 @@ export function LiveRecorder({
|
|||||||
cooldownUntil,
|
cooldownUntil,
|
||||||
} = useLiveSummary(chunks, {
|
} = useLiveSummary(chunks, {
|
||||||
enabled: liveSummaryEnabled && isListening,
|
enabled: liveSummaryEnabled && isListening,
|
||||||
|
template,
|
||||||
|
depth,
|
||||||
|
customPrompt,
|
||||||
})
|
})
|
||||||
|
|
||||||
const transcriptEndRef = useRef<HTMLDivElement>(null)
|
const transcriptEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|||||||
@@ -3,12 +3,16 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { TranscriptChunk } from '@/lib/transcript-formatter'
|
import type { TranscriptChunk } from '@/lib/transcript-formatter'
|
||||||
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
||||||
|
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||||
|
|
||||||
interface UseLiveSummaryOptions {
|
interface UseLiveSummaryOptions {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
pollIntervalMs?: number
|
pollIntervalMs?: number
|
||||||
minWords?: number
|
minWords?: number
|
||||||
incrementWords?: number
|
incrementWords?: number
|
||||||
|
template?: TemplateId
|
||||||
|
depth?: SummaryDepth
|
||||||
|
customPrompt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LiveSummaryState {
|
interface LiveSummaryState {
|
||||||
@@ -36,8 +40,16 @@ export function useLiveSummary(
|
|||||||
pollIntervalMs = 30_000,
|
pollIntervalMs = 30_000,
|
||||||
minWords = 25,
|
minWords = 25,
|
||||||
incrementWords = 40,
|
incrementWords = 40,
|
||||||
|
template = 'meeting',
|
||||||
|
depth,
|
||||||
|
customPrompt,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
|
const configRef = useRef({ template, depth, customPrompt })
|
||||||
|
useEffect(() => {
|
||||||
|
configRef.current = { template, depth, customPrompt }
|
||||||
|
}, [template, depth, customPrompt])
|
||||||
|
|
||||||
const [summary, setSummary] = useState('')
|
const [summary, setSummary] = useState('')
|
||||||
const [isSummarizing, setIsSummarizing] = useState(false)
|
const [isSummarizing, setIsSummarizing] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -101,7 +113,12 @@ export function useLiveSummary(
|
|||||||
const res = await fetch('/api/summarize-live', {
|
const res = await fetch('/api/summarize-live', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ transcript }),
|
body: JSON.stringify({
|
||||||
|
transcript,
|
||||||
|
template: configRef.current.template,
|
||||||
|
depth: configRef.current.depth,
|
||||||
|
customPrompt: configRef.current.customPrompt,
|
||||||
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+21
-27
@@ -1,34 +1,22 @@
|
|||||||
|
import {
|
||||||
|
buildPrompt,
|
||||||
|
resolveDepth,
|
||||||
|
type SummaryDepth,
|
||||||
|
type TemplateId,
|
||||||
|
} from './templates'
|
||||||
|
|
||||||
type LiveSummaryResult =
|
type LiveSummaryResult =
|
||||||
| { success: true; markdown: string }
|
| { success: true; markdown: string }
|
||||||
| { success: false; error: string; rateLimited?: boolean }
|
| { success: false; error: string; rateLimited?: boolean }
|
||||||
|
|
||||||
interface LiveSummaryOptions {
|
interface LiveSummaryOptions {
|
||||||
apiKey: string
|
apiKey: string
|
||||||
|
template?: TemplateId
|
||||||
|
depth?: SummaryDepth
|
||||||
|
customPrompt?: string
|
||||||
fetchFn?: typeof fetch
|
fetchFn?: typeof fetch
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIVE_PROMPT = `회의가 아직 진행 중입니다. 지금까지의 발화 내용을 기반으로 짧고 구조화된 중간 요약을 작성하세요.
|
|
||||||
|
|
||||||
형식 (마크다운):
|
|
||||||
## 요약
|
|
||||||
(핵심 내용 3줄 이내)
|
|
||||||
|
|
||||||
## 주요 논의 사항
|
|
||||||
1. ...
|
|
||||||
2. ...
|
|
||||||
|
|
||||||
## 액션 아이템
|
|
||||||
- [ ] ...
|
|
||||||
|
|
||||||
규칙:
|
|
||||||
- 확정되지 않은 결정은 "(논의 중)"으로 표시
|
|
||||||
- 액션 아이템은 담당자/기한이 명확한 것만 포함
|
|
||||||
- 한국어로, 간결하게
|
|
||||||
|
|
||||||
---
|
|
||||||
음성 인식 텍스트:
|
|
||||||
`
|
|
||||||
|
|
||||||
export async function generateLiveSummary(
|
export async function generateLiveSummary(
|
||||||
transcript: string,
|
transcript: string,
|
||||||
options: LiveSummaryOptions,
|
options: LiveSummaryOptions,
|
||||||
@@ -44,6 +32,16 @@ export async function generateLiveSummary(
|
|||||||
return { success: false, error: '요약할 텍스트가 비어 있습니다.' }
|
return { success: false, error: '요약할 텍스트가 비어 있습니다.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const templateId = options.template ?? 'meeting'
|
||||||
|
const depth = resolveDepth(templateId, options.depth)
|
||||||
|
const prompt = buildPrompt({
|
||||||
|
templateId,
|
||||||
|
depth,
|
||||||
|
transcript: trimmed,
|
||||||
|
live: true,
|
||||||
|
customPrompt: options.customPrompt,
|
||||||
|
})
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
|
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
|
||||||
|
|
||||||
@@ -55,11 +53,7 @@ export async function generateLiveSummary(
|
|||||||
'x-goog-api-key': apiKey,
|
'x-goog-api-key': apiKey,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
contents: [
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
{
|
|
||||||
parts: [{ text: `${LIVE_PROMPT}${trimmed}` }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
|
import {
|
||||||
|
buildPrompt,
|
||||||
|
resolveDepth,
|
||||||
|
type SummaryDepth,
|
||||||
|
type TemplateId,
|
||||||
|
} from './templates'
|
||||||
|
|
||||||
export interface MinutesInput {
|
export interface MinutesInput {
|
||||||
title: string
|
title: string
|
||||||
date: Date
|
date: Date
|
||||||
transcript: string
|
transcript: string
|
||||||
|
template?: TemplateId
|
||||||
|
depth?: SummaryDepth
|
||||||
|
customPrompt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeminiResult =
|
type GeminiResult =
|
||||||
@@ -39,20 +49,6 @@ ${content}
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
const GEMINI_PROMPT = `당신은 회의록 작성 전문가입니다. 아래 음성 인식 텍스트를 분석하여 구조화된 회의록을 마크다운 형식으로 작성해주세요.
|
|
||||||
|
|
||||||
포함할 섹션:
|
|
||||||
- ## 요약 (핵심 내용 3-5줄)
|
|
||||||
- ## 주요 논의 사항 (번호 매기기)
|
|
||||||
- ## 액션 아이템 (체크리스트 형식)
|
|
||||||
- ## 다음 단계
|
|
||||||
|
|
||||||
간결하고 명확하게 작성해주세요. 한국어로 작성합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
음성 인식 텍스트:
|
|
||||||
`
|
|
||||||
|
|
||||||
export async function generateGeminiMinutes(
|
export async function generateGeminiMinutes(
|
||||||
input: MinutesInput,
|
input: MinutesInput,
|
||||||
options: GeminiOptions,
|
options: GeminiOptions,
|
||||||
@@ -63,6 +59,16 @@ export async function generateGeminiMinutes(
|
|||||||
return { success: false, error: 'Gemini API 키가 필요합니다.' }
|
return { success: false, error: 'Gemini API 키가 필요합니다.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const templateId = input.template ?? 'meeting'
|
||||||
|
const depth = resolveDepth(templateId, input.depth)
|
||||||
|
const prompt = buildPrompt({
|
||||||
|
templateId,
|
||||||
|
depth,
|
||||||
|
transcript: input.transcript,
|
||||||
|
live: false,
|
||||||
|
customPrompt: input.customPrompt,
|
||||||
|
})
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
|
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
|
||||||
|
|
||||||
@@ -74,11 +80,7 @@ export async function generateGeminiMinutes(
|
|||||||
'x-goog-api-key': apiKey,
|
'x-goog-api-key': apiKey,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
contents: [
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
{
|
|
||||||
parts: [{ text: `${GEMINI_PROMPT}${input.transcript}` }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
export type TemplateId =
|
||||||
|
| 'meeting'
|
||||||
|
| 'lecture'
|
||||||
|
| 'one_on_one'
|
||||||
|
| 'brainstorm'
|
||||||
|
| 'interview'
|
||||||
|
| 'raw'
|
||||||
|
| 'custom'
|
||||||
|
|
||||||
|
export type SummaryDepth = 'concise' | 'standard' | 'detailed'
|
||||||
|
|
||||||
|
export interface Template {
|
||||||
|
id: TemplateId
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
description: string
|
||||||
|
defaultDepth: SummaryDepth
|
||||||
|
liveSupported: boolean
|
||||||
|
depthAdjustable: boolean
|
||||||
|
basePrompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_TEMPLATE_ID: TemplateId = 'meeting'
|
||||||
|
export const DEFAULT_DEPTH: SummaryDepth = 'standard'
|
||||||
|
|
||||||
|
export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
|
||||||
|
meeting: {
|
||||||
|
id: 'meeting',
|
||||||
|
name: '회의록',
|
||||||
|
icon: '🗂️',
|
||||||
|
description: '참석자 / 액션 아이템 중심',
|
||||||
|
defaultDepth: 'standard',
|
||||||
|
liveSupported: true,
|
||||||
|
depthAdjustable: true,
|
||||||
|
basePrompt: `당신은 회의록 작성 전문가입니다. 아래 발화 내용을 분석하여 마크다운 회의록을 작성하세요.
|
||||||
|
|
||||||
|
포함할 섹션:
|
||||||
|
## 요약
|
||||||
|
(3~5줄 핵심 내용)
|
||||||
|
|
||||||
|
## 주요 논의 사항
|
||||||
|
1. 순번 매기기
|
||||||
|
|
||||||
|
## 액션 아이템
|
||||||
|
- [ ] 담당자와 기한이 명확한 항목만
|
||||||
|
|
||||||
|
## 결정 사항
|
||||||
|
- 합의 또는 확정된 것만
|
||||||
|
|
||||||
|
한국어로 작성하세요.`,
|
||||||
|
},
|
||||||
|
|
||||||
|
lecture: {
|
||||||
|
id: 'lecture',
|
||||||
|
name: '강의·세미나 노트',
|
||||||
|
icon: '🎓',
|
||||||
|
description: '주제별 핵심 개념과 예시',
|
||||||
|
defaultDepth: 'detailed',
|
||||||
|
liveSupported: true,
|
||||||
|
depthAdjustable: true,
|
||||||
|
basePrompt: `당신은 학습용 노트를 작성하는 전문가입니다. 강의/세미나 녹취를 구조화된 학습 자료로 정리하세요.
|
||||||
|
|
||||||
|
포함할 섹션:
|
||||||
|
## 주요 주제
|
||||||
|
(한 줄 요약)
|
||||||
|
|
||||||
|
## 핵심 개념
|
||||||
|
각 개념마다 ### 소제목 + 설명 + 구체 예시
|
||||||
|
|
||||||
|
## 기억할 인용·예시
|
||||||
|
> 중요한 문장 인용 형식으로
|
||||||
|
|
||||||
|
## 후속 질문
|
||||||
|
- 스스로 탐구해볼 만한 질문
|
||||||
|
|
||||||
|
한국어로 작성하세요.`,
|
||||||
|
},
|
||||||
|
|
||||||
|
one_on_one: {
|
||||||
|
id: 'one_on_one',
|
||||||
|
name: '1:1 미팅',
|
||||||
|
icon: '🤝',
|
||||||
|
description: '고민 / 피드백 / 다음 액션',
|
||||||
|
defaultDepth: 'standard',
|
||||||
|
liveSupported: true,
|
||||||
|
depthAdjustable: true,
|
||||||
|
basePrompt: `당신은 1:1 미팅 정리 전문가입니다. 개인적이고 신뢰 기반의 대화임을 존중하며 정리하세요.
|
||||||
|
|
||||||
|
포함할 섹션:
|
||||||
|
## 이번 세션 요지
|
||||||
|
(2~3줄)
|
||||||
|
|
||||||
|
## 논의한 주제
|
||||||
|
- 주요 대화 흐름
|
||||||
|
|
||||||
|
## 고민·블로커
|
||||||
|
- 공유된 어려움
|
||||||
|
|
||||||
|
## 받은·제공한 피드백
|
||||||
|
- 건설적 피드백 위주
|
||||||
|
|
||||||
|
## 다음 미팅까지 할 일
|
||||||
|
- [ ] 합의된 후속 조치
|
||||||
|
|
||||||
|
한국어로 작성하세요.`,
|
||||||
|
},
|
||||||
|
|
||||||
|
brainstorm: {
|
||||||
|
id: 'brainstorm',
|
||||||
|
name: '브레인스토밍',
|
||||||
|
icon: '💡',
|
||||||
|
description: '아이디어 카테고리화 + 우선순위',
|
||||||
|
defaultDepth: 'detailed',
|
||||||
|
liveSupported: true,
|
||||||
|
depthAdjustable: true,
|
||||||
|
basePrompt: `당신은 브레인스토밍 세션을 정리하는 전문가입니다. 나온 아이디어를 분류하고 실행 가능성 관점에서 정리하세요.
|
||||||
|
|
||||||
|
포함할 섹션:
|
||||||
|
## 세션 개요
|
||||||
|
(1~2줄 — 주제/목표)
|
||||||
|
|
||||||
|
## 아이디어 (카테고리별)
|
||||||
|
### [카테고리 이름]
|
||||||
|
- 아이디어 요약
|
||||||
|
|
||||||
|
## 즉시 시도 가능
|
||||||
|
- 빠르게 검증 가능한 것
|
||||||
|
|
||||||
|
## 보류·탈락
|
||||||
|
- 사유 간단히
|
||||||
|
|
||||||
|
## 다음 스텝
|
||||||
|
- 구체적인 후속 행동
|
||||||
|
|
||||||
|
한국어로 작성하세요.`,
|
||||||
|
},
|
||||||
|
|
||||||
|
interview: {
|
||||||
|
id: 'interview',
|
||||||
|
name: '인터뷰',
|
||||||
|
icon: '🎤',
|
||||||
|
description: 'Q&A 포맷 + 인상적 발언',
|
||||||
|
defaultDepth: 'standard',
|
||||||
|
liveSupported: false,
|
||||||
|
depthAdjustable: true,
|
||||||
|
basePrompt: `당신은 인터뷰 녹취를 Q&A 형식으로 정리하는 전문가입니다.
|
||||||
|
|
||||||
|
포함할 섹션:
|
||||||
|
## 개요
|
||||||
|
(인터뷰 대상/주제 1~2줄)
|
||||||
|
|
||||||
|
## Q&A
|
||||||
|
**Q: [질문]**
|
||||||
|
A: [답변 요약]
|
||||||
|
|
||||||
|
(의미있는 Q&A 쌍을 순서대로)
|
||||||
|
|
||||||
|
## 인상적 발언
|
||||||
|
> 직접 인용
|
||||||
|
|
||||||
|
## 종합 인상
|
||||||
|
- 전반적 테마 / 놓치지 말 것
|
||||||
|
|
||||||
|
한국어로 작성하세요.`,
|
||||||
|
},
|
||||||
|
|
||||||
|
raw: {
|
||||||
|
id: 'raw',
|
||||||
|
name: '원문 정리',
|
||||||
|
icon: '📝',
|
||||||
|
description: '요약 없이 문단화·오탈자 정리만',
|
||||||
|
defaultDepth: 'detailed',
|
||||||
|
liveSupported: false,
|
||||||
|
depthAdjustable: false,
|
||||||
|
basePrompt: `당신은 음성 인식 텍스트의 편집자입니다. 요약이나 재해석 없이 다음만 수행하세요:
|
||||||
|
- 문장을 자연스러운 문단으로 묶기
|
||||||
|
- 명백한 오탈자 교정
|
||||||
|
- 의미 없는 반복 / 군더더기 최소한 제거
|
||||||
|
- 시간 순서 유지
|
||||||
|
|
||||||
|
엄격한 규칙:
|
||||||
|
- 내용을 삭제하거나 축약하지 마세요
|
||||||
|
- 구조적 제목(## 섹션)을 덧붙이지 마세요
|
||||||
|
- 타임스탬프가 있으면 그대로 유지하세요
|
||||||
|
- 한국어로 작성하세요.`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEPTH_MODIFIERS: Record<SummaryDepth, string> = {
|
||||||
|
concise:
|
||||||
|
'작성 강도: **간결**. 각 섹션은 3줄 이내로 핵심만. 부연 설명과 맥락은 생략하세요.',
|
||||||
|
standard:
|
||||||
|
'작성 강도: **표준**. 읽는 사람이 맥락을 이해할 수 있도록 적절한 상세도로 작성하세요.',
|
||||||
|
detailed:
|
||||||
|
'작성 강도: **상세**. 원문의 주요 세부사항·수치·고유명사를 누락 없이 포함하세요. 구조만 바꾸고 내용은 최대한 보존합니다.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIVE_MODIFIER = `회의가 아직 진행 중입니다. 지금까지의 내용을 기반으로 **중간 정리**를 작성하세요. 확정되지 않은 결정은 "(논의 중)"으로 표시하세요.`
|
||||||
|
|
||||||
|
export interface BuildPromptArgs {
|
||||||
|
templateId: TemplateId
|
||||||
|
depth: SummaryDepth
|
||||||
|
transcript: string
|
||||||
|
live?: boolean
|
||||||
|
customPrompt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTemplate(id: TemplateId): Template | null {
|
||||||
|
if (id === 'custom') return null
|
||||||
|
return TEMPLATES[id] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDepth(
|
||||||
|
templateId: TemplateId,
|
||||||
|
depth?: SummaryDepth,
|
||||||
|
): SummaryDepth {
|
||||||
|
if (templateId === 'custom') return depth ?? DEFAULT_DEPTH
|
||||||
|
const template = TEMPLATES[templateId]
|
||||||
|
if (!template) return DEFAULT_DEPTH
|
||||||
|
if (!template.depthAdjustable) return template.defaultDepth
|
||||||
|
return depth ?? template.defaultDepth
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPrompt(args: BuildPromptArgs): string {
|
||||||
|
const { templateId, depth, transcript, live = false, customPrompt } = args
|
||||||
|
|
||||||
|
let instruction: string
|
||||||
|
|
||||||
|
if (templateId === 'custom') {
|
||||||
|
const custom = customPrompt?.trim()
|
||||||
|
if (!custom) {
|
||||||
|
instruction = TEMPLATES.meeting.basePrompt
|
||||||
|
} else {
|
||||||
|
instruction = custom
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const template = TEMPLATES[templateId] ?? TEMPLATES.meeting
|
||||||
|
const parts: string[] = [template.basePrompt]
|
||||||
|
|
||||||
|
if (template.depthAdjustable) {
|
||||||
|
parts.push(DEPTH_MODIFIERS[depth])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (live && template.liveSupported) {
|
||||||
|
parts.push(LIVE_MODIFIER)
|
||||||
|
}
|
||||||
|
|
||||||
|
instruction = parts.join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${instruction}\n\n---\n음성 인식 텍스트:\n${transcript}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function liveEnabledFor(templateId: TemplateId): boolean {
|
||||||
|
if (templateId === 'custom') return true
|
||||||
|
return TEMPLATES[templateId]?.liveSupported ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function depthAdjustableFor(templateId: TemplateId): boolean {
|
||||||
|
if (templateId === 'custom') return false
|
||||||
|
return TEMPLATES[templateId]?.depthAdjustable ?? true
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user