feat: 실시간 롤링 요약 + 자동 최종 회의록 생성

녹음 중 30초 간격으로 Gemini가 중간 회의록을 갱신하고, 녹음 중지 시
최종 회의록을 자동 생성한다. Gemini 실패 시 단순 변환 마크다운으로
폴백하여 사용자는 항상 결과물을 받는다.

- 실시간 요약 폴링 (25단어 시작, 40단어 증분 게이트)
- 429 쿼터 초과 60초 쿨다운 + 일반 실패 지수 백오프
- Web Speech API 네트워크 단절 자동 재연결 (최대 8회)
- Hydration mismatch (React #418) 수정 — isSupported를 mount 후 결정
- gemini-2.5-flash-lite 모델로 통일 (무료 등급 15 RPM / 1000 RPD)
- Gemini 호출 URL → x-goog-api-key 헤더 인증으로 전환
- 좌우 분할 뷰 (실시간 텍스트 / 실시간 회의록) + 자동 스크롤
- 진행률 바, 쿨다운 카운터, interim 텍스트 커서
- Prisma 7 호환 위해 schema.prisma의 datasource url 제거
  (prisma.config.ts로 이동됨)
- Docker Compose에 test 프로필 서비스 추가 (vitest 38 tests)
This commit is contained in:
nad4 committed 2026-04-21 17:24:25 +09:00
1 parent 77af89aed7
commit 3b9692e857
11 files changed
+824 -79

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
import { describe, it, expect, vi } from 'vitest'
import { generateLiveSummary } from '@/lib/live-summary'
describe('generateLiveSummary', () => {
it('Gemini API 응답을 받아 중간 요약 마크다운을 반환한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
candidates: [
{
content: {
parts: [
{
text: '## 요약\n진행 중인 회의 내용 요약\n\n## 액션 아이템\n- [ ] QA 진행',
},
],
},
},
],
}),
})
const result = await generateLiveSummary('안녕하세요 회의 시작합니다', {
apiKey: 'test-key',
fetchFn: mockFetch,
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.markdown).toContain('요약')
expect(result.markdown).toContain('액션 아이템')
}
})
it('API 키가 비어있으면 에러를 반환한다', async () => {
const result = await generateLiveSummary('텍스트', {
apiKey: '',
fetchFn: vi.fn(),
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('API 키')
}
})
it('transcript가 비어있으면 에러를 반환한다', async () => {
const result = await generateLiveSummary(' ', {
apiKey: 'test-key',
fetchFn: vi.fn(),
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('비어')
}
})
it('429 응답은 rateLimited 플래그와 안내 메시지를 반환한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
statusText: 'Too Many Requests',
})
const result = await generateLiveSummary('회의 내용', {
apiKey: 'test-key',
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.rateLimited).toBe(true)
expect(result.error).toContain('한도 초과')
}
})
it('429 이외의 실패는 상태 코드를 포함한 에러를 반환한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
})
const result = await generateLiveSummary('회의 내용', {
apiKey: 'test-key',
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('500')
expect(result.rateLimited).toBeUndefined()
}
})
it('API가 빈 응답을 반환하면 에러로 처리한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ candidates: [] }),
})
const result = await generateLiveSummary('회의 내용', {
apiKey: 'test-key',
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
})
it('x-goog-api-key 헤더로 인증한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
candidates: [{ content: { parts: [{ text: '요약' }] } }],
}),
})
await generateLiveSummary('회의 내용', {
apiKey: 'secret-key',
fetchFn: mockFetch,
})
const [url, init] = mockFetch.mock.calls[0]
expect(url).not.toContain('secret-key')
expect(init.headers['x-goog-api-key']).toBe('secret-key')
})
})