fix(meeting): persist transcript segment edits, restore live recording view, and route settings deep links by tab id

This commit is contained in:
Yun Chan 2026-09-28 00:54:03 +09:00
parent a8983c583a
commit a85b24d385
15 changed files with 974 additions and 69 deletions

View file

@ -6,7 +6,11 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ErrorCode, ipcSuccess, ipcError, D3ROError } from '@d3ro/core/errors'
import { getMeetingModeService } from '../services/MeetingModeService'
import { getLogger } from '../services/LoggerService'
import { LiveSegmentEditor } from '../services/meeting/live-segment-edits'
import type { LiveSegmentEditResult } from '../services/meeting/live-segment-edits'
import type {
CaptionSegment,
MeetingSessionDetail,
MeetingAddMemoParams,
MeetingGetSessionsParams,
MeetingGetSessionParams,
@ -19,8 +23,47 @@ import type {
const logger = getLogger('meeting-mode-handlers')
/** 렌더러 editSegment 요청 — preload `meetingMode.editSegment` 와 같은 모양 */
interface MeetingEditSegmentParams {
sessionId: string
segmentId: string
text: string
}
let liveSegmentEditor: LiveSegmentEditor | null = null
/** MeetingModeService 를 LiveMeetingPort 로 감싸 실시간 세그먼트 편집기를 붙인다(재등록 시 이전 구독 해지). */
function attachLiveSegmentEditor(): LiveSegmentEditor {
liveSegmentEditor?.dispose()
const service = getMeetingModeService()
liveSegmentEditor = new LiveSegmentEditor(
{
getStateInfo: () => service.getStateInfo(),
onSegment: (listener: (segment: CaptionSegment) => void) => {
service.on('segment', listener)
return () => { service.off('segment', listener) }
},
onSessionCompleted: (listener: (detail: MeetingSessionDetail) => void) => {
service.on('session-completed', listener)
return () => { service.off('session-completed', listener) }
},
saveEditedTranscript: (sessionId, editedTranscript) =>
service.updateTranscript(sessionId, editedTranscript),
},
logger,
)
return liveSegmentEditor
}
const EDIT_SEGMENT_FAILURES: Readonly<Record<Exclude<LiveSegmentEditResult, 'ok'>, { code: ErrorCode; message: string }>> = {
'not-live': { code: ErrorCode.MeetingNotRecording, message: '진행 중인 회의 세션이 아닙니다' },
'unknown-segment': { code: ErrorCode.MeetingTranscriptUpdateFailed, message: '수정할 발화를 찾을 수 없습니다' },
empty: { code: ErrorCode.MeetingTranscriptUpdateFailed, message: '빈 내용으로는 수정할 수 없습니다' },
}
export function registerMeetingModeHandlers(): void {
const ch = IPC_CHANNELS.MEETING_MODE
const liveEditor = attachLiveSegmentEditor()
ipcMain.handle(ch.START_RECORDING, async (_event, params?: { force?: boolean }) => {
try {
@ -143,6 +186,23 @@ export function registerMeetingModeHandlers(): void {
}
})
// 녹음 중 실시간 세그먼트 수정 — 세션 완료 시 editedTranscript 로 반영된다.
ipcMain.handle(ch.EDIT_SEGMENT, (_event, params: MeetingEditSegmentParams) => {
try {
const result = liveEditor.edit(params.sessionId, params.segmentId, params.text)
if (result === 'ok') return ipcSuccess(undefined)
const failure = EDIT_SEGMENT_FAILURES[result]
logger.warn(`editSegment 거부(${result}): sessionId=${params.sessionId}, segmentId=${params.segmentId}`)
return ipcError(failure.code, failure.message)
} catch (err) {
logger.error(`editSegment 실패: ${err instanceof Error ? err.message : String(err)}`)
return ipcError(
ErrorCode.MeetingTranscriptUpdateFailed,
err instanceof Error ? err.message : String(err),
)
}
})
ipcMain.handle(ch.GENERATE_DOCUMENT, async (_event, params: MeetingGenerateDocParams) => {
try {
const doc = await getMeetingModeService().generateDocument(params)

View file

@ -0,0 +1,146 @@
// src/main/services/meeting/live-segment-edits.ts
// 회의 녹음 중 사용자가 고친 실시간 세그먼트를 모아 두었다가, 세션이 완료되면
// 원문 전사(rawTranscript)는 그대로 두고 수정본(editedTranscript)에 반영한다.
//
// MeetingModeService 에 직접 기대지 않고 LiveMeetingPort 로만 소통한다(DIP) —
// IPC 핸들러가 서비스 어댑터를 넘기고, 테스트는 가짜 포트를 넘긴다.
import type {
CaptionSegment,
MeetingModeStateInfo,
MeetingSessionDetail,
} from '@d3ro/core/types'
/** 세그먼트는 전사에서 한 줄로 저장되므로, 수정 텍스트의 줄바꿈은 공백으로 접는다. */
export function normalizeSegmentText(text: string): string {
return text.replace(/\s*\r?\n\s*/g, ' ').trim()
}
/**
* 원문 전사에 실시간 세그먼트 수정을 적용한다.
*
* rawTranscript 는 세그먼트마다 `<시각 접두사> <text>` 를 '\n' 으로 이은 문자열이다
* (세그먼트 텍스트에 줄바꿈이 있으면 그 세그먼트가 여러 줄을 차지한다).
* 접두사 형식에 의존하지 않도록, 각 세그먼트가 차지하는 줄 묶음이 원래 텍스트로
* 끝나는지 확인한 뒤 그 꼬리만 바꾼다. 구조가 맞지 않으면 엉뚱한 줄을 덮어쓰지 않도록 null.
* 바뀐 것이 없어도 null.
*/
export function applyLiveSegmentEdits(
rawTranscript: string,
segments: ReadonlyArray<Pick<CaptionSegment, 'id' | 'text'>>,
edits: ReadonlyMap<string, string>,
): string | null {
if (edits.size === 0 || segments.length === 0) return null
const lines = rawTranscript.split('\n')
const spans = segments.map((s) => s.text.split('\n').length)
const expectedLines = spans.reduce((sum, n) => sum + n, 0)
if (expectedLines !== lines.length) return null
const out: string[] = []
let cursor = 0
let changed = false
segments.forEach((segment, idx) => {
const block = lines.slice(cursor, cursor + spans[idx]).join('\n')
cursor += spans[idx]
const edited = edits.get(segment.id)
if (edited === undefined || edited === segment.text) {
out.push(block)
return
}
if (!block.endsWith(segment.text)) {
out.push(block)
return
}
out.push(block.slice(0, block.length - segment.text.length) + edited)
changed = true
})
// 구조 검증에서 끝을 맞추지 못한 세그먼트는 원문을 유지한다(부분 적용).
return changed ? out.join('\n') : null
}
/** 진행 중인 회의 세션에 대한 최소 계약 — MeetingModeService 가 어댑터로 구현한다. */
export interface LiveMeetingPort {
getStateInfo(): MeetingModeStateInfo
/** 구독하고, 해지 함수를 돌려준다 */
onSegment(listener: (segment: CaptionSegment) => void): () => void
onSessionCompleted(listener: (detail: MeetingSessionDetail) => void): () => void
saveEditedTranscript(sessionId: string, editedTranscript: string): void
}
export interface LiveSegmentEditLogger {
warn(message: string): void
info(message: string): void
}
export type LiveSegmentEditResult = 'ok' | 'not-live' | 'unknown-segment' | 'empty'
export class LiveSegmentEditor {
private sessionId: string | null = null
private segments: CaptionSegment[] = []
private readonly edits = new Map<string, string>()
private readonly unsubscribers: Array<() => void>
constructor(
private readonly port: LiveMeetingPort,
private readonly logger?: LiveSegmentEditLogger,
) {
this.unsubscribers = [
port.onSegment((segment) => this.recordSegment(segment)),
port.onSessionCompleted((detail) => this.flush(detail)),
]
}
/** 녹음·후처리 중인 세션의 세그먼트 수정을 기록한다. 실제 저장은 세션 완료 시. */
edit(sessionId: string, segmentId: string, text: string): LiveSegmentEditResult {
const state = this.port.getStateInfo()
if (state.state === 'idle' || state.sessionId !== sessionId) return 'not-live'
this.syncSession(sessionId)
if (!this.segments.some((s) => s.id === segmentId)) return 'unknown-segment'
const normalized = normalizeSegmentText(text)
if (!normalized) return 'empty'
this.edits.set(segmentId, normalized)
return 'ok'
}
dispose(): void {
for (const unsubscribe of this.unsubscribers) unsubscribe()
this.unsubscribers.length = 0
this.reset(null)
}
private recordSegment(segment: CaptionSegment): void {
const sessionId = this.port.getStateInfo().sessionId
if (!sessionId) return
this.syncSession(sessionId)
this.segments.push(segment)
}
private syncSession(sessionId: string): void {
if (this.sessionId !== sessionId) this.reset(sessionId)
}
private reset(sessionId: string | null): void {
this.sessionId = sessionId
this.segments = []
this.edits.clear()
}
/** 서비스의 emit 안에서 불린다 — 예외가 새면 후처리가 실패로 기록되므로 여기서 삼킨다. */
private flush(detail: MeetingSessionDetail): void {
try {
if (detail.id !== this.sessionId || this.edits.size === 0) return
const next = applyLiveSegmentEdits(detail.rawTranscript ?? '', this.segments, this.edits)
const editCount = this.edits.size
if (next === null) {
this.logger?.warn(`실시간 세그먼트 수정 ${editCount}건을 전사에 맞추지 못했습니다: sessionId=${detail.id}`)
return
}
this.port.saveEditedTranscript(detail.id, next)
this.logger?.info(`실시간 세그먼트 수정 ${editCount}건 반영: sessionId=${detail.id}`)
} catch (err) {
this.logger?.warn(`실시간 세그먼트 수정 반영 실패: ${err instanceof Error ? err.message : String(err)}`)
} finally {
this.reset(null)
}
}
}