diff --git a/apps/desktop/src/main/services/CustomInstructionService.ts b/apps/desktop/src/main/services/CustomInstructionService.ts index 37f49e9..72a6dd3 100644 --- a/apps/desktop/src/main/services/CustomInstructionService.ts +++ b/apps/desktop/src/main/services/CustomInstructionService.ts @@ -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 { diff --git a/apps/desktop/src/main/services/sync/SyncEngine.ts b/apps/desktop/src/main/services/sync/SyncEngine.ts index 8dd3cb1..48b2471 100644 --- a/apps/desktop/src/main/services/sync/SyncEngine.ts +++ b/apps/desktop/src/main/services/sync/SyncEngine.ts @@ -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())) } diff --git a/apps/desktop/src/main/services/sync/builtin-instruction-sync.ts b/apps/desktop/src/main/services/sync/builtin-instruction-sync.ts new file mode 100644 index 0000000..743c5fd --- /dev/null +++ b/apps/desktop/src/main/services/sync/builtin-instruction-sync.ts @@ -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> = { + 'builtin-translate': 'translate_en', + 'builtin-summarize': 'summarize', + 'builtin-formal': 'formal', + 'builtin-explain-code': 'explain_code', +} + +const SERVER_TO_DESKTOP_BUILTIN: Readonly> = 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 { + 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 { + 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 + 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): 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 +} diff --git a/apps/desktop/src/main/services/sync/supabase-sync-remote.ts b/apps/desktop/src/main/services/sync/supabase-sync-remote.ts index fce6e72..faa821e 100644 --- a/apps/desktop/src/main/services/sync/supabase-sync-remote.ts +++ b/apps/desktop/src/main/services/sync/supabase-sync-remote.ts @@ -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 { + 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): Promise { const { data, error } = await this.client.functions.invoke(name, { body }) if (error) throw toSyncRemoteError(error) diff --git a/apps/desktop/src/main/services/sync/sync-adapters.ts b/apps/desktop/src/main/services/sync/sync-adapters.ts index 57099c2..0ec6a6c 100644 --- a/apps/desktop/src/main/services/sync/sync-adapters.ts +++ b/apps/desktop/src/main/services/sync/sync-adapters.ts @@ -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) { diff --git a/apps/desktop/src/main/services/sync/sync-types.ts b/apps/desktop/src/main/services/sync/sync-types.ts index 5b24fa6..54f643e 100644 --- a/apps/desktop/src/main/services/sync/sync-types.ts +++ b/apps/desktop/src/main/services/sync/sync-types.ts @@ -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, patch: RemoteRow): Promise deleteByIds(table: string, userId: string, ids: string[]): Promise deleteChildren(table: string, parentColumn: string, parentId: string): Promise + /** 부모의 자식 중 indexColumn >= fromIndex 인 행을 지운다 (줄어든 구간 정리) */ + deleteChildrenFrom(table: string, parentColumn: string, parentId: string, indexColumn: string, fromIndex: number): Promise rpc(name: string, params: Record): Promise invokeFunction(name: string, body: Record): Promise uploadObject(bucket: string, key: string, bytes: Uint8Array, contentType: string): Promise diff --git a/apps/desktop/src/main/services/sync/transcript-sync.ts b/apps/desktop/src/main/services/sync/transcript-sync.ts new file mode 100644 index 0000000..6aec8d5 --- /dev/null +++ b/apps/desktop/src/main/services/sync/transcript-sync.ts @@ -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 { + 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) +} diff --git a/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx b/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx index 56bc073..9e08f7f 100644 --- a/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx +++ b/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx @@ -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 diff --git a/apps/desktop/tests/helpers/fakeSyncRemote.ts b/apps/desktop/tests/helpers/fakeSyncRemote.ts index 774f64e..8b73939 100644 --- a/apps/desktop/tests/helpers/fakeSyncRemote.ts +++ b/apps/desktop/tests/helpers/fakeSyncRemote.ts @@ -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 { + 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 = { + 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): Promise { 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 diff --git a/apps/desktop/tests/integration/cross-device-sync.supabase.test.ts b/apps/desktop/tests/integration/cross-device-sync.supabase.test.ts index 4d519a9..4df760f 100644 --- a/apps/desktop/tests/integration/cross-device-sync.supabase.test.ts +++ b/apps/desktop/tests/integration/cross-device-sync.supabase.test.ts @@ -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) diff --git a/apps/desktop/tests/main/sync/SyncTranscriptsPresets.test.ts b/apps/desktop/tests/main/sync/SyncTranscriptsPresets.test.ts new file mode 100644 index 0000000..c3e28e6 --- /dev/null +++ b/apps/desktop/tests/main/sync/SyncTranscriptsPresets.test.ts @@ -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 +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> { + 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}}') + }) +}) diff --git a/packages/core/__tests__/meeting-transcript.test.ts b/packages/core/__tests__/meeting-transcript.test.ts new file mode 100644 index 0000000..aefe723 --- /dev/null +++ b/packages/core/__tests__/meeting-transcript.test.ts @@ -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([]) + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index 705eaba..1750553 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" diff --git a/packages/core/src/meeting-transcript.ts b/packages/core/src/meeting-transcript.ts new file mode 100644 index 0000000..10edfdf --- /dev/null +++ b/packages/core/src/meeting-transcript.ts @@ -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') +}