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
|
|
@ -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