mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
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:
1 parent
77af89aed7
commit
3b9692e857
11 files changed
+824
-79
No files matched your search
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user