From 99f06c253c2776b986ed94d6b7dc807d12a7e3a2 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 21 Sep 2026 14:29:43 +0900 Subject: [PATCH] fix(llm): stop leaking instruction prompts instead of processed text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running a custom instruction (translate, summarise, rewrite, explain code, free prompt) inserted the instruction's own wording instead of the result. Two faults stacked: The instruction was passed as the text to process, leaving the system-prompt argument empty. `BASE_SYSTEM_PROMPTS` has no `custom` key, so resolution fell back to `refine` without saying so, and the model dutifully polished the instruction it had been handed. The transcript never reached it. And only `{{text}}` was substituted, which none of the five built-in instructions use — they carry `{{targetLanguage}}`, `{{userPrompt}}`, or no placeholder at all. The substitution was a no-op from the day it was written: the presets landed ten hours before the code that expected them. - Instruction prompts now go to the system-prompt argument and the transcript to the text argument. Instructions that spell out `{{text}}` keep their old meaning, so hand-written ones still work. - `renderInstructionPrompt` resolves `{{text}}`, `{{userPrompt}}` and `{{targetLanguage}}` in one place, and warns by name when a placeholder is left standing rather than letting it reach the model. - `resolveSystemPrompt` no longer drops silently to `refine` for `custom`. - Voice shortcuts no longer die at the `defaultLLMAction === 'none'` gate; an explicitly named instruction outranks the default. Without one, `none` still passes the transcript through untouched. - `translate` receives its target language instead of relying on a default two call frames away. It is still always English — `AppConfig` has no key for it, and neither `language` (UI locale) nor `sttLanguage` (source language) can stand in. Choosing a target language needs a setting and is not in this fix. - Chains ran instructions with placeholders intact; they share the same resolution now. - The command screen's pipeline bench called `llm.generate`, which preload does not expose, so every run threw and the catch showed the input back as if it had succeeded. It uses `llm.process` now, over the same path production takes, and a failure reads as a failure. Present since the feature shipped: the custom-instruction path has never worked. Plain actions (refine, summarise, grammar, expand) were unaffected and are now covered by tests so they stay that way. --- apps/desktop/src/main/ipc/llm-handlers.ts | 17 +- .../desktop/src/main/services/ChainService.ts | 16 +- .../src/main/services/VoiceModeService.ts | 47 +++- apps/desktop/src/main/services/llm-prompts.ts | 103 ++++++- .../src/renderer/pages/CommandsPage.tsx | 42 ++- .../tests/main/ipc/llm-handlers.test.ts | 163 +++++++++++ .../tests/main/services/ChainService.test.ts | 174 ++++++++++++ .../main/services/VoiceModeService.test.ts | 260 +++++++++++++++++- .../tests/main/services/llm-prompts.test.ts | 169 ++++++++++++ docs/map/00-index.md | 4 +- docs/map/02-infrastructure.md | 2 +- docs/map/04-desktop-app.md | 13 +- docs/map/10-feature-catalog.md | 12 +- docs/map/11-gap-backlog.md | 14 +- 14 files changed, 992 insertions(+), 44 deletions(-) create mode 100644 apps/desktop/tests/main/ipc/llm-handlers.test.ts create mode 100644 apps/desktop/tests/main/services/ChainService.test.ts create mode 100644 apps/desktop/tests/main/services/llm-prompts.test.ts diff --git a/apps/desktop/src/main/ipc/llm-handlers.ts b/apps/desktop/src/main/ipc/llm-handlers.ts index 1e29f78..68d84d3 100644 --- a/apps/desktop/src/main/ipc/llm-handlers.ts +++ b/apps/desktop/src/main/ipc/llm-handlers.ts @@ -7,6 +7,7 @@ import { getLocalLLMService } from '../services/LocalLLMService' import { getPremiumLLMService } from '../services/PremiumLLMService' import { getOnlineLLMService } from '../services/OnlineLLMService' import { configGet, configSet } from '../services/ConfigService' +import { buildInstructionInvocation, resolveTargetLanguage } from '../services/llm-prompts' import { normalizeLoopbackUrl } from '../utils/loopback' import { getMainWindow } from '../windows/WindowManager' import type { SetLLMModelParams, SetServerUrlParams, LLMProcessParams } from '@d3ro/core/types' @@ -87,11 +88,23 @@ export function registerLLMHandlers(): void { backend === 'online' ? getOnlineLLMService() : getLocalLLMService() const start = performance.now() + // 지시문 프롬프트를 시스템 프롬프트 자리로 보내고 플레이스홀더를 치환하는 판단은 + // VoiceModeService·ChainService와 같은 함수를 쓴다. 렌더러는 지시문 원문만 + // 넘기고, 치환 규칙을 복제하지 않는다. + const invocation = + params.action === 'custom' && params.customPrompt + ? buildInstructionInvocation( + params.customPrompt, + params.text, + params.targetLanguage ?? resolveTargetLanguage() + ) + : { text: params.text, systemPrompt: params.customPrompt } + const processedText = await service.processText( - params.text, + invocation.text, params.action, params.targetLanguage, - params.customPrompt + invocation.systemPrompt ) return ipcSuccess({ diff --git a/apps/desktop/src/main/services/ChainService.ts b/apps/desktop/src/main/services/ChainService.ts index 47325ea..2bf2ef7 100644 --- a/apps/desktop/src/main/services/ChainService.ts +++ b/apps/desktop/src/main/services/ChainService.ts @@ -6,6 +6,7 @@ import { getLogger } from './LoggerService' import { configGet, configSet } from './ConfigService' import { getCustomInstructionService } from './CustomInstructionService' import { getPremiumLLMService } from './PremiumLLMService' +import { buildInstructionInvocation, resolveTargetLanguage } from './llm-prompts' import { getMainWindow } from '../windows/WindowManager' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { D3ROError, ErrorCode } from '@d3ro/core/errors' @@ -151,6 +152,7 @@ class ChainService { const llm = getPremiumLLMService() const instructionService = getCustomInstructionService() + const targetLanguage = resolveTargetLanguage() logger.info( `Executing chain "${chain.name}" (${chain.steps.length} steps) with input length ${inputText.length}` @@ -189,7 +191,19 @@ class ChainService { // LLM 호출 const stepStart = Date.now() try { - const result = await llm.processText(stepInput, 'custom', undefined, instruction.prompt) + // 지시문은 시스템 프롬프트, 스텝 입력(이전 스텝 출력)은 처리 대상 텍스트. + // {{text}}를 쓰는 지시문은 치환 결과가 처리 대상 텍스트가 된다. + const invocation = buildInstructionInvocation( + instruction.prompt, + stepInput, + targetLanguage, + ) + const result = await llm.processText( + invocation.text, + 'custom', + undefined, + invocation.systemPrompt, + ) const stepDuration = Date.now() - stepStart stepResults.push({ diff --git a/apps/desktop/src/main/services/VoiceModeService.ts b/apps/desktop/src/main/services/VoiceModeService.ts index f2e18fc..78eff77 100644 --- a/apps/desktop/src/main/services/VoiceModeService.ts +++ b/apps/desktop/src/main/services/VoiceModeService.ts @@ -18,6 +18,7 @@ import type { KeyBindingTriggerPayload } from './KeyBindingService' import { configGet } from './ConfigService' import { getTextInsertService } from './TextInsertService' import { getLocalLLMService } from './LocalLLMService' +import { buildInstructionInvocation, resolveTargetLanguage } from './llm-prompts' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { TIMING } from '@d3ro/core/constants' import { RecognitionState, AudioState } from '@d3ro/core/types' @@ -772,12 +773,17 @@ class VoiceModeService extends EventEmitter { if (this._isInTerminalState()) return try { - const action = configGet('defaultLLMAction') - if (action === 'none') { + const configuredAction = configGet('defaultLLMAction') + // 음성 단축키가 특정 명령을 지목해 들어왔다면 기본 액션이 'none'이어도 처리한다. + // 명시적 요청을 기본 설정이 무효화하면 안 된다. + if (configuredAction === 'none' && !overrideInstructionId) { this._completeSession(transcribedText) return } + // 여기서 'none'이 남아 있다면 overrideInstructionId가 반드시 있다 → custom 경로. + const action: LLMAction = configuredAction === 'none' ? 'custom' : configuredAction + // Phase 10.2: 스크린 컨텍스트를 LLM 프롬프트에 주입 let contextPrefix = '' if (this._session?.screenContext) { @@ -818,22 +824,49 @@ class VoiceModeService extends EventEmitter { const effectiveInstructionId = overrideInstructionId ?? configGet('activeInstructionId') + const targetLanguage = resolveTargetLanguage() + if (action === 'custom' || overrideInstructionId) { - let customPrompt = contextPrefix + transcribedText + const userText = contextPrefix + transcribedText + let invocationText = userText + let instructionPrompt: string | undefined if (effectiveInstructionId) { const { getCustomInstructionService } = await import('./CustomInstructionService') const instruction = getCustomInstructionService().getById(effectiveInstructionId) if (instruction) { - customPrompt = instruction.prompt.replace(/\{\{text\}\}/g, contextPrefix + transcribedText) + const invocation = buildInstructionInvocation( + instruction.prompt, + userText, + targetLanguage, + ) + invocationText = invocation.text + instructionPrompt = invocation.systemPrompt logger.info(`Using custom instruction: "${instruction.name}"`) + } else { + logger.warn( + `Custom instruction not found: ${effectiveInstructionId} — processing without an instruction`, + ) } } - processedText = await this._runProcessorWithFallback(customPrompt, 'custom') + processedText = await this._runProcessorWithFallback( + invocationText, + 'custom', + undefined, + instructionPrompt, + ) } else { - logger.info(`Processing with LLM (action: ${action})`) - processedText = await this._runProcessorWithFallback(contextPrefix + transcribedText, action) + logger.info( + action === 'translate' + ? `Processing with LLM (action: ${action}, targetLanguage: ${targetLanguage})` + : `Processing with LLM (action: ${action})`, + ) + processedText = await this._runProcessorWithFallback( + contextPrefix + transcribedText, + action, + action === 'translate' ? targetLanguage : undefined, + ) } if (this._isInTerminalState()) return diff --git a/apps/desktop/src/main/services/llm-prompts.ts b/apps/desktop/src/main/services/llm-prompts.ts index eefc19c..5e42300 100644 --- a/apps/desktop/src/main/services/llm-prompts.ts +++ b/apps/desktop/src/main/services/llm-prompts.ts @@ -1,8 +1,11 @@ // src/main/services/llm-prompts.ts // LLM 시스템 프롬프트 SSOT — PremiumLLMService 공용. +import { getLogger } from './LoggerService' import type { LLMAction } from '@d3ro/core/types' +const logger = getLogger('llm-prompts') + /** * 기본 시스템 프롬프트 (NO_THINK prefix 없음). * PremiumLLMService는 NO_THINK prefix 없이 바로 사용. @@ -26,6 +29,88 @@ const BASE_SYSTEM_PROMPTS: Record = { 확장된 텍스트만 출력하세요.`, } +/** + * 번역 대상 언어 기본값. + * `AppConfig`에는 번역 대상 언어 키가 없다 (`language`는 UI 로케일, + * `sttLanguage`는 입력 언어라 둘 다 대상 언어가 아니다). + */ +export const DEFAULT_TARGET_LANGUAGE = 'English' + +/** + * 번역 대상 언어를 해석한다. LLM을 호출하는 모든 경로가 이 함수를 쓴다. + * + * `AppConfig`에는 번역 대상 언어 키가 없다 — `language`는 UI 로케일이고 + * `sttLanguage`는 입력(원문) 언어라 어느 쪽도 대상 언어로 쓸 수 없다. + * (입력 언어로 번역하면 원문 그대로가 되고, UI 로케일은 `'ko'` 같은 코드라 + * 프롬프트에 그대로 넣으면 문장이 깨진다.) + * + * 따라서 현재는 {@link DEFAULT_TARGET_LANGUAGE} 고정이다. 사용자가 대상 언어를 + * 고를 수 있으려면 설정 키가 필요하다. + */ +export function resolveTargetLanguage(): string { + return DEFAULT_TARGET_LANGUAGE +} + +/** + * `action: 'custom'`인데 시스템 프롬프트가 주어지지 않았을 때 쓰는 액션. + * 지시문이 `{{text}}`로 사용자 텍스트 위치를 직접 지정한 하위 호환 경로에서 발생한다. + */ +const CUSTOM_FALLBACK_ACTION = 'refine' + +/** 지시문이 사용자 텍스트 위치를 직접 지정할 때 쓰는 플레이스홀더. */ +const TEXT_PLACEHOLDER = /\{\{text\}\}/g + +/** 치환 후에도 남아 있는 플레이스홀더 탐지용. */ +const ANY_PLACEHOLDER = /\{\{([^{}]+)\}\}/g + +export interface InstructionVars { + /** 사용자 음성 텍스트 (스크린 컨텍스트 프리픽스 포함). */ + text: string + /** 번역 대상 언어. 생략 시 {@link DEFAULT_TARGET_LANGUAGE}. */ + targetLanguage?: string +} + +/** + * 사용자 정의 지시문의 플레이스홀더를 치환한다. + * 치환되지 않고 남은 `{{...}}`는 그대로 두되 경고를 남긴다 — + * 조용히 새어나간 플레이스홀더가 LLM에 그대로 전달되는 사고가 있었다. + */ +export function renderInstructionPrompt(prompt: string, vars: InstructionVars): string { + const rendered = prompt + .replace(TEXT_PLACEHOLDER, vars.text) + .replace(/\{\{userPrompt\}\}/g, vars.text) + .replace(/\{\{targetLanguage\}\}/g, vars.targetLanguage ?? DEFAULT_TARGET_LANGUAGE) + + const leftovers = [...rendered.matchAll(ANY_PLACEHOLDER)].map((m) => m[1]) + if (leftovers.length > 0) { + logger.warn( + `Unresolved instruction placeholders passed to the LLM: ${[...new Set(leftovers)].join(', ')}`, + ) + } + + return rendered +} + +/** + * 지시문과 사용자 텍스트로 `processText(text, action, targetLanguage, customPrompt)` 인자를 만든다. + * + * - 기본: 지시문은 **시스템 프롬프트**, 사용자 텍스트는 **처리 대상 텍스트**. + * - 하위 호환: 지시문에 `{{text}}`가 있으면 사용자가 텍스트 위치를 직접 지정한 것이므로 + * 치환된 지시문을 처리 대상 텍스트로 넘기고 시스템 프롬프트는 비워 기본 동작을 따른다. + */ +export function buildInstructionInvocation( + instructionPrompt: string, + userText: string, + targetLanguage?: string, +): { text: string; systemPrompt?: string } { + const rendered = renderInstructionPrompt(instructionPrompt, { text: userText, targetLanguage }) + + if (instructionPrompt.includes('{{text}}')) { + return { text: rendered } + } + return { text: userText, systemPrompt: rendered } +} + /** * 액션 + 옵션으로 시스템 프롬프트를 해석한다. * translate 액션은 targetLanguage 치환, custom 액션은 customPrompt 사용. @@ -35,16 +120,26 @@ export function resolveSystemPrompt( targetLanguage?: string, customPrompt?: string, ): string { - if (action === 'custom' && customPrompt) { - return customPrompt + if (action === 'custom') { + if (customPrompt && customPrompt.trim().length > 0) { + return customPrompt + } + // BASE_SYSTEM_PROMPTS에 'custom' 키가 없어 조용히 refine으로 떨어지던 자리. + // 하위 호환({{text}}) 경로에서는 정상이지만, 지시문을 시스템 프롬프트로 + // 넘기지 못한 버그도 같은 모양이라 반드시 드러나야 한다. + logger.warn( + `resolveSystemPrompt: action 'custom' without a custom prompt — ` + + `falling back to the '${CUSTOM_FALLBACK_ACTION}' system prompt`, + ) + return BASE_SYSTEM_PROMPTS[CUSTOM_FALLBACK_ACTION] } if (action === 'translate') { return BASE_SYSTEM_PROMPTS.translate.replace( '{{targetLanguage}}', - targetLanguage ?? 'English', + targetLanguage ?? DEFAULT_TARGET_LANGUAGE, ) } - return BASE_SYSTEM_PROMPTS[action] ?? BASE_SYSTEM_PROMPTS.refine + return BASE_SYSTEM_PROMPTS[action] ?? BASE_SYSTEM_PROMPTS[CUSTOM_FALLBACK_ACTION] } export { BASE_SYSTEM_PROMPTS } diff --git a/apps/desktop/src/renderer/pages/CommandsPage.tsx b/apps/desktop/src/renderer/pages/CommandsPage.tsx index 78664a4..12a4d12 100644 --- a/apps/desktop/src/renderer/pages/CommandsPage.tsx +++ b/apps/desktop/src/renderer/pages/CommandsPage.tsx @@ -26,6 +26,7 @@ import { Mic, Cpu, ClipboardCopy, + TriangleAlert, } from 'lucide-react' import { MetalCard, @@ -68,6 +69,7 @@ export function CommandsPage(): React.ReactElement { // Interactive Pipeline Test State const [testInput, setTestInput] = useState('') const [testResult, setTestResult] = useState(null) + const [testError, setTestError] = useState(null) const [testing, setTesting] = useState(false) const loadData = useCallback(async () => { @@ -175,23 +177,28 @@ export function CommandsPage(): React.ReactElement { } } + // 이 벤치는 실제 받아쓰기와 같은 경로(llm.process → processText)를 탄다. + // 지시문 원문을 customPrompt로 넘기면 메인 프로세스가 플레이스홀더 치환과 + // 시스템 프롬프트 배치를 처리한다 — 치환 규칙을 렌더러에 복제하지 않는다. const handleRunPipelineTest = async () => { if (!testInput.trim()) return setTesting(true) setTestResult(null) + setTestError(null) try { const activeInst = instructions.find((i) => i.id === activeId) - const prompt = activeInst?.prompt || '다음 문장을 자연스럽고 깔끔하게 다듬어주세요:' - const res = await window.electronAPI.llm.generate({ - prompt: `${prompt}\n\n${testInput.trim()}`, - }) + const res = await window.electronAPI.llm.process( + activeInst + ? { text: testInput.trim(), action: 'custom', customPrompt: activeInst.prompt } + : { text: testInput.trim(), action: 'refine' }, + ) if (res.success) { - setTestResult(res.data.text) + setTestResult(res.data.processedText) } else { - setTestResult(testInput.trim()) + setTestError(res.error.message || t('popup.error.default')) } - } catch { - setTestResult(testInput.trim()) + } catch (error) { + setTestError(error instanceof Error ? error.message : t('popup.error.default')) } finally { setTesting(false) } @@ -543,6 +550,25 @@ export function CommandsPage(): React.ReactElement { )} + + {testError && ( + + + + {testError} + + + )} diff --git a/apps/desktop/tests/main/ipc/llm-handlers.test.ts b/apps/desktop/tests/main/ipc/llm-handlers.test.ts new file mode 100644 index 0000000..ff6a9e5 --- /dev/null +++ b/apps/desktop/tests/main/ipc/llm-handlers.test.ts @@ -0,0 +1,163 @@ +// tests/main/ipc/llm-handlers.test.ts +// LLM.PROCESS 핸들러가 지시문 프롬프트를 시스템 프롬프트 자리로 정규화하는지 잠근다. +// 명령 화면 테스트 벤치가 이 경로를 타므로, 프로덕션(VoiceModeService)과 +// 동일한 인자 배치·치환이 적용되어야 한다. + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' +import type { LLMProcessParams } from '@d3ro/core/types' + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) +})) + +const handlers = vi.hoisted( + () => new Map Promise>() +) + +vi.mock('electron', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + ipcMain: { + handle: vi.fn((channel: string, fn: (event: unknown, params: unknown) => Promise) => { + handlers.set(channel, fn) + }), + on: vi.fn(), + removeHandler: vi.fn() + } + } +}) + +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: vi.fn(() => 'local'), + configSet: vi.fn() +})) + +const mockLocalLLM = vi.hoisted(() => ({ + processText: vi.fn(), + cancelGeneration: vi.fn(), + on: vi.fn(), + off: vi.fn() +})) + +vi.mock('../../../src/main/services/LocalLLMService', () => ({ + getLocalLLMService: () => mockLocalLLM +})) + +vi.mock('../../../src/main/services/PremiumLLMService', () => ({ + getPremiumLLMService: () => ({ cancelGeneration: vi.fn(), on: vi.fn(), off: vi.fn() }) +})) + +vi.mock('../../../src/main/services/OnlineLLMService', () => ({ + getOnlineLLMService: () => mockLocalLLM +})) + +async function invokeProcess(params: LLMProcessParams): Promise { + const handler = handlers.get(IPC_CHANNELS.LLM.PROCESS) + if (!handler) throw new Error('LLM.PROCESS handler not registered') + return handler({}, params) +} + +beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + handlers.clear() + mockLocalLLM.processText.mockResolvedValue('LLM 결과') + + const mod = await import('../../../src/main/ipc/llm-handlers') + mod.registerLLMHandlers() +}) + +describe('LLM.PROCESS — 지시문 인자 정규화', () => { + it('custom 액션의 지시문을 시스템 프롬프트로, 입력 텍스트를 처리 대상으로 넘긴다', async () => { + await invokeProcess({ + text: '오늘 배포 일정을 정했습니다', + action: 'custom', + customPrompt: '다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요.' + }) + + const [text, action, , systemPrompt] = mockLocalLLM.processText.mock.calls[0] + + expect(text).toBe('오늘 배포 일정을 정했습니다') + expect(action).toBe('custom') + expect(systemPrompt).toBe('다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요.') + }) + + it('{{targetLanguage}}를 치환해 시스템 프롬프트로 넘긴다', async () => { + await invokeProcess({ + text: '안녕하세요', + action: 'custom', + customPrompt: '다음 텍스트를 {{targetLanguage}}로 번역해주세요.' + }) + + const [text, , , systemPrompt] = mockLocalLLM.processText.mock.calls[0] + + expect(text).toBe('안녕하세요') + expect(systemPrompt).toBe('다음 텍스트를 English로 번역해주세요.') + expect(systemPrompt).not.toContain('{{') + }) + + it('{{userPrompt}}를 입력 텍스트로 치환한다', async () => { + await invokeProcess({ + text: '피보나치 짜줘', + action: 'custom', + customPrompt: '{{userPrompt}}' + }) + + const [text, , , systemPrompt] = mockLocalLLM.processText.mock.calls[0] + + expect(text).toBe('피보나치 짜줘') + expect(systemPrompt).toBe('피보나치 짜줘') + }) + + it('{{text}}를 쓰는 지시문은 치환 결과를 처리 대상 텍스트로 넘긴다', async () => { + await invokeProcess({ + text: '가 나 다', + action: 'custom', + customPrompt: '아래를 불릿으로 정리해줘:\n{{text}}' + }) + + const [text, , , systemPrompt] = mockLocalLLM.processText.mock.calls[0] + + expect(text).toBe('아래를 불릿으로 정리해줘:\n가 나 다') + expect(systemPrompt).toBeUndefined() + }) + + it('명시적 targetLanguage를 그대로 쓴다', async () => { + await invokeProcess({ + text: '안녕', + action: 'custom', + customPrompt: '{{targetLanguage}}로 번역해줘.', + targetLanguage: '프랑스어' + }) + + const [, , targetLanguage, systemPrompt] = mockLocalLLM.processText.mock.calls[0] + + expect(targetLanguage).toBe('프랑스어') + expect(systemPrompt).toBe('프랑스어로 번역해줘.') + }) + + it('일반 액션은 지시문 정규화를 거치지 않고 그대로 전달한다', async () => { + await invokeProcess({ text: '다듬어줘 이 문장', action: 'refine' }) + + const [text, action, targetLanguage, systemPrompt] = mockLocalLLM.processText.mock.calls[0] + + expect(text).toBe('다듬어줘 이 문장') + expect(action).toBe('refine') + expect(targetLanguage).toBeUndefined() + expect(systemPrompt).toBeUndefined() + }) + + it('실패를 성공으로 위장하지 않는다', async () => { + mockLocalLLM.processText.mockRejectedValue(new Error('Ollama unreachable')) + + const result = (await invokeProcess({ text: '아무 말', action: 'refine' })) as { + success: boolean + error: { message: string } + } + + expect(result.success).toBe(false) + expect(result.error.message).toContain('Ollama unreachable') + }) +}) diff --git a/apps/desktop/tests/main/services/ChainService.test.ts b/apps/desktop/tests/main/services/ChainService.test.ts new file mode 100644 index 0000000..41d5a51 --- /dev/null +++ b/apps/desktop/tests/main/services/ChainService.test.ts @@ -0,0 +1,174 @@ +// tests/main/services/ChainService.test.ts +// 체인 스텝이 지시문을 시스템 프롬프트로 넘기고 플레이스홀더를 치환하는지 잠근다. +// 인자 자리는 원래 옳았으나 치환이 없어 {{targetLanguage}} 등이 그대로 새어나갔다. + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { LLMChain } from '@d3ro/core/types' + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) +})) + +const config = vi.hoisted(() => ({ values: {} as Record })) + +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: vi.fn((key: string) => config.values[key]), + configSet: vi.fn((key: string, value: unknown) => { + config.values[key] = value + }) +})) + +const instructionStore = vi.hoisted(() => ({ + byId: {} as Record +})) + +vi.mock('../../../src/main/services/CustomInstructionService', () => ({ + getCustomInstructionService: () => ({ + getById: (id: string) => instructionStore.byId[id] ?? null + }) +})) + +const mockLLM = vi.hoisted(() => ({ + processText: vi.fn(), + cancelGeneration: vi.fn() +})) + +vi.mock('../../../src/main/services/PremiumLLMService', () => ({ + getPremiumLLMService: () => mockLLM +})) + +let getChainService: typeof import('../../../src/main/services/ChainService')['getChainService'] + +function chain(steps: Array<{ instructionId: string }>): LLMChain { + return { + id: 'chain-1', + name: '테스트 체인', + steps: steps.map((s) => ({ instructionId: s.instructionId, inputSource: 'previous' as const })), + createdAt: 0, + updatedAt: 0 + } +} + +beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + config.values = {} + instructionStore.byId = {} + mockLLM.processText.mockResolvedValue('LLM 결과') + + const mod = await import('../../../src/main/services/ChainService') + mod.resetChainServiceForTests() + getChainService = mod.getChainService +}) + +describe('ChainService.execute — 지시문 인자 전달', () => { + it('지시문을 시스템 프롬프트로, 스텝 입력을 처리 대상 텍스트로 넘긴다', async () => { + instructionStore.byId['builtin-summarize'] = { + id: 'builtin-summarize', + name: '요약', + prompt: '다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요.' + } + config.values.llmChains = [chain([{ instructionId: 'builtin-summarize' }])] + + const svc = getChainService() + svc.initialize() + await svc.execute('chain-1', '오늘 배포 일정을 정했습니다') + + expect(mockLLM.processText).toHaveBeenCalledTimes(1) + const [text, action, targetLanguage, systemPrompt] = mockLLM.processText.mock.calls[0] + + expect(text).toBe('오늘 배포 일정을 정했습니다') + expect(action).toBe('custom') + expect(targetLanguage).toBeUndefined() + expect(systemPrompt).toBe('다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요.') + }) + + it('{{targetLanguage}}를 치환해 시스템 프롬프트로 넘긴다', async () => { + instructionStore.byId['builtin-translate'] = { + id: 'builtin-translate', + name: '번역', + prompt: '다음 텍스트를 {{targetLanguage}}로 번역해주세요.\n자연스럽고 정확한 번역만 출력하세요.' + } + config.values.llmChains = [chain([{ instructionId: 'builtin-translate' }])] + + const svc = getChainService() + svc.initialize() + await svc.execute('chain-1', '안녕하세요') + + const [text, , , systemPrompt] = mockLLM.processText.mock.calls[0] + + expect(text).toBe('안녕하세요') + expect(systemPrompt).toBe( + '다음 텍스트를 English로 번역해주세요.\n자연스럽고 정확한 번역만 출력하세요.' + ) + expect(systemPrompt).not.toContain('{{') + }) + + it('{{userPrompt}}를 스텝 입력으로 치환한다', async () => { + instructionStore.byId['builtin-free-prompt'] = { + id: 'builtin-free-prompt', + name: '자유 프롬프트', + prompt: '{{userPrompt}}' + } + config.values.llmChains = [chain([{ instructionId: 'builtin-free-prompt' }])] + + const svc = getChainService() + svc.initialize() + await svc.execute('chain-1', '피보나치 짜줘') + + const [text, , , systemPrompt] = mockLLM.processText.mock.calls[0] + + expect(text).toBe('피보나치 짜줘') + expect(systemPrompt).toBe('피보나치 짜줘') + }) + + it('{{text}}를 쓰는 지시문은 치환 결과를 처리 대상 텍스트로 넘긴다', async () => { + instructionStore.byId['user-bullets'] = { + id: 'user-bullets', + name: '불릿 정리', + prompt: '아래를 불릿으로 정리해줘:\n{{text}}' + } + config.values.llmChains = [chain([{ instructionId: 'user-bullets' }])] + + const svc = getChainService() + svc.initialize() + await svc.execute('chain-1', '가 나 다') + + const [text, action, , systemPrompt] = mockLLM.processText.mock.calls[0] + + expect(text).toBe('아래를 불릿으로 정리해줘:\n가 나 다') + expect(action).toBe('custom') + expect(systemPrompt).toBeUndefined() + }) + + it('두 번째 스텝은 이전 스텝 출력을 처리 대상 텍스트로 받는다', async () => { + instructionStore.byId['builtin-summarize'] = { + id: 'builtin-summarize', + name: '요약', + prompt: '요약해줘.' + } + instructionStore.byId['builtin-translate'] = { + id: 'builtin-translate', + name: '번역', + prompt: '{{targetLanguage}}로 번역해줘.' + } + config.values.llmChains = [ + chain([{ instructionId: 'builtin-summarize' }, { instructionId: 'builtin-translate' }]) + ] + mockLLM.processText.mockResolvedValueOnce('요약된 텍스트') + + const svc = getChainService() + svc.initialize() + await svc.execute('chain-1', '긴 원문') + + expect(mockLLM.processText).toHaveBeenCalledTimes(2) + + const [firstText, , , firstSystem] = mockLLM.processText.mock.calls[0] + expect(firstText).toBe('긴 원문') + expect(firstSystem).toBe('요약해줘.') + + const [secondText, , , secondSystem] = mockLLM.processText.mock.calls[1] + expect(secondText).toBe('요약된 텍스트') + expect(secondSystem).toBe('English로 번역해줘.') + }) +}) diff --git a/apps/desktop/tests/main/services/VoiceModeService.test.ts b/apps/desktop/tests/main/services/VoiceModeService.test.ts index 334c8cf..1fa6664 100644 --- a/apps/desktop/tests/main/services/VoiceModeService.test.ts +++ b/apps/desktop/tests/main/services/VoiceModeService.test.ts @@ -60,15 +60,49 @@ vi.mock('../../../src/main/services/KeyBindingService', () => ({ getKeyBindingService: () => mockKeyBinding })) +const CONFIG_DEFAULTS: Record = { + sttModelId: 'base', + defaultLLMAction: 'refine', + ollamaServerUrl: 'http://localhost:11434', + llmModelId: 'gemma4:e4b' +} + +const config = vi.hoisted(() => ({ values: {} as Record })) + vi.mock('../../../src/main/services/ConfigService', () => ({ - configGet: vi.fn((key: string) => { - const defaults: Record = { - sttModelId: 'base', - defaultLLMAction: 'refine', - ollamaServerUrl: 'http://localhost:11434', - llmModelId: 'gemma4:e4b' - } - return defaults[key] + configGet: vi.fn((key: string) => config.values[key]) +})) + +const instructionStore = vi.hoisted(() => ({ + byId: {} as Record +})) + +vi.mock('../../../src/main/services/CustomInstructionService', () => ({ + getCustomInstructionService: () => ({ + getById: (id: string) => instructionStore.byId[id] ?? null + }) +})) + +// 음성 단축키(두 번째 진입점) — 기본은 비활성 +const voiceCommand = vi.hoisted(() => ({ + enabled: false, + instructionId: null as string | null, + cleanedText: '' +})) + +vi.mock('../../../src/main/services/VoiceCommandService', () => ({ + getVoiceCommandService: () => ({ + isEnabled: () => voiceCommand.enabled, + match: (text: string) => + voiceCommand.enabled && voiceCommand.instructionId + ? { + matched: true, + ruleId: 'rule-1', + instructionId: voiceCommand.instructionId, + cleanedText: voiceCommand.cleanedText || text, + matchedKeyword: '번역' + } + : { matched: false, ruleId: null, instructionId: null, cleanedText: text, matchedKeyword: null } }) })) @@ -97,6 +131,11 @@ beforeEach(async () => { vi.resetModules() vi.clearAllMocks() audioBus.removeAllListeners() + config.values = { ...CONFIG_DEFAULTS } + instructionStore.byId = {} + voiceCommand.enabled = false + voiceCommand.instructionId = null + voiceCommand.cleanedText = '' mockSTT.initialize.mockResolvedValue(undefined as never) mockSTT.transcribe.mockResolvedValue({ text: '테스트 전사', @@ -223,6 +262,211 @@ describe('VoiceModeService', () => { }) }) + describe('LLM 후처리 인자 전달', () => { + const TRANSCRIPT = '회의 끝나고 배포하자' + + /** 한 세션을 끝까지 돌린다. */ + async function runSession(): Promise { + mockSTT.transcribe.mockResolvedValue({ + text: TRANSCRIPT, + segments: [], + language: 'ko', + duration: 2, + processingTime: 50, + } as never) + + const svc = getVoiceModeService() + const settled = new Promise((resolve) => { + svc.once('session-completed', () => resolve()) + svc.once('session-cancelled', () => resolve()) + }) + + await svc.startSession('dictation') + audioBus.emit('audio-data', { buffer: Buffer.alloc(16000 * 2) }) + await new Promise((r) => setTimeout(r, 850)) + await svc.stopSession() + await Promise.race([settled, new Promise((r) => setTimeout(r, 2000))]) + } + + function registerInstruction(inst: { id: string; name: string; prompt: string }): void { + instructionStore.byId[inst.id] = inst + } + + // ── 고친 경로: 커스텀 지시문 ─────────────────────────── + describe('커스텀 지시문 경로 (고친 경로)', () => { + it('{{text}} 없는 지시문은 시스템 프롬프트로, 전사 텍스트는 처리 대상으로 전달한다', async () => { + registerInstruction({ + id: 'builtin-summarize', + name: '요약', + prompt: '다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요.\n요약문만 출력하세요.', + }) + config.values.defaultLLMAction = 'custom' + config.values.activeInstructionId = 'builtin-summarize' + + await runSession() + + expect(mockLLM.processText).toHaveBeenCalledTimes(1) + const [text, action, targetLanguage, systemPrompt] = mockLLM.processText.mock.calls[0] + + expect(text).toBe(TRANSCRIPT) + expect(action).toBe('custom') + expect(targetLanguage).toBeUndefined() + expect(systemPrompt).toBe( + '다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요.\n요약문만 출력하세요.', + ) + }) + + it('builtin-translate의 {{targetLanguage}}를 치환해 시스템 프롬프트로 전달한다', async () => { + registerInstruction({ + id: 'builtin-translate', + name: '번역', + prompt: + '다음 텍스트를 {{targetLanguage}}로 번역해주세요.\n자연스럽고 정확한 번역만 출력하세요.', + }) + config.values.defaultLLMAction = 'custom' + config.values.activeInstructionId = 'builtin-translate' + + await runSession() + const [text, , , systemPrompt] = mockLLM.processText.mock.calls[0] + + expect(text).toBe(TRANSCRIPT) + expect(systemPrompt).toContain('English로 번역') + expect(systemPrompt).not.toContain('{{') + }) + + it('builtin-free-prompt의 {{userPrompt}}를 전사 텍스트로 치환한다', async () => { + registerInstruction({ + id: 'builtin-free-prompt', + name: '자유 프롬프트', + prompt: '{{userPrompt}}', + }) + config.values.defaultLLMAction = 'custom' + config.values.activeInstructionId = 'builtin-free-prompt' + + await runSession() + const [text, , , systemPrompt] = mockLLM.processText.mock.calls[0] + + expect(text).toBe(TRANSCRIPT) + expect(systemPrompt).toBe(TRANSCRIPT) + }) + + it('{{text}}를 쓰는 사용자 정의 지시문은 치환 결과를 처리 대상 텍스트로 넘긴다', async () => { + registerInstruction({ + id: 'user-bullets', + name: '불릿 정리', + prompt: '아래 내용을 불릿으로 정리해줘:\n{{text}}', + }) + config.values.defaultLLMAction = 'custom' + config.values.activeInstructionId = 'user-bullets' + + await runSession() + const [text, action, , systemPrompt] = mockLLM.processText.mock.calls[0] + + expect(text).toBe(`아래 내용을 불릿으로 정리해줘:\n${TRANSCRIPT}`) + expect(action).toBe('custom') + expect(systemPrompt).toBeUndefined() + }) + + it('지시문을 찾지 못하면 전사 텍스트만 전달한다', async () => { + config.values.defaultLLMAction = 'custom' + config.values.activeInstructionId = 'does-not-exist' + + await runSession() + const [text, action, , systemPrompt] = mockLLM.processText.mock.calls[0] + + expect(text).toBe(TRANSCRIPT) + expect(action).toBe('custom') + expect(systemPrompt).toBeUndefined() + }) + }) + + // ── 두 번째 진입점: 음성 단축키 ──────────────────────── + describe('음성 단축키 진입점 (overrideInstructionId)', () => { + it('음성 명령으로 지목된 지시문도 시스템 프롬프트로 전달한다', async () => { + registerInstruction({ + id: 'builtin-translate', + name: '번역', + prompt: '다음 텍스트를 {{targetLanguage}}로 번역해주세요.', + }) + voiceCommand.enabled = true + voiceCommand.instructionId = 'builtin-translate' + voiceCommand.cleanedText = '회의 끝나고 배포하자' + config.values.defaultLLMAction = 'refine' + + await runSession() + const [text, action, , systemPrompt] = mockLLM.processText.mock.calls[0] + + expect(text).toBe(TRANSCRIPT) + expect(action).toBe('custom') + expect(systemPrompt).toBe('다음 텍스트를 English로 번역해주세요.') + }) + + it("defaultLLMAction이 'none'이어도 음성 명령은 스킵되지 않는다", async () => { + registerInstruction({ + id: 'builtin-summarize', + name: '요약', + prompt: '다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요.', + }) + voiceCommand.enabled = true + voiceCommand.instructionId = 'builtin-summarize' + config.values.defaultLLMAction = 'none' + + await runSession() + + expect(mockLLM.processText).toHaveBeenCalledTimes(1) + const [text, action, , systemPrompt] = mockLLM.processText.mock.calls[0] + + expect(text).toBe(TRANSCRIPT) + expect(action).toBe('custom') + expect(systemPrompt).toBe('다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요.') + }) + + it("음성 명령이 없으면 'none'은 기존대로 LLM을 건너뛴다", async () => { + config.values.defaultLLMAction = 'none' + + await runSession() + + expect(mockLLM.processText).not.toHaveBeenCalled() + }) + }) + + // ── 안 고친 경로: 일반 액션이 원래 맞았고 계속 맞다 ──── + describe('일반 액션 경로 (수정 대상 아님 — 계속 정상이어야 한다)', () => { + it.each(['refine', 'summarize', 'grammar', 'expand'])( + "'%s' 액션은 전사 텍스트를 text로 넘기고 customPrompt를 넘기지 않는다", + async (action) => { + config.values.defaultLLMAction = action + // 활성 지시문이 있어도 일반 액션 경로는 지시문을 타지 않아야 한다. + registerInstruction({ id: 'builtin-summarize', name: '요약', prompt: '요약해줘' }) + config.values.activeInstructionId = 'builtin-summarize' + + await runSession() + + expect(mockLLM.processText).toHaveBeenCalledTimes(1) + const [text, passedAction, targetLanguage, systemPrompt] = + mockLLM.processText.mock.calls[0] + + expect(text).toBe(TRANSCRIPT) + expect(passedAction).toBe(action) + expect(targetLanguage).toBeUndefined() + expect(systemPrompt).toBeUndefined() + }, + ) + + it("'translate' 액션은 대상 언어를 함께 넘긴다", async () => { + config.values.defaultLLMAction = 'translate' + + await runSession() + const [text, action, targetLanguage, systemPrompt] = mockLLM.processText.mock.calls[0] + + expect(text).toBe(TRANSCRIPT) + expect(action).toBe('translate') + expect(targetLanguage).toBe('English') + expect(systemPrompt).toBeUndefined() + }) + }) + }) + describe('getState', () => { it('현재 상태를 VoiceState 형태로 반환한다', () => { const svc = getVoiceModeService() diff --git a/apps/desktop/tests/main/services/llm-prompts.test.ts b/apps/desktop/tests/main/services/llm-prompts.test.ts new file mode 100644 index 0000000..17a6d57 --- /dev/null +++ b/apps/desktop/tests/main/services/llm-prompts.test.ts @@ -0,0 +1,169 @@ +// 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, + DEFAULT_TARGET_LANGUAGE, + BASE_SYSTEM_PROMPTS, +} 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) + } + }) +}) diff --git a/docs/map/00-index.md b/docs/map/00-index.md index a59f238..846c2fe 100644 --- a/docs/map/00-index.md +++ b/docs/map/00-index.md @@ -2,8 +2,8 @@ > Status: ACTIVE > Last full audit: 2026-09-13 -> Last update: 2026-09-21 — CAP-16 (desktop key bindings rebuilt on one `@d3ro/core/keybinding` SSOT: multiple bindings per action, mouse buttons, `HOTKEY` → `KEYBINDING` IPC group); verified on Windows by a manual run, so CAP-16 and CAP-02 are `[x]` and GAP-KEY-01 is closed; GAP-KEY-02/03, GAP-QA-02, GAP-I18N-01/02, GAP-INFRA-06 remain open; `11` gained §7 for accepted design constraints (things deliberately kept, not gaps) -> Scope: entire monorepo `D:/workspace/D3ROVoice` at product version `1.3.7` +> Last update: 2026-09-21 — LLM instruction-prompt fix (`9c2b4d4`): the custom-instruction path inserted the instruction's own wording instead of the processed result and had **never worked in any shipped release** (`v0.1.0-alpha`..`v1.4.0`, introduced `fea923d` 2026-04-05, not a regression). `llm-prompts.ts` is now the SSOT for prompt resolution and placeholder substitution, shared by `VoiceModeService` / `ChainService` / `LLM.PROCESS`. AI-04/05/06/07 are demoted to `[~]` on desktop — fixed with unit tests, but **not verified in a running app** and the four related `tests/red/*.usecase.test.ts` could not execute (`better-sqlite3` ABI). New: GAP-LLM-01 (no target-language setting), GAP-LLM-02 (this fix unverified); GAP-INFRA-06 amended (the ABI masks verification, not just dev-env switching cost); GAP-I18N-01 amended (`popup.error.default` missing in 10 locales). Earlier the same day: CAP-16 (desktop key bindings rebuilt on one `@d3ro/core/keybinding` SSOT — multiple bindings per action, mouse buttons, `HOTKEY` → `KEYBINDING` IPC group), verified on Windows by a manual run, so CAP-16 and CAP-02 are `[x]` and GAP-KEY-01 is closed. Still open: GAP-KEY-02/03, GAP-QA-02, GAP-I18N-01/02, GAP-INFRA-06, GAP-LLM-01/02; `11` §7 holds accepted design constraints (things deliberately kept, not gaps) +> Scope: entire monorepo `D:/workspace/D3ROVoice` at product version `1.4.0` (`release/product-version.json`, released 2026-09-21) > Purpose: let any agent (or human) answer two questions in under a minute: > 1. **What infrastructure exists?** (build, CI, services, APIs, data, packages, deploy) > 2. **How far is each feature developed?** (per surface, with file anchors and status) diff --git a/docs/map/02-infrastructure.md b/docs/map/02-infrastructure.md index f0cdee0..63504c5 100644 --- a/docs/map/02-infrastructure.md +++ b/docs/map/02-infrastructure.md @@ -192,7 +192,7 @@ Full detail: [`09-supabase-backend.md`](./09-supabase-backend.md). | File | Purpose | |---|---| -| `release/product-version.json` | version `1.3.7`, `androidVersionCode`/`iosBuildNumber` `1031007`, releaseDate, desktop license keyId | +| `release/product-version.json` | version `1.4.0`, `androidVersionCode`/`iosBuildNumber` `1041000`, releaseDate `2026-09-21`, desktop license keyId | | `release/android-release-identity.json` | package `com.d3ro.voice`, Play app ID, app-signing SHA-256, upload cert SHA-256, evidence keyId, AdMob unit IDs | | `release/desktop-license-public.pem` | Ed25519 public key for desktop offline licenses | | `release/mobile-release-evidence-public.pem` | Ed25519 public key for mobile release evidence | diff --git a/docs/map/04-desktop-app.md b/docs/map/04-desktop-app.md index 5152121..2429191 100644 --- a/docs/map/04-desktop-app.md +++ b/docs/map/04-desktop-app.md @@ -48,7 +48,7 @@ Singleton + `EventEmitter` pattern (`getXService()` accessors). | `LocalLLMService` | Ollama REST (models, pull w/ progress, server start, NDJSON streaming) | | `PremiumLLMService` | Claude via Supabase `llm-proxy`, local fallback | | `OnlineLLMService` | JWT-authenticated .NET backend client | -| `llm-prompts.ts` | `resolveSystemPrompt` SSOT for action prompts | +| `llm-prompts.ts` | **SSOT for prompt resolution, placeholder substitution, and argument placement.** `resolveSystemPrompt` (`:118`) maps an `LLMAction` to its base prompt and handles `custom` explicitly instead of dropping silently to `refine`. `renderInstructionPrompt` (`:78`) substitutes `{{text}}` / `{{userPrompt}}` / `{{targetLanguage}}` and **warns by name** for any placeholder left standing rather than letting it reach the model. `buildInstructionInvocation` (`:101`) decides where an instruction goes in `processText(text, action, targetLanguage, customPrompt)`: the instruction becomes the **system prompt** and the transcript the **text**, except for instructions that spell out `{{text}}`, which keep the old meaning for backward compatibility. `resolveTargetLanguage` (`:50`) is the one place translate targets are decided (still `English`, see `11` GAP-LLM-01). **All three LLM entry paths call the same functions** — `VoiceModeService` (`:838`), `ChainService` (`:196`), and the `LLM.PROCESS` IPC handler (`llm-handlers.ts:96`) — so no caller re-implements the rules | ### Memory & knowledge | Service | Purpose | @@ -59,7 +59,7 @@ Singleton + `EventEmitter` pattern (`getXService()` accessors). | `RAGService` | Local RAG: `nomic-embed-text` embeddings, cosine search over `rag_chunks` | | `CustomInstructionService` | User LLM commands (5 built-ins) | | `VoiceCommandService` | Keyword → command rule matching | -| `ChainService` | Multi-step LLM pipelines (LLMChain) | +| `ChainService` | Multi-step LLM pipelines (LLMChain). Each step resolves its instruction through `llm-prompts.ts` (`ChainService.ts:196`); before that, chain steps sent placeholders through unsubstituted | | `ScreenContextService` | Active-window + selected-text context | ### Phase 10+ features @@ -140,6 +140,8 @@ Registry: `src/main/ipc/index.ts` calls 29 `registerXHandlers()` in fixed order. The **`KEYBINDING`** group replaced the old per-action `HOTKEY` group. `HOTKEY` had 14 channels — a get/set pair per action plus three that were never implemented — so every new action meant new channels. `KEYBINDING` is 9 channels that take the action **as a parameter**: `getMap`, `setBindings`, `resetAction`, `resetAll`, `validate`, `isEnabled`, `setEnabled`, plus the `triggered` / `changed` events (`packages/core/src/ipc-channels.ts:104`). Adding an action now costs zero channels. +**`LLM.PROCESS` normalizes at the IPC boundary.** The handler runs `buildInstructionInvocation` itself when `action === 'custom'` with a `customPrompt` (`llm-handlers.ts:94-108`), so the renderer passes the **raw instruction text** and never duplicates the substitution or argument-placement rules. This is what makes `VoiceModeService`, `ChainService`, and `LLM.PROCESS` literally share one implementation. No channel or type changed for this; `LLMProcessParams` is unchanged. + Preload exposes **`window.electronAPI`** with 33 namespaces: `platform, audio, config, voice, stt, keybinding, llm (incl. premium), history, dictionary, stats, window, system, instruction, app, memo, voiceCommand, context, chain, caption, license, fileTranscription, meetingSummary, dictationTemplate, rag, voiceAction, voiceConversation, meetingMode, meetingChat, meetingDocTemplate, cloudSync, onlineAuth, ads, support, payment`. The `keybinding` bridge is 9 methods mirroring the channels above (`src/preload/index.ts:323`), replacing the 11-method `hotkey` bridge. Envelope: `IPCResult` (success/error); `app.onDataChanged` is the global refresh channel. --- @@ -191,12 +193,16 @@ DB schema (`src/main/db/schema.ts`, drizzle SQLite): `history`, `dictionary`, `s ## 6. Desktop status summary -- Core dictation/LLM/history pipeline: **implemented + tested**. Measured 2026-09-21: 1314 vitest cases in `apps/desktop`, 1311 passing; playwright e2e is separate. The failures are environment-dependent rather than regressions — two need a local sidecar venv or embedding server, one pins an error message that has since changed (`11` GAP-QA-02). These numbers hold with `better-sqlite3` built for the host Node ABI; rebuilding it for Electron to run the app invalidates them until you rebuild back (`11` GAP-INFRA-06). +- Core dictation/LLM/history pipeline: **implemented + tested**. The vitest case count in `apps/desktop` is **1360** after the 2026-09-21 LLM fix added 46 cases; playwright e2e is separate. Read the pass numbers together with the `better-sqlite3` ABI the tree is built for (`11` GAP-INFRA-06) — they are not comparable across configurations: + - **Host Node ABI** (2026-09-21, before the LLM fix): 1311 / 1314 passing. The three failures are environment-dependent rather than regressions — two need a local sidecar venv or embedding server, one pins an error message that has since changed (`11` GAP-QA-02). **This configuration has not been re-measured since the LLM fix.** + - **Electron ABI** (2026-09-21, after the LLM fix): `366 failed | 994 passed (1360)`, against a clean-tree baseline of `366 failed | 948 passed (1314)` in the same configuration — identical failure count, +46 passed, **zero new failures**. 365 of those 366 are `tests/red/*.usecase.test.ts` files dying at DB creation because of the ABI mismatch, not assertions. - Cross-platform packaging: Windows NSIS (signed, `forceCodeSigning`), macOS DMG/ZIP arm64 (ad-hoc signing); auto-update via canonical Forgejo feed with update policy (`release/update-policy.json`). - Local-first AI (SoX + faster-whisper sidecar + bundled Ollama) and cloud paths both present. - **Local STT is packaged** (`1.3.0`): `electron-builder.yml` `extraResources` copies `sidecar-dist/sidecar` → `resources/sidecar` and `resources/ffmpeg` → `resources/ffmpeg`; `scripts/ci/verify-sidecar-bundle.mjs` gates packaging. Build locally with `npm --prefix apps/desktop run sidecar:setup && npm --prefix apps/desktop run sidecar:build`. The sidecar stays in console mode so `stdout`/`stderr` reach the app log (UTF-8, line-buffered); a packaged sidecar **must** exist or startup fails loudly instead of silently falling back to a system Python. - All local engine URLs (`LocalSTTService`, `LocalLLMService`, `RAGService`, `OnlineLLMService`, `STTManager`) pass through `src/main/utils/loopback.ts`, which rewrites `localhost` to `127.0.0.1`, because some Windows hosts resolve `localhost` to IPv6 only and local engines bind IPv4. - Meeting intelligence, RAG, voice conversation (local + Realtime), captions, file transcription: implemented. +- **LLM instruction prompts: fixed 2026-09-21 (`9c2b4d4`), not yet verified in a running app.** Running a custom instruction inserted the instruction's own wording instead of the processed result. Two faults stacked: the instruction was passed in the `text` argument with the system-prompt argument left empty, and `BASE_SYSTEM_PROMPTS` has no `custom` key so resolution fell back to `refine` **silently** — the model polished the instruction and the transcript never reached it; separately, only `{{text}}` was substituted and none of the five built-in presets use it (`{{targetLanguage}}`, `{{userPrompt}}`, or no placeholder), so the substitution was a no-op from the day it was written. Introduced in `fea923d` (2026-04-05) and present in every release `v0.1.0-alpha`..`v1.4.0` — **the path never worked; this is not a regression.** Plain actions (`refine`/`summarize`/`grammar`/`expand`) were unaffected and are now pinned by regression cases. The fix routes all three entry paths through `llm-prompts.ts` (see §2) and additionally corrects two things found alongside it: a voice shortcut naming an instruction was nullified by the `defaultLLMAction === 'none'` gate (`VoiceModeService.ts:779`), and the commands-page pipeline bench called `llm.generate`, which preload does not expose, so every run threw and the `catch` displayed the **input** as if it had succeeded — a fail-closed violation that is the reason the bug went unnoticed for five months (`CommandsPage.tsx:180-205`, now on `llm.process` with failures rendered as failures). + - **Verification limits — do not read this as verified.** Unit tests pass (`llm-prompts.test.ts` 21, `llm-handlers.test.ts` 7, `VoiceModeService.test.ts` 22, `ChainService.test.ts` 5), and each of the four fixes was reverted individually to confirm the tests actually fail without it. `npm run lint` (apps/desktop scope) passes; `tsconfig.check.json` errors went 36 → 35 (the `llm.generate` error is gone) with no errors in the touched files. But there is **no running-app run**, and `tests/red/{instruction,chain,voice,config}.usecase.test.ts` — precisely the related paths — never executed because of the `better-sqlite3` ABI mismatch. That range is neither passing nor failing; it is untested (`11` GAP-LLM-02, GAP-INFRA-06). - **Key bindings: implemented and verified on Windows.** Every global shortcut now comes from one contract (`@d3ro/core/keybinding`) with multiple bindings per action, mouse-button support, and no hardcoded accelerators left in `bootstrap.ts`. A manual run on 2026-09-21 confirmed legacy migration (custom values preserved), 6 actions loaded, the uiohook keyboard **and** mouse hook active with zero boot errors, and multi-binding working; contract side is `packages/core` 117 tests GREEN with no type errors in the key-binding files (`11` GAP-KEY-01 `[x]`). Two things remain open: `KeyBindingService` has no unit test of its own, and macOS/Linux mouse behavior is unconfirmed (`11` GAP-KEY-02). The rewrite also fixed a dead hands-free double-press path, an order-dependent reserved-combo check, a `globalShortcut.unregisterAll()` that wiped the popup accelerators, and a `setEnabled(true)` that re-enabled hooking with an empty binding set. - The same pass fixed an unrelated pre-existing dashboard bug: `caption.onStateChanged` delivers `{ state }`, but `DashboardPage` passed the whole object into `setCaptionState`, so the caption status readout never showed the right value (`DashboardPage.tsx:148`). - **Ad mediation**: `DirectHouseSponsorAdapter` performs real configurable REST bids; the other 9 adapters remain fail-closed stubs pending official SDKs (see `11-gap-backlog.md` GAP-ADS-01/02). @@ -218,6 +224,7 @@ DB schema (`src/main/db/schema.ts`, drizzle SQLite): `history`, `dictionary`, `s | Preload API | `src/preload/index.ts` | | Windows | `src/main/windows/WindowManager.ts` | | Voice orchestrator | `src/main/services/VoiceModeService.ts` | +| LLM prompt / placeholder SSOT | `src/main/services/llm-prompts.ts` (shared by `VoiceModeService`, `ChainService`, `ipc/llm-handlers.ts`) | | DB schema | `src/main/db/schema.ts` | | Renderer shell / routes | `src/renderer/components/AppLayout.tsx` | | Update feed SSOT | `src/main/update-feed.ts` | diff --git a/docs/map/10-feature-catalog.md b/docs/map/10-feature-catalog.md index 34e6c72..56019a4 100644 --- a/docs/map/10-feature-catalog.md +++ b/docs/map/10-feature-catalog.md @@ -39,11 +39,11 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]` |---|---|---|---|---|---|---| | AI-01 | Local LLM (Ollama) | [x] | [-] | [ ] | [-] | Desktop bundled Ollama | | AI-02 | Cloud LLM (Claude/OpenAI) | [x] | [x] | [x] | [x] | Desktop `PremiumLLMService`; web/mobile via `llm-proxy`; .NET `LlmProxyService` | -| AI-03 | Auto Polish (cleanup/filler removal) | [x] | [~] | [~] | [x] | Desktop built-in; web/mobile via commands | -| AI-04 | Translate / summarize / rephrase | [x] | [x] | [x] | [x] | Built-in instructions | -| AI-05 | Custom instructions (user commands) | [x] | [x] | [x] | [x] | Desktop `CommandsPage` (Red Team RT-03 verified); web `commands`; mobile `CommandsScreen` | -| AI-06 | Voice keyword commands | [x] | [-] | [ ] | [-] | Desktop `VoiceCommandService` + command popup | -| AI-07 | LLM Chains (multi-step pipelines) | [x] | [ ] | [ ] | [-] | Desktop `ChainService` | +| AI-03 | Auto Polish (cleanup/filler removal) | [x] | [~] | [~] | [x] | Desktop built-in; web/mobile via commands. Desktop Auto Polish is the plain `refine` action (`llm-prompts.ts:14`), not a custom instruction, so it was **not** affected by the 2026-09-21 instruction-prompt fix (AI-05); regression cases now pin `refine`/`summarize`/`grammar`/`expand` (`VoiceModeService.test.ts:435`, `llm-prompts.test.ts:153`) | +| AI-04 | Translate / summarize / rephrase | [~] | [x] | [x] | [x] | Built-in instructions. **Desktop has two paths and only one of them worked.** The plain-action path (Settings → `defaultLLMAction`, `SettingsModal.tsx:653`) reads `BASE_SYSTEM_PROMPTS` directly and was always correct. The built-in *instruction* presets (`CustomInstructionService.ts:26/35/44/53/62`) ran through the custom-instruction path and inserted the instruction's own wording instead of the result — see AI-05. Fixed in `9c2b4d4` (2026-09-21), **not verified in a running app** (`11` GAP-LLM-02). Translate still always targets English: `AppConfig` has no target-language key and neither `language` (UI locale) nor `sttLanguage` (source language) can stand in (`llm-prompts.ts:37-52`, `11` GAP-LLM-01) | +| AI-05 | Custom instructions (user commands) | [~] | [x] | [x] | [x] | Web `commands`; mobile `CommandsScreen` — both go through Edge Functions and are unaffected. **Desktop: the custom-instruction path never worked in any shipped release.** The instruction was passed in the `text` argument of `processText(text, action, targetLanguage, customPrompt)` with the system-prompt argument left empty; `BASE_SYSTEM_PROMPTS` has no `custom` key, so resolution fell back to `refine` silently and the model polished the instruction it was handed — the transcript never reached it. Separately, only `{{text}}` was substituted and **none of the five built-ins use it** (`{{targetLanguage}}`, `{{userPrompt}}`, or no placeholder), so the substitution was a no-op from the day it was written. Introduced `fea923d` (2026-04-05); present `v0.1.0-alpha`..`v1.4.0`; **not a regression**. Three entry points were affected: commands-page activation (`CommandsPage.tsx:102-117`), command-popup selection (`bootstrap.ts:377-381`), voice keyword match (`VoiceModeService.ts:681-686`). Fixed in `9c2b4d4` (2026-09-21) — `llm-prompts.ts` is now the SSOT for placeholder substitution and argument placement (`renderInstructionPrompt:78`, `buildInstructionInvocation:101`, `resolveSystemPrompt:118`). The earlier "Red Team RT-03 verified" claim did not catch this and its cited evidence file (`red_team_log.md`) is not in the repo. `[~]` because unit tests pass (`llm-prompts.test.ts` 21, `llm-handlers.test.ts` 7, `VoiceModeService.test.ts` 22, `ChainService.test.ts` 5) but there is **no running-app verification** and the related `tests/red/{instruction,chain,voice,config}.usecase.test.ts` could not execute (`11` GAP-INFRA-06). See `11` GAP-LLM-01/02 | +| AI-06 | Voice keyword commands | [~] | [-] | [ ] | [-] | Desktop `VoiceCommandService` + command popup. Keyword matching itself works (`VoiceModeService.ts:681-686`), but execution went through the broken custom-instruction path (AI-05), and a **second, separate defect** made the shortcut skip LLM processing entirely whenever `defaultLLMAction === 'none'` — which is exactly the value the commands UI and the command popup write when no command is active (`CommandsPage.tsx:116`, `bootstrap.ts:385`), so an explicitly named instruction was nullified by the default setting. Both fixed in `9c2b4d4` (`VoiceModeService.ts:779`/`:785`); **not verified in a running app** (`11` GAP-LLM-02) | +| AI-07 | LLM Chains (multi-step pipelines) | [~] | [ ] | [ ] | [-] | Desktop `ChainService`. The argument placement was already correct here, but chain steps **never substituted placeholders**, so `{{targetLanguage}}` / `{{userPrompt}}` reached the model verbatim as the system prompt. Now shares the same resolution function as the other two paths (`ChainService.ts:196`). Fixed in `9c2b4d4` (2026-09-21); 5 unit tests GREEN, but `tests/red/chain.usecase.test.ts` could not execute (`11` GAP-INFRA-06) and there is **no running-app verification** (`11` GAP-LLM-02) | | AI-08 | Screen/context capture for prompts | [x] | [-] | [ ] | [-] | Desktop `ScreenContextService` | | AI-09 | Streaming responses | [x] | [x] | [x] | [x] | SSE/NDJSON streaming | | AI-10 | Dictation templates (voice form fill) | [x] | [ ] | [x] | [~] | Desktop `DictationTemplateService`; mobile `TemplatesScreen` | @@ -204,7 +204,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]` | Surface | `[x]` | `[~]` | `[ ]` | Notable strength | Notable weakness | |---|---|---|---|---|---| -| Desktop | ~40 | 3 | ~8 | Local AI pipeline, meetings, RAG, conversation, key bindings | Ads stubs, no team admin, no email account | +| Desktop | ~40 | 3 | ~8 | Local AI pipeline, meetings, RAG, conversation, key bindings | Ads stubs, no team admin, no email account; custom-instruction/chain LLM path (AI-04..07) fixed 2026-09-21 but unverified in a running app | | Web | ~22 | 6 | ~14 | Server-shared data UX, billing, meetings, teams | No local AI, limited knowledge upload/search | | Mobile | ~40 | 12 | ~18 | Cloud + native recording, portability, admin, IAP/ads | External store/console gates, a11y, deep E2E pending | | Backend | ~45 | 6 | ~4 | RLS, Edge functions, billing, fail-closed AI | Payple webhook signature, some external provider keys | diff --git a/docs/map/11-gap-backlog.md b/docs/map/11-gap-backlog.md index cf6e39a..476599b 100644 --- a/docs/map/11-gap-backlog.md +++ b/docs/map/11-gap-backlog.md @@ -63,9 +63,11 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res | GAP-KEY-02 | Key bindings | 마우스 버튼 지원이 **Windows 기준으로만** 설계·확인됐다. `KeyBindingService` 에는 마우스 관련 플랫폼 분기가 없고(`process.platform` 은 meta 수정자 라벨 표기에만 쓰인다), macOS/Linux 에서 uiohook 이 보고하는 X1/X2 버튼 번호와 OS 기본 "뒤로/앞으로" 동작과의 간섭은 확인하지 않았다. 마우스 이벤트는 suppress 가 불가능하므로 원래 동작이 항상 함께 실행된다. | `KeyBindingService.ts:235`(`readMouseButton`), `:301`(meta 라벨 분기), `packages/core/src/keybinding.ts:561-612`(마우스 카탈로그 5종). 2026-09-21 실앱 검증(GAP-KEY-01)은 **Windows 에서만** 이뤄졌고 거기서는 MB4/MB5 가 정상 동작했다. | macOS/Linux 에서 MB2~MB5 수신 여부와 버튼 번호 매핑을 확인하고, 다르면 카탈로그를 플랫폼별로 분기한다. | | GAP-KEY-03 | Key bindings | `command` 액션에 전용 핸들러가 없다. 이번에 처음으로 설정 UI 에 노출됐지만, 트리거되면 dictation 파이프라인으로 fallback 하며 `KEYBINDING_ACTIONS` 의 `holdMode:false` 대신 dictation 과 같은 hold-to-talk 로 강제된다. 개편 이전부터 같은 동작이었고 이번 작업은 그 사실을 코드에 명시화만 했다(기능 변화 없음). | `apps/desktop/src/main/services/VoiceModeService.ts:1071`(`_resolveHoldMode`), `packages/core/src/keybinding.ts:740`(액션 정의) | `command` 전용 동작을 정의하고 `_resolveHoldMode` 의 예외를 제거하거나, 액션을 카탈로그에서 뺀다. | | GAP-QA-02 | Quality | 캡션 테스트 2건이 **개발 머신에 사이드카 venv 가 있는지에 따라 결과가 갈린다**. `LocalSTTService.initialize()`(`:239`) → `_ensureSidecarRunning()`(`:583`) → `_spawnSidecar()`(`:650`) → `_waitForHealth()`(`:794`) 경로에서 venv 가 존재하면 실제 Python 프로세스를 띄우고 health 폴링이 vitest 기본 타임아웃 10초를 넘긴다. venv 가 없으면 `getSidecarCommand()`(`apps/desktop/src/main/utils/paths.ts:174`)가 즉시 throw 해서 같은 테스트가 빠르게 통과한다. 테스트가 로컬 환경을 격리하지 못한 것이 결함이다. | `tests/red/ipc-surfaces.usecase.test.ts`(`캡션 시작 실패는 success:false 로 나온다`), `tests/red/silent-errors.usecase.test.ts:48`. **키바인딩 개편의 회귀가 아니다** — 2026-09-21 에 HEAD(`0ca9e24`) 무수정 코드를 같은 환경(venv 연결)에서 돌려 동일하게 재현했다. 같은 날 같은 머신에서도 실행 방식에 따라 결과가 갈렸다: 전체 실행은 `3 failed / 1311 passed (1314)`(`rag.usecase` + `silent-errors` 캡션 + `paths.test`)이고 `ipc-surfaces` 캡션 케이스는 통과했는데, 그 파일만 단독 실행하면 같은 케이스가 10초 타임아웃으로 실패한다. 테스트 총수 1314 는 어느 실행에서나 같고, 새로 깨진 테스트는 0건이다. | 사이드카 기동을 테스트 경계에서 주입·모킹해 환경 의존을 끊는다. 함께 실패하는 `rag.usecase`(임베딩 서버 부재)도 같은 성격이다. `tests/main/utils/paths.test.ts:78` 은 성격이 다르다 — 기대 정규식이 `사이드카를 찾을 수 없습니다` 인데 실제 메시지는 `로컬 음성 엔진이 아직 설치되지 않았습니다…` 로 바뀌어 테스트가 문구를 따라가지 못한 것이다. | -| GAP-I18N-01 | i18n | 로케일별 키 수가 크게 어긋난다. 2026-09-21 실측: `ko` 1716 / `en` 1709 / 나머지 10개 로케일 각 327. `keybinding.*` 55개는 12개 로케일 전부에 동일하게 들어갔지만, 그 밖 약 1,380개 키가 비영어 로케일에 없어 폴백 체인(locale → `en` → `ko`)으로 표시된다. 키바인딩 작업 이전부터 있던 부채이며 그 작업 범위 밖이었다. | `packages/i18n/src/locales/*.json`, 카탈로그 SHELL-03 | 로케일 간 키 diff 를 내는 커버리지 게이트를 만들어 회귀를 막고, 누락 키를 채운다. | +| GAP-I18N-01 | i18n | 로케일별 키 수가 크게 어긋난다. 2026-09-21 실측: `ko` 1716 / `en` 1709 / 나머지 10개 로케일 각 327. `keybinding.*` 55개는 12개 로케일 전부에 동일하게 들어갔지만, 그 밖 약 1,380개 키가 비영어 로케일에 없어 폴백 체인(locale → `en` → `ko`)으로 표시된다. 키바인딩 작업 이전부터 있던 부채이며 그 작업 범위 밖이었다. | `packages/i18n/src/locales/*.json`, 카탈로그 SHELL-03. **구체 사례 (2026-09-21 실측)**: `popup.error.default` 가 `en.json`·`ko.json` 에만 있고 나머지 10개 로케일에 없다. 소비처는 `WindowManager.ts:59`(팝업 문자열 주입, 선재)와 `CommandsPage.tsx:198`·`:201`(LLM 수정으로 추가된 파이프라인 벤치 오류 표시) 두 곳이며, 비영어 사용자에게는 오류 메시지가 영어로 폴백된다. 새 갭이 아니라 이 행이 세는 약 1,380개 중 하나다 — 별도 행을 열지 마라. | 로케일 간 키 diff 를 내는 커버리지 게이트를 만들어 회귀를 막고, 누락 키를 채운다. | | GAP-I18N-02 | i18n | 렌더러가 `ko.json` 에 없는 `license.*` 키를 쓴다. `TranslationKey` 가 `ko.json` 에서 파생되므로 누락은 타입 에러로 드러난다. 타입 에러로만 끝나지 않는다 — 폴백 체인이 `locale → en → ko → 키 문자열` 이므로 마스터 로케일에도 없으면 **`license.team` 같은 키가 화면에 그대로 노출된다**. 2026-09-21 실측: `license.feature.premium_llm`·`license.team`·`license.enterprise` 가 없고 이로 인한 TS2345 가 4건이다. HEAD 에서도 없던 키이므로 선재 결함이며 키바인딩 작업과 무관하다. | `apps/desktop/src/renderer/components/UpgradePromptModal.tsx:47`·`:192`, `apps/desktop/src/renderer/pages/DashboardPage.tsx:481`·`:529`, `packages/i18n/src/locales/ko.json` | 세 키를 `ko.json` 에 추가하고 12개 로케일에 반영한다. 같은 타입체크에 잡히는 `LicenseTab.tsx`(6건)·`LicenseModal.tsx`(2건)는 원인이 다르다 — `TFunction` 을 `(k: string) => string` 에 넘기는 TS2322 4건과 `currentTier` 미정의 TS2304 2건으로, 후자는 컴파일이 깨지는 별개 결함이다(GAP-INFRA-04 범위). | -| GAP-INFRA-06 | Dev env | `better-sqlite3` 네이티브 ABI 가 **앱 실행과 로컬 테스트에서 서로 다른 값을 요구**한다. Electron 33 은 ABI 130, 호스트 Node 23 은 ABI 131 이라 한쪽에 맞추면 다른 쪽이 깨진다. 2026-09-21 실측: `electron-rebuild -f -w better-sqlite3` 직후 vitest 가 `366 failed / 948 passed` 로 무너졌고, 리빌드 전에는 `1311 passed` 였다. 같은 날 확인한 현재 워크스페이스는 Node ABI 쪽(호스트 `node -e "require('better-sqlite3')"` 성공)이라 테스트는 돌고 앱 실행에는 재리빌드가 필요하다. **배포 차단 이슈가 아니다** — `node_modules/` 는 gitignore(`.gitignore:1`)이고 패키징 경로는 `scripts/ci/verify-native-abi.mjs` 가 이미 막는다(GAP-REL-07 `[x]`). 순수하게 로컬 개발 환경 전환 비용 문제다. | `scripts/ci/verify-native-abi.mjs`, `scripts/ci/fix-native-abi.mjs`, `package.json`(현재 리빌드용 스크립트 없음) | 두 ABI 를 오가는 npm 스크립트를 둔다(예: `rebuild:app` = Electron ABI, `rebuild:test` = Node ABI). 지금은 전환 방법이 문서화도 스크립트화도 되어 있지 않아 매번 수동으로 알아내야 한다. | +| GAP-LLM-01 | LLM | **번역 대상 언어를 사용자가 고를 수 없다.** 항상 `English` 고정이다. `AppConfig` 에 대상 언어 키가 없고, 기존 두 키 모두 대용할 수 없다 — `language` 는 UI 로케일이라 `'ko'` 같은 코드가 프롬프트에 그대로 들어가 문장이 깨지고, `sttLanguage` 는 입력(원문) 언어라 그 값으로 번역하면 원문이 그대로 나온다. 2026-09-21 LLM 수정(`9c2b4d4`)은 대상 언어가 호출 프레임 두 단계 밖의 기본값에 의존하던 것을 명시 인자로 바로잡았을 뿐, 선택지를 만들지는 않았다(설정 키 + 설정 UI + i18n 이 필요해 patch 범위 밖으로 뒀다). | `apps/desktop/src/main/services/llm-prompts.ts:37`(`DEFAULT_TARGET_LANGUAGE`)·`:50`(`resolveTargetLanguage`), `packages/core/src/types.ts`(`AppConfig` 에 키 없음), 내장 프리셋 `CustomInstructionService.ts:26` | `AppConfig` 에 대상 언어 키를 추가하고, 설정 UI(LLM 탭)에 노출하고, `resolveTargetLanguage()` 가 설정을 읽게 한다. 언어 목록과 라벨은 i18n 키가 필요하다. | +| GAP-LLM-02 | LLM | **2026-09-21 LLM 지시문 수정(`9c2b4d4`)이 실앱 구동으로 검증되지 않았다.** 유닛 테스트는 통과하지만(`llm-prompts.test.ts` 21, `llm-handlers.test.ts` 7, `VoiceModeService.test.ts` 22, `ChainService.test.ts` 5 — 수정 4건을 각각 되돌려 실제로 실패하는 것까지 확인), 실행 중인 Electron 에서 실제 지시문을 돌려 결과가 삽입되는 것을 본 적이 없다. 에이전트는 데스크톱 GUI 를 띄울 수 없다(`AGENTS.md` §3). **게다가 이 수정과 직접 관련된 usecase 테스트 4개가 실행조차 되지 않았다** — `tests/red/{instruction,chain,voice,config}.usecase.test.ts` 가 `better-sqlite3` ABI 불일치로 DB 생성 단계에서 먼저 죽는다(GAP-INFRA-06). 즉 그 범위는 통과도 실패도 아닌 **미검증**이다. 영향 받는 카탈로그 행: AI-04, AI-05, AI-06, AI-07(전부 데스크톱 `[~]`). | `apps/desktop/src/main/services/llm-prompts.ts`, `VoiceModeService.ts:779`·`:827-870`, `ChainService.ts:196`, `src/main/ipc/llm-handlers.ts:96`, `src/renderer/pages/CommandsPage.tsx:180-205` | 사용자가 `run-desktop.bat` 로 앱을 띄워 (1) 명령 페이지에서 내장 프리셋(번역/요약/전문 리라이트/코드 설명/자유 프롬프트)을 활성화한 뒤 받아쓰기, (2) 명령 팝업에서 선택 후 받아쓰기, (3) 음성 키워드로 명령 호출, (4) 체인 실행, (5) 명령 페이지 파이프라인 벤치를 각각 돌려 **지시문 문구가 아니라 처리 결과가** 삽입되는지 확인한다. GAP-INFRA-06 의 ABI 전환 스크립트가 생기면 usecase 4종을 함께 돌린다. | +| GAP-INFRA-06 | Dev env | `better-sqlite3` 네이티브 ABI 가 **앱 실행과 로컬 테스트에서 서로 다른 값을 요구**한다. Electron 33 은 ABI 130, 호스트 Node 23 은 ABI 131 이라 한쪽에 맞추면 다른 쪽이 깨진다. 2026-09-21 실측: `electron-rebuild -f -w better-sqlite3` 직후 vitest 가 `366 failed / 948 passed` 로 무너졌고, 리빌드 전에는 `1311 passed` 였다. 같은 날 확인한 현재 워크스페이스는 Node ABI 쪽(호스트 `node -e "require('better-sqlite3')"` 성공)이라 테스트는 돌고 앱 실행에는 재리빌드가 필요하다. **배포 차단 이슈가 아니다** — `node_modules/` 는 gitignore(`.gitignore:1`)이고 패키징 경로는 `scripts/ci/verify-native-abi.mjs` 가 이미 막는다(GAP-REL-07 `[x]`). 순수하게 로컬 개발 환경 전환 비용 문제다. **다만 전환 비용으로 끝나지 않는다 — 검증을 가린다.** Electron ABI 쪽으로 리빌드된 상태에서는 `tests/red/*.usecase.test.ts` 가 DB 생성 단계에서 먼저 죽어 그 안의 케이스가 통과도 실패도 하지 않는다. 2026-09-21 LLM 지시문 수정(`9c2b4d4`)이 그 사례다: 전체 실행이 `366 failed / 994 passed (1360)` 였고 실패 366건 중 365건이 이 ABI 로 죽은 usecase 파일들인데, 하필 `instruction`·`chain`·`voice`·`config` usecase 가 그 수정의 직접 영향 범위였다(GAP-LLM-02). 참고로 같은 날 clean tree 베이스라인은 `366 failed / 948 passed (1314)` 로 실패 수가 동일해 신규 실패는 0건이다. | `scripts/ci/verify-native-abi.mjs`, `scripts/ci/fix-native-abi.mjs`, `package.json`(현재 리빌드용 스크립트 없음), `apps/desktop/tests/red/*.usecase.test.ts` | 두 ABI 를 오가는 npm 스크립트를 둔다(예: `rebuild:app` = Electron ABI, `rebuild:test` = Node ABI). 지금은 전환 방법이 문서화도 스크립트화도 되어 있지 않아 매번 수동으로 알아내야 한다. | | GAP-STT-08 | Local STT | 1.3.5 설치본에서 엔진 설치가 "런타임 아카이브 해시 불일치 (sidecar)"로 항상 실패했다. 부품 검증은 **메모리 스트림**에서 센 값으로, 결합 검증은 **디스크 파일**에서 계산해 기준이 서로 달랐다. 디스크 쓰기가 잘려도 부품 검사를 통과하고 결합 단계에서만 터지므로 원인 파악도 불가능했다. 재시도가 없어 전송이 한 번 끊기면 곧바로 설치 실패였다. | `apps/desktop/src/main/services/RuntimeProvisioner.ts` | `[x]` 2026-09-18: 부품 크기·해시를 디스크 파일 기준으로 통일하고, 결합본은 크기를 먼저 검사한 뒤 해시를 본다(오류 메시지에 실제/기대값 포함). 부품 다운로드는 실패 시 해당 파일을 지우고 최대 3회 재시도한다. 서버 아티팩트는 무결함을 확인했고(부품 2개 해시 일치, 결합본 `e203aa53…` = 인덱스 기대값), 실제 feed로 설치를 재현해 18초 만에 성공. **1.3.6으로 게시 완료** — `latest.yml`이 1.3.6/90.6MiB를 서빙하고 설치본 sha512가 피드 메타데이터와 일치. 설치본 asar에 수정 코드가 포함되고 구버전 `archiveHash` 경로는 제거됨을 확인. | | GAP-INFRA-05 | Build | 패키징된 렌더러 팝업 스크립트가 번들에 없었다. 팝업 HTML이 classic `