From 6347115ae7291d084f12a7d67735a896f8598d48 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 02:16:25 +0900 Subject: [PATCH] fix(meeting): stop transcript view toggle from overwriting edits and repair notes enhancement --- .../components/meeting/MeetingDetailTabs.tsx | 154 +++++--------- .../components/meeting/TranscriptTab.tsx | 74 +++---- .../meeting/meeting-transcript-model.ts | 115 ++++++++++ .../components/meeting/notes-enhancement.ts | 52 +++++ .../components/meeting/transcript-segments.ts | 32 ++- .../meeting/useMeetingTranscript.ts | 131 ++++++++++++ ...ting-transcript-view-redteam-r2-32.test.ts | 198 ++++++++++++++++++ 7 files changed, 607 insertions(+), 149 deletions(-) create mode 100644 apps/desktop/src/renderer/components/meeting/meeting-transcript-model.ts create mode 100644 apps/desktop/src/renderer/components/meeting/notes-enhancement.ts create mode 100644 apps/desktop/src/renderer/components/meeting/useMeetingTranscript.ts create mode 100644 apps/desktop/tests/unit/meeting-transcript-view-redteam-r2-32.test.ts diff --git a/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx b/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx index 56fbdc3..0e97fa5 100644 --- a/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx +++ b/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx @@ -1,8 +1,9 @@ // src/renderer/components/meeting/MeetingDetailTabs.tsx // Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad) -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { parseTranscriptSegments, applyTranscriptSegmentEdit } from './transcript-segments' +import React, { useState, useEffect, useCallback, useRef } from 'react' +import { useMeetingTranscript } from './useMeetingTranscript' +import { buildNotesEnhanceRequest } from './notes-enhancement' import { Box, Tabs, @@ -87,7 +88,6 @@ export function MeetingDetailTabs({ const [dialogOpen, setDialogOpen] = useState(false) const [generating, setGenerating] = useState(false) - const [diarizing, setDiarizing] = useState(false) const [editingTitle, setEditingTitle] = useState(false) const [titleDraft, setTitleDraft] = useState(detail.title ?? '') const [viewMode, setViewMode] = useState('split') @@ -97,11 +97,20 @@ export function MeetingDetailTabs({ const [actionItems, setActionItems] = useState>([]) const [enhancingNotes, setEnhancingNotes] = useState(false) - // 세그먼트 파싱 (rawTranscript / editedTranscript 기반) - const segments = useMemo( - () => parseTranscriptSegments(detail.rawTranscript, detail.editedTranscript), - [detail.rawTranscript, detail.editedTranscript], - ) + // 전사 보기 상태·편집 저장·AI 다듬기·화자 구분은 훅이 전담한다(보기 전환은 저장하지 않는다). + const transcript = useMeetingTranscript(initialDetail) + const [enhanceError, setEnhanceError] = useState(null) + 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(() => { @@ -151,55 +160,6 @@ export function MeetingDetailTabs({ setEditingTitle(false) }, [detail.id, titleDraft]) - // ── 세그먼트 편집 ── - // 세그먼트는 전사 줄에서 파생되므로, 고친 줄로 전사 전체를 재조립해 updateTranscript 로 저장한다. - // 화면은 낙관적으로 먼저 바꾸고, 저장이 실패하면 이전 수정본으로 되돌린다. - const [segmentEditError, setSegmentEditError] = useState(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( async (docId: string, content: string) => { @@ -257,44 +217,40 @@ export function MeetingDetailTabs({ [], ) - // ── 화자 구분 ── + // ── 화자 구분 ── (전사 갱신은 훅이, 세션의 나머지 필드 갱신은 여기서) + const { diarize } = transcript const handleDiarize = useCallback(async () => { - if (diarizing) return - setDiarizing(true) - try { - 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]) + const updated = await diarize() + if (updated) setDetail(updated) + }, [diarize]) // ── Granola "Jot & Enhance" AI Note Enhancement ── + // main 의 generateDocument 한 번으로 생성한다(전사·메모는 main 이 직접 읽는다). 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) + setEnhanceError(null) try { - const prompt = `다음은 회의 중 작성한 메모와 전체 회의 전사 내용입니다. 사용자 메모의 맥락을 살려 체계적인 핵심 요약 및 결정 사항, 실행 과제로 깔끔하게 확장 정리해주세요:\n\n[사용자 메모]:\n${scratchpadNotes}\n\n[회의 전사]:\n${detail.editedTranscript || detail.rawTranscript}` - const resp = await window.electronAPI.llm.generate({ prompt }) - if (resp.success && resp.data.text) { - const newDocResult = await window.electronAPI.meetingMode.generateDocument({ - sessionId: detail.id, - templateId: 'custom', - customTitle: '📝 내 메모 기반 회의록 (AI 증강)', - customPrompt: prompt, - }) - if (newDocResult.success) { - setDocuments((prev) => [...prev, newDocResult.data]) - setActiveTabKey(`doc-${newDocResult.data.id}`) - } + const resp = await window.electronAPI.meetingMode.generateDocument(request) + if (resp.success) { + setDocuments((prev) => [...prev, resp.data]) + setActiveTabKey(`doc-${resp.data.id}`) + } else { + setEnhanceError(resp.error.message) } + } catch (err: unknown) { + setEnhanceError(err instanceof Error ? err.message : String(err)) } finally { setEnhancingNotes(false) } - }, [scratchpadNotes, detail.editedTranscript, detail.rawTranscript, detail.id, enhancingNotes]) + }, [enhancingNotes, detail.id, scratchpadNotes, templates]) // ── 새 문서 생성 다이얼로그 ── const handleGenerate = useCallback( @@ -436,21 +392,23 @@ export function MeetingDetailTabs({ - {segments.length} UTTERANCES + {transcript.segments.length} UTTERANCES void transcript.polish()} + polishing={transcript.polishing} + onDiarize={() => void handleDiarize()} + diarizing={transcript.diarizing} /> @@ -652,13 +610,13 @@ export function MeetingDetailTabs({ /> setSegmentEditError(null)} + onClose={clearErrors} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > - setSegmentEditError(null)} sx={{ width: '100%' }}> - {segmentEditError} + + {errorMessage} diff --git a/apps/desktop/src/renderer/components/meeting/TranscriptTab.tsx b/apps/desktop/src/renderer/components/meeting/TranscriptTab.tsx index 68ddf3f..7dc7a80 100644 --- a/apps/desktop/src/renderer/components/meeting/TranscriptTab.tsx +++ b/apps/desktop/src/renderer/components/meeting/TranscriptTab.tsx @@ -1,24 +1,31 @@ // src/renderer/components/meeting/TranscriptTab.tsx // Phase 14.5: 전사 편집 탭 (상세 페이지용) -import { useState, useCallback } from 'react' -import { Box, Switch, FormControlLabel, LinearProgress, Snackbar, Alert } from '@mui/material' +import { useCallback } from 'react' +import { Box, Switch, FormControlLabel, LinearProgress } from '@mui/material' import { EditableSegment } from './EditableSegment' import { PhysicalButton } from '@d3ro/ui/components/ds' import { PhosphorText } from '@d3ro/ui/components/ds' import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import { Download, WandSparkles, Speech } from 'lucide-react' +import type { TranscriptSegmentView, TranscriptView } from './transcript-segments' +// 표시 전용 컴포넌트: 보기 상태·저장·AI 다듬기·화자 구분은 useMeetingTranscript 가 가진다. +// 보기 스위치는 onViewChange 만 부르고 어떤 저장도 하지 않는다. interface TranscriptTabProps { - sessionId: string - segments: Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }> - rawTranscript: string | null - editedTranscript: string | null + segments: TranscriptSegmentView[] + view: TranscriptView + onViewChange: (view: TranscriptView) => void + /** 현재 보기의 전사 텍스트 — 다운로드 내용. */ + selectedText: string + /** false 면 세그먼트를 읽기 전용으로 보여 준다(원문 보기). */ + editable: boolean memos: Array<{ id: string; timestampMs: number; content: string }> onEditSegment: (segmentId: string, newText: string) => void - onSaveTranscript: (editedTranscript: string) => void - onDiarize?: () => Promise + onPolish: () => void + polishing: boolean + onDiarize?: () => void diarizing?: boolean } @@ -27,43 +34,30 @@ type TimelineItem = | { kind: 'memo'; id: string; timestamp: number; content: string } export function TranscriptTab({ - sessionId, segments, - rawTranscript, - editedTranscript, + view, + onViewChange, + selectedText, + editable, memos, onEditSegment, - onSaveTranscript, + onPolish, + polishing, onDiarize, diarizing = false, }: TranscriptTabProps): React.ReactElement { const { t } = useI18n() - const [showEdited, setShowEdited] = useState(true) - const [polishing, setPolishing] = useState(false) - const [polishError, setPolishError] = useState(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 showEdited = view === 'edited' const handleDownloadTxt = useCallback(() => { - const content = showEdited && editedTranscript ? editedTranscript : (rawTranscript ?? '') - const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }) + const blob = new Blob([selectedText], { type: 'text/plain;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = 'transcript.txt' a.click() URL.revokeObjectURL(url) - }, [showEdited, editedTranscript, rawTranscript]) + }, [selectedText]) // 세그먼트 + 메모를 타임스탬프 순으로 인터리브 const timeline: TimelineItem[] = [ @@ -85,16 +79,6 @@ export function TranscriptTab({ return ( - setPolishError(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - setPolishError(null)} sx={{ width: '100%' }}> - {polishError} - - {/* 상단 컨트롤 */} { - setShowEdited(e.target.checked) - if (!e.target.checked && rawTranscript) { - onSaveTranscript(rawTranscript) - } - }} + onChange={(e) => onViewChange(e.target.checked ? 'edited' : 'raw')} sx={{ '& .MuiSwitch-thumb': { bgcolor: d3roPalette.accent.main }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: d3roPalette.accent.dim }, @@ -123,7 +102,7 @@ export function TranscriptTab({ @@ -188,6 +167,7 @@ export function TranscriptTab({ text={item.text} edited={item.edited} onEdit={onEditSegment} + readOnly={!editable} speaker={item.speaker} /> ) diff --git a/apps/desktop/src/renderer/components/meeting/meeting-transcript-model.ts b/apps/desktop/src/renderer/components/meeting/meeting-transcript-model.ts new file mode 100644 index 0000000..faa4276 --- /dev/null +++ b/apps/desktop/src/renderer/components/meeting/meeting-transcript-model.ts @@ -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 + +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>, + 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 { + 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() + } +} diff --git a/apps/desktop/src/renderer/components/meeting/notes-enhancement.ts b/apps/desktop/src/renderer/components/meeting/notes-enhancement.ts new file mode 100644 index 0000000..4e21ab9 --- /dev/null +++ b/apps/desktop/src/renderer/components/meeting/notes-enhancement.ts @@ -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, + } +} diff --git a/apps/desktop/src/renderer/components/meeting/transcript-segments.ts b/apps/desktop/src/renderer/components/meeting/transcript-segments.ts index ac5c287..f8c46a8 100644 --- a/apps/desktop/src/renderer/components/meeting/transcript-segments.ts +++ b/apps/desktop/src/renderer/components/meeting/transcript-segments.ts @@ -1,7 +1,7 @@ // src/renderer/components/meeting/transcript-segments.ts // 회의 상세 뷰의 전사 ↔ 편집 가능한 세그먼트 변환. -// 줄 형식 자체는 core `meeting-transcript` 가 정본이고, 여기서는 세그먼트 id 규칙과 -// "한 세그먼트를 고친 전사 텍스트 재조립"만 담당한다. +// 줄 형식 자체는 core `meeting-transcript` 가 정본이고, 여기서는 세그먼트 id 규칙, +// 보기(수정본/원문)별 전사 선택, "한 세그먼트를 고친 전사 텍스트 재조립"만 담당한다. import { parseTranscriptLines, formatTranscriptLines } from '@d3ro/core/meeting-transcript' @@ -27,12 +27,36 @@ export function transcriptSegmentIndex(segmentId: string): number | null { 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( rawTranscript: string | null, editedTranscript: string | null, + view: TranscriptView = 'edited', ): TranscriptSegmentView[] { - return parseTranscriptLines(editedTranscript || rawTranscript).map((line, idx) => ({ + return parseTranscriptLines(selectTranscript(view, rawTranscript, editedTranscript)).map((line, idx) => ({ id: transcriptSegmentId(idx), timestamp: line.timestampMs, text: line.text, diff --git a/apps/desktop/src/renderer/components/meeting/useMeetingTranscript.ts b/apps/desktop/src/renderer/components/meeting/useMeetingTranscript.ts new file mode 100644 index 0000000..f1147d2 --- /dev/null +++ b/apps/desktop/src/renderer/components/meeting/useMeetingTranscript.ts @@ -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 + polishing: boolean + /** 화자 구분 후 갱신된 세션을 돌려준다(실패하면 null). */ + diarize: () => Promise + 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(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 => { + 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, + } +} diff --git a/apps/desktop/tests/unit/meeting-transcript-view-redteam-r2-32.test.ts b/apps/desktop/tests/unit/meeting-transcript-view-redteam-r2-32.test.ts new file mode 100644 index 0000000..3ec1107 --- /dev/null +++ b/apps/desktop/tests/unit/meeting-transcript-view-redteam-r2-32.test.ts @@ -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(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'/) + }) +})