feat(desktop): send meeting transcript segments and preset prompt edits to the phone
The phone draws a meeting from its transcript segments before the edited
transcript, so desktop edits, auto-polish and diarization never showed there.
Every desktop transcript change now rebuilds the meeting's segments from its
[MM:SS] [speaker] lines and trims the rest; the line parser moves to
@d3ro/core/meeting-transcript and the meeting view uses it too.
Prompt edits of the four desktop presets that exist on the phone update the
server preset row (a reset restores its default; {{targetLanguage}} is sent
as English, the only target on both sides), and edits made on another desktop
come back. The free-prompt preset has no phone counterpart and stays local.
This commit is contained in:
parent
08c6504589
commit
2aac10fc5d
14 changed files with 552 additions and 28 deletions
|
|
@ -159,10 +159,11 @@ class CustomInstructionService {
|
||||||
|
|
||||||
const existing = instructions[index]
|
const existing = instructions[index]
|
||||||
|
|
||||||
// 프리셋은 프롬프트만 수정 가능
|
// 프리셋은 프롬프트만 수정 가능 — 폰과 짝이 있는 프리셋은 서버 프리셋 행으로도 올린다
|
||||||
if (existing.isBuiltin) {
|
if (existing.isBuiltin) {
|
||||||
if (data.prompt !== undefined) {
|
if (data.prompt !== undefined) {
|
||||||
instructions[index] = { ...existing, prompt: data.prompt, updatedAt: Date.now() }
|
instructions[index] = { ...existing, prompt: data.prompt, updatedAt: Date.now() }
|
||||||
|
getCloudSyncService().pushOne('builtin_instructions', id)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
instructions[index] = { ...existing, ...data, updatedAt: Date.now() }
|
instructions[index] = { ...existing, ...data, updatedAt: Date.now() }
|
||||||
|
|
@ -203,6 +204,15 @@ class CustomInstructionService {
|
||||||
saveInstructions()
|
saveInstructions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 동기화: 다른 기기에서 바꾼 프리셋 프롬프트를 반영한다(outbox에 넣지 않는다). 바꿨으면 true */
|
||||||
|
applyRemoteBuiltinPrompt(id: string, prompt: string): boolean {
|
||||||
|
const index = instructions.findIndex((i) => i.id === id)
|
||||||
|
if (index === -1 || !instructions[index].isBuiltin || instructions[index].prompt === prompt) return false
|
||||||
|
instructions[index] = { ...instructions[index], prompt, updatedAt: Date.now() }
|
||||||
|
saveInstructions()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/** 동기화: 다른 기기에서 지운 사용자 명령을 지운다. 프리셋은 건드리지 않는다. */
|
/** 동기화: 다른 기기에서 지운 사용자 명령을 지운다. 프리셋은 건드리지 않는다. */
|
||||||
removeRemote(id: string): boolean {
|
removeRemote(id: string): boolean {
|
||||||
const index = instructions.findIndex((i) => i.id === id)
|
const index = instructions.findIndex((i) => i.id === id)
|
||||||
|
|
@ -252,6 +262,7 @@ class CustomInstructionService {
|
||||||
|
|
||||||
instructions = [...builtins, ...userInstructions]
|
instructions = [...builtins, ...userInstructions]
|
||||||
saveInstructions()
|
saveInstructions()
|
||||||
|
for (const builtin of builtins) getCloudSyncService().pushOne('builtin_instructions', builtin.id)
|
||||||
logger.info('Builtin instructions reset')
|
logger.info('Builtin instructions reset')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,6 +271,11 @@ class CustomInstructionService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 데스크톱 프리셋의 기본 프롬프트(되돌리기·수정 여부 판정용). 프리셋이 아니면 null */
|
||||||
|
export function getBuiltinInstructionDefaultPrompt(id: string): string | null {
|
||||||
|
return BUILTIN_INSTRUCTIONS.find((b) => b.id === id)?.prompt ?? null
|
||||||
|
}
|
||||||
|
|
||||||
let instance: CustomInstructionService | null = null
|
let instance: CustomInstructionService | null = null
|
||||||
|
|
||||||
export function getCustomInstructionService(): CustomInstructionService {
|
export function getCustomInstructionService(): CustomInstructionService {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@ import {
|
||||||
} from './memo-tag-sync'
|
} from './memo-tag-sync'
|
||||||
import { fetchRemoteAudioOwners, isAudioSyncEnabled, listLocalAudioOwners, pushAudio } from './audio-sync'
|
import { fetchRemoteAudioOwners, isAudioSyncEnabled, listLocalAudioOwners, pushAudio } from './audio-sync'
|
||||||
import { SETTINGS_ROW_ID, applyRemoteSettings, fetchRemoteSettings, pushSettings } from './settings-sync'
|
import { SETTINGS_ROW_ID, applyRemoteSettings, fetchRemoteSettings, pushSettings } from './settings-sync'
|
||||||
|
import {
|
||||||
|
applyRemoteBuiltins,
|
||||||
|
editedBuiltinsDifferingFrom,
|
||||||
|
fetchRemoteBuiltins,
|
||||||
|
pushBuiltinPrompts,
|
||||||
|
} from './builtin-instruction-sync'
|
||||||
import {
|
import {
|
||||||
completeEntry,
|
completeEntry,
|
||||||
dropEntry,
|
dropEntry,
|
||||||
|
|
@ -223,6 +229,11 @@ export class SyncEngine extends EventEmitter {
|
||||||
enqueueChange('user_settings', SETTINGS_ROW_ID, 'upsert', now)
|
enqueueChange('user_settings', SETTINGS_ROW_ID, 'upsert', now)
|
||||||
queued++
|
queued++
|
||||||
}
|
}
|
||||||
|
// 데스크톱에서 고친 프리셋 프롬프트가 서버와 다르면 올린다(폰은 프리셋을 고칠 수 없다).
|
||||||
|
for (const id of editedBuiltinsDifferingFrom(await fetchRemoteBuiltins(ctx))) {
|
||||||
|
enqueueChange('builtin_instructions', id, 'upsert', now)
|
||||||
|
queued++
|
||||||
|
}
|
||||||
if (isAudioSyncEnabled()) {
|
if (isAudioSyncEnabled()) {
|
||||||
for (const owner of ['history', 'meeting'] as const) {
|
for (const owner of ['history', 'meeting'] as const) {
|
||||||
const local = listLocalAudioOwners(owner)
|
const local = listLocalAudioOwners(owner)
|
||||||
|
|
@ -290,6 +301,7 @@ export class SyncEngine extends EventEmitter {
|
||||||
'meeting_memos',
|
'meeting_memos',
|
||||||
'meeting_documents',
|
'meeting_documents',
|
||||||
'custom_instructions',
|
'custom_instructions',
|
||||||
|
'builtin_instructions',
|
||||||
'user_settings',
|
'user_settings',
|
||||||
'user_templates',
|
'user_templates',
|
||||||
'knowledge_documents',
|
'knowledge_documents',
|
||||||
|
|
@ -326,6 +338,8 @@ export class SyncEngine extends EventEmitter {
|
||||||
return pushMemoTagAdds(ctx, ids)
|
return pushMemoTagAdds(ctx, ids)
|
||||||
case 'user_settings':
|
case 'user_settings':
|
||||||
return [{ id: SETTINGS_ROW_ID, error: await pushSettings(ctx) }]
|
return [{ id: SETTINGS_ROW_ID, error: await pushSettings(ctx) }]
|
||||||
|
case 'builtin_instructions':
|
||||||
|
return pushBuiltinPrompts(ctx, ids)
|
||||||
case 'history_audio':
|
case 'history_audio':
|
||||||
return pushAudio(ctx, 'history', ids)
|
return pushAudio(ctx, 'history', ids)
|
||||||
case 'meeting_audio':
|
case 'meeting_audio':
|
||||||
|
|
@ -416,6 +430,19 @@ export class SyncEngine extends EventEmitter {
|
||||||
logger.warn(`Pull user_settings failed: ${message}`)
|
logger.warn(`Pull user_settings failed: ${message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const builtins = await fetchRemoteBuiltins({ remote: this.remote, userId: this.userId })
|
||||||
|
const applied = applyRemoteBuiltins(builtins, new Set(pendingOps('builtin_instructions').keys()))
|
||||||
|
if (applied > 0) {
|
||||||
|
changed.add('custom_instructions')
|
||||||
|
result.pulled += applied
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
result.errors.push(`builtin_instructions: ${message}`)
|
||||||
|
logger.warn(`Pull builtin_instructions failed: ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
result.changed = [...changed]
|
result.changed = [...changed]
|
||||||
if (result.errors.length === 0) setSyncState('lastPullAt', String(this.now()))
|
if (result.errors.length === 0) setSyncState('lastPullAt', String(this.now()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
118
apps/desktop/src/main/services/sync/builtin-instruction-sync.ts
Normal file
118
apps/desktop/src/main/services/sync/builtin-instruction-sync.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
// src/main/services/sync/builtin-instruction-sync.ts
|
||||||
|
// 데스크톱 프리셋 명령의 프롬프트 수정을 서버 프리셋 행(builtin_key)에 맞춘다.
|
||||||
|
// 폰은 명령을 서버 행의 프롬프트로 실행하므로, 데스크톱에서 고친 프롬프트가 폰에서도 쓰인다.
|
||||||
|
// 서버 행은 RLS상 직접 못 고치므로 sync_set_builtin_instruction_prompt_v1 로만 바꾼다(null = 기본값 복원).
|
||||||
|
|
||||||
|
import { getBuiltinInstructionDefaultPrompt, getCustomInstructionService } from '../CustomInstructionService'
|
||||||
|
import type { PushContext } from './sync-adapters'
|
||||||
|
import { toSyncRemoteError, type PushOutcome } from './sync-types'
|
||||||
|
|
||||||
|
/** 데스크톱 프리셋 id → 서버 builtin_key. '자유 프롬프트'는 실행 때 입력받는 명령이라 폰에 대응이 없다. */
|
||||||
|
export const DESKTOP_TO_SERVER_BUILTIN: Readonly<Record<string, string>> = {
|
||||||
|
'builtin-translate': 'translate_en',
|
||||||
|
'builtin-summarize': 'summarize',
|
||||||
|
'builtin-formal': 'formal',
|
||||||
|
'builtin-explain-code': 'explain_code',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVER_TO_DESKTOP_BUILTIN: Readonly<Record<string, string>> = Object.fromEntries(
|
||||||
|
Object.entries(DESKTOP_TO_SERVER_BUILTIN).map(([desktop, server]) => [server, desktop])
|
||||||
|
)
|
||||||
|
|
||||||
|
export function isSyncedBuiltin(id: string): boolean {
|
||||||
|
return id in DESKTOP_TO_SERVER_BUILTIN
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폰은 `{{text}}` 외의 자리표시자를 치환하지 않는다. 서버 번역 프리셋은 영어 고정(translate_en)이고
|
||||||
|
* 데스크톱 번역도 대상 언어 설정이 없어 영어로만 번역하므로 `{{targetLanguage}}` 를 English 로 바꿔 올린다.
|
||||||
|
*/
|
||||||
|
export function toServerPrompt(desktopId: string, prompt: string): string {
|
||||||
|
return desktopId === 'builtin-translate' ? prompt.replace(/\{\{targetLanguage\}\}/g, 'English') : prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEdited(desktopId: string, prompt: string): boolean {
|
||||||
|
const defaultPrompt = getBuiltinInstructionDefaultPrompt(desktopId)
|
||||||
|
return defaultPrompt !== null && prompt.trim() !== defaultPrompt.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pushBuiltinPrompts(ctx: PushContext, ids: string[]): Promise<PushOutcome[]> {
|
||||||
|
const service = getCustomInstructionService()
|
||||||
|
const outcomes: PushOutcome[] = []
|
||||||
|
for (const id of ids) {
|
||||||
|
const key = DESKTOP_TO_SERVER_BUILTIN[id]
|
||||||
|
const local = service.getById(id)
|
||||||
|
if (!key || !local) {
|
||||||
|
outcomes.push({ id, error: null })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ctx.remote.rpc('sync_set_builtin_instruction_prompt_v1', {
|
||||||
|
p_builtin_key: key,
|
||||||
|
p_prompt: isEdited(id, local.prompt) ? toServerPrompt(id, local.prompt) : null,
|
||||||
|
})
|
||||||
|
outcomes.push({ id, error: null })
|
||||||
|
} catch (err) {
|
||||||
|
outcomes.push({ id, error: toSyncRemoteError(err) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outcomes
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteBuiltin {
|
||||||
|
key: string
|
||||||
|
prompt: string
|
||||||
|
isDefault: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRemoteBuiltins(ctx: PushContext): Promise<RemoteBuiltin[]> {
|
||||||
|
const data = await ctx.remote.rpc('sync_list_builtin_instructions_v1', {})
|
||||||
|
if (!Array.isArray(data)) return []
|
||||||
|
const rows: RemoteBuiltin[] = []
|
||||||
|
for (const item of data) {
|
||||||
|
if (typeof item !== 'object' || item === null) continue
|
||||||
|
const row = item as Record<string, unknown>
|
||||||
|
if (typeof row.builtin_key === 'string' && typeof row.prompt === 'string') {
|
||||||
|
rows.push({ key: row.builtin_key, prompt: row.prompt, isDefault: row.is_default === true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 데스크톱에서 고쳤지만 서버와 다른 프리셋 (최초 대조용) */
|
||||||
|
export function editedBuiltinsDifferingFrom(remote: RemoteBuiltin[]): string[] {
|
||||||
|
const service = getCustomInstructionService()
|
||||||
|
const byKey = new Map(remote.map((r) => [r.key, r]))
|
||||||
|
return Object.entries(DESKTOP_TO_SERVER_BUILTIN)
|
||||||
|
.filter(([id, key]) => {
|
||||||
|
const local = service.getById(id)
|
||||||
|
if (!local || !isEdited(id, local.prompt)) return false
|
||||||
|
const server = byKey.get(key)
|
||||||
|
return !server || server.prompt !== toServerPrompt(id, local.prompt)
|
||||||
|
})
|
||||||
|
.map(([id]) => id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버 프리셋 프롬프트를 로컬에 반영한다. 기본값이면 데스크톱 기본값으로, 아니면 서버 문구로.
|
||||||
|
* 올린 적이 있는 같은 문구(번역의 English 치환 포함)는 그대로 둔다. 바꾼 개수를 돌려준다.
|
||||||
|
*/
|
||||||
|
export function applyRemoteBuiltins(remote: RemoteBuiltin[], pending: ReadonlySet<string>): number {
|
||||||
|
const service = getCustomInstructionService()
|
||||||
|
let changed = 0
|
||||||
|
for (const row of remote) {
|
||||||
|
const id = SERVER_TO_DESKTOP_BUILTIN[row.key]
|
||||||
|
if (!id || pending.has(id)) continue
|
||||||
|
const local = service.getById(id)
|
||||||
|
if (!local) continue
|
||||||
|
let next: string | null = null
|
||||||
|
if (row.isDefault) {
|
||||||
|
const defaultPrompt = getBuiltinInstructionDefaultPrompt(id)
|
||||||
|
if (defaultPrompt !== null && isEdited(id, local.prompt)) next = defaultPrompt
|
||||||
|
} else if (toServerPrompt(id, local.prompt) !== row.prompt) {
|
||||||
|
next = row.prompt
|
||||||
|
}
|
||||||
|
if (next !== null && service.applyRemoteBuiltinPrompt(id, next)) changed++
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
@ -122,6 +122,17 @@ export class SupabaseSyncRemote implements SyncRemote {
|
||||||
if (error) throw toSyncRemoteError(error)
|
if (error) throw toSyncRemoteError(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteChildrenFrom(
|
||||||
|
table: string,
|
||||||
|
parentColumn: string,
|
||||||
|
parentId: string,
|
||||||
|
indexColumn: string,
|
||||||
|
fromIndex: number
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await this.client.from(table).delete().eq(parentColumn, parentId).gte(indexColumn, fromIndex)
|
||||||
|
if (error) throw toSyncRemoteError(error)
|
||||||
|
}
|
||||||
|
|
||||||
async invokeFunction(name: string, body: Record<string, unknown>): Promise<unknown> {
|
async invokeFunction(name: string, body: Record<string, unknown>): Promise<unknown> {
|
||||||
const { data, error } = await this.client.functions.invoke(name, { body })
|
const { data, error } = await this.client.functions.invoke(name, { body })
|
||||||
if (error) throw toSyncRemoteError(error)
|
if (error) throw toSyncRemoteError(error)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { getDictationTemplateService } from '../DictationTemplateService'
|
||||||
import { getMeetingDocTemplateService } from '../MeetingDocTemplateService'
|
import { getMeetingDocTemplateService } from '../MeetingDocTemplateService'
|
||||||
import { getRAGService } from '../RAGService'
|
import { getRAGService } from '../RAGService'
|
||||||
import { purgeRemoteAudio } from './audio-sync'
|
import { purgeRemoteAudio } from './audio-sync'
|
||||||
|
import { pushMeetingSegments } from './transcript-sync'
|
||||||
import { dropEntry } from './sync-outbox'
|
import { dropEntry } from './sync-outbox'
|
||||||
import {
|
import {
|
||||||
SyncRemoteError,
|
SyncRemoteError,
|
||||||
|
|
@ -441,6 +442,17 @@ const meetingsAdapter: SyncAdapter = {
|
||||||
'meetings',
|
'meetings',
|
||||||
rows.map((r) => ({ id: r.id, row: meetingToRemote(r, ctx.userId) }))
|
rows.map((r) => ({ id: r.id, row: meetingToRemote(r, ctx.userId) }))
|
||||||
)
|
)
|
||||||
|
// 회의 행이 올라간 뒤 전사 구간을 맞춘다 — 폰은 구간이 있으면 구간을 그린다.
|
||||||
|
const byId = new Map(rows.map((r) => [r.id, r]))
|
||||||
|
for (const outcome of outcomes) {
|
||||||
|
const row = byId.get(outcome.id)
|
||||||
|
if (outcome.error !== null || !row) continue
|
||||||
|
try {
|
||||||
|
await pushMeetingSegments(ctx, row)
|
||||||
|
} catch (err) {
|
||||||
|
outcome.error = toSyncRemoteError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return [...outcomes, ...missingAsDone(ids, found)]
|
return [...outcomes, ...missingAsDone(ids, found)]
|
||||||
},
|
},
|
||||||
async pushDeletes(ctx, ids) {
|
async pushDeletes(ctx, ids) {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ export const SYNC_ENTITIES = [
|
||||||
/** 녹음 파일 업로드(row_id = history id / meeting id) */
|
/** 녹음 파일 업로드(row_id = history id / meeting id) */
|
||||||
'history_audio',
|
'history_audio',
|
||||||
'meeting_audio',
|
'meeting_audio',
|
||||||
|
/** 데스크톱 프리셋 명령 프롬프트(row_id = 데스크톱 프리셋 id) → 서버 프리셋 행 */
|
||||||
|
'builtin_instructions',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type SyncEntity = (typeof SYNC_ENTITIES)[number]
|
export type SyncEntity = (typeof SYNC_ENTITIES)[number]
|
||||||
|
|
@ -68,6 +70,8 @@ export interface SyncRemote {
|
||||||
updateMatching(table: string, userId: string, match: Record<string, string | number>, patch: RemoteRow): Promise<number>
|
updateMatching(table: string, userId: string, match: Record<string, string | number>, patch: RemoteRow): Promise<number>
|
||||||
deleteByIds(table: string, userId: string, ids: string[]): Promise<void>
|
deleteByIds(table: string, userId: string, ids: string[]): Promise<void>
|
||||||
deleteChildren(table: string, parentColumn: string, parentId: string): Promise<void>
|
deleteChildren(table: string, parentColumn: string, parentId: string): Promise<void>
|
||||||
|
/** 부모의 자식 중 indexColumn >= fromIndex 인 행을 지운다 (줄어든 구간 정리) */
|
||||||
|
deleteChildrenFrom(table: string, parentColumn: string, parentId: string, indexColumn: string, fromIndex: number): Promise<void>
|
||||||
rpc(name: string, params: Record<string, unknown>): Promise<unknown>
|
rpc(name: string, params: Record<string, unknown>): Promise<unknown>
|
||||||
invokeFunction(name: string, body: Record<string, unknown>): Promise<unknown>
|
invokeFunction(name: string, body: Record<string, unknown>): Promise<unknown>
|
||||||
uploadObject(bucket: string, key: string, bytes: Uint8Array, contentType: string): Promise<void>
|
uploadObject(bucket: string, key: string, bytes: Uint8Array, contentType: string): Promise<void>
|
||||||
|
|
|
||||||
44
apps/desktop/src/main/services/sync/transcript-sync.ts
Normal file
44
apps/desktop/src/main/services/sync/transcript-sync.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// src/main/services/sync/transcript-sync.ts
|
||||||
|
// 데스크톱 회의 전사를 Supabase `transcripts` 구간으로 맞춘다.
|
||||||
|
// 모바일은 구간이 하나라도 있으면 edited_transcript 대신 구간을 그리므로, 데스크톱이 전사를 바꾸면
|
||||||
|
// (녹음 완료·직접 수정·자동 다듬기·화자 구분) 구간도 같이 바꿔야 폰에 보인다.
|
||||||
|
|
||||||
|
import { parseTranscriptLines } from '@d3ro/core/meeting-transcript'
|
||||||
|
import type { meetingSessions } from '../../db/schema'
|
||||||
|
import type { PushContext } from './sync-adapters'
|
||||||
|
import type { RemoteRow } from './sync-types'
|
||||||
|
|
||||||
|
const SEGMENT_BATCH = 500
|
||||||
|
|
||||||
|
/** 로컬 회의 행 → 서버 구간 행. 전사가 없으면(녹음 중 등) null — 서버 구간을 건드리지 않는다. */
|
||||||
|
export function meetingSegmentsToRemote(row: typeof meetingSessions.$inferSelect): RemoteRow[] | null {
|
||||||
|
const edited = row.editedTranscript !== null && row.editedTranscript.trim() !== ''
|
||||||
|
const lines = parseTranscriptLines(edited ? row.editedTranscript : row.rawTranscript)
|
||||||
|
if (lines.length === 0) return null
|
||||||
|
return lines.map((line, index) => {
|
||||||
|
const next = lines[index + 1]
|
||||||
|
const duration = next ? next.timestampMs - line.timestampMs : null
|
||||||
|
return {
|
||||||
|
meeting_id: row.id,
|
||||||
|
segment_index: index,
|
||||||
|
timestamp_ms: line.timestampMs,
|
||||||
|
duration_ms: duration !== null && duration > 0 ? duration : null,
|
||||||
|
text: line.text,
|
||||||
|
speaker: line.speaker,
|
||||||
|
edited,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 구간을 upsert 하고, 줄 수가 줄었으면 뒤쪽 구간을 지운다(한 번에 비우지 않아 폰 화면이 깜빡이지 않는다). */
|
||||||
|
export async function pushMeetingSegments(
|
||||||
|
ctx: PushContext,
|
||||||
|
row: typeof meetingSessions.$inferSelect
|
||||||
|
): Promise<void> {
|
||||||
|
const segments = meetingSegmentsToRemote(row)
|
||||||
|
if (!segments) return
|
||||||
|
for (let i = 0; i < segments.length; i += SEGMENT_BATCH) {
|
||||||
|
await ctx.remote.upsert('transcripts', segments.slice(i, i + SEGMENT_BATCH), 'meeting_id,segment_index')
|
||||||
|
}
|
||||||
|
await ctx.remote.deleteChildrenFrom('transcripts', 'meeting_id', row.id, 'segment_index', segments.length)
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// 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, useMemo, useRef } from 'react'
|
||||||
|
import { parseTranscriptLines } from '@d3ro/core/meeting-transcript'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Tabs,
|
Tabs,
|
||||||
|
|
@ -54,33 +55,14 @@ function parseSegments(
|
||||||
rawTranscript: string | null,
|
rawTranscript: string | null,
|
||||||
editedTranscript: string | null,
|
editedTranscript: string | null,
|
||||||
): Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }> {
|
): Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }> {
|
||||||
const source = editedTranscript || rawTranscript || ''
|
// 줄 ↔ 구간 변환은 core 정본을 쓴다 — 서버 transcripts 구간도 같은 규칙으로 만들어진다.
|
||||||
const regex = /^\[(\d{2}):(\d{2})\]\s*(?:\[([^\]]+)\]\s*)?(.+)$/
|
return parseTranscriptLines(editedTranscript || rawTranscript).map((line, idx) => ({
|
||||||
return source
|
|
||||||
.split('\n')
|
|
||||||
.filter((line) => line.trim())
|
|
||||||
.map((line, idx) => {
|
|
||||||
const m = regex.exec(line.trim())
|
|
||||||
if (m) {
|
|
||||||
const min = parseInt(m[1], 10)
|
|
||||||
const sec = parseInt(m[2], 10)
|
|
||||||
const speaker = m[3] || undefined
|
|
||||||
const text = m[4]
|
|
||||||
return {
|
|
||||||
id: `seg-${idx}`,
|
id: `seg-${idx}`,
|
||||||
timestamp: (min * 60 + sec) * 1000,
|
timestamp: line.timestampMs,
|
||||||
text,
|
text: line.text,
|
||||||
edited: false,
|
edited: false,
|
||||||
speaker,
|
speaker: line.speaker ?? undefined,
|
||||||
}
|
}))
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: `seg-${idx}`,
|
|
||||||
timestamp: idx * 4000,
|
|
||||||
text: line.trim(),
|
|
||||||
edited: false,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract Action items from documents
|
// Extract Action items from documents
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,36 @@ export class FakeSyncRemote implements SyncRemote {
|
||||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][parentColumn] === parentId) rows.splice(i, 1)
|
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][parentColumn] === parentId) rows.splice(i, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteChildrenFrom(
|
||||||
|
table: string,
|
||||||
|
parentColumn: string,
|
||||||
|
parentId: string,
|
||||||
|
indexColumn: string,
|
||||||
|
fromIndex: number
|
||||||
|
): Promise<void> {
|
||||||
|
this.guard()
|
||||||
|
const rows = this.rows(table)
|
||||||
|
for (let i = rows.length - 1; i >= 0; i--) {
|
||||||
|
if (rows[i][parentColumn] === parentId && Number(rows[i][indexColumn]) >= fromIndex) rows.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 서버 프리셋 기본 프롬프트 (마이그레이션 0036 과 같은 문구) */
|
||||||
|
static readonly BUILTIN_DEFAULTS: Record<string, string> = {
|
||||||
|
translate_en: 'Translate the following text into natural English. Return only the translation.',
|
||||||
|
summarize: 'Summarize the following text in no more than three concise lines.',
|
||||||
|
formal: 'Rewrite the following text in a formal business style while preserving its meaning. Return only the rewritten text.',
|
||||||
|
explain_code: 'Explain the following code in Korean, including its responsibilities and important caveats.',
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureBuiltins(): void {
|
||||||
|
for (const [key, prompt] of Object.entries(FakeSyncRemote.BUILTIN_DEFAULTS)) {
|
||||||
|
if (!this.rows('custom_instructions').some((r) => r.builtin_key === key)) {
|
||||||
|
this.mobileInsert('custom_instructions', { id: crypto.randomUUID(), builtin_key: key, name: key, prompt, icon: 'x', sort_order: 10 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async invokeFunction(name: string, body: Record<string, unknown>): Promise<unknown> {
|
async invokeFunction(name: string, body: Record<string, unknown>): Promise<unknown> {
|
||||||
this.guard()
|
this.guard()
|
||||||
this.invoked.push(`${name}:${String(body.document_id ?? '')}`)
|
this.invoked.push(`${name}:${String(body.document_id ?? '')}`)
|
||||||
|
|
@ -298,6 +328,26 @@ export class FakeSyncRemote implements SyncRemote {
|
||||||
else this.mobileInsert('user_settings', { active_instruction_id: instructionId, revision: 1 })
|
else this.mobileInsert('user_settings', { active_instruction_id: instructionId, revision: 1 })
|
||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
|
if (name === 'sync_set_builtin_instruction_prompt_v1') {
|
||||||
|
this.ensureBuiltins()
|
||||||
|
const key = String(params.p_builtin_key)
|
||||||
|
const fallback = FakeSyncRemote.BUILTIN_DEFAULTS[key]
|
||||||
|
if (!fallback) throw new SyncRemoteError('invalid_builtin_key', '22023', false)
|
||||||
|
const next = typeof params.p_prompt === 'string' && params.p_prompt.trim() ? params.p_prompt.trim() : fallback
|
||||||
|
const row = this.rows('custom_instructions').find((r) => r.builtin_key === key)!
|
||||||
|
if (row.prompt !== next) Object.assign(row, { prompt: next, revision: Number(row.revision) + 1, updated_at: this.now() })
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
if (name === 'sync_list_builtin_instructions_v1') {
|
||||||
|
return this.rows('custom_instructions')
|
||||||
|
.filter((r) => typeof r.builtin_key === 'string')
|
||||||
|
.map((r) => ({
|
||||||
|
builtin_key: r.builtin_key,
|
||||||
|
prompt: r.prompt,
|
||||||
|
is_default: r.prompt === FakeSyncRemote.BUILTIN_DEFAULTS[String(r.builtin_key)],
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
if (name === 'sync_delete_user_template_v1') {
|
if (name === 'sync_delete_user_template_v1') {
|
||||||
const id = String(params.p_id)
|
const id = String(params.p_id)
|
||||||
if (!this.find('user_templates', id)) return false
|
if (!this.find('user_templates', id)) return false
|
||||||
|
|
|
||||||
|
|
@ -332,6 +332,40 @@ describe.skipIf(!enabled)('cross-device sync against local Supabase', () => {
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
}, 60_000)
|
}, 60_000)
|
||||||
|
|
||||||
|
it('데스크톱 전사가 폰이 그리는 구간으로 올라가고, 고친 프리셋 프롬프트가 폰 명령에 쓰인다', async () => {
|
||||||
|
const db = testDb.db
|
||||||
|
const meetingId = crypto.randomUUID()
|
||||||
|
const now = Date.now()
|
||||||
|
db.insert(meetingSessions).values({
|
||||||
|
id: meetingId,
|
||||||
|
title: 'segments',
|
||||||
|
status: 'completed',
|
||||||
|
startedAt: now,
|
||||||
|
rawTranscript: '[00:00] one\n[00:04] two\n[00:09] three',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}).run()
|
||||||
|
enqueueChange('meetings', meetingId, 'upsert')
|
||||||
|
getCustomInstructionService().update('builtin-summarize', { prompt: '두 줄로 요약' })
|
||||||
|
enqueueChange('builtin_instructions', 'builtin-summarize', 'upsert')
|
||||||
|
const first = await engine.flush()
|
||||||
|
expect(first.errors).toEqual([])
|
||||||
|
const segments = must(await mobile.from('transcripts').select('segment_index,text,edited').eq('meeting_id', meetingId).order('segment_index'))
|
||||||
|
expect(segments.map((s) => s.text)).toEqual(['one', 'two', 'three'])
|
||||||
|
|
||||||
|
db.update(meetingSessions).set({ editedTranscript: '[00:00] [화자 1] one fixed', updatedAt: Date.now() }).where(eq(meetingSessions.id, meetingId)).run()
|
||||||
|
enqueueChange('meetings', meetingId, 'upsert')
|
||||||
|
expect((await engine.flush()).errors).toEqual([])
|
||||||
|
const edited = must(await mobile.from('transcripts').select('text,speaker,edited').eq('meeting_id', meetingId))
|
||||||
|
expect(edited).toEqual([{ text: 'one fixed', speaker: '화자 1', edited: true }])
|
||||||
|
|
||||||
|
const summarize = must(await mobile.from('custom_instructions').select('prompt').eq('user_id', userId).eq('builtin_key', 'summarize').single())
|
||||||
|
expect(summarize.prompt).toBe('두 줄로 요약')
|
||||||
|
// 폰은 여전히 프리셋 행을 직접 고칠 수 없다(RLS)
|
||||||
|
const blocked = await mobile.from('custom_instructions').update({ prompt: 'hack' }).eq('user_id', userId).eq('builtin_key', 'summarize').select('id')
|
||||||
|
expect(blocked.data ?? []).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
it('데스크톱이 기기 목록에 나타나고, 모바일에서 해제하면 revoked가 된다', async () => {
|
it('데스크톱이 기기 목록에 나타나고, 모바일에서 해제하면 revoked가 된다', async () => {
|
||||||
const info = { deviceName: 'IT-DESKTOP', appVersion: '9.9.9', osVersion: 'test' }
|
const info = { deviceName: 'IT-DESKTOP', appVersion: '9.9.9', osVersion: 'test' }
|
||||||
const first = await checkInDesktopDevice(desktop, userId, 'signin', info, null)
|
const first = await checkInDesktopDevice(desktop, userId, 'signin', info, null)
|
||||||
|
|
|
||||||
135
apps/desktop/tests/main/sync/SyncTranscriptsPresets.test.ts
Normal file
135
apps/desktop/tests/main/sync/SyncTranscriptsPresets.test.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
// 회의 전사 구간(transcripts)과 프리셋 명령 프롬프트 동기화.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { createTestDb } from '../../helpers/createTestDb'
|
||||||
|
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
|
||||||
|
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
|
||||||
|
import { meetingSessions } from '../../../src/main/db/schema'
|
||||||
|
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
|
||||||
|
import {
|
||||||
|
getBuiltinInstructionDefaultPrompt,
|
||||||
|
getCustomInstructionService,
|
||||||
|
resetCustomInstructionServiceForTests,
|
||||||
|
} from '../../../src/main/services/CustomInstructionService'
|
||||||
|
import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService'
|
||||||
|
import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService'
|
||||||
|
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
|
||||||
|
import { enqueueChange, outboxCounts } from '../../../src/main/services/sync/sync-outbox'
|
||||||
|
|
||||||
|
const USER = '11111111-1111-4111-8111-111111111111'
|
||||||
|
|
||||||
|
let testDb: ReturnType<typeof createTestDb>
|
||||||
|
let remote: FakeSyncRemote
|
||||||
|
let engine: SyncEngine
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDb = createTestDb()
|
||||||
|
bindTestDatabase(testDb.db, USER)
|
||||||
|
initInMemoryConfig()
|
||||||
|
resetCustomInstructionServiceForTests()
|
||||||
|
resetDictationTemplateServiceForTests()
|
||||||
|
resetMeetingDocTemplateServiceForTests()
|
||||||
|
getCustomInstructionService().initialize()
|
||||||
|
remote = new FakeSyncRemote(USER)
|
||||||
|
engine = new SyncEngine({ remote, userId: USER })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
engine.dispose()
|
||||||
|
unbindTestDatabase()
|
||||||
|
resetInMemoryConfig()
|
||||||
|
testDb.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
function segmentsOf(meetingId: string): Array<Record<string, unknown>> {
|
||||||
|
return remote
|
||||||
|
.rows('transcripts')
|
||||||
|
.filter((r) => r.meeting_id === meetingId)
|
||||||
|
.sort((a, b) => Number(a.segment_index) - Number(b.segment_index))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('회의 전사 구간', () => {
|
||||||
|
it('데스크톱 전사를 구간으로 올리고, 수정·화자 구분을 반영하며 줄어든 구간은 지운다', async () => {
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const now = Date.now()
|
||||||
|
testDb.db
|
||||||
|
.insert(meetingSessions)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
title: 'desk',
|
||||||
|
status: 'completed',
|
||||||
|
startedAt: now,
|
||||||
|
rawTranscript: '[00:00] 시작합니다\n[00:07] 첫 안건\n[00:15] 마무리',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
await engine.runFullSync()
|
||||||
|
expect(segmentsOf(id).map((s) => [s.segment_index, s.timestamp_ms, s.duration_ms, s.text, s.edited])).toEqual([
|
||||||
|
[0, 0, 7000, '시작합니다', false],
|
||||||
|
[1, 7000, 8000, '첫 안건', false],
|
||||||
|
[2, 15000, null, '마무리', false],
|
||||||
|
])
|
||||||
|
|
||||||
|
testDb.db
|
||||||
|
.update(meetingSessions)
|
||||||
|
.set({ editedTranscript: '[00:00] [화자 1] 시작합니다\n[00:15] [화자 2] 마무리', updatedAt: Date.now() })
|
||||||
|
.where(eq(meetingSessions.id, id))
|
||||||
|
.run()
|
||||||
|
enqueueChange('meetings', id, 'upsert')
|
||||||
|
await engine.flush()
|
||||||
|
expect(segmentsOf(id).map((s) => [s.segment_index, s.speaker, s.text, s.edited])).toEqual([
|
||||||
|
[0, '화자 1', '시작합니다', true],
|
||||||
|
[1, '화자 2', '마무리', true],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('전사가 없는 회의(녹음 중)는 서버 구간을 건드리지 않는다', async () => {
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
remote.mobileInsert('meetings', { id, title: 'phone', status: 'completed', started_at: remote.now() })
|
||||||
|
remote.rows('transcripts').push({ id: crypto.randomUUID(), meeting_id: id, segment_index: 0, timestamp_ms: 0, text: 'phone text' })
|
||||||
|
await engine.runFullSync()
|
||||||
|
testDb.db.update(meetingSessions).set({ title: 'renamed', rawTranscript: null, updatedAt: Date.now() }).where(eq(meetingSessions.id, id)).run()
|
||||||
|
enqueueChange('meetings', id, 'upsert')
|
||||||
|
await engine.flush()
|
||||||
|
expect(segmentsOf(id).map((s) => s.text)).toEqual(['phone text'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('프리셋 명령 프롬프트', () => {
|
||||||
|
it('데스크톱에서 고친 프리셋만 서버 프리셋 행으로 올리고, 번역 자리표시자는 English 로 바꾼다', async () => {
|
||||||
|
const service = getCustomInstructionService()
|
||||||
|
service.update('builtin-summarize', { prompt: '핵심만 두 줄로' })
|
||||||
|
service.update('builtin-translate', { prompt: '{{targetLanguage}}로 자연스럽게 번역' })
|
||||||
|
await engine.runFullSync()
|
||||||
|
const byKey = (key: string) => remote.rows('custom_instructions').find((r) => r.builtin_key === key)
|
||||||
|
expect(byKey('summarize')?.prompt).toBe('핵심만 두 줄로')
|
||||||
|
expect(byKey('translate_en')?.prompt).toBe('English로 자연스럽게 번역')
|
||||||
|
expect(byKey('formal')?.prompt).toBe(FakeSyncRemote.BUILTIN_DEFAULTS.formal)
|
||||||
|
expect(outboxCounts().pending).toBe(0)
|
||||||
|
|
||||||
|
// 다시 받아도 로컬 문구({{targetLanguage}})를 바꾸지 않는다
|
||||||
|
await engine.pull()
|
||||||
|
expect(service.getById('builtin-translate')?.prompt).toBe('{{targetLanguage}}로 자연스럽게 번역')
|
||||||
|
|
||||||
|
// 되돌리기 → 서버도 기본값
|
||||||
|
service.update('builtin-summarize', { prompt: getBuiltinInstructionDefaultPrompt('builtin-summarize') ?? '' })
|
||||||
|
enqueueChange('builtin_instructions', 'builtin-summarize', 'upsert')
|
||||||
|
await engine.flush()
|
||||||
|
expect(byKey('summarize')?.prompt).toBe(FakeSyncRemote.BUILTIN_DEFAULTS.summarize)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('다른 데스크톱에서 고친 프리셋 프롬프트를 받고, 기본값 복원도 반영한다', async () => {
|
||||||
|
await remote.rpc('sync_set_builtin_instruction_prompt_v1', { p_builtin_key: 'formal', p_prompt: '회사 공문 말투로' })
|
||||||
|
const service = getCustomInstructionService()
|
||||||
|
await engine.runFullSync()
|
||||||
|
expect(service.getById('builtin-formal')?.prompt).toBe('회사 공문 말투로')
|
||||||
|
|
||||||
|
await remote.rpc('sync_set_builtin_instruction_prompt_v1', { p_builtin_key: 'formal', p_prompt: null })
|
||||||
|
await engine.pull()
|
||||||
|
expect(service.getById('builtin-formal')?.prompt).toBe(getBuiltinInstructionDefaultPrompt('builtin-formal'))
|
||||||
|
// '자유 프롬프트'는 폰에 대응이 없어 건드리지 않는다
|
||||||
|
expect(service.getById('builtin-free-prompt')?.prompt).toBe('{{userPrompt}}')
|
||||||
|
})
|
||||||
|
})
|
||||||
33
packages/core/__tests__/meeting-transcript.test.ts
Normal file
33
packages/core/__tests__/meeting-transcript.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { formatTranscriptLines, parseTranscriptLines } from '../src/meeting-transcript'
|
||||||
|
|
||||||
|
describe('meeting transcript lines', () => {
|
||||||
|
it('parses timestamps and speakers written by the desktop recorder and diarization', () => {
|
||||||
|
const text = '[00:05] 안녕하세요\n\n[01:12] [화자 2] 다음 안건입니다\n[12:03] [Speaker 1] 끝'
|
||||||
|
expect(parseTranscriptLines(text)).toEqual([
|
||||||
|
{ timestampMs: 5_000, timed: true, speaker: null, text: '안녕하세요' },
|
||||||
|
{ timestampMs: 72_000, timed: true, speaker: '화자 2', text: '다음 안건입니다' },
|
||||||
|
{ timestampMs: 723_000, timed: true, speaker: 'Speaker 1', text: '끝' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps untimed lines (phone transcripts, manual edits) and carries the last time forward', () => {
|
||||||
|
expect(parseTranscriptLines('first line\n[00:30] timed\nfollow-up')).toEqual([
|
||||||
|
{ timestampMs: 0, timed: false, speaker: null, text: 'first line' },
|
||||||
|
{ timestampMs: 30_000, timed: true, speaker: null, text: 'timed' },
|
||||||
|
{ timestampMs: 30_000, timed: false, speaker: null, text: 'follow-up' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops empty and timestamp-only lines and round-trips through the formatter', () => {
|
||||||
|
const text = '[00:01] [화자 1] a\n[00:02]\nb'
|
||||||
|
const lines = parseTranscriptLines(text)
|
||||||
|
expect(lines).toHaveLength(2)
|
||||||
|
expect(formatTranscriptLines(lines)).toBe('[00:01] [화자 1] a\nb')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nothing for empty input', () => {
|
||||||
|
expect(parseTranscriptLines(null)).toEqual([])
|
||||||
|
expect(parseTranscriptLines(' \n ')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -42,6 +42,10 @@
|
||||||
"types": "./src/caption-streaming.ts",
|
"types": "./src/caption-streaming.ts",
|
||||||
"default": "./src/caption-streaming.ts"
|
"default": "./src/caption-streaming.ts"
|
||||||
},
|
},
|
||||||
|
"./meeting-transcript": {
|
||||||
|
"types": "./src/meeting-transcript.ts",
|
||||||
|
"default": "./src/meeting-transcript.ts"
|
||||||
|
},
|
||||||
"./entitlement": {
|
"./entitlement": {
|
||||||
"types": "./src/entitlement.ts",
|
"types": "./src/entitlement.ts",
|
||||||
"default": "./src/entitlement.ts"
|
"default": "./src/entitlement.ts"
|
||||||
|
|
|
||||||
54
packages/core/src/meeting-transcript.ts
Normal file
54
packages/core/src/meeting-transcript.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// packages/core/src/meeting-transcript.ts
|
||||||
|
// 회의 전사 텍스트 ↔ 구간(segment) 변환의 정본.
|
||||||
|
// 데스크톱은 전사를 `[MM:SS] [화자] 내용` 줄로 저장하고, Supabase `transcripts` 는 구간 행으로 저장한다
|
||||||
|
// (모바일은 구간이 있으면 구간을 우선 표시한다). 두 표현을 이 한 곳에서만 바꾼다.
|
||||||
|
|
||||||
|
export interface TranscriptLine {
|
||||||
|
/** 회의 시작 기준 ms. 시각 표기가 없는 줄은 앞 줄의 시각을 이어받는다(처음이면 0). */
|
||||||
|
timestampMs: number
|
||||||
|
/** 시각 표기가 있었는지 — 없으면 화면이 순번으로 대신 표시할 수 있다 */
|
||||||
|
timed: boolean
|
||||||
|
speaker: string | null
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINE_PATTERN = /^\[(\d{1,3}):(\d{2})\]\s*(?:\[([^\]]+)\]\s*)?(.*)$/
|
||||||
|
|
||||||
|
/** 빈 줄을 버리고 각 줄을 구간으로 나눈다. 내용이 빈 줄(`[00:01]` 만 있는 줄)도 버린다. */
|
||||||
|
export function parseTranscriptLines(text: string | null | undefined): TranscriptLine[] {
|
||||||
|
if (!text) return []
|
||||||
|
const lines: TranscriptLine[] = []
|
||||||
|
let lastTimestamp = 0
|
||||||
|
for (const raw of text.split('\n')) {
|
||||||
|
const line = raw.trim()
|
||||||
|
if (!line) continue
|
||||||
|
const match = LINE_PATTERN.exec(line)
|
||||||
|
if (match) {
|
||||||
|
const content = match[4].trim()
|
||||||
|
lastTimestamp = (Number(match[1]) * 60 + Number(match[2])) * 1000
|
||||||
|
if (!content) continue
|
||||||
|
lines.push({ timestampMs: lastTimestamp, timed: true, speaker: match[3]?.trim() || null, text: content })
|
||||||
|
} else {
|
||||||
|
lines.push({ timestampMs: lastTimestamp, timed: false, speaker: null, text: line })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTranscriptTimestamp(ms: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(ms / 1000))
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** parseTranscriptLines 의 역변환(시각 표기가 없던 줄은 그대로). */
|
||||||
|
export function formatTranscriptLines(lines: readonly TranscriptLine[]): string {
|
||||||
|
return lines
|
||||||
|
.map((l) => {
|
||||||
|
if (!l.timed) return l.text
|
||||||
|
const speaker = l.speaker ? `[${l.speaker}] ` : ''
|
||||||
|
return `[${formatTranscriptTimestamp(l.timestampMs)}] ${speaker}${l.text}`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue