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:
parent
30d51c952f
commit
99f06c253c
14 changed files with 992 additions and 44 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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 사용.
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null)
|
||||
const [testError, setTestError] = useState<string | null>(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 {
|
|||
</Typography>
|
||||
</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>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue