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

@ -63,9 +63,11 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
| GAP-KEY-02 | Key bindings | 마우스 버튼 지원이 **Windows 기준으로만** 설계·확인됐다. `KeyBindingService` 에는 마우스 관련 플랫폼 분기가 없고(`process.platform` 은 meta 수정자 라벨 표기에만 쓰인다), macOS/Linux 에서 uiohook 이 보고하는 X1/X2 버튼 번호와 OS 기본 "뒤로/앞으로" 동작과의 간섭은 확인하지 않았다. 마우스 이벤트는 suppress 가 불가능하므로 원래 동작이 항상 함께 실행된다. | `KeyBindingService.ts:235`(`readMouseButton`), `:301`(meta 라벨 분기), `packages/core/src/keybinding.ts:561-612`(마우스 카탈로그 5종). 2026-09-21 실앱 검증(GAP-KEY-01)은 **Windows 에서만** 이뤄졌고 거기서는 MB4/MB5 가 정상 동작했다. | macOS/Linux 에서 MB2~MB5 수신 여부와 버튼 번호 매핑을 확인하고, 다르면 카탈로그를 플랫폼별로 분기한다. |
| GAP-KEY-03 | Key bindings | `command` 액션에 전용 핸들러가 없다. 이번에 처음으로 설정 UI 에 노출됐지만, 트리거되면 dictation 파이프라인으로 fallback 하며 `KEYBINDING_ACTIONS` 의 `holdMode:false` 대신 dictation 과 같은 hold-to-talk 로 강제된다. 개편 이전부터 같은 동작이었고 이번 작업은 그 사실을 코드에 명시화만 했다(기능 변화 없음). | `apps/desktop/src/main/services/VoiceModeService.ts:1071`(`_resolveHoldMode`), `packages/core/src/keybinding.ts:740`(액션 정의) | `command` 전용 동작을 정의하고 `_resolveHoldMode` 의 예외를 제거하거나, 액션을 카탈로그에서 뺀다. |
| GAP-QA-02 | Quality | 캡션 테스트 2건이 **개발 머신에 사이드카 venv 가 있는지에 따라 결과가 갈린다**. `LocalSTTService.initialize()`(`:239`) → `_ensureSidecarRunning()`(`:583`) → `_spawnSidecar()`(`:650`) → `_waitForHealth()`(`:794`) 경로에서 venv 가 존재하면 실제 Python 프로세스를 띄우고 health 폴링이 vitest 기본 타임아웃 10초를 넘긴다. venv 가 없으면 `getSidecarCommand()`(`apps/desktop/src/main/utils/paths.ts:174`)가 즉시 throw 해서 같은 테스트가 빠르게 통과한다. 테스트가 로컬 환경을 격리하지 못한 것이 결함이다. | `tests/red/ipc-surfaces.usecase.test.ts`(`캡션 시작 실패는 success:false 로 나온다`), `tests/red/silent-errors.usecase.test.ts:48`. **키바인딩 개편의 회귀가 아니다** — 2026-09-21 에 HEAD(`0ca9e24`) 무수정 코드를 같은 환경(venv 연결)에서 돌려 동일하게 재현했다. 같은 날 같은 머신에서도 실행 방식에 따라 결과가 갈렸다: 전체 실행은 `3 failed / 1311 passed (1314)`(`rag.usecase` + `silent-errors` 캡션 + `paths.test`)이고 `ipc-surfaces` 캡션 케이스는 통과했는데, 그 파일만 단독 실행하면 같은 케이스가 10초 타임아웃으로 실패한다. 테스트 총수 1314 는 어느 실행에서나 같고, 새로 깨진 테스트는 0건이다. | 사이드카 기동을 테스트 경계에서 주입·모킹해 환경 의존을 끊는다. 함께 실패하는 `rag.usecase`(임베딩 서버 부재)도 같은 성격이다. `tests/main/utils/paths.test.ts:78` 은 성격이 다르다 — 기대 정규식이 `사이드카를 찾을 수 없습니다` 인데 실제 메시지는 `로컬 음성 엔진이 아직 설치되지 않았습니다…` 로 바뀌어 테스트가 문구를 따라가지 못한 것이다. |
| GAP-I18N-01 | i18n | 로케일별 키 수가 크게 어긋난다. 2026-09-21 실측: `ko` 1716 / `en` 1709 / 나머지 10개 로케일 각 327. `keybinding.*` 55개는 12개 로케일 전부에 동일하게 들어갔지만, 그 밖 약 1,380개 키가 비영어 로케일에 없어 폴백 체인(locale → `en` → `ko`)으로 표시된다. 키바인딩 작업 이전부터 있던 부채이며 그 작업 범위 밖이었다. | `packages/i18n/src/locales/*.json`, 카탈로그 SHELL-03 | 로케일 간 키 diff 를 내는 커버리지 게이트를 만들어 회귀를 막고, 누락 키를 채운다. |
| GAP-I18N-01 | i18n | 로케일별 키 수가 크게 어긋난다. 2026-09-21 실측: `ko` 1716 / `en` 1709 / 나머지 10개 로케일 각 327. `keybinding.*` 55개는 12개 로케일 전부에 동일하게 들어갔지만, 그 밖 약 1,380개 키가 비영어 로케일에 없어 폴백 체인(locale → `en` → `ko`)으로 표시된다. 키바인딩 작업 이전부터 있던 부채이며 그 작업 범위 밖이었다. | `packages/i18n/src/locales/*.json`, 카탈로그 SHELL-03. **구체 사례 (2026-09-21 실측)**: `popup.error.default` 가 `en.json`·`ko.json` 에만 있고 나머지 10개 로케일에 없다. 소비처는 `WindowManager.ts:59`(팝업 문자열 주입, 선재)와 `CommandsPage.tsx:198`·`:201`(LLM 수정으로 추가된 파이프라인 벤치 오류 표시) 두 곳이며, 비영어 사용자에게는 오류 메시지가 영어로 폴백된다. 새 갭이 아니라 이 행이 세는 약 1,380개 중 하나다 — 별도 행을 열지 마라. | 로케일 간 키 diff 를 내는 커버리지 게이트를 만들어 회귀를 막고, 누락 키를 채운다. |
| GAP-I18N-02 | i18n | 렌더러가 `ko.json` 에 없는 `license.*` 키를 쓴다. `TranslationKey` 가 `ko.json` 에서 파생되므로 누락은 타입 에러로 드러난다. 타입 에러로만 끝나지 않는다 — 폴백 체인이 `locale → en → ko → 키 문자열` 이므로 마스터 로케일에도 없으면 **`license.team` 같은 키가 화면에 그대로 노출된다**. 2026-09-21 실측: `license.feature.premium_llm`·`license.team`·`license.enterprise` 가 없고 이로 인한 TS2345 가 4건이다. HEAD 에서도 없던 키이므로 선재 결함이며 키바인딩 작업과 무관하다. | `apps/desktop/src/renderer/components/UpgradePromptModal.tsx:47`·`:192`, `apps/desktop/src/renderer/pages/DashboardPage.tsx:481`·`:529`, `packages/i18n/src/locales/ko.json` | 세 키를 `ko.json` 에 추가하고 12개 로케일에 반영한다. 같은 타입체크에 잡히는 `LicenseTab.tsx`(6건)·`LicenseModal.tsx`(2건)는 원인이 다르다 — `TFunction` 을 `(k: string) => string` 에 넘기는 TS2322 4건과 `currentTier` 미정의 TS2304 2건으로, 후자는 컴파일이 깨지는 별개 결함이다(GAP-INFRA-04 범위). |
| GAP-INFRA-06 | Dev env | `better-sqlite3` 네이티브 ABI 가 **앱 실행과 로컬 테스트에서 서로 다른 값을 요구**한다. Electron 33 은 ABI 130, 호스트 Node 23 은 ABI 131 이라 한쪽에 맞추면 다른 쪽이 깨진다. 2026-09-21 실측: `electron-rebuild -f -w better-sqlite3` 직후 vitest 가 `366 failed / 948 passed` 로 무너졌고, 리빌드 전에는 `1311 passed` 였다. 같은 날 확인한 현재 워크스페이스는 Node ABI 쪽(호스트 `node -e "require('better-sqlite3')"` 성공)이라 테스트는 돌고 앱 실행에는 재리빌드가 필요하다. **배포 차단 이슈가 아니다** — `node_modules/` 는 gitignore(`.gitignore:1`)이고 패키징 경로는 `scripts/ci/verify-native-abi.mjs` 가 이미 막는다(GAP-REL-07 `[x]`). 순수하게 로컬 개발 환경 전환 비용 문제다. | `scripts/ci/verify-native-abi.mjs`, `scripts/ci/fix-native-abi.mjs`, `package.json`(현재 리빌드용 스크립트 없음) | 두 ABI 를 오가는 npm 스크립트를 둔다(예: `rebuild:app` = Electron ABI, `rebuild:test` = Node ABI). 지금은 전환 방법이 문서화도 스크립트화도 되어 있지 않아 매번 수동으로 알아내야 한다. |
| GAP-LLM-01 | LLM | **번역 대상 언어를 사용자가 고를 수 없다.** 항상 `English` 고정이다. `AppConfig` 에 대상 언어 키가 없고, 기존 두 키 모두 대용할 수 없다 — `language` 는 UI 로케일이라 `'ko'` 같은 코드가 프롬프트에 그대로 들어가 문장이 깨지고, `sttLanguage` 는 입력(원문) 언어라 그 값으로 번역하면 원문이 그대로 나온다. 2026-09-21 LLM 수정(`9c2b4d4`)은 대상 언어가 호출 프레임 두 단계 밖의 기본값에 의존하던 것을 명시 인자로 바로잡았을 뿐, 선택지를 만들지는 않았다(설정 키 + 설정 UI + i18n 이 필요해 patch 범위 밖으로 뒀다). | `apps/desktop/src/main/services/llm-prompts.ts:37`(`DEFAULT_TARGET_LANGUAGE`)·`:50`(`resolveTargetLanguage`), `packages/core/src/types.ts`(`AppConfig` 에 키 없음), 내장 프리셋 `CustomInstructionService.ts:26` | `AppConfig` 에 대상 언어 키를 추가하고, 설정 UI(LLM 탭)에 노출하고, `resolveTargetLanguage()` 가 설정을 읽게 한다. 언어 목록과 라벨은 i18n 키가 필요하다. |
| GAP-LLM-02 | LLM | **2026-09-21 LLM 지시문 수정(`9c2b4d4`)이 실앱 구동으로 검증되지 않았다.** 유닛 테스트는 통과하지만(`llm-prompts.test.ts` 21, `llm-handlers.test.ts` 7, `VoiceModeService.test.ts` 22, `ChainService.test.ts` 5 — 수정 4건을 각각 되돌려 실제로 실패하는 것까지 확인), 실행 중인 Electron 에서 실제 지시문을 돌려 결과가 삽입되는 것을 본 적이 없다. 에이전트는 데스크톱 GUI 를 띄울 수 없다(`AGENTS.md` §3). **게다가 이 수정과 직접 관련된 usecase 테스트 4개가 실행조차 되지 않았다** — `tests/red/{instruction,chain,voice,config}.usecase.test.ts` 가 `better-sqlite3` ABI 불일치로 DB 생성 단계에서 먼저 죽는다(GAP-INFRA-06). 즉 그 범위는 통과도 실패도 아닌 **미검증**이다. 영향 받는 카탈로그 행: AI-04, AI-05, AI-06, AI-07(전부 데스크톱 `[~]`). | `apps/desktop/src/main/services/llm-prompts.ts`, `VoiceModeService.ts:779`·`:827-870`, `ChainService.ts:196`, `src/main/ipc/llm-handlers.ts:96`, `src/renderer/pages/CommandsPage.tsx:180-205` | 사용자가 `run-desktop.bat` 로 앱을 띄워 (1) 명령 페이지에서 내장 프리셋(번역/요약/전문 리라이트/코드 설명/자유 프롬프트)을 활성화한 뒤 받아쓰기, (2) 명령 팝업에서 선택 후 받아쓰기, (3) 음성 키워드로 명령 호출, (4) 체인 실행, (5) 명령 페이지 파이프라인 벤치를 각각 돌려 **지시문 문구가 아니라 처리 결과가** 삽입되는지 확인한다. GAP-INFRA-06 의 ABI 전환 스크립트가 생기면 usecase 4종을 함께 돌린다. |
| GAP-INFRA-06 | Dev env | `better-sqlite3` 네이티브 ABI 가 **앱 실행과 로컬 테스트에서 서로 다른 값을 요구**한다. Electron 33 은 ABI 130, 호스트 Node 23 은 ABI 131 이라 한쪽에 맞추면 다른 쪽이 깨진다. 2026-09-21 실측: `electron-rebuild -f -w better-sqlite3` 직후 vitest 가 `366 failed / 948 passed` 로 무너졌고, 리빌드 전에는 `1311 passed` 였다. 같은 날 확인한 현재 워크스페이스는 Node ABI 쪽(호스트 `node -e "require('better-sqlite3')"` 성공)이라 테스트는 돌고 앱 실행에는 재리빌드가 필요하다. **배포 차단 이슈가 아니다** — `node_modules/` 는 gitignore(`.gitignore:1`)이고 패키징 경로는 `scripts/ci/verify-native-abi.mjs` 가 이미 막는다(GAP-REL-07 `[x]`). 순수하게 로컬 개발 환경 전환 비용 문제다. **다만 전환 비용으로 끝나지 않는다 — 검증을 가린다.** Electron ABI 쪽으로 리빌드된 상태에서는 `tests/red/*.usecase.test.ts` 가 DB 생성 단계에서 먼저 죽어 그 안의 케이스가 통과도 실패도 하지 않는다. 2026-09-21 LLM 지시문 수정(`9c2b4d4`)이 그 사례다: 전체 실행이 `366 failed / 994 passed (1360)` 였고 실패 366건 중 365건이 이 ABI 로 죽은 usecase 파일들인데, 하필 `instruction`·`chain`·`voice`·`config` usecase 가 그 수정의 직접 영향 범위였다(GAP-LLM-02). 참고로 같은 날 clean tree 베이스라인은 `366 failed / 948 passed (1314)` 로 실패 수가 동일해 신규 실패는 0건이다. | `scripts/ci/verify-native-abi.mjs`, `scripts/ci/fix-native-abi.mjs`, `package.json`(현재 리빌드용 스크립트 없음), `apps/desktop/tests/red/*.usecase.test.ts` | 두 ABI 를 오가는 npm 스크립트를 둔다(예: `rebuild:app` = Electron ABI, `rebuild:test` = Node ABI). 지금은 전환 방법이 문서화도 스크립트화도 되어 있지 않아 매번 수동으로 알아내야 한다. |
| GAP-STT-08 | Local STT | 1.3.5 설치본에서 엔진 설치가 "런타임 아카이브 해시 불일치 (sidecar)"로 항상 실패했다. 부품 검증은 **메모리 스트림**에서 센 값으로, 결합 검증은 **디스크 파일**에서 계산해 기준이 서로 달랐다. 디스크 쓰기가 잘려도 부품 검사를 통과하고 결합 단계에서만 터지므로 원인 파악도 불가능했다. 재시도가 없어 전송이 한 번 끊기면 곧바로 설치 실패였다. | `apps/desktop/src/main/services/RuntimeProvisioner.ts` | `[x]` 2026-09-18: 부품 크기·해시를 디스크 파일 기준으로 통일하고, 결합본은 크기를 먼저 검사한 뒤 해시를 본다(오류 메시지에 실제/기대값 포함). 부품 다운로드는 실패 시 해당 파일을 지우고 최대 3회 재시도한다. 서버 아티팩트는 무결함을 확인했고(부품 2개 해시 일치, 결합본 `e203aa53…` = 인덱스 기대값), 실제 feed로 설치를 재현해 18초 만에 성공. **1.3.6으로 게시 완료** — `latest.yml`이 1.3.6/90.6MiB를 서빙하고 설치본 sha512가 피드 메타데이터와 일치. 설치본 asar에 수정 코드가 포함되고 구버전 `archiveHash` 경로는 제거됨을 확인. |
| GAP-INFRA-05 | Build | 패키징된 렌더러 팝업 스크립트가 번들에 없었다. 팝업 HTML이 classic `<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
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.
- **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
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