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:
Yun Chan 2026-09-27 16:24:25 +09:00
parent 08c6504589
commit 2aac10fc5d
14 changed files with 552 additions and 28 deletions

View file

@ -159,10 +159,11 @@ class CustomInstructionService {
const existing = instructions[index]
// 프리셋은 프롬프트만 수정 가능
// 프리셋은 프롬프트만 수정 가능 — 폰과 짝이 있는 프리셋은 서버 프리셋 행으로도 올린다
if (existing.isBuiltin) {
if (data.prompt !== undefined) {
instructions[index] = { ...existing, prompt: data.prompt, updatedAt: Date.now() }
getCloudSyncService().pushOne('builtin_instructions', id)
}
} else {
instructions[index] = { ...existing, ...data, updatedAt: Date.now() }
@ -203,6 +204,15 @@ class CustomInstructionService {
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 {
const index = instructions.findIndex((i) => i.id === id)
@ -252,6 +262,7 @@ class CustomInstructionService {
instructions = [...builtins, ...userInstructions]
saveInstructions()
for (const builtin of builtins) getCloudSyncService().pushOne('builtin_instructions', builtin.id)
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
export function getCustomInstructionService(): CustomInstructionService {

View file

@ -20,6 +20,12 @@ import {
} from './memo-tag-sync'
import { fetchRemoteAudioOwners, isAudioSyncEnabled, listLocalAudioOwners, pushAudio } from './audio-sync'
import { SETTINGS_ROW_ID, applyRemoteSettings, fetchRemoteSettings, pushSettings } from './settings-sync'
import {
applyRemoteBuiltins,
editedBuiltinsDifferingFrom,
fetchRemoteBuiltins,
pushBuiltinPrompts,
} from './builtin-instruction-sync'
import {
completeEntry,
dropEntry,
@ -223,6 +229,11 @@ export class SyncEngine extends EventEmitter {
enqueueChange('user_settings', SETTINGS_ROW_ID, 'upsert', now)
queued++
}
// 데스크톱에서 고친 프리셋 프롬프트가 서버와 다르면 올린다(폰은 프리셋을 고칠 수 없다).
for (const id of editedBuiltinsDifferingFrom(await fetchRemoteBuiltins(ctx))) {
enqueueChange('builtin_instructions', id, 'upsert', now)
queued++
}
if (isAudioSyncEnabled()) {
for (const owner of ['history', 'meeting'] as const) {
const local = listLocalAudioOwners(owner)
@ -290,6 +301,7 @@ export class SyncEngine extends EventEmitter {
'meeting_memos',
'meeting_documents',
'custom_instructions',
'builtin_instructions',
'user_settings',
'user_templates',
'knowledge_documents',
@ -326,6 +338,8 @@ export class SyncEngine extends EventEmitter {
return pushMemoTagAdds(ctx, ids)
case 'user_settings':
return [{ id: SETTINGS_ROW_ID, error: await pushSettings(ctx) }]
case 'builtin_instructions':
return pushBuiltinPrompts(ctx, ids)
case 'history_audio':
return pushAudio(ctx, 'history', ids)
case 'meeting_audio':
@ -416,6 +430,19 @@ export class SyncEngine extends EventEmitter {
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]
if (result.errors.length === 0) setSyncState('lastPullAt', String(this.now()))
}

View 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
}

View file

@ -122,6 +122,17 @@ export class SupabaseSyncRemote implements SyncRemote {
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> {
const { data, error } = await this.client.functions.invoke(name, { body })
if (error) throw toSyncRemoteError(error)

View file

@ -18,6 +18,7 @@ import { getDictationTemplateService } from '../DictationTemplateService'
import { getMeetingDocTemplateService } from '../MeetingDocTemplateService'
import { getRAGService } from '../RAGService'
import { purgeRemoteAudio } from './audio-sync'
import { pushMeetingSegments } from './transcript-sync'
import { dropEntry } from './sync-outbox'
import {
SyncRemoteError,
@ -441,6 +442,17 @@ const meetingsAdapter: SyncAdapter = {
'meetings',
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)]
},
async pushDeletes(ctx, ids) {

View file

@ -17,6 +17,8 @@ export const SYNC_ENTITIES = [
/** 녹음 파일 업로드(row_id = history id / meeting id) */
'history_audio',
'meeting_audio',
/** 데스크톱 프리셋 명령 프롬프트(row_id = 데스크톱 프리셋 id) → 서버 프리셋 행 */
'builtin_instructions',
] as const
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>
deleteByIds(table: string, userId: string, ids: 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>
invokeFunction(name: string, body: Record<string, unknown>): Promise<unknown>
uploadObject(bucket: string, key: string, bytes: Uint8Array, contentType: string): Promise<void>

View 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)
}

View file

@ -2,6 +2,7 @@
// Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad)
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { parseTranscriptLines } from '@d3ro/core/meeting-transcript'
import {
Box,
Tabs,
@ -54,33 +55,14 @@ function parseSegments(
rawTranscript: string | null,
editedTranscript: string | null,
): Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }> {
const source = editedTranscript || rawTranscript || ''
const regex = /^\[(\d{2}):(\d{2})\]\s*(?:\[([^\]]+)\]\s*)?(.+)$/
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}`,
timestamp: (min * 60 + sec) * 1000,
text,
edited: false,
speaker,
}
}
return {
id: `seg-${idx}`,
timestamp: idx * 4000,
text: line.trim(),
edited: false,
}
})
// 줄 ↔ 구간 변환은 core 정본을 쓴다 — 서버 transcripts 구간도 같은 규칙으로 만들어진다.
return parseTranscriptLines(editedTranscript || rawTranscript).map((line, idx) => ({
id: `seg-${idx}`,
timestamp: line.timestampMs,
text: line.text,
edited: false,
speaker: line.speaker ?? undefined,
}))
}
// Extract Action items from documents

View file

@ -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)
}
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> {
this.guard()
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 })
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') {
const id = String(params.p_id)
if (!this.find('user_templates', id)) return false

View file

@ -332,6 +332,40 @@ describe.skipIf(!enabled)('cross-device sync against local Supabase', () => {
vi.restoreAllMocks()
}, 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 () => {
const info = { deviceName: 'IT-DESKTOP', appVersion: '9.9.9', osVersion: 'test' }
const first = await checkInDesktopDevice(desktop, userId, 'signin', info, null)

View 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}}')
})
})

View 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([])
})
})

View file

@ -42,6 +42,10 @@
"types": "./src/caption-streaming.ts",
"default": "./src/caption-streaming.ts"
},
"./meeting-transcript": {
"types": "./src/meeting-transcript.ts",
"default": "./src/meeting-transcript.ts"
},
"./entitlement": {
"types": "./src/entitlement.ts",
"default": "./src/entitlement.ts"

View 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')
}