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]
|
||||
|
||||
// 프리셋은 프롬프트만 수정 가능
|
||||
// 프리셋은 프롬프트만 수정 가능 — 폰과 짝이 있는 프리셋은 서버 프리셋 행으로도 올린다
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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)
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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}}')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue