d3ro-voice/apps/desktop/tests/main/services/llm-prompts.test.ts
Yun Chan 39b8e7448e feat(caption): stream live captions and polish finished lines in context
Replaces the fixed six-second batches with a streaming track per audio
source: the uncommitted audio is re-recognised every second and sent as a
partial with its agreed (stable) prefix, a short pause finalises the line,
and long unbroken speech is committed at Whisper segment boundaries. Idle
audio is trimmed so silence cannot produce invented sentences.

Finished lines are corrected by the local model against the previous lines
and replaced in place; edits that change too much are rejected. The
behaviour can be switched off in Settings.
2026-09-24 20:22:21 +09:00

269 lines
11 KiB
TypeScript

// tests/main/services/llm-prompts.test.ts
// 플레이스홀더 치환 + 지시문 인자 배치 회귀 테스트.
// 지시문이 시스템 프롬프트가 아닌 처리 대상 텍스트 자리로 들어가 LLM이
// 지시문 자체를 다듬어 반환하던 버그를 막는다.
import { describe, it, expect, beforeEach, vi } from 'vitest'
const mockLogger = vi.hoisted(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => mockLogger,
}))
import {
renderInstructionPrompt,
buildInstructionInvocation,
resolveSystemPrompt,
buildSuggestionPrompt,
DEFAULT_TARGET_LANGUAGE,
BASE_SYSTEM_PROMPTS,
SUGGESTION_NO_THINK_PREFIX,
SUGGESTION_SYSTEM_PROMPT,
} from '../../../src/main/services/llm-prompts'
import { buildCaptionRefinePrompt } from '../../../src/main/services/llm-prompts'
beforeEach(() => {
vi.clearAllMocks()
})
describe('renderInstructionPrompt', () => {
it('{{text}}를 사용자 텍스트로 치환한다', () => {
const result = renderInstructionPrompt('요약해줘:\n{{text}}', { text: '안녕하세요' })
expect(result).toBe('요약해줘:\n안녕하세요')
})
it('{{userPrompt}}를 사용자 텍스트로 치환한다 (자유 프롬프트 프리셋)', () => {
const result = renderInstructionPrompt('{{userPrompt}}', { text: '오늘 날씨 알려줘' })
expect(result).toBe('오늘 날씨 알려줘')
})
it('{{targetLanguage}}를 주어진 대상 언어로 치환한다', () => {
const result = renderInstructionPrompt('{{targetLanguage}}로 번역해줘', {
text: '안녕',
targetLanguage: '일본어',
})
expect(result).toBe('일본어로 번역해줘')
})
it('대상 언어가 없으면 기본값으로 치환한다', () => {
const result = renderInstructionPrompt('{{targetLanguage}}로 번역해줘', { text: '안녕' })
expect(result).toBe(`${DEFAULT_TARGET_LANGUAGE}로 번역해줘`)
expect(result).not.toContain('{{')
})
it('같은 플레이스홀더가 여러 번 나와도 모두 치환한다', () => {
const result = renderInstructionPrompt('{{text}} / {{text}}', { text: 'A' })
expect(result).toBe('A / A')
})
it('치환되지 않고 남은 플레이스홀더는 경고로 남기되 동작은 계속한다', () => {
const result = renderInstructionPrompt('{{text}}를 {{unknownVar}}로 처리해줘', { text: 'A' })
expect(result).toBe('A를 {{unknownVar}}로 처리해줘')
expect(mockLogger.warn).toHaveBeenCalledTimes(1)
expect(mockLogger.warn.mock.calls[0][0]).toContain('unknownVar')
})
it('플레이스홀더가 모두 치환되면 경고하지 않는다', () => {
renderInstructionPrompt('{{text}}', { text: 'A' })
expect(mockLogger.warn).not.toHaveBeenCalled()
})
})
describe('buildInstructionInvocation', () => {
it('{{text}}가 없는 지시문은 시스템 프롬프트로, 사용자 텍스트는 처리 대상으로 보낸다', () => {
// builtin-summarize 와 같은 모양 — 플레이스홀더 없음
const invocation = buildInstructionInvocation(
'다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요.',
'오늘 회의에서 배포 일정을 정했습니다',
)
expect(invocation.text).toBe('오늘 회의에서 배포 일정을 정했습니다')
expect(invocation.systemPrompt).toBe('다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요.')
})
it('builtin-translate 지시문은 대상 언어를 치환해 시스템 프롬프트로 보낸다', () => {
const invocation = buildInstructionInvocation(
'다음 텍스트를 {{targetLanguage}}로 번역해주세요.\n자연스럽고 정확한 번역만 출력하세요.',
'안녕하세요',
)
expect(invocation.text).toBe('안녕하세요')
expect(invocation.systemPrompt).toBe(
`다음 텍스트를 ${DEFAULT_TARGET_LANGUAGE}로 번역해주세요.\n자연스럽고 정확한 번역만 출력하세요.`,
)
expect(invocation.systemPrompt).not.toContain('{{')
})
it('builtin-free-prompt는 사용자 텍스트를 시스템 프롬프트로 보낸다', () => {
const invocation = buildInstructionInvocation('{{userPrompt}}', '파이썬으로 피보나치 짜줘')
expect(invocation.text).toBe('파이썬으로 피보나치 짜줘')
expect(invocation.systemPrompt).toBe('파이썬으로 피보나치 짜줘')
})
it('{{text}}를 쓰는 지시문은 하위 호환을 위해 치환 결과를 처리 대상 텍스트로 보낸다', () => {
const invocation = buildInstructionInvocation('아래를 불릿으로 정리해줘:\n{{text}}', '가 나 다')
expect(invocation.text).toBe('아래를 불릿으로 정리해줘:\n가 나 다')
expect(invocation.systemPrompt).toBeUndefined()
})
it('명시적 대상 언어를 그대로 사용한다', () => {
const invocation = buildInstructionInvocation(
'{{targetLanguage}}로 번역해줘',
'안녕',
'프랑스어',
)
expect(invocation.systemPrompt).toBe('프랑스어로 번역해줘')
})
})
describe('resolveSystemPrompt', () => {
it("action 'custom'에 프롬프트가 있으면 그대로 시스템 프롬프트로 쓴다", () => {
expect(resolveSystemPrompt('custom', undefined, '전문 용어를 풀어써라')).toBe(
'전문 용어를 풀어써라',
)
expect(mockLogger.warn).not.toHaveBeenCalled()
})
it("action 'custom'인데 프롬프트가 비어 있으면 조용히 떨어지지 않고 경고한다", () => {
const result = resolveSystemPrompt('custom', undefined, undefined)
expect(mockLogger.warn).toHaveBeenCalledTimes(1)
expect(mockLogger.warn.mock.calls[0][0]).toContain('custom')
expect(result).toBe(resolveSystemPrompt('refine'))
})
it("공백뿐인 custom 프롬프트도 비어 있는 것으로 보고 경고한다", () => {
resolveSystemPrompt('custom', undefined, ' ')
expect(mockLogger.warn).toHaveBeenCalledTimes(1)
})
it('translate 액션은 대상 언어를 치환한다', () => {
expect(resolveSystemPrompt('translate', '독일어')).toContain('독일어로 번역')
expect(resolveSystemPrompt('translate')).toContain(`${DEFAULT_TARGET_LANGUAGE}로 번역`)
})
// 수정 대상이 아닌 일반 액션들이 각자의 프롬프트로 해석되는지 잠근다.
// 이들이 refine으로 조용히 폴백하면 커스텀 경로와 같은 사고가 난다.
it.each(['refine', 'summarize', 'grammar', 'expand'] as const)(
"'%s' 액션은 자기 자신의 시스템 프롬프트로 해석된다",
(action) => {
const resolved = resolveSystemPrompt(action)
expect(resolved).toBe(BASE_SYSTEM_PROMPTS[action])
expect(resolved).not.toContain('{{')
expect(mockLogger.warn).not.toHaveBeenCalled()
},
)
it('refine 이외의 일반 액션이 refine 프롬프트로 폴백하지 않는다', () => {
for (const action of ['summarize', 'grammar', 'expand'] as const) {
expect(resolveSystemPrompt(action)).not.toBe(BASE_SYSTEM_PROMPTS.refine)
}
})
})
describe('buildSuggestionPrompt', () => {
it('지시문은 시스템 프롬프트에만 있고 사용자 텍스트에는 들어가지 않는다', () => {
const { systemPrompt, text } = buildSuggestionPrompt({
prefix: '오늘 회의에서 논의한 내용을 정리해서',
appName: 'chrome.exe',
windowTitle: '회의록 - Chrome',
phraseHints: ['지난번에는 이렇게 정리했습니다'],
candidates: 3,
maxChars: 120
})
// 지시문이 사용자 텍스트 자리로 새면 모델이 지시문 자체를 다듬어 돌려준다
// (commit 9c2b4d4 회와 같은 부류).
expect(systemPrompt).toContain('다음 문장')
expect(text).not.toContain('규칙:')
expect(text).toContain('오늘 회의에서 논의한 내용을 정리해서')
expect(text).toContain('지난번에는 이렇게 정리했습니다')
expect(text).toContain('chrome.exe')
})
it('후보 수와 길이 상한을 프롬프트에 반영한다', () => {
const { systemPrompt, text } = buildSuggestionPrompt({ prefix: 'abc', candidates: 2, maxChars: 60 })
expect(systemPrompt).toContain('60')
expect(text).toContain('2')
})
it('추론 모델용 no_think prefix 를 붙인다', () => {
const { systemPrompt } = buildSuggestionPrompt({ prefix: 'abc' })
expect(systemPrompt.startsWith(SUGGESTION_NO_THINK_PREFIX)).toBe(true)
})
it('후보 수/길이는 안전 범위로 클램프한다', () => {
const { systemPrompt, text } = buildSuggestionPrompt({
prefix: 'abc',
candidates: 99,
maxChars: 99999
})
expect(systemPrompt).toContain('400')
expect(text).toContain('5')
})
it('비서처럼 되묻거나 도와주겠다고 하지 말라는 규칙이 시스템 프롬프트에 있고 사용자 텍스트에는 없다', () => {
// 실측: 모델이 "혹시 이 영상 내용에 대해 궁금한 점이 있으신가요?" 처럼
// 사용자에게 되묻는 비서형 응답을 낸 회귀를 막는다.
const { systemPrompt, text } = buildSuggestionPrompt({ prefix: '오늘 회의에서 논의한 내용을 정리해서' })
expect(SUGGESTION_SYSTEM_PROMPT).toMatch(/비서가 아닙니다/)
expect(SUGGESTION_SYSTEM_PROMPT).toMatch(/질문하지 말고/)
expect(SUGGESTION_SYSTEM_PROMPT).toMatch(/검색어나 폼 입력/)
expect(systemPrompt).toContain('비서가 아닙니다')
expect(systemPrompt).toContain('질문하지 말고')
expect(systemPrompt).toContain('검색어나 폼 입력')
expect(text).not.toContain('비서가 아닙니다')
expect(text).not.toContain('질문하지 말고')
expect(text).not.toContain('검색어나 폼 입력')
})
it('avoidCandidates는 데이터 섹션으로만 들어가고 이미 나온 후보를 모두 나열한다', () => {
const { systemPrompt, text } = buildSuggestionPrompt({
prefix: '오늘 회의에서 논의한 내용을 정리해서',
avoidCandidates: ['공유드리겠습니다.', '전달드리겠습니다.']
})
expect(text).toContain('공유드리겠습니다.')
expect(text).toContain('전달드리겠습니다.')
expect(text).not.toContain('규칙:')
expect(systemPrompt).not.toContain('공유드리겠습니다.')
})
it('avoidCandidates가 없으면 해당 섹션이 아예 없다', () => {
const { text } = buildSuggestionPrompt({ prefix: 'abc' })
expect(text).not.toContain('이미 제안한 문장')
})
})
describe('buildCaptionRefinePrompt', () => {
it('지시문은 시스템 프롬프트에만 두고, 본문에는 앞 문맥 2줄과 다듬을 자막만 넣는다', () => {
const { systemPrompt, text } = buildCaptionRefinePrompt({
text: '오늘회의는 세시에',
previous: ['첫 줄', '둘째 줄', '셋째 줄']
})
expect(systemPrompt).toContain('뜻을 바꾸거나')
expect(text).not.toContain('규칙')
expect(text).toContain(['둘째 줄', '셋째 줄'].join('\n'))
expect(text).not.toContain('첫 줄')
expect(text.endsWith('오늘회의는 세시에')).toBe(true)
})
it('앞 문맥이 없으면 다듬을 자막만 넣는다', () => {
expect(buildCaptionRefinePrompt({ text: '안녕', previous: [] }).text).toBe(['[다듬을 자막]', '안녕'].join('\n'))
})
})