fix(meeting): persist transcript segment edits, restore live recording view, and route settings deep links by tab id
This commit is contained in:
parent
a8983c583a
commit
a85b24d385
15 changed files with 974 additions and 69 deletions
|
|
@ -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)
|
||||
|
|
|
|||
146
apps/desktop/src/main/services/meeting/live-segment-edits.ts
Normal file
146
apps/desktop/src/main/services/meeting/live-segment-edits.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue