refactor(suggestion): drive suggestion decisions from one core step and split SuggestionService collaborators

This commit is contained in:
Yun Chan 2026-09-28 00:53:42 +09:00
parent 9aa7302944
commit 190b6db284
13 changed files with 1994 additions and 496 deletions

View file

@ -14,6 +14,32 @@
// CaretGeometryQuality.Estimated 경로와 같다.
//
// 순수 함수만 둔다 — Electron/Node 의존 금지 (테스트 가능성 유지).
//
// 후보 텍스트 정제는 suggestion-text.ts, 터미널 입력 줄 파싱은 terminal-prompt.ts 가
// 정본이다. 기존 import 경로(@d3ro/core/input-intelligence)를 유지하려고 여기서 다시 export 한다.
import {
SENTENCE_TERMINATORS,
SUGGESTION_MAX_OUTPUT_CHARS,
normalizeRequestPrefix,
normalizeSessionPrefix
} from './suggestion-text'
import { NON_PROSE_GLYPH_PATTERN } from './terminal-prompt'
export {
SUGGESTION_CONTEXT_MAX_CHARS,
SUGGESTION_MAX_OUTPUT_CHARS,
buildLocalSuggestionCandidates,
endsSentence,
normalizeRequestPrefix,
normalizeSessionPrefix,
normalizeSuggestionText,
parseSuggestionCandidates,
sanitizeSuggestionLine,
stripModelArtifacts
} from './suggestion-text'
export type { LocalSuggestionHints } from './suggestion-text'
export { extractTerminalPromptPrefix } from './terminal-prompt'
// ============================================================
// 기하
@ -194,8 +220,6 @@ export function isWordBoundaryKey(keyClass: KeyStrokeClass): boolean {
// 스트 지표
// ============================================================
const SENTENCE_TERMINATORS = /[.!?。!?…]/
/**
* 단어 수.
*
@ -222,14 +246,6 @@ export function countSentences(text: string): number {
return count
}
/** 마지막 문장 종결 여부 — 참이면 직전 문장이 끝난 것이므로 제안을 지운다. */
export function endsSentence(text: string): boolean {
const trimmed = text.replace(/\s+$/u, '')
if (!trimmed) return false
const last = trimmed[trimmed.length - 1]
return SENTENCE_TERMINATORS.test(last) || last === '\n'
}
/** 케어 앞 텍스트만 잘라낸다 (UIA 스냅샷 → 컨텍스트). */
export function textBeforeCaret(text: string, caretOffset: number | null): string {
if (caretOffset === null || caretOffset < 0) return text
@ -535,7 +551,7 @@ export function decideSuggestion(input: SuggestionPolicyInput): SuggestionDecisi
return { action: 'clear', reason: 'not-typing' }
}
const prefix = input.prefix.replace(/\s+$/u, '')
const prefix = normalizeRequestPrefix(input.prefix)
if (!prefix) return { action: 'clear', reason: 'empty-prefix' }
// 문장이 끝났다고 막지 않는다.
//
@ -576,8 +592,8 @@ export function decideSuggestion(input: SuggestionPolicyInput): SuggestionDecisi
* 연속 확장으로 인정한다 (예: 생성 시점 "하" → 현재 "한").
*/
export function extendsPrefix(generatedPrefix: string, currentPrefix: string): boolean {
const generated = generatedPrefix.replace(/\s+$/u, '')
const current = currentPrefix.replace(/\s+$/u, '')
const generated = normalizeRequestPrefix(generatedPrefix)
const current = normalizeRequestPrefix(currentPrefix)
if (current.startsWith(generated)) return true
return generated.length > 0 && current.startsWith(generated.slice(0, -1))
}
@ -588,10 +604,14 @@ export function extendsPrefix(generatedPrefix: string, currentPrefix: string): b
* `extendsPrefix` 와 달리 성장(이어 치기)은 허용하지 않는다 — "다음 문장이 시작됐다"
* 는 곧 세션 종료다(설계). 마지막 글자만 IME 조합으로 바뀐 경우만 예외로 둔다
* (생성 시점 "하" → 지금 "한": 길이는 같고 마지막 글자만 다르다).
*
* 세션은 마지막 SUGGESTION_CONTEXT_MAX_CHARS 자로 만든 후보이므로 양쪽 모두
* normalizeSessionPrefix 로 맞춘 뒤 비교한다 — 한쪽만 자르면 400자를 넘는 입력창에서
* 텍스트가 그대로여도 세션이 stale 로 닫힌다.
*/
export function matchesSessionPrefix(generatedPrefix: string, currentPrefix: string): boolean {
const generated = generatedPrefix.replace(/\s+$/u, '')
const current = currentPrefix.replace(/\s+$/u, '')
const generated = normalizeSessionPrefix(generatedPrefix)
const current = normalizeSessionPrefix(currentPrefix)
if (current === generated) return true
if (generated.length === 0) return false
return current.length === generated.length && current.slice(0, -1) === generated.slice(0, -1)
@ -609,13 +629,128 @@ export function decideSuggestionRefresh(
currentPrefix: string,
minimumGrowth = SUGGESTION_DEFAULTS.regenerateAfterChars
): SuggestionRefreshDecision {
const generated = generatedPrefix.replace(/\s+$/u, '')
const current = currentPrefix.replace(/\s+$/u, '')
const generated = normalizeRequestPrefix(generatedPrefix)
const current = normalizeRequestPrefix(currentPrefix)
if (!generated) return 'regenerate'
if (!extendsPrefix(generated, current)) return 'stale'
return current.length - generated.length >= minimumGrowth ? 'regenerate' : 'keep'
}
/**
* 한 번의 입력 문맥에 대해 제안 서비스가 할 일 (판별 합집합).
*
* - `end-session`: 표시 중인 세션의 접두가 어긋났다 — 세션을 stale 로 닫는다.
* - `generate`: 모델 생성을 시작한다.
* - `settle`: 생성하지 않는다. `reason` 을 남기고, `tryLocalMemory` 면 로컬 기억 후보를
* 먼저 게시해 보고, 게시하지 못하면 `then` 을 수행한다.
*/
export type SuggestionStep =
| { action: 'end-session'; reason: 'stale' }
| { action: 'generate'; prefix: string }
| {
action: 'settle'
reason: SuggestionSkipReason
tryLocalMemory: boolean
/**
* record: 사유만 남긴다. dismiss: 표시 중이면 이 사유로 닫는다.
* show-warming: "준비 중" 을 보여준다. warm-up: 워밍업을 시작하고 "준비 중" 을 보여준다.
*/
then: 'record' | 'dismiss' | 'show-warming' | 'warm-up'
}
export interface SuggestionStepInput {
/** 정책 입력. overlayVisible 은 "생성/워밍업/후보 표시 중" 전체를 뜻한다. */
policy: SuggestionPolicyInput
/** 후보 세션이 화면에 떠 있는가 (후보 1개 이상) */
sessionVisible: boolean
/** 세션을 만든 접두 */
sessionPrefix: string
/** 연속 실패 쿨다운 중인가 */
coolingDown: boolean
/** 사용자가 X 로 닫은 직후의 조용한 구간인가 */
userDismissedQuiet: boolean
/** 마지막으로 모델에 실제 요청한 접두 (normalizeRequestPrefix 적용) */
lastRequestedPrefix: string
/** 지금 쓸 모델이 메모리에 떠 있다고 볼 수 있는가 */
modelWarm: boolean
/** 직전 워밍업이 실패해 재시도를 잠시 미루는 중인가 */
warmUpBackoff: boolean
}
/**
* 제안 서비스의 판단 순서 정본: 세션 유효성 → 쿨다운 → 사용자 닫기 → 정책 → 모델 온기.
*
* 서비스는 이 결과를 실행만 한다. 쿨다운 경로도 decideSuggestion 의 'already-visible'
* 규칙을 거친다 — 쿨다운 중 표시된 로컬 기억 세션을 매 스냅샷마다 다시 만들거나
* (선택 초기화 · 이력 중복) 닫지 않는다.
*/
export function decideSuggestionStep(input: SuggestionStepInput): SuggestionStep {
const { policy } = input
if (input.sessionVisible && !matchesSessionPrefix(input.sessionPrefix, policy.prefix)) {
return { action: 'end-session', reason: 'stale' }
}
// 로컬 기억은 모델과 무관하다 — 같은 정책을 "모델 있음 · 표시 없음" 으로 평가한다.
const localMemoryAllowed =
decideSuggestion({ ...policy, modelAvailable: true, overlayVisible: false }).action === 'request'
if (input.coolingDown) {
const presentation = decideSuggestion({ ...policy, modelAvailable: true })
if (presentation.action === 'skip' && presentation.reason === 'already-visible') {
return { action: 'settle', reason: 'cooldown', tryLocalMemory: false, then: 'record' }
}
return { action: 'settle', reason: 'cooldown', tryLocalMemory: localMemoryAllowed, then: 'dismiss' }
}
if (input.userDismissedQuiet) {
return { action: 'settle', reason: 'cooldown', tryLocalMemory: false, then: 'record' }
}
const decision = decideSuggestion(policy)
if (decision.action === 'clear') {
return { action: 'settle', reason: decision.reason, tryLocalMemory: false, then: 'dismiss' }
}
if (decision.action === 'skip') {
// 같은 텍스트로 이미 요청했다면 다시 만들지 않는다 (클릭만 했거나, 엔터로 보낸 뒤
// UIA 가 옛 텍스트를 돌려줄 때 제안이 반복 생성되던 문제).
const current = normalizeRequestPrefix(policy.prefix)
if (input.lastRequestedPrefix && current === input.lastRequestedPrefix) {
return { action: 'settle', reason: 'unchanged', tryLocalMemory: false, then: 'record' }
}
if (decision.reason === 'model-unavailable') {
return {
action: 'settle',
reason: 'model-unavailable',
tryLocalMemory: localMemoryAllowed,
then: 'show-warming'
}
}
return { action: 'settle', reason: decision.reason, tryLocalMemory: false, then: 'record' }
}
// 모델이 유휴로 내려갔을 것으로 보이면 생성 대신 워밍업부터 한다 — 콜드 리로드는
// 요청 타임아웃보다 길어 그대로 요청하면 항상 시간 초과한다.
if (!input.modelWarm) {
if (input.warmUpBackoff) {
return {
action: 'settle',
reason: 'generation-failed',
tryLocalMemory: localMemoryAllowed,
then: 'record'
}
}
return {
action: 'settle',
reason: 'model-unavailable',
tryLocalMemory: localMemoryAllowed,
then: 'warm-up'
}
}
return { action: 'generate', prefix: decision.prefix }
}
/** 제외 앱 판정 — 대소문자 무시, 실행 파일명(확장자 무관) 부분 일치. */
export function isAppExcluded(appName: string, excludedApps: readonly string[]): boolean {
const target = appName.trim().toLowerCase()
@ -654,47 +789,6 @@ export function isTerminalApp(appName: string | null | undefined): boolean {
return !!appName && isAppExcluded(appName, TERMINAL_APPS)
}
/**
* 입력 줄의 시작 표시: Claude Code/Codex `>`·`›`, starship `❯`, PowerShell `PS …>`, cmd `C:\…>`,
* POSIX `$ # %`(단독이거나 앞에 경로·호스트 표지가 있을 때만 — 출력의 `100% 완료` 를 입력으로 보지 않는다).
*/
const TERMINAL_PROMPT_PATTERN =
/^(?:PS [^>\n]{1,260}>|[A-Za-z]:\\[^>\n]{0,260}>|(?:[^\s]*[@:~/\\][^\s]*)?[$#%]|>|›|❯|»)[ \u00A0]?(.*)$/u
/** 테두리·상자 줄(─━═╭╮╰╯ 등)이나 비어 있는 줄에서 입력 영역이 끝난다. */
const TERMINAL_BORDER_PATTERN = /^[\s─━═┄┈╌╍╭╮╰╯┌┐└┘├┤┬┴┼│┃║▔▁▏▕-]*$/u
const TERMINAL_EDGE_PATTERN = /^[\s│┃║]+|[\s│┃║]+$/gu
const TERMINAL_SCAN_LINES = 40
/**
* 터미널 화면 텍스트에서 "지금 치고 있는 입력" 을 뽑는다.
*
* 문서형 터미널은 캐럿 위치를 주지 않고 화면 전체를 돌려준다 — 마지막 줄은 대개
* 상태줄이다. 아래에서 위로 가장 가까운 프롬프트 줄을 찾고, 그 아래로 테두리/빈 줄이
* 나올 때까지의 이어진 줄(줄바꿈된 긴 입력)을 붙인다. 프롬프트를 못 찾으면 null —
* 제안하지 않는다(출력·상태줄로 제안을 만들지 않는다).
*/
export function extractTerminalPromptPrefix(screen: string | null | undefined): string | null {
if (!screen) return null
const lines = screen.replace(/\r/g, '').split('\n')
const start = Math.max(0, lines.length - TERMINAL_SCAN_LINES)
for (let i = lines.length - 1; i >= start; i--) {
const line = lines[i].replace(TERMINAL_EDGE_PATTERN, '')
const match = TERMINAL_PROMPT_PATTERN.exec(line)
if (!match) continue
const parts = [match[1].trimEnd()]
for (let j = i + 1; j < lines.length; j++) {
const raw = lines[j]
if (TERMINAL_BORDER_PATTERN.test(raw)) break
const continuation = raw.replace(TERMINAL_EDGE_PATTERN, '')
// 상태줄 기호(◑ ▕░ 등)가 섞인 줄은 입력이 아니다.
if (!continuation || NON_PROSE_GLYPH_PATTERN.test(continuation)) break
parts.push(continuation)
}
return parts.join(' ').replace(/\s+/gu, ' ').trimStart()
}
return null
}
/**
* 학습에서 빼는 앱 — 터미널 + 코드 에디터 + 코딩 에이전트 허브.
*
@ -722,8 +816,6 @@ export const LEARNING_EXCLUDED_APPS: readonly string[] = Object.freeze([
'Agent Switchboard'
])
/** 상자·블록·도형·기타 기호·딩뱃 — 터미널 UI/상태줄의 지문이다. */
const NON_PROSE_GLYPH_PATTERN = /[←-⇿─-➿⬀-⯿]/u
/** 공백을 뺀 글자 중 문자(모든 언어)가 이 비율 이상이어야 문장으로 본다. */
const MIN_LETTER_RATIO = 0.6
@ -752,62 +844,6 @@ export function withoutPlaceholderText(snapshot: FocusSnapshot): FocusSnapshot {
return { ...snapshot, text: '', caretOffset: null }
}
/**
* 프롬프트에 넣을 컨텍스트 길이 상한.
*
* 길이가 곧 로컬 추론 지연이므로 짧게 유지한다.
*/
export const SUGGESTION_CONTEXT_MAX_CHARS = 400
/** 후보 문자열 길이 상한. */
export const SUGGESTION_MAX_OUTPUT_CHARS = 160
/**
* 모델 출력에서 후보 목록을 뽑아 정제한다.
*
* gemma 계열 소형 모델은 번호/따옴표/머리말을 붙이기 쉬우므로 여기서 걷어낸다.
* 접두를 그대로 되풀이하는 후보는 버린다 (ghost text 로 쓸 수 없음).
*/
export function parseSuggestionCandidates(
raw: string,
prefix: string,
maxCandidates = 3,
maxChars = SUGGESTION_MAX_OUTPUT_CHARS
): string[] {
if (!raw) return []
const prefixTail = prefix.replace(/\s+$/u, '').slice(-24).toLowerCase()
const out: string[] = []
for (const line of raw.split(/\r?\n/u)) {
const cleaned = sanitizeSuggestionLine(line, maxChars)
if (!cleaned) continue
if (prefixTail && cleaned.toLowerCase().startsWith(prefixTail)) continue
if (out.some((existing) => existing.toLowerCase() === cleaned.toLowerCase())) continue
out.push(cleaned)
if (out.length >= maxCandidates) break
}
return out
}
/** 한 줄 정제: 번호/불릿/따옴표/마크다운 제거 + 길이 제한 + 접두 반복 제거. */
export function sanitizeSuggestionLine(line: string, maxChars = SUGGESTION_MAX_OUTPUT_CHARS): string | null {
let text = line.trim()
if (!text) return null
text = text.replace(/^[-*•\d]+[.)\]]?\s+/u, '')
text = text.replace(/^["'“”‘’`]+|["'“”‘’`]+$/gu, '')
text = text.replace(/\s+/gu, ' ').trim()
if (text.length < 2) return null
// 모델이 지시문을 되풀이한 경우 방어 (llm-prompts 회귀와 같은 부류)
if (/^(suggestion|completion|candidate|output|answer|다음 문장)\s*[::]/iu.test(text)) return null
if (/^\{\{.*\}\}$/u.test(text)) return null
if (text.length > maxChars) {
text = text.slice(0, maxChars)
const lastSpace = text.lastIndexOf(' ')
if (lastSpace > maxChars * 0.6) text = text.slice(0, lastSpace)
text = text.trim()
}
return text.length >= 2 ? text : null
}
// ============================================================
// 집계 버킷 / 리포트
// ============================================================
@ -1145,73 +1181,6 @@ export interface SuggestionProvenance {
appPhraseCount: number
}
export interface LocalSuggestionHints {
continuationHints: readonly string[]
relatedHints: readonly string[]
phraseHints: readonly string[]
}
function suffixPrefixOverlap(prefix: string, candidate: string): number {
const normalizedPrefix = prefix.toLowerCase()
const normalizedCandidate = candidate.toLowerCase()
const maximum = Math.min(normalizedPrefix.length, normalizedCandidate.length)
for (let length = maximum; length >= 2; length -= 1) {
if (normalizedPrefix.slice(-length) === normalizedCandidate.slice(0, length)) return length
}
return 0
}
/**
* 로컬 기억만으로 삽입 가능한 다음 문자열을 만든다.
* 모델 실패를 성공처럼 숨기지 않고, 호출부가 provenance.mode 로 출처를 명시한다.
*/
export function buildLocalSuggestionCandidates(
prefix: string,
hints: LocalSuggestionHints,
limit = 3,
maxChars = SUGGESTION_MAX_OUTPUT_CHARS
): string[] {
const safeLimit = Math.max(0, Math.floor(limit))
const safeMaxChars = Math.max(0, Math.floor(maxChars))
if (safeLimit === 0 || safeMaxChars < 2) return []
const normalizedPrefix = prefix.replace(/\s+$/u, '')
const normalizedPrefixLower = normalizedPrefix.toLowerCase()
const acceptsWholePhrase = endsSentence(normalizedPrefix)
const out: string[] = []
const append = (candidate: string | null): void => {
if (candidate === null || candidate.length === 0) return
if (candidate.toLowerCase() === normalizedPrefixLower) return
if (out.some((existing) => existing.toLowerCase() === candidate.toLowerCase())) return
out.push(candidate)
}
for (const raw of hints.continuationHints) {
if (out.length >= safeLimit) break
const candidate = sanitizeSuggestionLine(raw, safeMaxChars)
if (candidate === null) continue
if (normalizedPrefixLower && candidate.toLowerCase().startsWith(normalizedPrefixLower)) continue
append(candidate)
}
for (const source of [hints.relatedHints, hints.phraseHints]) {
for (const raw of source) {
if (out.length >= safeLimit) break
const candidate = sanitizeSuggestionLine(raw, safeMaxChars)
if (candidate === null) continue
const overlap = suffixPrefixOverlap(normalizedPrefix, candidate)
if (overlap >= 2) {
append(sanitizeSuggestionLine(candidate.slice(overlap), safeMaxChars))
} else if (acceptsWholePhrase) {
append(candidate)
}
}
}
return out
}
export interface SuggestionState {
enabled: boolean
modelId: string | null