mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
feat: 회의록 자동 작성 웹 서비스 구현
- 오디오 파일 업로드 + 유효성 검사 - 실시간 음성 인식 (Web Speech API) - 회의록 생성 (단순 STT→MD 변환 / Gemini AI 요약) - 마크다운/HTML 내보내기 - PostgreSQL + Prisma ORM - Docker Compose 배포 지원 - TDD: 31개 테스트, 96% 커버리지
This commit is contained in:
1 parent
b5743fe896
commit
adb48c2352
31 files changed
+4288
-82
No files matched your search
@@ -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 }
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user