feat: 회의록 자동 작성 웹 서비스 구현

- 오디오 파일 업로드 + 유효성 검사
- 실시간 음성 인식 (Web Speech API)
- 회의록 생성 (단순 STT→MD 변환 / Gemini AI 요약)
- 마크다운/HTML 내보내기
- PostgreSQL + Prisma ORM
- Docker Compose 배포 지원
- TDD: 31개 테스트, 96% 커버리지
This commit is contained in:
nad4 committed 2026-04-11 23:18:05 +09:00
1 parent b5743fe896
commit adb48c2352
31 files changed
+4288 -82

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
export const ALLOWED_MIME_TYPES = [
'audio/mpeg',
'audio/wav',
'audio/webm',
'audio/mp4',
'audio/ogg',
'audio/flac',
'audio/x-m4a',
] as const
export const MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024 // 500MB
type ValidationResult =
| { valid: true; error?: undefined }
| { valid: false; error: string }
export function validateAudioFile(file: File): ValidationResult {
if (file.size === 0) {
return { valid: false, error: '파일이 비어 있습니다.' }
}
if (file.size > MAX_FILE_SIZE_BYTES) {
const maxMB = MAX_FILE_SIZE_BYTES / (1024 * 1024)
return {
valid: false,
error: `파일 크기가 ${maxMB}MB를 초과합니다.`,
}
}
if (!ALLOWED_MIME_TYPES.includes(file.type as (typeof ALLOWED_MIME_TYPES)[number])) {
return {
valid: false,
error: `지원하지 않는 파일 형식입니다: ${file.type}`,
}
}
return { valid: true }
}
+61
View File
@@ -0,0 +1,61 @@
export function exportAsMarkdown(markdown: string): Blob {
return new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
}
export function exportAsHtml(markdown: string, title: string): string {
const lines = markdown.split('\n')
const bodyHtml = lines
.map((line) => {
if (line.startsWith('# ')) return `<h1>${escapeHtml(line.slice(2))}</h1>`
if (line.startsWith('## ')) return `<h2>${escapeHtml(line.slice(3))}</h2>`
if (line.startsWith('### ')) return `<h3>${escapeHtml(line.slice(4))}</h3>`
if (line.startsWith('---')) return '<hr>'
if (line.startsWith('- ')) return `<li>${escapeHtml(line.slice(2))}</li>`
if (line.startsWith('**') && line.endsWith('**'))
return `<p><strong>${escapeHtml(line.slice(2, -2))}</strong></p>`
if (line.trim() === '') return ''
return `<p>${escapeHtml(line)}</p>`
})
.join('\n')
return `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)}</title>
<style>
body {
font-family: 'Noto Sans KR', Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
line-height: 1.6;
color: #333;
}
h1 { font-size: 1.8rem; border-bottom: 2px solid #333; padding-bottom: 0.5rem; }
h2 { font-size: 1.4rem; color: #555; margin-top: 1.5rem; }
hr { border: none; border-top: 1px solid #ddd; margin: 1.5rem 0; }
p { margin: 0.5rem 0; }
li { margin: 0.3rem 0; }
</style>
</head>
<body>
${bodyHtml}
</body>
</html>`
}
export function generateDownloadFilename(title: string, date: Date): string {
const dateStr = date.toISOString().split('T')[0]
const sanitized = title.replace(/[^a-zA-Z0-9가-힣\s]/g, '_').replace(/\s+/g, '_')
return `${sanitized}_${dateStr}`
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
+114
View File
@@ -0,0 +1,114 @@
export interface MinutesInput {
title: string
date: Date
transcript: string
}
type GeminiResult =
| { success: true; markdown: string }
| { success: false; error: string }
interface GeminiOptions {
apiKey: string
fetchFn?: typeof fetch
}
function formatDate(date: Date): string {
return date.toISOString().split('T')[0]
}
export function generateSimpleMinutes(input: MinutesInput): string {
const { title, date, transcript } = input
const dateStr = formatDate(date)
const content = transcript.trim().length > 0 ? transcript : '*내용 없음*'
return `# ${title}
**날짜**: ${dateStr}
---
## 회의 내용
${content}
---
*이 회의록은 음성 인식 결과를 기반으로 자동 생성되었습니다.*
`
}
const GEMINI_PROMPT = `당신은 회의록 작성 전문가입니다. 아래 음성 인식 텍스트를 분석하여 구조화된 회의록을 마크다운 형식으로 작성해주세요.
포함할 섹션:
- ## 요약 (핵심 내용 3-5줄)
- ## 주요 논의 사항 (번호 매기기)
- ## 액션 아이템 (체크리스트 형식)
- ## 다음 단계
간결하고 명확하게 작성해주세요. 한국어로 작성합니다.
---
음성 인식 텍스트:
`
export async function generateGeminiMinutes(
input: MinutesInput,
options: GeminiOptions,
): Promise<GeminiResult> {
const { apiKey, fetchFn = fetch } = options
if (!apiKey || apiKey.trim().length === 0) {
return { success: false, error: 'Gemini API 키가 필요합니다.' }
}
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`
try {
const response = await fetchFn(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [
{
parts: [{ text: `${GEMINI_PROMPT}${input.transcript}` }],
},
],
}),
})
if (!response.ok) {
return {
success: false,
error: `Gemini API 호출 실패: ${response.status} ${response.statusText}`,
}
}
const data = await response.json()
const generatedText =
data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
const dateStr = formatDate(input.date)
const markdown = `# ${input.title}
**날짜**: ${dateStr}
---
${generatedText}
---
*이 회의록은 Gemini AI를 활용하여 자동 생성되었습니다.*
`
return { success: true, markdown }
} catch (err) {
const message = err instanceof Error ? err.message : '알 수 없는 오류'
return {
success: false,
error: `Gemini API 호출 중 오류 발생: ${message}`,
}
}
}
+52
View File
@@ -0,0 +1,52 @@
export interface TranscriptChunk {
text: string
startTime: number
endTime: number
isFinal: boolean
}
function formatTimestamp(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
if (h > 0) {
return `[${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}]`
}
return `[${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}]`
}
export function formatTranscriptChunks(chunks: readonly TranscriptChunk[]): string {
if (chunks.length === 0) return ''
return chunks
.map((chunk) => `${formatTimestamp(chunk.startTime)} ${chunk.text}`)
.join('\n')
}
export function mergeAdjacentChunks(
chunks: readonly TranscriptChunk[],
gapThreshold: number,
): TranscriptChunk[] {
if (chunks.length === 0) return []
const result: TranscriptChunk[] = [{ ...chunks[0] }]
for (let i = 1; i < chunks.length; i++) {
const current = chunks[i]
const last = result[result.length - 1]
if (current.startTime - last.endTime <= gapThreshold) {
result[result.length - 1] = {
text: `${last.text} ${current.text}`,
startTime: last.startTime,
endTime: current.endTime,
isFinal: current.isFinal,
}
} else {
result.push({ ...current })
}
}
return result
}
+42
View File
@@ -0,0 +1,42 @@
import { validateAudioFile } from './audio-validation'
type UploadResult =
| {
success: true
data: {
title: string
fileName: string
mimeType: string
fileSize: number
}
}
| { success: false; error: string }
export async function handleUpload(formData: FormData): Promise<UploadResult> {
const audioFile = formData.get('audio')
if (!audioFile || !(audioFile instanceof File)) {
return { success: false, error: '오디오 파일이 필요합니다.' }
}
const validation = validateAudioFile(audioFile)
if (!validation.valid) {
return { success: false, error: validation.error }
}
const titleInput = formData.get('title')
const title =
typeof titleInput === 'string' && titleInput.trim().length > 0
? titleInput.trim()
: audioFile.name.replace(/\.[^.]+$/, '')
return {
success: true,
data: {
title,
fileName: audioFile.name,
mimeType: audioFile.type,
fileSize: audioFile.size,
},
}
}