fix(meeting): stop transcript view toggle from overwriting edits and repair notes enhancement
This commit is contained in:
parent
ae2d923b9c
commit
6347115ae7
7 changed files with 607 additions and 149 deletions
|
|
@ -1,8 +1,9 @@
|
||||||
// src/renderer/components/meeting/MeetingDetailTabs.tsx
|
// src/renderer/components/meeting/MeetingDetailTabs.tsx
|
||||||
// Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad)
|
// Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad)
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { parseTranscriptSegments, applyTranscriptSegmentEdit } from './transcript-segments'
|
import { useMeetingTranscript } from './useMeetingTranscript'
|
||||||
|
import { buildNotesEnhanceRequest } from './notes-enhancement'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Tabs,
|
Tabs,
|
||||||
|
|
@ -87,7 +88,6 @@ export function MeetingDetailTabs({
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [generating, setGenerating] = useState(false)
|
const [generating, setGenerating] = useState(false)
|
||||||
const [diarizing, setDiarizing] = useState(false)
|
|
||||||
const [editingTitle, setEditingTitle] = useState(false)
|
const [editingTitle, setEditingTitle] = useState(false)
|
||||||
const [titleDraft, setTitleDraft] = useState(detail.title ?? '')
|
const [titleDraft, setTitleDraft] = useState(detail.title ?? '')
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
||||||
|
|
@ -97,11 +97,20 @@ export function MeetingDetailTabs({
|
||||||
const [actionItems, setActionItems] = useState<Array<{ id: string; text: string; done: boolean }>>([])
|
const [actionItems, setActionItems] = useState<Array<{ id: string; text: string; done: boolean }>>([])
|
||||||
const [enhancingNotes, setEnhancingNotes] = useState(false)
|
const [enhancingNotes, setEnhancingNotes] = useState(false)
|
||||||
|
|
||||||
// 세그먼트 파싱 (rawTranscript / editedTranscript 기반)
|
// 전사 보기 상태·편집 저장·AI 다듬기·화자 구분은 훅이 전담한다(보기 전환은 저장하지 않는다).
|
||||||
const segments = useMemo(
|
const transcript = useMeetingTranscript(initialDetail)
|
||||||
() => parseTranscriptSegments(detail.rawTranscript, detail.editedTranscript),
|
const [enhanceError, setEnhanceError] = useState<string | null>(null)
|
||||||
[detail.rawTranscript, detail.editedTranscript],
|
const transcriptErrorMessage = transcript.error
|
||||||
)
|
? transcript.error.kind === 'polishFailed'
|
||||||
|
? t('meeting.polishFailed')
|
||||||
|
: transcript.error.message
|
||||||
|
: null
|
||||||
|
const errorMessage = transcriptErrorMessage ?? enhanceError
|
||||||
|
const { clearError: clearTranscriptError } = transcript
|
||||||
|
const clearErrors = useCallback(() => {
|
||||||
|
clearTranscriptError()
|
||||||
|
setEnhanceError(null)
|
||||||
|
}, [clearTranscriptError])
|
||||||
|
|
||||||
// 템플릿 로드 (다른 기기에서 바뀐 템플릿이 반영되면 다시)
|
// 템플릿 로드 (다른 기기에서 바뀐 템플릿이 반영되면 다시)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -151,55 +160,6 @@ export function MeetingDetailTabs({
|
||||||
setEditingTitle(false)
|
setEditingTitle(false)
|
||||||
}, [detail.id, titleDraft])
|
}, [detail.id, titleDraft])
|
||||||
|
|
||||||
// ── 세그먼트 편집 ──
|
|
||||||
// 세그먼트는 전사 줄에서 파생되므로, 고친 줄로 전사 전체를 재조립해 updateTranscript 로 저장한다.
|
|
||||||
// 화면은 낙관적으로 먼저 바꾸고, 저장이 실패하면 이전 수정본으로 되돌린다.
|
|
||||||
const [segmentEditError, setSegmentEditError] = useState<string | null>(null)
|
|
||||||
const latestTranscriptRef = useRef<{ edited: string | null; raw: string | null }>({
|
|
||||||
edited: detail.editedTranscript,
|
|
||||||
raw: detail.rawTranscript,
|
|
||||||
})
|
|
||||||
latestTranscriptRef.current = { edited: detail.editedTranscript, raw: detail.rawTranscript }
|
|
||||||
|
|
||||||
const handleEditSegment = useCallback(
|
|
||||||
async (segmentId: string, newText: string) => {
|
|
||||||
const { edited: previous, raw } = latestTranscriptRef.current
|
|
||||||
const next = applyTranscriptSegmentEdit(previous || raw, segmentId, newText)
|
|
||||||
if (next === null) return
|
|
||||||
latestTranscriptRef.current = { edited: next, raw }
|
|
||||||
setDetail((prev) => ({ ...prev, editedTranscript: next }))
|
|
||||||
const rollback = (message: string): void => {
|
|
||||||
if (latestTranscriptRef.current.edited === next) {
|
|
||||||
latestTranscriptRef.current = { edited: previous, raw }
|
|
||||||
setDetail((prev) => (prev.editedTranscript === next ? { ...prev, editedTranscript: previous } : prev))
|
|
||||||
}
|
|
||||||
setSegmentEditError(message)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const resp = await window.electronAPI.meetingMode.updateTranscript({
|
|
||||||
sessionId: detail.id,
|
|
||||||
editedTranscript: next,
|
|
||||||
})
|
|
||||||
if (!resp.success) rollback(resp.error.message)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
rollback(err instanceof Error ? err.message : String(err))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[detail.id],
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── 전사 저장 ──
|
|
||||||
const handleSaveTranscript = useCallback(
|
|
||||||
async (editedTranscript: string) => {
|
|
||||||
await window.electronAPI.meetingMode.updateTranscript({
|
|
||||||
sessionId: detail.id,
|
|
||||||
editedTranscript,
|
|
||||||
})
|
|
||||||
setDetail((prev) => ({ ...prev, editedTranscript }))
|
|
||||||
},
|
|
||||||
[detail.id],
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── 문서 내용 자동저장 ──
|
// ── 문서 내용 자동저장 ──
|
||||||
const handleDocContentChange = useCallback(
|
const handleDocContentChange = useCallback(
|
||||||
async (docId: string, content: string) => {
|
async (docId: string, content: string) => {
|
||||||
|
|
@ -257,44 +217,40 @@ export function MeetingDetailTabs({
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── 화자 구분 ──
|
// ── 화자 구분 ── (전사 갱신은 훅이, 세션의 나머지 필드 갱신은 여기서)
|
||||||
|
const { diarize } = transcript
|
||||||
const handleDiarize = useCallback(async () => {
|
const handleDiarize = useCallback(async () => {
|
||||||
if (diarizing) return
|
const updated = await diarize()
|
||||||
setDiarizing(true)
|
if (updated) setDetail(updated)
|
||||||
try {
|
}, [diarize])
|
||||||
const resp = await window.electronAPI.meetingMode.diarize({ sessionId: detail.id })
|
|
||||||
if (resp.success) {
|
|
||||||
const updated = await window.electronAPI.meetingMode.getSession({ sessionId: detail.id })
|
|
||||||
if (updated.success) setDetail(updated.data)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setDiarizing(false)
|
|
||||||
}
|
|
||||||
}, [detail.id, diarizing])
|
|
||||||
|
|
||||||
// ── Granola "Jot & Enhance" AI Note Enhancement ──
|
// ── Granola "Jot & Enhance" AI Note Enhancement ──
|
||||||
|
// main 의 generateDocument 한 번으로 생성한다(전사·메모는 main 이 직접 읽는다).
|
||||||
const handleEnhanceScratchpad = useCallback(async () => {
|
const handleEnhanceScratchpad = useCallback(async () => {
|
||||||
if (!scratchpadNotes.trim() || enhancingNotes) return
|
if (enhancingNotes) return
|
||||||
|
const request = buildNotesEnhanceRequest({
|
||||||
|
sessionId: detail.id,
|
||||||
|
notes: scratchpadNotes,
|
||||||
|
templates,
|
||||||
|
title: '📝 내 메모 기반 회의록 (AI 증강)',
|
||||||
|
})
|
||||||
|
if (!request) return
|
||||||
setEnhancingNotes(true)
|
setEnhancingNotes(true)
|
||||||
|
setEnhanceError(null)
|
||||||
try {
|
try {
|
||||||
const prompt = `다음은 회의 중 작성한 메모와 전체 회의 전사 내용입니다. 사용자 메모의 맥락을 살려 체계적인 핵심 요약 및 결정 사항, 실행 과제로 깔끔하게 확장 정리해주세요:\n\n[사용자 메모]:\n${scratchpadNotes}\n\n[회의 전사]:\n${detail.editedTranscript || detail.rawTranscript}`
|
const resp = await window.electronAPI.meetingMode.generateDocument(request)
|
||||||
const resp = await window.electronAPI.llm.generate({ prompt })
|
if (resp.success) {
|
||||||
if (resp.success && resp.data.text) {
|
setDocuments((prev) => [...prev, resp.data])
|
||||||
const newDocResult = await window.electronAPI.meetingMode.generateDocument({
|
setActiveTabKey(`doc-${resp.data.id}`)
|
||||||
sessionId: detail.id,
|
} else {
|
||||||
templateId: 'custom',
|
setEnhanceError(resp.error.message)
|
||||||
customTitle: '📝 내 메모 기반 회의록 (AI 증강)',
|
|
||||||
customPrompt: prompt,
|
|
||||||
})
|
|
||||||
if (newDocResult.success) {
|
|
||||||
setDocuments((prev) => [...prev, newDocResult.data])
|
|
||||||
setActiveTabKey(`doc-${newDocResult.data.id}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setEnhanceError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
setEnhancingNotes(false)
|
setEnhancingNotes(false)
|
||||||
}
|
}
|
||||||
}, [scratchpadNotes, detail.editedTranscript, detail.rawTranscript, detail.id, enhancingNotes])
|
}, [enhancingNotes, detail.id, scratchpadNotes, templates])
|
||||||
|
|
||||||
// ── 새 문서 생성 다이얼로그 ──
|
// ── 새 문서 생성 다이얼로그 ──
|
||||||
const handleGenerate = useCallback(
|
const handleGenerate = useCallback(
|
||||||
|
|
@ -436,21 +392,23 @@ export function MeetingDetailTabs({
|
||||||
</PhosphorText>
|
</PhosphorText>
|
||||||
</Box>
|
</Box>
|
||||||
<TactileBadge mono tone="mono">
|
<TactileBadge mono tone="mono">
|
||||||
{segments.length} UTTERANCES
|
{transcript.segments.length} UTTERANCES
|
||||||
</TactileBadge>
|
</TactileBadge>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
<Box sx={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||||
<TranscriptTab
|
<TranscriptTab
|
||||||
sessionId={detail.id}
|
segments={transcript.segments}
|
||||||
segments={segments}
|
view={transcript.view}
|
||||||
rawTranscript={detail.rawTranscript}
|
onViewChange={transcript.setView}
|
||||||
editedTranscript={detail.editedTranscript}
|
selectedText={transcript.selectedText}
|
||||||
|
editable={transcript.editable}
|
||||||
memos={detail.memos ?? []}
|
memos={detail.memos ?? []}
|
||||||
onEditSegment={handleEditSegment}
|
onEditSegment={transcript.editSegment}
|
||||||
onSaveTranscript={handleSaveTranscript}
|
onPolish={() => void transcript.polish()}
|
||||||
onDiarize={handleDiarize}
|
polishing={transcript.polishing}
|
||||||
diarizing={diarizing}
|
onDiarize={() => void handleDiarize()}
|
||||||
|
diarizing={transcript.diarizing}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -652,13 +610,13 @@ export function MeetingDetailTabs({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={segmentEditError !== null}
|
open={errorMessage !== null}
|
||||||
autoHideDuration={4000}
|
autoHideDuration={4000}
|
||||||
onClose={() => setSegmentEditError(null)}
|
onClose={clearErrors}
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
>
|
>
|
||||||
<Alert severity="error" onClose={() => setSegmentEditError(null)} sx={{ width: '100%' }}>
|
<Alert severity="error" onClose={clearErrors} sx={{ width: '100%' }}>
|
||||||
{segmentEditError}
|
{errorMessage}
|
||||||
</Alert>
|
</Alert>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,31 @@
|
||||||
// src/renderer/components/meeting/TranscriptTab.tsx
|
// src/renderer/components/meeting/TranscriptTab.tsx
|
||||||
// Phase 14.5: 전사 편집 탭 (상세 페이지용)
|
// Phase 14.5: 전사 편집 탭 (상세 페이지용)
|
||||||
|
|
||||||
import { useState, useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { Box, Switch, FormControlLabel, LinearProgress, Snackbar, Alert } from '@mui/material'
|
import { Box, Switch, FormControlLabel, LinearProgress } from '@mui/material'
|
||||||
import { EditableSegment } from './EditableSegment'
|
import { EditableSegment } from './EditableSegment'
|
||||||
import { PhysicalButton } from '@d3ro/ui/components/ds'
|
import { PhysicalButton } from '@d3ro/ui/components/ds'
|
||||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||||
import { useI18n } from '@d3ro/i18n'
|
import { useI18n } from '@d3ro/i18n'
|
||||||
import { Download, WandSparkles, Speech } from 'lucide-react'
|
import { Download, WandSparkles, Speech } from 'lucide-react'
|
||||||
|
import type { TranscriptSegmentView, TranscriptView } from './transcript-segments'
|
||||||
|
|
||||||
|
// 표시 전용 컴포넌트: 보기 상태·저장·AI 다듬기·화자 구분은 useMeetingTranscript 가 가진다.
|
||||||
|
// 보기 스위치는 onViewChange 만 부르고 어떤 저장도 하지 않는다.
|
||||||
interface TranscriptTabProps {
|
interface TranscriptTabProps {
|
||||||
sessionId: string
|
segments: TranscriptSegmentView[]
|
||||||
segments: Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }>
|
view: TranscriptView
|
||||||
rawTranscript: string | null
|
onViewChange: (view: TranscriptView) => void
|
||||||
editedTranscript: string | null
|
/** 현재 보기의 전사 텍스트 — 다운로드 내용. */
|
||||||
|
selectedText: string
|
||||||
|
/** false 면 세그먼트를 읽기 전용으로 보여 준다(원문 보기). */
|
||||||
|
editable: boolean
|
||||||
memos: Array<{ id: string; timestampMs: number; content: string }>
|
memos: Array<{ id: string; timestampMs: number; content: string }>
|
||||||
onEditSegment: (segmentId: string, newText: string) => void
|
onEditSegment: (segmentId: string, newText: string) => void
|
||||||
onSaveTranscript: (editedTranscript: string) => void
|
onPolish: () => void
|
||||||
onDiarize?: () => Promise<void>
|
polishing: boolean
|
||||||
|
onDiarize?: () => void
|
||||||
diarizing?: boolean
|
diarizing?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,43 +34,30 @@ type TimelineItem =
|
||||||
| { kind: 'memo'; id: string; timestamp: number; content: string }
|
| { kind: 'memo'; id: string; timestamp: number; content: string }
|
||||||
|
|
||||||
export function TranscriptTab({
|
export function TranscriptTab({
|
||||||
sessionId,
|
|
||||||
segments,
|
segments,
|
||||||
rawTranscript,
|
view,
|
||||||
editedTranscript,
|
onViewChange,
|
||||||
|
selectedText,
|
||||||
|
editable,
|
||||||
memos,
|
memos,
|
||||||
onEditSegment,
|
onEditSegment,
|
||||||
onSaveTranscript,
|
onPolish,
|
||||||
|
polishing,
|
||||||
onDiarize,
|
onDiarize,
|
||||||
diarizing = false,
|
diarizing = false,
|
||||||
}: TranscriptTabProps): React.ReactElement {
|
}: TranscriptTabProps): React.ReactElement {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [showEdited, setShowEdited] = useState(true)
|
const showEdited = view === 'edited'
|
||||||
const [polishing, setPolishing] = useState(false)
|
|
||||||
const [polishError, setPolishError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handlePolish = useCallback(async () => {
|
|
||||||
setPolishing(true)
|
|
||||||
setPolishError(null)
|
|
||||||
const resp = await window.electronAPI.meetingMode.polishTranscript({ sessionId })
|
|
||||||
if (resp.success) {
|
|
||||||
onSaveTranscript(resp.data)
|
|
||||||
} else {
|
|
||||||
setPolishError(t('meeting.polishFailed'))
|
|
||||||
}
|
|
||||||
setPolishing(false)
|
|
||||||
}, [sessionId, onSaveTranscript, t])
|
|
||||||
|
|
||||||
const handleDownloadTxt = useCallback(() => {
|
const handleDownloadTxt = useCallback(() => {
|
||||||
const content = showEdited && editedTranscript ? editedTranscript : (rawTranscript ?? '')
|
const blob = new Blob([selectedText], { type: 'text/plain;charset=utf-8' })
|
||||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = 'transcript.txt'
|
a.download = 'transcript.txt'
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}, [showEdited, editedTranscript, rawTranscript])
|
}, [selectedText])
|
||||||
|
|
||||||
// 세그먼트 + 메모를 타임스탬프 순으로 인터리브
|
// 세그먼트 + 메모를 타임스탬프 순으로 인터리브
|
||||||
const timeline: TimelineItem[] = [
|
const timeline: TimelineItem[] = [
|
||||||
|
|
@ -85,16 +79,6 @@ export function TranscriptTab({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
<Snackbar
|
|
||||||
open={polishError !== null}
|
|
||||||
autoHideDuration={4000}
|
|
||||||
onClose={() => setPolishError(null)}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
||||||
>
|
|
||||||
<Alert severity="error" onClose={() => setPolishError(null)} sx={{ width: '100%' }}>
|
|
||||||
{polishError}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
{/* 상단 컨트롤 */}
|
{/* 상단 컨트롤 */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
|
|
@ -102,12 +86,7 @@ export function TranscriptTab({
|
||||||
<Switch
|
<Switch
|
||||||
size="small"
|
size="small"
|
||||||
checked={showEdited}
|
checked={showEdited}
|
||||||
onChange={(e) => {
|
onChange={(e) => onViewChange(e.target.checked ? 'edited' : 'raw')}
|
||||||
setShowEdited(e.target.checked)
|
|
||||||
if (!e.target.checked && rawTranscript) {
|
|
||||||
onSaveTranscript(rawTranscript)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiSwitch-thumb': { bgcolor: d3roPalette.accent.main },
|
'& .MuiSwitch-thumb': { bgcolor: d3roPalette.accent.main },
|
||||||
'& .Mui-checked + .MuiSwitch-track': { bgcolor: d3roPalette.accent.dim },
|
'& .Mui-checked + .MuiSwitch-track': { bgcolor: d3roPalette.accent.dim },
|
||||||
|
|
@ -123,7 +102,7 @@ export function TranscriptTab({
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
<PhysicalButton
|
<PhysicalButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handlePolish}
|
onClick={onPolish}
|
||||||
disabled={polishing || diarizing}
|
disabled={polishing || diarizing}
|
||||||
sx={{ height: 30, fontSize: d3roTypo.engrave.size }}
|
sx={{ height: 30, fontSize: d3roTypo.engrave.size }}
|
||||||
>
|
>
|
||||||
|
|
@ -188,6 +167,7 @@ export function TranscriptTab({
|
||||||
text={item.text}
|
text={item.text}
|
||||||
edited={item.edited}
|
edited={item.edited}
|
||||||
onEdit={onEditSegment}
|
onEdit={onEditSegment}
|
||||||
|
readOnly={!editable}
|
||||||
speaker={item.speaker}
|
speaker={item.speaker}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
// src/renderer/components/meeting/meeting-transcript-model.ts
|
||||||
|
// 회의 상세 전사의 "보기 상태"와 "수정본 저장"을 한곳에서 다루는 React 없는 모델.
|
||||||
|
//
|
||||||
|
// - 조회(보기 전환)는 상태만 바꾸고 절대 저장하지 않는다(CQS).
|
||||||
|
// - 저장은 세그먼트 편집 한 경로뿐이며, 낙관적으로 먼저 반영하고 실패하면 되돌린다.
|
||||||
|
// - AI 다듬기·화자 구분처럼 main 이 이미 저장한 결과는 replaceTranscript 로 반영만 한다.
|
||||||
|
// 영속화는 SaveEditedTranscript 포트로 주입받아 IPC 없이도 검증할 수 있다.
|
||||||
|
|
||||||
|
import type { IPCResult } from '@d3ro/core/errors'
|
||||||
|
import {
|
||||||
|
applyTranscriptSegmentEdit,
|
||||||
|
isTranscriptViewEditable,
|
||||||
|
selectTranscript,
|
||||||
|
type TranscriptView,
|
||||||
|
} from './transcript-segments'
|
||||||
|
|
||||||
|
export interface TranscriptSnapshot {
|
||||||
|
raw: string | null
|
||||||
|
edited: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeetingTranscriptState extends TranscriptSnapshot {
|
||||||
|
view: TranscriptView
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TranscriptSaveOutcome = { ok: true } | { ok: false; message: string }
|
||||||
|
|
||||||
|
/** 수정본 영속화 포트. 렌더러에서는 meetingMode.updateTranscript IPC 가 구현한다. */
|
||||||
|
export type SaveEditedTranscript = (editedTranscript: string) => Promise<TranscriptSaveOutcome>
|
||||||
|
|
||||||
|
export type TranscriptCommandResult =
|
||||||
|
| { status: 'saved' }
|
||||||
|
| { status: 'skipped'; reason: 'read-only-view' | 'no-change' }
|
||||||
|
| { status: 'failed'; message: string }
|
||||||
|
|
||||||
|
function errorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** meetingMode.updateTranscript 형태의 IPC 호출을 저장 포트로 감싼다. */
|
||||||
|
export function saveViaUpdateTranscript(
|
||||||
|
updateTranscript: (params: { sessionId: string; editedTranscript: string }) => Promise<IPCResult<void>>,
|
||||||
|
sessionId: string,
|
||||||
|
): SaveEditedTranscript {
|
||||||
|
return async (editedTranscript) => {
|
||||||
|
const resp = await updateTranscript({ sessionId, editedTranscript })
|
||||||
|
return resp.success ? { ok: true } : { ok: false, message: resp.error.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MeetingTranscriptModel {
|
||||||
|
private state: MeetingTranscriptState
|
||||||
|
private readonly listeners = new Set<() => void>()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
initial: TranscriptSnapshot,
|
||||||
|
private readonly save: SaveEditedTranscript,
|
||||||
|
view: TranscriptView = 'edited',
|
||||||
|
) {
|
||||||
|
this.state = { raw: initial.raw, edited: initial.edited, view }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 불변 스냅샷 — 바뀔 때만 새 객체가 된다(useSyncExternalStore 호환). */
|
||||||
|
readonly getState = (): MeetingTranscriptState => this.state
|
||||||
|
|
||||||
|
readonly subscribe = (listener: () => void): (() => void) => {
|
||||||
|
this.listeners.add(listener)
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 보기만 바꾼다. 저장하지 않는다. */
|
||||||
|
setView(view: TranscriptView): void {
|
||||||
|
if (this.state.view === view) return
|
||||||
|
this.setState({ ...this.state, view })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** main 이 이미 저장한 전사(AI 다듬기·화자 구분 결과)를 반영한다. 저장하지 않는다. */
|
||||||
|
replaceTranscript(snapshot: TranscriptSnapshot): void {
|
||||||
|
if (this.state.raw === snapshot.raw && this.state.edited === snapshot.edited) return
|
||||||
|
this.setState({ ...this.state, raw: snapshot.raw, edited: snapshot.edited })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세그먼트 하나를 고쳐 수정본 전체를 저장한다. 수정본 보기에서만 동작한다.
|
||||||
|
* 화면은 먼저 바꾸고, 저장이 실패하면 그 사이 다시 고치지 않은 경우에만 이전 수정본으로 되돌린다.
|
||||||
|
*/
|
||||||
|
async editSegment(segmentId: string, newText: string): Promise<TranscriptCommandResult> {
|
||||||
|
if (!isTranscriptViewEditable(this.state.view)) {
|
||||||
|
return { status: 'skipped', reason: 'read-only-view' }
|
||||||
|
}
|
||||||
|
const { raw, edited: previous } = this.state
|
||||||
|
const next = applyTranscriptSegmentEdit(selectTranscript('edited', raw, previous), segmentId, newText)
|
||||||
|
if (next === null) return { status: 'skipped', reason: 'no-change' }
|
||||||
|
|
||||||
|
this.setState({ ...this.state, edited: next })
|
||||||
|
let outcome: TranscriptSaveOutcome
|
||||||
|
try {
|
||||||
|
outcome = await this.save(next)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
outcome = { ok: false, message: errorMessage(err) }
|
||||||
|
}
|
||||||
|
if (outcome.ok) return { status: 'saved' }
|
||||||
|
if (this.state.edited === next) {
|
||||||
|
this.setState({ ...this.state, edited: previous })
|
||||||
|
}
|
||||||
|
return { status: 'failed', message: outcome.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(next: MeetingTranscriptState): void {
|
||||||
|
this.state = next
|
||||||
|
for (const listener of this.listeners) listener()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
// src/renderer/components/meeting/notes-enhancement.ts
|
||||||
|
// 회의 노트패드 "AI 증강" 요청을 만드는 순수 함수.
|
||||||
|
//
|
||||||
|
// 증강은 main 의 generateDocument 유스케이스 한 번으로 끝난다. main 이 세션의 전사
|
||||||
|
// (수정본 우선)와 참석자 메모를 LLM 사용자 메시지로 넣으므로, 렌더러는 사용자 노트를
|
||||||
|
// 담은 시스템 프롬프트만 넘긴다. 템플릿은 실제로 존재하는 id 여야 한다 — 'custom' 은
|
||||||
|
// templateType 값일 뿐 id 가 아니어서 MeetingDocTemplateNotFound 로 실패했다.
|
||||||
|
|
||||||
|
import type { MeetingDocTemplate, MeetingGenerateDocParams } from '@d3ro/core/types'
|
||||||
|
|
||||||
|
/** 삭제할 수 없는 빌트인 회의록 템플릿 id(MeetingDocTemplateService BUILTIN_TEMPLATES). */
|
||||||
|
export const NOTES_ENHANCE_FALLBACK_TEMPLATE_ID = 'builtin-minutes'
|
||||||
|
|
||||||
|
/** 불러온 템플릿 중 빌트인 회의록을 고르고, 목록이 아직 없으면 빌트인 id 로 폴백한다. */
|
||||||
|
export function pickNotesEnhanceTemplateId(templates: readonly MeetingDocTemplate[]): string {
|
||||||
|
const builtinMinutes =
|
||||||
|
templates.find((t) => t.id === NOTES_ENHANCE_FALLBACK_TEMPLATE_ID) ??
|
||||||
|
templates.find((t) => t.isBuiltin && t.templateType === 'minutes')
|
||||||
|
return builtinMinutes?.id ?? NOTES_ENHANCE_FALLBACK_TEMPLATE_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LLM 시스템 프롬프트(모델 지시문 — UI 문자열이 아니다). */
|
||||||
|
export function buildNotesEnhancePrompt(notes: string): string {
|
||||||
|
return [
|
||||||
|
'당신은 회의록 작성 비서입니다. 사용자가 회의 중 직접 적은 메모의 맥락을 살려,',
|
||||||
|
'함께 제공되는 회의 전사록과 참석자 메모를 근거로 핵심 요약, 결정 사항, 실행 과제를 체계적으로 확장 정리하세요.',
|
||||||
|
'',
|
||||||
|
'규칙:',
|
||||||
|
'- 전사록에 없는 내용을 지어내지 마세요',
|
||||||
|
'- Markdown 으로 작성하세요',
|
||||||
|
'- 실행 과제는 "- [ ] 할 일" 체크리스트 형식으로 쓰세요',
|
||||||
|
'',
|
||||||
|
'[사용자 메모]',
|
||||||
|
notes.trim(),
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 노트가 비었으면 null — 호출할 것이 없다. */
|
||||||
|
export function buildNotesEnhanceRequest(input: {
|
||||||
|
sessionId: string
|
||||||
|
notes: string
|
||||||
|
templates: readonly MeetingDocTemplate[]
|
||||||
|
title?: string
|
||||||
|
}): MeetingGenerateDocParams | null {
|
||||||
|
if (!input.notes.trim()) return null
|
||||||
|
return {
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
templateId: pickNotesEnhanceTemplateId(input.templates),
|
||||||
|
customPrompt: buildNotesEnhancePrompt(input.notes),
|
||||||
|
customTitle: input.title,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// src/renderer/components/meeting/transcript-segments.ts
|
// src/renderer/components/meeting/transcript-segments.ts
|
||||||
// 회의 상세 뷰의 전사 ↔ 편집 가능한 세그먼트 변환.
|
// 회의 상세 뷰의 전사 ↔ 편집 가능한 세그먼트 변환.
|
||||||
// 줄 형식 자체는 core `meeting-transcript` 가 정본이고, 여기서는 세그먼트 id 규칙과
|
// 줄 형식 자체는 core `meeting-transcript` 가 정본이고, 여기서는 세그먼트 id 규칙,
|
||||||
// "한 세그먼트를 고친 전사 텍스트 재조립"만 담당한다.
|
// 보기(수정본/원문)별 전사 선택, "한 세그먼트를 고친 전사 텍스트 재조립"만 담당한다.
|
||||||
|
|
||||||
import { parseTranscriptLines, formatTranscriptLines } from '@d3ro/core/meeting-transcript'
|
import { parseTranscriptLines, formatTranscriptLines } from '@d3ro/core/meeting-transcript'
|
||||||
|
|
||||||
|
|
@ -27,12 +27,36 @@ export function transcriptSegmentIndex(segmentId: string): number | null {
|
||||||
return Number(raw)
|
return Number(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 수정본이 있으면 수정본을, 없으면 원문을 세그먼트로 나눈다. */
|
/** 회의 상세에서 보여 줄 전사 버전. 'edited' = 수정본(없으면 원문), 'raw' = 원문. */
|
||||||
|
export type TranscriptView = 'edited' | 'raw'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보기에 해당하는 전사 텍스트 — "수정본이 없으면 원문" 규칙의 유일한 정본.
|
||||||
|
* 목록·다운로드·세그먼트 편집이 모두 이 함수로 텍스트를 고른다.
|
||||||
|
*/
|
||||||
|
export function selectTranscript(
|
||||||
|
view: TranscriptView,
|
||||||
|
rawTranscript: string | null,
|
||||||
|
editedTranscript: string | null,
|
||||||
|
): string | null {
|
||||||
|
return view === 'edited' ? editedTranscript || rawTranscript : rawTranscript
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세그먼트 편집은 수정본 보기에서만 허용한다. 원문 보기의 줄 번호로 수정본을 고치면
|
||||||
|
* 두 버전의 줄 구성이 다를 때 엉뚱한 줄이 바뀐다.
|
||||||
|
*/
|
||||||
|
export function isTranscriptViewEditable(view: TranscriptView): boolean {
|
||||||
|
return view === 'edited'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 보기(기본: 수정본, 없으면 원문)에 해당하는 전사를 세그먼트로 나눈다. */
|
||||||
export function parseTranscriptSegments(
|
export function parseTranscriptSegments(
|
||||||
rawTranscript: string | null,
|
rawTranscript: string | null,
|
||||||
editedTranscript: string | null,
|
editedTranscript: string | null,
|
||||||
|
view: TranscriptView = 'edited',
|
||||||
): TranscriptSegmentView[] {
|
): TranscriptSegmentView[] {
|
||||||
return parseTranscriptLines(editedTranscript || rawTranscript).map((line, idx) => ({
|
return parseTranscriptLines(selectTranscript(view, rawTranscript, editedTranscript)).map((line, idx) => ({
|
||||||
id: transcriptSegmentId(idx),
|
id: transcriptSegmentId(idx),
|
||||||
timestamp: line.timestampMs,
|
timestamp: line.timestampMs,
|
||||||
text: line.text,
|
text: line.text,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
// src/renderer/components/meeting/useMeetingTranscript.ts
|
||||||
|
// 회의 상세 전사의 보기 상태·편집 저장·AI 다듬기·화자 구분을 묶는 얇은 React 훅.
|
||||||
|
// 규칙은 MeetingTranscriptModel(React 없음)에 있고, 여기서는 IPC 포트 연결과 진행 상태만 다룬다.
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
|
||||||
|
import type { MeetingSessionDetail } from '@d3ro/core/types'
|
||||||
|
import {
|
||||||
|
MeetingTranscriptModel,
|
||||||
|
saveViaUpdateTranscript,
|
||||||
|
type MeetingTranscriptState,
|
||||||
|
} from './meeting-transcript-model'
|
||||||
|
import {
|
||||||
|
isTranscriptViewEditable,
|
||||||
|
parseTranscriptSegments,
|
||||||
|
selectTranscript,
|
||||||
|
type TranscriptSegmentView,
|
||||||
|
type TranscriptView,
|
||||||
|
} from './transcript-segments'
|
||||||
|
|
||||||
|
export type MeetingTranscriptError =
|
||||||
|
| { kind: 'polishFailed' }
|
||||||
|
| { kind: 'saveFailed'; message: string }
|
||||||
|
|
||||||
|
export interface MeetingTranscriptSource {
|
||||||
|
id: string
|
||||||
|
rawTranscript: string | null
|
||||||
|
editedTranscript: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseMeetingTranscriptResult {
|
||||||
|
view: TranscriptView
|
||||||
|
setView: (view: TranscriptView) => void
|
||||||
|
/** 현재 보기에서 파생한 세그먼트. */
|
||||||
|
segments: TranscriptSegmentView[]
|
||||||
|
/** 현재 보기의 전사 텍스트(다운로드용). */
|
||||||
|
selectedText: string
|
||||||
|
/** 원문 보기에서는 false — 세그먼트는 읽기 전용. */
|
||||||
|
editable: boolean
|
||||||
|
editSegment: (segmentId: string, newText: string) => void
|
||||||
|
polish: () => Promise<void>
|
||||||
|
polishing: boolean
|
||||||
|
/** 화자 구분 후 갱신된 세션을 돌려준다(실패하면 null). */
|
||||||
|
diarize: () => Promise<MeetingSessionDetail | null>
|
||||||
|
diarizing: boolean
|
||||||
|
error: MeetingTranscriptError | null
|
||||||
|
clearError: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMeetingTranscript(source: MeetingTranscriptSource): UseMeetingTranscriptResult {
|
||||||
|
const sessionId = source.id
|
||||||
|
const [model] = useState(
|
||||||
|
() =>
|
||||||
|
new MeetingTranscriptModel(
|
||||||
|
{ raw: source.rawTranscript, edited: source.editedTranscript },
|
||||||
|
saveViaUpdateTranscript((params) => window.electronAPI.meetingMode.updateTranscript(params), sessionId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const state: MeetingTranscriptState = useSyncExternalStore(model.subscribe, model.getState)
|
||||||
|
|
||||||
|
const [polishing, setPolishing] = useState(false)
|
||||||
|
const [diarizing, setDiarizing] = useState(false)
|
||||||
|
const [error, setError] = useState<MeetingTranscriptError | null>(null)
|
||||||
|
|
||||||
|
const segments = useMemo(
|
||||||
|
() => parseTranscriptSegments(state.raw, state.edited, state.view),
|
||||||
|
[state.raw, state.edited, state.view],
|
||||||
|
)
|
||||||
|
const selectedText = selectTranscript(state.view, state.raw, state.edited) ?? ''
|
||||||
|
|
||||||
|
const setView = useCallback((view: TranscriptView) => model.setView(view), [model])
|
||||||
|
|
||||||
|
const editSegment = useCallback(
|
||||||
|
(segmentId: string, newText: string) => {
|
||||||
|
void model.editSegment(segmentId, newText).then((result) => {
|
||||||
|
if (result.status === 'failed') setError({ kind: 'saveFailed', message: result.message })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[model],
|
||||||
|
)
|
||||||
|
|
||||||
|
// main 의 polishTranscript 가 수정본을 저장·동기화까지 하므로 여기서는 반영만 한다.
|
||||||
|
const polish = useCallback(async () => {
|
||||||
|
if (polishing) return
|
||||||
|
setPolishing(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const resp = await window.electronAPI.meetingMode.polishTranscript({ sessionId })
|
||||||
|
if (resp.success) {
|
||||||
|
model.replaceTranscript({ raw: model.getState().raw, edited: resp.data })
|
||||||
|
} else {
|
||||||
|
setError({ kind: 'polishFailed' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError({ kind: 'polishFailed' })
|
||||||
|
} finally {
|
||||||
|
setPolishing(false)
|
||||||
|
}
|
||||||
|
}, [model, polishing, sessionId])
|
||||||
|
|
||||||
|
const diarize = useCallback(async (): Promise<MeetingSessionDetail | null> => {
|
||||||
|
if (diarizing) return null
|
||||||
|
setDiarizing(true)
|
||||||
|
try {
|
||||||
|
const resp = await window.electronAPI.meetingMode.diarize({ sessionId })
|
||||||
|
if (!resp.success) return null
|
||||||
|
const updated = await window.electronAPI.meetingMode.getSession({ sessionId })
|
||||||
|
if (!updated.success) return null
|
||||||
|
model.replaceTranscript({ raw: updated.data.rawTranscript, edited: updated.data.editedTranscript })
|
||||||
|
return updated.data
|
||||||
|
} finally {
|
||||||
|
setDiarizing(false)
|
||||||
|
}
|
||||||
|
}, [diarizing, model, sessionId])
|
||||||
|
|
||||||
|
const clearError = useCallback(() => setError(null), [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
view: state.view,
|
||||||
|
setView,
|
||||||
|
segments,
|
||||||
|
selectedText,
|
||||||
|
editable: isTranscriptViewEditable(state.view),
|
||||||
|
editSegment,
|
||||||
|
polish,
|
||||||
|
polishing,
|
||||||
|
diarize,
|
||||||
|
diarizing,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { MeetingDocTemplate, MeetingDocTemplateType } from '@d3ro/core/types'
|
||||||
|
import {
|
||||||
|
isTranscriptViewEditable,
|
||||||
|
parseTranscriptSegments,
|
||||||
|
selectTranscript,
|
||||||
|
} from '../../src/renderer/components/meeting/transcript-segments'
|
||||||
|
import {
|
||||||
|
MeetingTranscriptModel,
|
||||||
|
saveViaUpdateTranscript,
|
||||||
|
type SaveEditedTranscript,
|
||||||
|
} from '../../src/renderer/components/meeting/meeting-transcript-model'
|
||||||
|
import {
|
||||||
|
NOTES_ENHANCE_FALLBACK_TEMPLATE_ID,
|
||||||
|
buildNotesEnhanceRequest,
|
||||||
|
pickNotesEnhanceTemplateId,
|
||||||
|
} from '../../src/renderer/components/meeting/notes-enhancement'
|
||||||
|
|
||||||
|
const RAW = '[00:01] [화자 1] 음 안녕하세요\n[00:05] 어 오늘 안건은\n[01:10] 예산입니다'
|
||||||
|
const EDITED = '[00:01] [화자 1] 안녕하세요\n[00:05] 오늘 안건은\n[01:10] 예산 확정입니다'
|
||||||
|
|
||||||
|
function okSave() {
|
||||||
|
return vi.fn<SaveEditedTranscript>(async () => ({ ok: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('selectTranscript — 보기별 전사 선택 규칙의 정본', () => {
|
||||||
|
it('수정본 보기는 수정본, 없으면 원문', () => {
|
||||||
|
expect(selectTranscript('edited', RAW, EDITED)).toBe(EDITED)
|
||||||
|
expect(selectTranscript('edited', RAW, null)).toBe(RAW)
|
||||||
|
expect(selectTranscript('edited', RAW, '')).toBe(RAW)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('원문 보기는 수정본이 있어도 원문', () => {
|
||||||
|
expect(selectTranscript('raw', RAW, EDITED)).toBe(RAW)
|
||||||
|
expect(selectTranscript('raw', null, EDITED)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('세그먼트도 보기에 따라 파생된다(기본은 수정본)', () => {
|
||||||
|
expect(parseTranscriptSegments(RAW, EDITED).map((s) => s.text)[2]).toBe('예산 확정입니다')
|
||||||
|
expect(parseTranscriptSegments(RAW, EDITED, 'raw').map((s) => s.text)[0]).toBe('음 안녕하세요')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('원문 보기는 읽기 전용', () => {
|
||||||
|
expect(isTranscriptViewEditable('edited')).toBe(true)
|
||||||
|
expect(isTranscriptViewEditable('raw')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MeetingTranscriptModel — 보기 전환은 저장하지 않는다(CQS)', () => {
|
||||||
|
it('원문 보기로 바꿔도 저장하지 않고, 다시 켜면 수정본이 그대로 있다', () => {
|
||||||
|
const save = okSave()
|
||||||
|
const model = new MeetingTranscriptModel({ raw: RAW, edited: EDITED }, save)
|
||||||
|
const listener = vi.fn()
|
||||||
|
model.subscribe(listener)
|
||||||
|
|
||||||
|
model.setView('raw')
|
||||||
|
expect(model.getState()).toEqual({ raw: RAW, edited: EDITED, view: 'raw' })
|
||||||
|
model.setView('edited')
|
||||||
|
model.setView('edited') // 같은 보기는 알리지 않는다
|
||||||
|
|
||||||
|
expect(save).not.toHaveBeenCalled()
|
||||||
|
expect(model.getState().edited).toBe(EDITED)
|
||||||
|
expect(listener).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('원문 보기에서는 세그먼트 편집을 저장하지 않는다', async () => {
|
||||||
|
const save = okSave()
|
||||||
|
const model = new MeetingTranscriptModel({ raw: RAW, edited: EDITED }, save, 'raw')
|
||||||
|
const result = await model.editSegment('seg-0', '다른 내용')
|
||||||
|
expect(result).toEqual({ status: 'skipped', reason: 'read-only-view' })
|
||||||
|
expect(save).not.toHaveBeenCalled()
|
||||||
|
expect(model.getState().edited).toBe(EDITED)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('수정본 보기의 편집은 수정본 위에 고쳐 저장한다', async () => {
|
||||||
|
const save = okSave()
|
||||||
|
const model = new MeetingTranscriptModel({ raw: RAW, edited: EDITED }, save)
|
||||||
|
const result = await model.editSegment('seg-1', '오늘 안건은 두 가지')
|
||||||
|
const expected = '[00:01] [화자 1] 안녕하세요\n[00:05] 오늘 안건은 두 가지\n[01:10] 예산 확정입니다'
|
||||||
|
expect(result).toEqual({ status: 'saved' })
|
||||||
|
expect(save).toHaveBeenCalledTimes(1)
|
||||||
|
expect(save).toHaveBeenCalledWith(expected)
|
||||||
|
expect(model.getState().edited).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('수정본이 없으면 원문에서 시작해 수정본을 만든다', async () => {
|
||||||
|
const save = okSave()
|
||||||
|
const model = new MeetingTranscriptModel({ raw: RAW, edited: null }, save)
|
||||||
|
await model.editSegment('seg-0', '안녕하세요')
|
||||||
|
expect(model.getState().raw).toBe(RAW)
|
||||||
|
expect(parseTranscriptSegments(RAW, model.getState().edited)[0].text).toBe('안녕하세요')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('바뀐 것이 없으면 저장하지 않는다', async () => {
|
||||||
|
const save = okSave()
|
||||||
|
const model = new MeetingTranscriptModel({ raw: RAW, edited: EDITED }, save)
|
||||||
|
expect(await model.editSegment('seg-0', '안녕하세요')).toEqual({ status: 'skipped', reason: 'no-change' })
|
||||||
|
expect(await model.editSegment('seg-9', 'x')).toEqual({ status: 'skipped', reason: 'no-change' })
|
||||||
|
expect(save).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('저장이 실패하거나 던지면 이전 수정본으로 되돌린다', async () => {
|
||||||
|
const failing = new MeetingTranscriptModel(
|
||||||
|
{ raw: RAW, edited: EDITED },
|
||||||
|
async () => ({ ok: false, message: 'db locked' }),
|
||||||
|
)
|
||||||
|
expect(await failing.editSegment('seg-0', 'x')).toEqual({ status: 'failed', message: 'db locked' })
|
||||||
|
expect(failing.getState().edited).toBe(EDITED)
|
||||||
|
|
||||||
|
const throwing = new MeetingTranscriptModel({ raw: RAW, edited: null }, async () => {
|
||||||
|
throw new Error('ipc down')
|
||||||
|
})
|
||||||
|
expect(await throwing.editSegment('seg-0', 'x')).toEqual({ status: 'failed', message: 'ipc down' })
|
||||||
|
expect(throwing.getState().edited).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('실패를 기다리는 사이 다시 반영된 전사는 덮어쓰지 않는다', async () => {
|
||||||
|
let reject: (value: { ok: false; message: string }) => void = () => undefined
|
||||||
|
const model = new MeetingTranscriptModel(
|
||||||
|
{ raw: RAW, edited: EDITED },
|
||||||
|
() => new Promise((resolve) => { reject = resolve }),
|
||||||
|
)
|
||||||
|
const pending = model.editSegment('seg-0', 'x')
|
||||||
|
model.replaceTranscript({ raw: RAW, edited: 'polished' })
|
||||||
|
reject({ ok: false, message: 'late failure' })
|
||||||
|
await pending
|
||||||
|
expect(model.getState().edited).toBe('polished')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('main 이 저장한 결과(다듬기·화자 구분) 반영은 저장을 부르지 않는다', () => {
|
||||||
|
const save = okSave()
|
||||||
|
const model = new MeetingTranscriptModel({ raw: RAW, edited: null }, save)
|
||||||
|
model.replaceTranscript({ raw: RAW, edited: EDITED })
|
||||||
|
expect(model.getState().edited).toBe(EDITED)
|
||||||
|
expect(save).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateTranscript IPC 결과를 저장 결과로 옮긴다', async () => {
|
||||||
|
const update = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ success: true, data: undefined })
|
||||||
|
.mockResolvedValueOnce({ success: false, error: { code: 1, message: 'nope' } })
|
||||||
|
const save = saveViaUpdateTranscript(update, 'sess-1')
|
||||||
|
expect(await save('t')).toEqual({ ok: true })
|
||||||
|
expect(await save('t')).toEqual({ ok: false, message: 'nope' })
|
||||||
|
expect(update).toHaveBeenCalledWith({ sessionId: 'sess-1', editedTranscript: 't' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('노트패드 AI 증강 요청', () => {
|
||||||
|
const template = (id: string, templateType: string, isBuiltin: boolean): MeetingDocTemplate => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
description: '',
|
||||||
|
templateType: templateType as MeetingDocTemplateType,
|
||||||
|
systemPrompt: '',
|
||||||
|
isBuiltin,
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
it('실제로 존재하는 빌트인 회의록 템플릿 id 를 쓴다("custom" 아님)', () => {
|
||||||
|
const templates = [template('user-1', 'custom', false), template('builtin-minutes', 'minutes', true)]
|
||||||
|
expect(pickNotesEnhanceTemplateId(templates)).toBe('builtin-minutes')
|
||||||
|
expect(pickNotesEnhanceTemplateId([])).toBe(NOTES_ENHANCE_FALLBACK_TEMPLATE_ID)
|
||||||
|
expect(pickNotesEnhanceTemplateId([template('user-1', 'custom', false)])).not.toBe('custom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('노트를 시스템 프롬프트에 담아 generateDocument 한 번으로 요청한다', () => {
|
||||||
|
const req = buildNotesEnhanceRequest({ sessionId: 's1', notes: ' - 예산 재검토 ', templates: [], title: 'T' })
|
||||||
|
expect(req).not.toBeNull()
|
||||||
|
expect(req?.sessionId).toBe('s1')
|
||||||
|
expect(req?.templateId).toBe('builtin-minutes')
|
||||||
|
expect(req?.customTitle).toBe('T')
|
||||||
|
expect(req?.customPrompt).toContain('- 예산 재검토')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('노트가 비었으면 요청하지 않는다', () => {
|
||||||
|
expect(buildNotesEnhanceRequest({ sessionId: 's1', notes: ' ', templates: [] })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('회의 상세 구조 회귀', () => {
|
||||||
|
const dir = path.join(__dirname, '../../src/renderer/components/meeting')
|
||||||
|
const transcriptTab = fs.readFileSync(path.join(dir, 'TranscriptTab.tsx'), 'utf-8')
|
||||||
|
const detailTabs = fs.readFileSync(path.join(dir, 'MeetingDetailTabs.tsx'), 'utf-8')
|
||||||
|
|
||||||
|
it('TranscriptTab 은 저장 명령을 받지 않는다(보기 스위치가 수정본을 덮어쓰지 않는다)', () => {
|
||||||
|
expect(transcriptTab).not.toMatch(/onSaveTranscript/)
|
||||||
|
expect(transcriptTab).not.toMatch(/updateTranscript|polishTranscript/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('노트 증강은 없는 llm.generate 나 가짜 템플릿 id 를 쓰지 않는다', () => {
|
||||||
|
expect(detailTabs).not.toMatch(/llm\.generate/)
|
||||||
|
expect(detailTabs).not.toMatch(/templateId:\s*'custom'/)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue