fix(llm): stop leaking instruction prompts instead of processed text

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.
This commit is contained in:
Yun Chan 2026-09-21 14:29:43 +09:00
parent 30d51c952f
commit 99f06c253c
14 changed files with 992 additions and 44 deletions

View file

@ -7,6 +7,7 @@ import { getLocalLLMService } from '../services/LocalLLMService'
import { getPremiumLLMService } from '../services/PremiumLLMService' import { getPremiumLLMService } from '../services/PremiumLLMService'
import { getOnlineLLMService } from '../services/OnlineLLMService' import { getOnlineLLMService } from '../services/OnlineLLMService'
import { configGet, configSet } from '../services/ConfigService' import { configGet, configSet } from '../services/ConfigService'
import { buildInstructionInvocation, resolveTargetLanguage } from '../services/llm-prompts'
import { normalizeLoopbackUrl } from '../utils/loopback' import { normalizeLoopbackUrl } from '../utils/loopback'
import { getMainWindow } from '../windows/WindowManager' import { getMainWindow } from '../windows/WindowManager'
import type { SetLLMModelParams, SetServerUrlParams, LLMProcessParams } from '@d3ro/core/types' import type { SetLLMModelParams, SetServerUrlParams, LLMProcessParams } from '@d3ro/core/types'
@ -87,11 +88,23 @@ export function registerLLMHandlers(): void {
backend === 'online' ? getOnlineLLMService() : getLocalLLMService() backend === 'online' ? getOnlineLLMService() : getLocalLLMService()
const start = performance.now() 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( const processedText = await service.processText(
params.text, invocation.text,
params.action, params.action,
params.targetLanguage, params.targetLanguage,
params.customPrompt invocation.systemPrompt
) )
return ipcSuccess({ return ipcSuccess({

View file

@ -6,6 +6,7 @@ import { getLogger } from './LoggerService'
import { configGet, configSet } from './ConfigService' import { configGet, configSet } from './ConfigService'
import { getCustomInstructionService } from './CustomInstructionService' import { getCustomInstructionService } from './CustomInstructionService'
import { getPremiumLLMService } from './PremiumLLMService' import { getPremiumLLMService } from './PremiumLLMService'
import { buildInstructionInvocation, resolveTargetLanguage } from './llm-prompts'
import { getMainWindow } from '../windows/WindowManager' import { getMainWindow } from '../windows/WindowManager'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
@ -151,6 +152,7 @@ class ChainService {
const llm = getPremiumLLMService() const llm = getPremiumLLMService()
const instructionService = getCustomInstructionService() const instructionService = getCustomInstructionService()
const targetLanguage = resolveTargetLanguage()
logger.info( logger.info(
`Executing chain "${chain.name}" (${chain.steps.length} steps) with input length ${inputText.length}` `Executing chain "${chain.name}" (${chain.steps.length} steps) with input length ${inputText.length}`
@ -189,7 +191,19 @@ class ChainService {
// LLM 호출 // LLM 호출
const stepStart = Date.now() const stepStart = Date.now()
try { 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 const stepDuration = Date.now() - stepStart
stepResults.push({ stepResults.push({

View file

@ -18,6 +18,7 @@ import type { KeyBindingTriggerPayload } from './KeyBindingService'
import { configGet } from './ConfigService' import { configGet } from './ConfigService'
import { getTextInsertService } from './TextInsertService' import { getTextInsertService } from './TextInsertService'
import { getLocalLLMService } from './LocalLLMService' import { getLocalLLMService } from './LocalLLMService'
import { buildInstructionInvocation, resolveTargetLanguage } from './llm-prompts'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { TIMING } from '@d3ro/core/constants' import { TIMING } from '@d3ro/core/constants'
import { RecognitionState, AudioState } from '@d3ro/core/types' import { RecognitionState, AudioState } from '@d3ro/core/types'
@ -772,12 +773,17 @@ class VoiceModeService extends EventEmitter {
if (this._isInTerminalState()) return if (this._isInTerminalState()) return
try { try {
const action = configGet('defaultLLMAction') const configuredAction = configGet('defaultLLMAction')
if (action === 'none') { // 음성 단축키가 특정 명령을 지목해 들어왔다면 기본 액션이 'none'이어도 처리한다.
// 명시적 요청을 기본 설정이 무효화하면 안 된다.
if (configuredAction === 'none' && !overrideInstructionId) {
this._completeSession(transcribedText) this._completeSession(transcribedText)
return return
} }
// 여기서 'none'이 남아 있다면 overrideInstructionId가 반드시 있다 → custom 경로.
const action: LLMAction = configuredAction === 'none' ? 'custom' : configuredAction
// Phase 10.2: 스크린 컨텍스트를 LLM 프롬프트에 주입 // Phase 10.2: 스크린 컨텍스트를 LLM 프롬프트에 주입
let contextPrefix = '' let contextPrefix = ''
if (this._session?.screenContext) { if (this._session?.screenContext) {
@ -818,22 +824,49 @@ class VoiceModeService extends EventEmitter {
const effectiveInstructionId = overrideInstructionId const effectiveInstructionId = overrideInstructionId
?? configGet('activeInstructionId') ?? configGet('activeInstructionId')
const targetLanguage = resolveTargetLanguage()
if (action === 'custom' || overrideInstructionId) { if (action === 'custom' || overrideInstructionId) {
let customPrompt = contextPrefix + transcribedText const userText = contextPrefix + transcribedText
let invocationText = userText
let instructionPrompt: string | undefined
if (effectiveInstructionId) { if (effectiveInstructionId) {
const { getCustomInstructionService } = await import('./CustomInstructionService') const { getCustomInstructionService } = await import('./CustomInstructionService')
const instruction = getCustomInstructionService().getById(effectiveInstructionId) const instruction = getCustomInstructionService().getById(effectiveInstructionId)
if (instruction) { 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}"`) 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 { } else {
logger.info(`Processing with LLM (action: ${action})`) logger.info(
processedText = await this._runProcessorWithFallback(contextPrefix + transcribedText, action) 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 if (this._isInTerminalState()) return

View file

@ -1,8 +1,11 @@
// src/main/services/llm-prompts.ts // src/main/services/llm-prompts.ts
// LLM 시스템 프롬프트 SSOT — PremiumLLMService 공용. // LLM 시스템 프롬프트 SSOT — PremiumLLMService 공용.
import { getLogger } from './LoggerService'
import type { LLMAction } from '@d3ro/core/types' import type { LLMAction } from '@d3ro/core/types'
const logger = getLogger('llm-prompts')
/** /**
* 기본 시스템 프롬프트 (NO_THINK prefix 없음). * 기본 시스템 프롬프트 (NO_THINK prefix 없음).
* PremiumLLMService는 NO_THINK prefix 없이 바로 사용. * PremiumLLMService는 NO_THINK prefix 없이 바로 사용.
@ -26,6 +29,88 @@ const BASE_SYSTEM_PROMPTS: Record<string, string> = {
확장된 텍스트만 출력하세요.`, 확장된 텍스트만 출력하세요.`,
} }
/**
* 번역 대상 언어 기본값.
* `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 사용. * translate 액션은 targetLanguage 치환, custom 액션은 customPrompt 사용.
@ -35,16 +120,26 @@ export function resolveSystemPrompt(
targetLanguage?: string, targetLanguage?: string,
customPrompt?: string, customPrompt?: string,
): string { ): string {
if (action === 'custom' && customPrompt) { if (action === 'custom') {
return customPrompt 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') { if (action === 'translate') {
return BASE_SYSTEM_PROMPTS.translate.replace( return BASE_SYSTEM_PROMPTS.translate.replace(
'{{targetLanguage}}', '{{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 } export { BASE_SYSTEM_PROMPTS }

View file

@ -26,6 +26,7 @@ import {
Mic, Mic,
Cpu, Cpu,
ClipboardCopy, ClipboardCopy,
TriangleAlert,
} from 'lucide-react' } from 'lucide-react'
import { import {
MetalCard, MetalCard,
@ -68,6 +69,7 @@ export function CommandsPage(): React.ReactElement {
// Interactive Pipeline Test State // Interactive Pipeline Test State
const [testInput, setTestInput] = useState('') const [testInput, setTestInput] = useState('')
const [testResult, setTestResult] = useState<string | null>(null) const [testResult, setTestResult] = useState<string | null>(null)
const [testError, setTestError] = useState<string | null>(null)
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
@ -175,23 +177,28 @@ export function CommandsPage(): React.ReactElement {
} }
} }
// 이 벤치는 실제 받아쓰기와 같은 경로(llm.process → processText)를 탄다.
// 지시문 원문을 customPrompt로 넘기면 메인 프로세스가 플레이스홀더 치환과
// 시스템 프롬프트 배치를 처리한다 — 치환 규칙을 렌더러에 복제하지 않는다.
const handleRunPipelineTest = async () => { const handleRunPipelineTest = async () => {
if (!testInput.trim()) return if (!testInput.trim()) return
setTesting(true) setTesting(true)
setTestResult(null) setTestResult(null)
setTestError(null)
try { try {
const activeInst = instructions.find((i) => i.id === activeId) const activeInst = instructions.find((i) => i.id === activeId)
const prompt = activeInst?.prompt || '다음 문장을 자연스럽고 깔끔하게 다듬어주세요:' const res = await window.electronAPI.llm.process(
const res = await window.electronAPI.llm.generate({ activeInst
prompt: `${prompt}\n\n${testInput.trim()}`, ? { text: testInput.trim(), action: 'custom', customPrompt: activeInst.prompt }
}) : { text: testInput.trim(), action: 'refine' },
)
if (res.success) { if (res.success) {
setTestResult(res.data.text) setTestResult(res.data.processedText)
} else { } else {
setTestResult(testInput.trim()) setTestError(res.error.message || t('popup.error.default'))
} }
} catch { } catch (error) {
setTestResult(testInput.trim()) setTestError(error instanceof Error ? error.message : t('popup.error.default'))
} finally { } finally {
setTesting(false) setTesting(false)
} }
@ -543,6 +550,25 @@ export function CommandsPage(): React.ReactElement {
</Typography> </Typography>
</Box> </Box>
)} )}
{testError && (
<Box
sx={{
p: 2,
bgcolor: d3roPalette.status.dangerBg,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.status.danger}`,
display: 'flex',
alignItems: 'flex-start',
gap: 1.5,
}}
>
<TriangleAlert size={16} style={{ color: d3roPalette.status.danger, marginTop: 2, flexShrink: 0 }} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13.5px', color: d3roPalette.status.danger, lineHeight: 1.6 }}>
{testError}
</Typography>
</Box>
)}
</Box> </Box>
</MetalCard> </MetalCard>
</Box> </Box>

View file

@ -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<string, (event: unknown, params: unknown) => Promise<unknown>>()
)
vi.mock('electron', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return {
...actual,
ipcMain: {
handle: vi.fn((channel: string, fn: (event: unknown, params: unknown) => Promise<unknown>) => {
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<unknown> {
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')
})
})

View file

@ -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<string, unknown> }))
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<string, { id: string; name: string; prompt: string }>
}))
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로 번역해줘.')
})
})

View file

@ -60,15 +60,49 @@ vi.mock('../../../src/main/services/KeyBindingService', () => ({
getKeyBindingService: () => mockKeyBinding getKeyBindingService: () => mockKeyBinding
})) }))
const CONFIG_DEFAULTS: Record<string, unknown> = {
sttModelId: 'base',
defaultLLMAction: 'refine',
ollamaServerUrl: 'http://localhost:11434',
llmModelId: 'gemma4:e4b'
}
const config = vi.hoisted(() => ({ values: {} as Record<string, unknown> }))
vi.mock('../../../src/main/services/ConfigService', () => ({ vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn((key: string) => { configGet: vi.fn((key: string) => config.values[key])
const defaults: Record<string, unknown> = { }))
sttModelId: 'base',
defaultLLMAction: 'refine', const instructionStore = vi.hoisted(() => ({
ollamaServerUrl: 'http://localhost:11434', byId: {} as Record<string, { id: string; name: string; prompt: string }>
llmModelId: 'gemma4:e4b' }))
}
return defaults[key] 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.resetModules()
vi.clearAllMocks() vi.clearAllMocks()
audioBus.removeAllListeners() audioBus.removeAllListeners()
config.values = { ...CONFIG_DEFAULTS }
instructionStore.byId = {}
voiceCommand.enabled = false
voiceCommand.instructionId = null
voiceCommand.cleanedText = ''
mockSTT.initialize.mockResolvedValue(undefined as never) mockSTT.initialize.mockResolvedValue(undefined as never)
mockSTT.transcribe.mockResolvedValue({ mockSTT.transcribe.mockResolvedValue({
text: '테스트 전사', text: '테스트 전사',
@ -223,6 +262,211 @@ describe('VoiceModeService', () => {
}) })
}) })
describe('LLM 후처리 인자 전달', () => {
const TRANSCRIPT = '회의 끝나고 배포하자'
/** 한 세션을 끝까지 돌린다. */
async function runSession(): Promise<void> {
mockSTT.transcribe.mockResolvedValue({
text: TRANSCRIPT,
segments: [],
language: 'ko',
duration: 2,
processingTime: 50,
} as never)
const svc = getVoiceModeService()
const settled = new Promise<void>((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', () => { describe('getState', () => {
it('현재 상태를 VoiceState 형태로 반환한다', () => { it('현재 상태를 VoiceState 형태로 반환한다', () => {
const svc = getVoiceModeService() const svc = getVoiceModeService()

View file

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

View file

@ -2,8 +2,8 @@
> Status: ACTIVE > Status: ACTIVE
> Last full audit: 2026-09-13 > 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) > 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.3.7` > 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: > Purpose: let any agent (or human) answer two questions in under a minute:
> 1. **What infrastructure exists?** (build, CI, services, APIs, data, packages, deploy) > 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) > 2. **How far is each feature developed?** (per surface, with file anchors and status)

View file

@ -192,7 +192,7 @@ Full detail: [`09-supabase-backend.md`](./09-supabase-backend.md).
| File | Purpose | | 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/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/desktop-license-public.pem` | Ed25519 public key for desktop offline licenses |
| `release/mobile-release-evidence-public.pem` | Ed25519 public key for mobile release evidence | | `release/mobile-release-evidence-public.pem` | Ed25519 public key for mobile release evidence |

View file

@ -48,7 +48,7 @@ Singleton + `EventEmitter` pattern (`getXService()` accessors).
| `LocalLLMService` | Ollama REST (models, pull w/ progress, server start, NDJSON streaming) | | `LocalLLMService` | Ollama REST (models, pull w/ progress, server start, NDJSON streaming) |
| `PremiumLLMService` | Claude via Supabase `llm-proxy`, local fallback | | `PremiumLLMService` | Claude via Supabase `llm-proxy`, local fallback |
| `OnlineLLMService` | JWT-authenticated .NET backend client | | `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 ### Memory & knowledge
| Service | Purpose | | Service | Purpose |
@ -59,7 +59,7 @@ Singleton + `EventEmitter` pattern (`getXService()` accessors).
| `RAGService` | Local RAG: `nomic-embed-text` embeddings, cosine search over `rag_chunks` | | `RAGService` | Local RAG: `nomic-embed-text` embeddings, cosine search over `rag_chunks` |
| `CustomInstructionService` | User LLM commands (5 built-ins) | | `CustomInstructionService` | User LLM commands (5 built-ins) |
| `VoiceCommandService` | Keyword → command rule matching | | `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 | | `ScreenContextService` | Active-window + selected-text context |
### Phase 10+ features ### 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. 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<T>` (success/error); `app.onDataChanged` is the global refresh channel. 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<T>` (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 ## 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`). - 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-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. - **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. - 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. - 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. - **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`). - 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). - **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` | | Preload API | `src/preload/index.ts` |
| Windows | `src/main/windows/WindowManager.ts` | | Windows | `src/main/windows/WindowManager.ts` |
| Voice orchestrator | `src/main/services/VoiceModeService.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` | | DB schema | `src/main/db/schema.ts` |
| Renderer shell / routes | `src/renderer/components/AppLayout.tsx` | | Renderer shell / routes | `src/renderer/components/AppLayout.tsx` |
| Update feed SSOT | `src/main/update-feed.ts` | | Update feed SSOT | `src/main/update-feed.ts` |

View file

@ -39,11 +39,11 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| AI-01 | Local LLM (Ollama) | [x] | [-] | [ ] | [-] | Desktop bundled Ollama | | 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-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-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] | [x] | Built-in instructions | | 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] | [x] | Desktop `CommandsPage` (Red Team RT-03 verified); web `commands`; mobile `CommandsScreen` | | 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 | [x] | [-] | [ ] | [-] | Desktop `VoiceCommandService` + command popup | | 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) | [x] | [ ] | [ ] | [-] | Desktop `ChainService` | | 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-08 | Screen/context capture for prompts | [x] | [-] | [ ] | [-] | Desktop `ScreenContextService` |
| AI-09 | Streaming responses | [x] | [x] | [x] | [x] | SSE/NDJSON streaming | | AI-09 | Streaming responses | [x] | [x] | [x] | [x] | SSE/NDJSON streaming |
| AI-10 | Dictation templates (voice form fill) | [x] | [ ] | [x] | [~] | Desktop `DictationTemplateService`; mobile `TemplatesScreen` | | 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 | | 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 | | 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 | | 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 | | Backend | ~45 | 6 | ~4 | RLS, Edge functions, billing, fail-closed AI | Payple webhook signature, some external provider keys |

View file

@ -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-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-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-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-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-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 `<script src="./script.js">`를 참조해 Vite가 처리하지 않았고, dev에서는 로드되지만 설치본에는 파일이 없었다. 그래서 녹음 오버레이가 0:00에서 멈추고 웨이브 바가 뜨지 않았으며 실시간 자막이 렌더되지 않았다. 로드 전 `webContents.send`가 조용히 버려지는 문제와 `hide()` 이후 재표시의 z-order/repaint 유실도 함께 있었다. | `apps/desktop/src/renderer/popups/*/index.html`, `apps/desktop/src/main/windows/WindowManager.ts`, `scripts/ci/verify-desktop-renderer-bundles.mjs` | `[x]` 2026-09-19: 팝업 5종을 `type="module"`로 전환해 Vite가 해시된 번들로 방출하도록 고쳤고, 빌드 HTML이 참조하는 모든 로컬 asset이 디스크에 있는지 검사하는 `verify-desktop-renderer-bundles.mjs`(+ self-test)를 `.forgejo`/`.github` 패키징 파이프라인에 연결했다. WindowManager는 렌더러 준비 전 IPC를 `did-finish-load`까지 보관하고, 팝업을 표시할 때마다 topmost 재선언 + 강제 repaint를 수행하며, 팝업 렌더러 콘솔/로드 실패를 main 로그로 승격한다. | | GAP-INFRA-05 | Build | 패키징된 렌더러 팝업 스크립트가 번들에 없었다. 팝업 HTML이 classic `<script src="./script.js">`를 참조해 Vite가 처리하지 않았고, dev에서는 로드되지만 설치본에는 파일이 없었다. 그래서 녹음 오버레이가 0:00에서 멈추고 웨이브 바가 뜨지 않았으며 실시간 자막이 렌더되지 않았다. 로드 전 `webContents.send`가 조용히 버려지는 문제와 `hide()` 이후 재표시의 z-order/repaint 유실도 함께 있었다. | `apps/desktop/src/renderer/popups/*/index.html`, `apps/desktop/src/main/windows/WindowManager.ts`, `scripts/ci/verify-desktop-renderer-bundles.mjs` | `[x]` 2026-09-19: 팝업 5종을 `type="module"`로 전환해 Vite가 해시된 번들로 방출하도록 고쳤고, 빌드 HTML이 참조하는 모든 로컬 asset이 디스크에 있는지 검사하는 `verify-desktop-renderer-bundles.mjs`(+ self-test)를 `.forgejo`/`.github` 패키징 파이프라인에 연결했다. WindowManager는 렌더러 준비 전 IPC를 `did-finish-load`까지 보관하고, 팝업을 표시할 때마다 topmost 재선언 + 강제 repaint를 수행하며, 팝업 렌더러 콘솔/로드 실패를 main 로그로 승격한다. |
@ -127,6 +129,14 @@ These are the mobile SSOT rows still `[ ]` / `[~]`. Do not duplicate the full te
- **Desktop local dictation / LLM / history / meetings / RAG / conversation:** local - **Desktop local dictation / LLM / history / meetings / RAG / conversation:** local
dictation works in dev **and** in packaged builds as of `1.3.0` (engine bundled, paths dictation works in dev **and** in packaged builds as of `1.3.0` (engine bundled, paths
fixed, IPv4 loopback). Ads: one real adapter, rest stubs. fixed, IPv4 loopback). Ads: one real adapter, rest stubs.
- **Desktop LLM post-processing:** split the question. The **plain actions**
(`refine`/`summarize`/`grammar`/`expand`, Settings → `defaultLLMAction`) always worked
and are now regression-tested. The **custom-instruction path** — built-in presets,
user commands, voice keyword commands, chains — **never worked in any shipped release**
(`v0.1.0-alpha`..`v1.4.0`): it inserted the instruction's own wording instead of the
result. Fixed 2026-09-21 in `9c2b4d4` with unit tests, but **not yet verified in a
running app**, and the four related usecase test files could not execute (GAP-LLM-02,
GAP-INFRA-06). Translate is still hardcoded to English (GAP-LLM-01).
- **Desktop key bindings (CAP-16):** rewritten onto one SSOT with multiple bindings per - **Desktop key bindings (CAP-16):** rewritten onto one SSOT with multiple bindings per
action and mouse-button support; **verified on Windows** by a manual run on 2026-09-21 action and mouse-button support; **verified on Windows** by a manual run on 2026-09-21
(legacy migration, 6 actions loaded, keyboard + mouse hook live, multi-binding exercised (legacy migration, 6 actions loaded, keyboard + mouse hook live, multi-binding exercised