refactor(suggestion): drive suggestion decisions from one core step and split SuggestionService collaborators
This commit is contained in:
parent
9aa7302944
commit
190b6db284
13 changed files with 1994 additions and 496 deletions
|
|
@ -46,6 +46,8 @@ export function registerSuggestionHandlers(): void {
|
|||
async (_event, params: SetSuggestionConfigParams) => {
|
||||
try {
|
||||
const service = getSuggestionService()
|
||||
const modelChangeRequested = params.modelId !== undefined
|
||||
const previousModel = modelChangeRequested ? service.resolveModel() : null
|
||||
if (params.enabled !== undefined) service.setEnabled(params.enabled === true)
|
||||
if (params.modelId !== undefined) configSet('suggestionModelId', params.modelId || null)
|
||||
if (params.triggerDelayMs !== undefined) {
|
||||
|
|
@ -75,6 +77,8 @@ export function registerSuggestionHandlers(): void {
|
|||
}
|
||||
|
||||
service.applyConfig()
|
||||
// 새 모델은 콜드일 수 있다 — 첫 멈춤에서 타임아웃되기 전에 미리 올린다.
|
||||
if (modelChangeRequested && service.resolveModel() !== previousModel) service.handleModelChanged()
|
||||
return ipcSuccess(service.getState())
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ export interface TypingContext {
|
|||
available: boolean
|
||||
appName: string | null
|
||||
windowTitle: string | null
|
||||
/**
|
||||
* 입력창이 속한 최상위 창 핸들 (HWND). 알 수 없으면 null.
|
||||
* 제안 수락 직전 "아직 같은 창인가" 를 확인하는 기준이다.
|
||||
*/
|
||||
windowHandle?: number | null
|
||||
idleMs: number
|
||||
capturedAt: number
|
||||
/** 포커스가 바뀐 뒤 이 필드에서 실제로 편집이 있었는가 (마우스로 들어오기만 한 경우 false) */
|
||||
|
|
@ -105,6 +110,13 @@ interface ForegroundInfo {
|
|||
}
|
||||
|
||||
const MAX_SAMPLE_CHARS = 500
|
||||
/**
|
||||
* 프로그램 삽입(제안 수락 등) 예고의 유효 시간.
|
||||
*
|
||||
* 수락은 수정자 키가 떼어지길 최대 1.5초 기다린 뒤 붙여넣고, 스냅샷은 디바운스(700ms)·
|
||||
* settle·UIA 조회(최대 1.5초)를 거쳐 온다 — 그 합을 넉넉히 덮는다.
|
||||
*/
|
||||
const PROGRAMMATIC_INSERT_TTL_MS = 10_000
|
||||
const RETENTION_PRUNE_INTERVAL_MS = 6 * 60 * 60 * 1000
|
||||
const MAX_READABILITY_APPS = 64
|
||||
|
||||
|
|
@ -187,6 +199,9 @@ class InputTelemetryService extends EventEmitter {
|
|||
private _lastSnapshotSummary: InputSnapshotSummary | null = null
|
||||
/** 원문 없이 앱별 UIA 읽기 가능성만 누적한다. */
|
||||
private _appReadability = new Map<string, AppReadabilityEvidence>()
|
||||
/** 곧(또는 방금) 프로그램이 삽입한 텍스트 — 다음 diff 에서 "타이핑" 으로 학습하지 않는다. */
|
||||
private _expectedInserts: Array<{ id: number; text: string; expiresAt: number }> = []
|
||||
private _expectedInsertSeq = 0
|
||||
|
||||
private _prunedAt = 0
|
||||
|
||||
|
|
@ -584,6 +599,7 @@ class InputTelemetryService extends EventEmitter {
|
|||
available: snapshot.available,
|
||||
appName: this._foreground.appName,
|
||||
windowTitle: snapshot.windowTitle ?? this._foreground.windowTitle,
|
||||
windowHandle: this._foreground.hwnd,
|
||||
idleMs,
|
||||
capturedAt: snapshot.capturedAt,
|
||||
editedSinceFocus: false,
|
||||
|
|
@ -705,8 +721,9 @@ class InputTelemetryService extends EventEmitter {
|
|||
bucket.words += delta.insertedWords
|
||||
bucket.sentences += delta.insertedSentences
|
||||
|
||||
if (delta.insertedText) {
|
||||
this._learnText(delta.insertedText, {
|
||||
const typedText = delta.insertedText ? this._withoutExpectedInsert(delta.insertedText) : ''
|
||||
if (typedText) {
|
||||
this._learnText(typedText, {
|
||||
appName: this._foreground.appName,
|
||||
windowTitle: snapshot.windowTitle ?? this._foreground.windowTitle,
|
||||
source: 'typed'
|
||||
|
|
@ -714,6 +731,66 @@ class InputTelemetryService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 곧 프로그램이 이 텍스트를 삽입한다 (제안 수락 · 받아쓰기 삽입).
|
||||
*
|
||||
* 삽입된 텍스트는 다음 스냅샷 diff 에 그대로 나타난다. 붙여넣기 판정 임계(200자)보다
|
||||
* 짧으면 "타이핑" 으로 다시 학습돼, 삽입 경로가 이미 출처(source)를 붙여 기록한 문장이
|
||||
* 두 번 쌓이고 제안이 자기 출력을 강화했다. 예고된 텍스트는 diff 학습에서 뺀다.
|
||||
*
|
||||
* 삽입이 실패했으면 돌려준 함수로 예고를 취소한다.
|
||||
*/
|
||||
expectProgrammaticInsert(text: string): () => void {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return () => undefined
|
||||
const now = Date.now()
|
||||
this._pruneExpectedInserts(now)
|
||||
this._expectedInsertSeq += 1
|
||||
const id = this._expectedInsertSeq
|
||||
this._expectedInserts.push({ id, text: trimmed, expiresAt: now + PROGRAMMATIC_INSERT_TTL_MS })
|
||||
return () => {
|
||||
this._expectedInserts = this._expectedInserts.filter((entry) => entry.id !== id)
|
||||
}
|
||||
}
|
||||
|
||||
private _pruneExpectedInserts(now: number): void {
|
||||
if (this._expectedInserts.length === 0) return
|
||||
this._expectedInserts = this._expectedInserts.filter((entry) => entry.expiresAt > now)
|
||||
}
|
||||
|
||||
/**
|
||||
* diff 로 얻은 삽입 텍스트에서 예고된 프로그램 삽입을 뺀 "실제 타이핑" 부분.
|
||||
*
|
||||
* - 삽입 구간이 예고 텍스트를 품으면 그 부분만 빼고 나머지(앞뒤로 직접 친 글)는 남긴다.
|
||||
* - diff 경계가 반복 글자에서 한 칸 밀려 회전된 형태로 잡힌 경우도 같은 텍스트로 본다.
|
||||
* - 삽입 구간이 예고 텍스트의 앞부분이면(키보드 방식으로 치는 도중 잡힌 스냅샷) 전부
|
||||
* 프로그램 삽입이고, 남은 뒷부분을 계속 기다린다.
|
||||
*/
|
||||
private _withoutExpectedInsert(inserted: string): string {
|
||||
this._pruneExpectedInserts(Date.now())
|
||||
const trimmed = inserted.trim()
|
||||
if (!trimmed) return inserted
|
||||
for (let i = 0; i < this._expectedInserts.length; i += 1) {
|
||||
const expected = this._expectedInserts[i]
|
||||
const index = inserted.indexOf(expected.text)
|
||||
if (index >= 0) {
|
||||
this._expectedInserts.splice(i, 1)
|
||||
return `${inserted.slice(0, index)} ${inserted.slice(index + expected.text.length)}`.trim()
|
||||
}
|
||||
if (trimmed.length === expected.text.length && `${trimmed}${trimmed}`.includes(expected.text)) {
|
||||
this._expectedInserts.splice(i, 1)
|
||||
return ''
|
||||
}
|
||||
if (expected.text.startsWith(trimmed)) {
|
||||
const remainder = expected.text.slice(trimmed.length).trim()
|
||||
if (remainder) expected.text = remainder
|
||||
else this._expectedInserts.splice(i, 1)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return inserted
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부(음성 전사 등)에서 들어온 텍스트를 코퍼스에 넣는다.
|
||||
*
|
||||
|
|
@ -1239,11 +1316,26 @@ class InputTelemetryService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 학습 문구 삭제.
|
||||
*
|
||||
* 그래프 엣지(phrase_edges)는 문장 텍스트를 외래 키 없이 들고 있다 — 문구 행만 지우면
|
||||
* follows 엣지를 따라 지운 문장이 제안 프롬프트와 그래프 통계에 계속 나온다.
|
||||
* 문구와 그 문장을 가리키는 엣지를 한 트랜잭션에서 함께 지운다.
|
||||
*/
|
||||
deletePhrase(id: string): boolean {
|
||||
try {
|
||||
const db = getDatabase()
|
||||
const result = db.delete(personalPhrases).where(eq(personalPhrases.id, id)).run()
|
||||
return Number(result.changes ?? 0) > 0
|
||||
return db.transaction(() => {
|
||||
const row = db
|
||||
.select({ phrase: personalPhrases.phrase })
|
||||
.from(personalPhrases)
|
||||
.where(eq(personalPhrases.id, id))
|
||||
.get()
|
||||
const result = db.delete(personalPhrases).where(eq(personalPhrases.id, id)).run()
|
||||
if (row) getPersonalGraphService().removePhrase(row.phrase)
|
||||
return Number(result.changes ?? 0) > 0
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn(`개인 문구 제 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
type RelatedCandidate
|
||||
} from '@d3ro/core/personal-graph'
|
||||
import { isLearnablePhrase, prefixTail } from '@d3ro/core/input-intelligence'
|
||||
import { and, desc, eq, gte, inArray, sql } from 'drizzle-orm'
|
||||
import { and, desc, eq, gte, inArray, or, sql } from 'drizzle-orm'
|
||||
import { getDatabase } from '../db'
|
||||
import { personalPhrases, phraseEdges } from '../db/schema'
|
||||
import { getLogger } from './LoggerService'
|
||||
|
|
@ -357,6 +357,22 @@ class PersonalGraphService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 문장 노드에 닿는 엣지를 모두 지운다 (사용자가 문구를 삭제했을 때).
|
||||
*
|
||||
* 엣지는 문장 텍스트를 외래 키 없이 들고 있어, 노드만 지우면 follows 이웃으로
|
||||
* 지운 문장이 계속 제안 문맥에 끌려 나온다. 호출자의 트랜잭션 안에서 부를 수 있도록
|
||||
* 예외를 삼키지 않는다.
|
||||
*/
|
||||
removePhrase(phrase: string): number {
|
||||
const db = getDatabase()
|
||||
const result = db
|
||||
.delete(phraseEdges)
|
||||
.where(or(eq(phraseEdges.fromPhrase, phrase), eq(phraseEdges.toPhrase, phrase)))
|
||||
.run()
|
||||
return Number(result.changes ?? 0)
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
try {
|
||||
const db = getDatabase()
|
||||
|
|
|
|||
111
apps/desktop/src/main/services/SuggestionRepository.ts
Normal file
111
apps/desktop/src/main/services/SuggestionRepository.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// src/main/services/SuggestionRepository.ts
|
||||
//
|
||||
// 제안 이력 영속화 포트 — SuggestionService 가 DB 스키마에 직접 묶이지 않도록 분리한다 (DIP).
|
||||
// 기본 구현은 SQLite(drizzle)이다. 실패는 기존과 같이 삼키고 경고만 남긴다 — 이력 기록
|
||||
// 실패가 제안 자체를 막으면 안 된다.
|
||||
|
||||
import { desc, eq } from 'drizzle-orm'
|
||||
import { SUGGESTION_CONTEXT_MAX_CHARS } from '@d3ro/core/input-intelligence'
|
||||
import { getDatabase } from '../db'
|
||||
import { suggestions } from '../db/schema'
|
||||
import { getLogger } from './LoggerService'
|
||||
|
||||
const logger = getLogger('SuggestionRepository')
|
||||
|
||||
/** 이력 조회 상한 (UI 한 화면 + 여유). */
|
||||
const HISTORY_LIMIT_MAX = 200
|
||||
|
||||
export interface SuggestionHistoryEntry {
|
||||
id: string
|
||||
appName: string | null
|
||||
prefixText: string
|
||||
suggestionText: string
|
||||
model: string | null
|
||||
latencyMs: number | null
|
||||
accepted: boolean
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface SuggestionRecordInput {
|
||||
appName: string | null
|
||||
prefix: string
|
||||
text: string
|
||||
model: string
|
||||
latencyMs: number
|
||||
candidateCount: number
|
||||
}
|
||||
|
||||
export interface SuggestionRepository {
|
||||
/** 제안 후보 1건을 기록한다. */
|
||||
record(input: SuggestionRecordInput): void
|
||||
/** 같은 텍스트의 가장 최근 기록 1건을 수락으로 표시한다. */
|
||||
markAccepted(text: string): void
|
||||
/** 최근 이력 (limit 는 1~200 으로 클램프). */
|
||||
list(limit: number): SuggestionHistoryEntry[]
|
||||
}
|
||||
|
||||
export function createSqliteSuggestionRepository(): SuggestionRepository {
|
||||
return {
|
||||
record(input: SuggestionRecordInput): void {
|
||||
try {
|
||||
getDatabase()
|
||||
.insert(suggestions)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
appName: input.appName,
|
||||
prefixText: input.prefix.slice(-SUGGESTION_CONTEXT_MAX_CHARS),
|
||||
suggestionText: input.text,
|
||||
candidateCount: input.candidateCount,
|
||||
model: input.model,
|
||||
latencyMs: input.latencyMs,
|
||||
accepted: false,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
.run()
|
||||
} catch (error) {
|
||||
logger.warn(`제안 기록 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
},
|
||||
|
||||
markAccepted(text: string): void {
|
||||
try {
|
||||
const db = getDatabase()
|
||||
const row = db
|
||||
.select({ id: suggestions.id })
|
||||
.from(suggestions)
|
||||
.where(eq(suggestions.suggestionText, text))
|
||||
.orderBy(desc(suggestions.createdAt))
|
||||
.limit(1)
|
||||
.get()
|
||||
if (!row) return
|
||||
db.update(suggestions).set({ accepted: true }).where(eq(suggestions.id, row.id)).run()
|
||||
} catch (error) {
|
||||
logger.warn(`수락 기록 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
},
|
||||
|
||||
list(limit: number): SuggestionHistoryEntry[] {
|
||||
try {
|
||||
return getDatabase()
|
||||
.select()
|
||||
.from(suggestions)
|
||||
.orderBy(desc(suggestions.createdAt))
|
||||
.limit(Math.max(1, Math.min(limit, HISTORY_LIMIT_MAX)))
|
||||
.all()
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
appName: row.appName,
|
||||
prefixText: row.prefixText,
|
||||
suggestionText: row.suggestionText,
|
||||
model: row.model,
|
||||
latencyMs: row.latencyMs,
|
||||
accepted: row.accepted,
|
||||
createdAt: row.createdAt
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.warn(`제안 이력 조회 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,16 +11,16 @@
|
|||
// - 후보가 나온 접두와 현재 접두가 달라지면 그 즉시 오버레이를 지운다 (stale).
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { desc, eq } from 'drizzle-orm'
|
||||
import {
|
||||
SUGGESTION_CONTEXT_MAX_CHARS,
|
||||
SUGGESTION_DEFAULTS,
|
||||
SUGGESTION_MAX_OUTPUT_CHARS,
|
||||
buildLocalSuggestionCandidates,
|
||||
decideSuggestion,
|
||||
decideSuggestionRefresh,
|
||||
decideSuggestionStep,
|
||||
isAppExcluded,
|
||||
matchesSessionPrefix,
|
||||
normalizeRequestPrefix,
|
||||
normalizeSessionPrefix,
|
||||
parseSuggestionCandidates,
|
||||
sanitizeSuggestionLine,
|
||||
selectPhraseHints,
|
||||
|
|
@ -28,13 +28,13 @@ import {
|
|||
extractTerminalPromptPrefix,
|
||||
type AnchorKind,
|
||||
type SuggestionCandidate,
|
||||
type SuggestionPolicyInput,
|
||||
type SuggestionProvenance,
|
||||
type SuggestionSkipReason,
|
||||
type SuggestionState,
|
||||
type SuggestionStep,
|
||||
type UiRect
|
||||
} from '@d3ro/core/input-intelligence'
|
||||
import { getDatabase } from '../db'
|
||||
import { suggestions } from '../db/schema'
|
||||
import { configGet, configSet } from './ConfigService'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getLocalLLMService } from './LocalLLMService'
|
||||
|
|
@ -43,18 +43,67 @@ import { getInputTelemetryService, type TypingContext } from './InputTelemetrySe
|
|||
import { getPersonalGraphService } from './PersonalGraphService'
|
||||
import { getTextInsertService } from './TextInsertService'
|
||||
import { waitForModifiersReleased } from './modifier-state'
|
||||
import {
|
||||
createSqliteSuggestionRepository,
|
||||
type SuggestionHistoryEntry,
|
||||
type SuggestionRecordInput,
|
||||
type SuggestionRepository
|
||||
} from './SuggestionRepository'
|
||||
import { SuggestionBudget } from './suggestion/SuggestionBudget'
|
||||
import { ModelWarmTracker } from './suggestion/ModelWarmTracker'
|
||||
import { getForegroundWindowInfo } from '../utils/win32-foreground'
|
||||
|
||||
const logger = getLogger('SuggestionService')
|
||||
|
||||
export interface SuggestionHistoryEntry {
|
||||
id: string
|
||||
appName: string | null
|
||||
prefixText: string
|
||||
suggestionText: string
|
||||
model: string | null
|
||||
latencyMs: number | null
|
||||
accepted: boolean
|
||||
createdAt: number
|
||||
export type { SuggestionHistoryEntry }
|
||||
|
||||
/** 지금 포그라운드 창을 알려 주는 포트 — 수락 직전 대상 창 확인에 쓴다. */
|
||||
export interface SuggestionForegroundProbe {
|
||||
currentWindowHandle(): number | null
|
||||
}
|
||||
|
||||
/** 수락한 텍스트를 학습 쪽에 알리는 포트. */
|
||||
export interface SuggestionLearningPort {
|
||||
/** 곧 프로그램이 삽입할 텍스트 — 다음 스냅샷 diff 가 "타이핑" 으로 다시 학습하지 않게 한다. 취소 함수를 돌려준다. */
|
||||
expectProgrammaticInsert(text: string): () => void
|
||||
/** 수락한 문장을 사용자 문체 표본으로 기록한다 (학습 동의 시에만 저장됨). */
|
||||
recordAccepted(text: string, meta: { appName: string | null; windowTitle: string | null }): void
|
||||
}
|
||||
|
||||
/** 서비스 협력자 — 기본값은 getSuggestionService() 가 연결한다 (DIP). */
|
||||
export interface SuggestionServiceDeps {
|
||||
repository: SuggestionRepository
|
||||
budget: SuggestionBudget
|
||||
warmth: ModelWarmTracker
|
||||
foreground: SuggestionForegroundProbe
|
||||
learning: SuggestionLearningPort
|
||||
}
|
||||
|
||||
export function createDefaultSuggestionServiceDeps(): SuggestionServiceDeps {
|
||||
return {
|
||||
repository: createSqliteSuggestionRepository(),
|
||||
budget: new SuggestionBudget(),
|
||||
warmth: new ModelWarmTracker(),
|
||||
foreground: {
|
||||
currentWindowHandle: () => getForegroundWindowInfo()?.hwnd ?? null
|
||||
},
|
||||
learning: {
|
||||
expectProgrammaticInsert: (text) => getInputTelemetryService().expectProgrammaticInsert(text),
|
||||
recordAccepted: (text, meta) =>
|
||||
getInputTelemetryService().recordExternalText(text, { ...meta, source: 'suggestion' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 설정에서 읽은 정책 값 (SSOT 기본값과 상한 적용 후). */
|
||||
interface PolicyConfig {
|
||||
triggerDelayMs: number
|
||||
minPrefixChars: number
|
||||
maxRequestsPerMinute: number
|
||||
dailyBudget: number
|
||||
maxCandidates: number
|
||||
maxChars: number
|
||||
excludedApps: string[]
|
||||
}
|
||||
|
||||
export interface SuggestionRequestResult {
|
||||
|
|
@ -86,8 +135,12 @@ const MAX_RAW_OUTPUT_CHARS = 1200
|
|||
*/
|
||||
const SUGGESTION_KEEP_ALIVE = '10m'
|
||||
const SUGGESTION_KEEP_ALIVE_MS = 10 * 60_000
|
||||
/** keep-alive 만료 직전 여유 — 경계에서 콜드 모델로 요청하지 않는다. */
|
||||
const SUGGESTION_KEEP_ALIVE_MARGIN_MS = 30_000
|
||||
/** 워밍업 실패 뒤 같은 모델 재시도를 미루는 시간 — 멈출 때마다 실패 요청을 쏘지 않는다. */
|
||||
const WARM_UP_FAILURE_BACKOFF_MS = SUGGESTION_DEFAULTS.minIntervalMs
|
||||
|
||||
class SuggestionService extends EventEmitter {
|
||||
export class SuggestionService extends EventEmitter {
|
||||
private _candidates: SuggestionCandidate[] = []
|
||||
private _activeIndex = 0
|
||||
private _anchor: UiRect | null = null
|
||||
|
|
@ -119,10 +172,6 @@ class SuggestionService extends EventEmitter {
|
|||
private _ttlTimer: NodeJS.Timeout | null = null
|
||||
/** 마지막으로 실제 요청한 접두 (같은 텍스트 반복 요청 방지) */
|
||||
private _lastRequestedPrefix = ''
|
||||
/** 연속 실패 횟수 — 임계 도달 시 쿨다운 */
|
||||
private _consecutiveFailures = 0
|
||||
/** 쿨다운 종료 시각 (ms) — 이 동안은 요청하지 않는다 */
|
||||
private _cooldownUntil = 0
|
||||
/** 사용자가 X 로 닫은 뒤 재표시를 막는 시각 (ms) */
|
||||
private _userDismissedUntil = 0
|
||||
/** 스트리밍 누적 텍스트 */
|
||||
|
|
@ -131,16 +180,16 @@ class SuggestionService extends EventEmitter {
|
|||
private _lastPartialEmitAt = 0
|
||||
private _lastContext: TypingContext | null = null
|
||||
private _provenance: SuggestionProvenance | null = null
|
||||
/** 세션을 만든 입력창의 최상위 창 핸들 — 수락 직전 포커스가 그대로인지 확인한다. */
|
||||
private _windowHandle: number | null = null
|
||||
|
||||
private _lastRequestAt = 0
|
||||
/** 취소된 요청의 부담을 되돌리기 위해 직전 값을 기억한다. */
|
||||
private _prevRequestAt = 0
|
||||
/** 모델이 메모리에 남아 있다고 볼 수 있는 시각 (ms). 이 시각 이후에는 콜드 리로드로 본다. */
|
||||
private _modelWarmUntil = 0
|
||||
private _minuteWindowStart = 0
|
||||
private _minuteCount = 0
|
||||
private _dayKey = ''
|
||||
private _dayCount = 0
|
||||
/** 요청 예산 (간격 · 분/일 카운트 · 실패 쿨다운) */
|
||||
private readonly _budget: SuggestionBudget
|
||||
/** 모델별 온기 (keep-alive) · 워밍업 실패 기록 */
|
||||
private readonly _warmth: ModelWarmTracker
|
||||
private readonly _repository: SuggestionRepository
|
||||
private readonly _foreground: SuggestionForegroundProbe
|
||||
private readonly _learning: SuggestionLearningPort
|
||||
|
||||
private _inFlight = false
|
||||
private _abort: AbortController | null = null
|
||||
|
|
@ -153,6 +202,52 @@ class SuggestionService extends EventEmitter {
|
|||
private _warmUpAbort: AbortController | null = null
|
||||
private _warmUpRetryTimer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(deps: SuggestionServiceDeps = createDefaultSuggestionServiceDeps()) {
|
||||
super()
|
||||
this._budget = deps.budget
|
||||
this._warmth = deps.warmth
|
||||
this._repository = deps.repository
|
||||
this._foreground = deps.foreground
|
||||
this._learning = deps.learning
|
||||
}
|
||||
|
||||
// ── 진단 접근자 (기존 진단·테스트가 읽는 예산 필드 이름을 유지한다) ──
|
||||
// 예산 상태의 정본은 SuggestionBudget 이다. 새 코드는 _budget 을 직접 쓴다.
|
||||
|
||||
/** @internal */
|
||||
get _lastRequestAt(): number {
|
||||
return this._budget.lastRequestAt
|
||||
}
|
||||
|
||||
/** @internal 진행 플래그 누수(watchdog) 재현용 */
|
||||
set _lastRequestAt(at: number) {
|
||||
this._budget.overrideLastRequestAt(at)
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
get _prevRequestAt(): number {
|
||||
return this._budget.prevRequestAt
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
get _minuteCount(): number {
|
||||
return this._budget.minuteCount
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
get _dayCount(): number {
|
||||
return this._budget.dayCount
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
get _consecutiveFailures(): number {
|
||||
return this._budget.consecutiveFailures
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
get _cooldownUntil(): number {
|
||||
return this._budget.cooldownUntil
|
||||
}
|
||||
|
||||
override on<K extends keyof SuggestionEvents>(event: K, listener: SuggestionEvents[K]): this {
|
||||
return super.on(event, listener as (...args: unknown[]) => void)
|
||||
|
|
@ -189,7 +284,6 @@ class SuggestionService extends EventEmitter {
|
|||
|
||||
getState(): SuggestionState {
|
||||
const now = Date.now()
|
||||
this._rollCounters(now)
|
||||
return {
|
||||
enabled: this.isEnabled(),
|
||||
modelId: this.resolveModel(),
|
||||
|
|
@ -206,8 +300,8 @@ class SuggestionService extends EventEmitter {
|
|||
appName: this._appName,
|
||||
updatedAt: now,
|
||||
lastSkipReason: this._lastSkipReason,
|
||||
requestsToday: this._dayCount,
|
||||
coolingDown: Date.now() < this._cooldownUntil,
|
||||
requestsToday: this._budget.requestsToday(now),
|
||||
coolingDown: this._budget.isCoolingDown(now),
|
||||
dailyBudget: configGet('suggestionDailyBudget') ?? SUGGESTION_DEFAULTS.dailyBudget,
|
||||
lastLatencyMs: this._lastLatencyMs,
|
||||
triggerDelayMs: this.readPolicyConfig().triggerDelayMs,
|
||||
|
|
@ -235,6 +329,20 @@ class SuggestionService extends EventEmitter {
|
|||
this.emit('state-changed', this.getState())
|
||||
}
|
||||
|
||||
/**
|
||||
* 제안에 쓸 모델이 바뀌었다 (전용 모델 설정 또는 기본 LLM 모델 변경).
|
||||
*
|
||||
* 새 모델은 콜드일 수 있다 — 첫 멈춤에서 콜드 로드가 타임아웃되기 전에 미리 올린다.
|
||||
* 온기는 모델별로 추적하므로 이 호출이 없어도 첫 요청은 워밍업 경로로 간다.
|
||||
*/
|
||||
handleModelChanged(): void {
|
||||
if (!this.isEnabled()) return
|
||||
const model = this.resolveModel()
|
||||
if (!model || this._warmth.isWarm(model, Date.now())) return
|
||||
logger.info(`제안 모델 변경 감지 (model=${model}) — 미리 워밍업한다`)
|
||||
void this.warmUp()
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
configSet('suggestionEnabled', enabled)
|
||||
if (!enabled) this.dismiss('disabled')
|
||||
|
|
@ -283,13 +391,17 @@ class SuggestionService extends EventEmitter {
|
|||
})
|
||||
for await (const chunk of stream) void chunk
|
||||
if (abort.signal.aborted) return
|
||||
this._modelWarmUntil = Date.now() + SUGGESTION_KEEP_ALIVE_MS - 30_000
|
||||
this._noteModelWarm(model)
|
||||
logger.info(`제안 모델 워밍업 완료 (model=${model})`)
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) {
|
||||
logger.warn(
|
||||
`제안 모델 워밍업 실패: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger.warn(`제안 모델 워밍업 실패: ${message}`)
|
||||
// 실패한 워밍업도 실패다 — 기록하지 않으면 멈출 때마다 새 워밍업을 쏘며
|
||||
// "준비 중" 만 깜빡이고, 쿨다운·예산이 전혀 걸리지 않는다.
|
||||
this._warmth.noteWarmUpFailure(model, Date.now())
|
||||
this._lastSkipReason = 'generation-failed'
|
||||
this._noteFailure(`warm-up: ${message}`)
|
||||
}
|
||||
}
|
||||
return
|
||||
|
|
@ -334,7 +446,8 @@ class SuggestionService extends EventEmitter {
|
|||
/**
|
||||
* 입력 텔레메트리가 보내는 "지금 치고 있는 것" 이벤트.
|
||||
*
|
||||
* 정책 판단 → (stale 정리) → 필요하면 생성.
|
||||
* 판단은 core `decideSuggestionStep` 이 한다(세션 유효성 → 쿨다운 → 사용자 닫기 →
|
||||
* 정책 → 모델 온기). 서비스는 그 결과만 실행한다.
|
||||
*/
|
||||
handleTypingContext(input: TypingContext): void {
|
||||
// 터미널은 화면 전체가 읽힌다 — 입력 줄만 접두로 쓴다. 입력 줄이 없으면 빈 접두(제안 안 함).
|
||||
|
|
@ -345,165 +458,105 @@ class SuggestionService extends EventEmitter {
|
|||
const now = Date.now()
|
||||
this._lastContextAt = now
|
||||
this._watchdog()
|
||||
this._rollCounters(now)
|
||||
|
||||
const config = this.readPolicyConfig()
|
||||
this._logDecisionInputs(context)
|
||||
this._abortStaleGeneration(context.prefix)
|
||||
|
||||
// 세션(페이지 넘기며 보는 고정 목록)이 후보를 보여주는 중이면, 다음 문장이
|
||||
// 시작된 순간 — 이어 치기로 자란 것도 포함, IME 마지막 글자 조합만 예외 —
|
||||
// 즉시 세션을 끝낸다(사용자 요청 사양: 계속 자라는 접두를 따라가지 않는다).
|
||||
// 다음 멈춤에서 정책 게이트를 다시 거쳐 새 세션이 시작된다.
|
||||
if (this.isVisible && !matchesSessionPrefix(this._generatedForPrefix, context.prefix)) {
|
||||
logger.debug('제안 닫음: 이어서 입력함 — 멈추면 새 문맥으로 다시 만든다')
|
||||
this.dismiss('stale')
|
||||
// 사용자가 고르지 않고 계속 쳤다 — 이 세션 때문에 최소 간격(5초)에 걸려
|
||||
// 정작 멈췄을 때 아무것도 안 뜨면 안 된다. 분당·일일 상한은 그대로 둔다.
|
||||
this._lastRequestAt = Math.min(this._lastRequestAt, now - SUGGESTION_DEFAULTS.minIntervalMs)
|
||||
this.emit('state-changed', this.getState())
|
||||
return
|
||||
}
|
||||
|
||||
// 연속 실패 쿨다운. 모델이 다른 작업으로 바쁘면(로컬 에이전트 동시 사용 등)
|
||||
// 요청이 계속 타임아웃되어 스피너만 깜빡인다 — 잠시 요청을 멈춘다.
|
||||
if (Date.now() < this._cooldownUntil) {
|
||||
this._lastSkipReason = 'cooldown'
|
||||
if (
|
||||
this._canUseLocalMemory(context, config, now) &&
|
||||
this._publishLocalMemory(
|
||||
this._currentPrefix(context),
|
||||
context,
|
||||
config.maxCandidates,
|
||||
config.maxChars,
|
||||
0,
|
||||
null
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (this.isPresentationActive) this.dismiss('cooldown')
|
||||
this.emit('state-changed', this.getState())
|
||||
return
|
||||
}
|
||||
|
||||
// 사용자가 X 로 닫은 직후에는 다시 띄우지 않는다 (닫기가 확실히 먹도록).
|
||||
if (Date.now() < this._userDismissedUntil) {
|
||||
this._lastSkipReason = 'cooldown'
|
||||
this.emit('state-changed', this.getState())
|
||||
return
|
||||
}
|
||||
const decision = decideSuggestion({
|
||||
enabled: this.isEnabled(),
|
||||
modelAvailable: getLocalLLMService().isAvailable(),
|
||||
// 생성 중이거나 세션이 떠 있으면 "이미 진행 중" 으로 취급해 중복 요청과
|
||||
// 깜빡임을 막는다. 접두가 어긋난 세션은 위에서 이미 dismiss 하고 return 했으므로
|
||||
// 여기 도달했다는 것 자체가 "그대로 유효한 세션" 이라는 뜻이다.
|
||||
overlayVisible: this.isPresentationActive,
|
||||
composing: context.isComposing,
|
||||
hasSelection: context.hasSelection,
|
||||
isPassword: context.isPassword,
|
||||
isEditable: context.isEditable,
|
||||
appName: context.appName,
|
||||
excludedApps: config.excludedApps,
|
||||
editedSinceFocus: context.editedSinceFocus,
|
||||
typedRecently: context.typedRecently,
|
||||
prefix: context.prefix,
|
||||
idleMs: context.idleMs,
|
||||
triggerDelayMs: config.triggerDelayMs,
|
||||
minPrefixChars: config.minPrefixChars,
|
||||
sinceLastRequestMs: now - this._lastRequestAt,
|
||||
minIntervalMs: SUGGESTION_DEFAULTS.minIntervalMs,
|
||||
requestsThisMinute: this._minuteCount,
|
||||
maxRequestsPerMinute: config.maxRequestsPerMinute,
|
||||
requestsToday: this._dayCount,
|
||||
dailyBudget: config.dailyBudget
|
||||
const model = this.resolveModel()
|
||||
const step = decideSuggestionStep({
|
||||
policy: this._buildPolicyInput(context, config, now),
|
||||
sessionVisible: this.isVisible,
|
||||
sessionPrefix: this._generatedForPrefix,
|
||||
coolingDown: this._budget.isCoolingDown(now),
|
||||
userDismissedQuiet: now < this._userDismissedUntil,
|
||||
lastRequestedPrefix: this._lastRequestedPrefix,
|
||||
modelWarm: this._warmth.isWarm(model, now),
|
||||
warmUpBackoff: this._warmth.isBackingOff(model, now, WARM_UP_FAILURE_BACKOFF_MS)
|
||||
})
|
||||
this._executeStep(step, context, config, now)
|
||||
}
|
||||
|
||||
if (decision.action === 'clear') {
|
||||
logger.debug(`제안 삭제: ${decision.reason}`)
|
||||
if (this.isPresentationActive) this.dismiss(decision.reason)
|
||||
else this._lastSkipReason = decision.reason
|
||||
this.emit('state-changed', this.getState())
|
||||
return
|
||||
}
|
||||
|
||||
if (decision.action === 'skip') {
|
||||
logger.debug(`제안 건너: ${decision.reason}`)
|
||||
this._lastSkipReason = decision.reason
|
||||
|
||||
// 같은 텍스트로 이미 요청했다면 다시 만들지 않는다.
|
||||
// (필드를 클릭만 했거나, 엔터로 보낸 뒤 UIA 가 옛 텍스트를 돌려줄 때
|
||||
// 제안이 반복 생성되던 문제)
|
||||
if (this._currentPrefix(context) === this._lastRequestedPrefix && this._lastRequestedPrefix) {
|
||||
this._lastSkipReason = 'unchanged'
|
||||
private _executeStep(step: SuggestionStep, context: TypingContext, config: PolicyConfig, now: number): void {
|
||||
switch (step.action) {
|
||||
case 'end-session':
|
||||
// 세션(페이지 넘기며 보는 고정 목록)을 보여주는 중에 다음 문장이 시작됐다 —
|
||||
// 이어 치기로 자란 것도 포함, IME 마지막 글자 조합만 예외. 즉시 세션을 끝내고
|
||||
// 다음 멈춤에서 정책 게이트를 다시 거쳐 새 세션을 시작한다.
|
||||
logger.debug('제안 닫음: 이어서 입력함 — 멈추면 새 문맥으로 다시 만든다')
|
||||
this.dismiss(step.reason)
|
||||
// 사용자가 고르지 않고 계속 쳤다 — 이 세션 때문에 최소 간격(5초)에 걸려
|
||||
// 정작 멈췄을 때 아무것도 안 뜨면 안 된다. 분당·일일 상한은 그대로 둔다.
|
||||
this._budget.relaxInterval(now, SUGGESTION_DEFAULTS.minIntervalMs)
|
||||
this.emit('state-changed', this.getState())
|
||||
return
|
||||
}
|
||||
case 'generate':
|
||||
// 비동기 실패가 조용히 사라지면 기능이 죽은 이유를 알 수 없다.
|
||||
void this._generate(step.prefix, context, config.maxCandidates, config.maxChars).catch(
|
||||
(error: unknown) => {
|
||||
logger.warn(
|
||||
`제안 생성 파이프라인 예외: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
this._releaseGeneration()
|
||||
}
|
||||
)
|
||||
return
|
||||
case 'settle':
|
||||
this._settle(step, context, config)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** 생성하지 않는 결정 — 사유를 남기고, 필요하면 로컬 기억/준비 중/닫기를 수행한다. */
|
||||
private _settle(
|
||||
step: Extract<SuggestionStep, { action: 'settle' }>,
|
||||
context: TypingContext,
|
||||
config: PolicyConfig
|
||||
): void {
|
||||
logger.debug(`제안 보류: ${step.reason} (${step.then})`)
|
||||
this._lastSkipReason = step.reason
|
||||
|
||||
// 모델이 아직 안 떠 있으면 "준비 중" 을 보여준다 — 아무 반응이 없으면
|
||||
// 사용자는 기능이 죽었다고 판단한다(첫 실행에서 실제로 그랬다).
|
||||
if (decision.reason === 'model-unavailable') {
|
||||
if (this._canUseLocalMemory(context, config, now)) {
|
||||
const shown = this._publishLocalMemory(
|
||||
this._currentPrefix(context),
|
||||
context,
|
||||
config.maxCandidates,
|
||||
config.maxChars,
|
||||
0,
|
||||
null
|
||||
)
|
||||
if (shown) return
|
||||
}
|
||||
this._warmingUp = true
|
||||
this._anchor = context.anchor
|
||||
this._anchorKind = context.anchorKind
|
||||
this._appName = context.appName
|
||||
// 워밍업 안내도 수명이 있다 — 모델이 뜬 뒤 갱신이 없으면 스스로 사라진다.
|
||||
this._armVisibleTtl()
|
||||
this.emit('updated', this.getState())
|
||||
}
|
||||
|
||||
this.emit('state-changed', this.getState())
|
||||
if (
|
||||
step.tryLocalMemory &&
|
||||
this._publishLocalMemory(
|
||||
this._currentPrefix(context),
|
||||
context,
|
||||
config.maxCandidates,
|
||||
config.maxChars,
|
||||
0,
|
||||
null
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// 모델이 유휴로 내려갔을 것으로 보이면 생성 대신 워밍업부터 한다.
|
||||
// 콜드 리로드(실측 11.4s)는 요청 타임아웃(8s)보다 길어 그대로 요청하면 항상 시간 초과한다.
|
||||
if (now >= this._modelWarmUntil) {
|
||||
if (this._canUseLocalMemory(context, config, now)) {
|
||||
const shown = this._publishLocalMemory(
|
||||
this._currentPrefix(context),
|
||||
context,
|
||||
config.maxCandidates,
|
||||
config.maxChars,
|
||||
0,
|
||||
null
|
||||
)
|
||||
if (shown) return
|
||||
}
|
||||
void this.warmUp()
|
||||
this._warmingUp = true
|
||||
this._anchor = context.anchor
|
||||
this._anchorKind = context.anchorKind
|
||||
this._appName = context.appName
|
||||
this._armVisibleTtl()
|
||||
this.emit('updated', this.getState())
|
||||
this._lastSkipReason = 'model-unavailable'
|
||||
this.emit('state-changed', this.getState())
|
||||
return
|
||||
switch (step.then) {
|
||||
case 'record':
|
||||
break
|
||||
case 'dismiss':
|
||||
if (this.isPresentationActive) this.dismiss(step.reason)
|
||||
break
|
||||
case 'warm-up':
|
||||
void this.warmUp()
|
||||
this._showWarming(context)
|
||||
break
|
||||
case 'show-warming':
|
||||
// 모델이 아직 안 떠 있으면 "준비 중" 을 보여준다 — 아무 반응이 없으면
|
||||
// 사용자는 기능이 죽었다고 판단한다(첫 실행에서 실제로 그랬다).
|
||||
this._showWarming(context)
|
||||
break
|
||||
}
|
||||
this.emit('state-changed', this.getState())
|
||||
}
|
||||
|
||||
// 비동기 실패가 조용히 사라지면 기능이 죽은 이유를 알 수 없다.
|
||||
void this._generate(decision.prefix, context, config.maxCandidates, config.maxChars).catch(
|
||||
(error: unknown) => {
|
||||
logger.warn(
|
||||
`제안 생성 파이프라인 예외: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
this._releaseGeneration()
|
||||
}
|
||||
)
|
||||
/** "준비 중" 오버레이 — 워밍업 안내도 수명이 있어, 갱신이 없으면 스스로 사라진다. */
|
||||
private _showWarming(context: TypingContext): void {
|
||||
this._warmingUp = true
|
||||
this._anchor = context.anchor
|
||||
this._anchorKind = context.anchorKind
|
||||
this._appName = context.appName
|
||||
this._armVisibleTtl()
|
||||
this.emit('updated', this.getState())
|
||||
}
|
||||
|
||||
/** 수동 요청 (설정/단축키 경로). 디바운스를 건너뛴다. */
|
||||
|
|
@ -519,7 +572,7 @@ class SuggestionService extends EventEmitter {
|
|||
if (this.isPresentationActive) this.dismiss(reason)
|
||||
return { ok: false, reason }
|
||||
}
|
||||
const prefix = context.prefix.replace(/\s+$/u, '')
|
||||
const prefix = normalizeRequestPrefix(context.prefix)
|
||||
if (prefix.length < 1) return { ok: false, reason: 'empty-prefix' }
|
||||
|
||||
// 명시적 사용자 액션(단축키/설정)이다 — 텔레메트리가 판단한 "최근에 타이핑했는가" 에
|
||||
|
|
@ -534,15 +587,22 @@ class SuggestionService extends EventEmitter {
|
|||
return this.isVisible ? { ok: true } : { ok: false, reason: this._lastSkipReason ?? 'generation-failed' }
|
||||
}
|
||||
|
||||
private _canUseLocalMemory(
|
||||
/**
|
||||
* 정책 입력 조립 — 모든 경로가 이 한 곳에서 만든다.
|
||||
*
|
||||
* 기본값: 모델 가용성은 실제 값, overlayVisible 은 "생성/워밍업/후보 표시 중" 전체.
|
||||
*/
|
||||
private _buildPolicyInput(
|
||||
context: TypingContext,
|
||||
config: ReturnType<SuggestionService['readPolicyConfig']>,
|
||||
now: number
|
||||
): boolean {
|
||||
const decision = decideSuggestion({
|
||||
config: PolicyConfig,
|
||||
now: number,
|
||||
overrides: Partial<Pick<SuggestionPolicyInput, 'modelAvailable' | 'overlayVisible'>> = {}
|
||||
): SuggestionPolicyInput {
|
||||
const budget = this._budget.snapshot(now)
|
||||
return {
|
||||
enabled: this.isEnabled(),
|
||||
modelAvailable: true,
|
||||
overlayVisible: false,
|
||||
modelAvailable: overrides.modelAvailable ?? getLocalLLMService().isAvailable(),
|
||||
overlayVisible: overrides.overlayVisible ?? this.isPresentationActive,
|
||||
composing: context.isComposing,
|
||||
hasSelection: context.hasSelection,
|
||||
isPassword: context.isPassword,
|
||||
|
|
@ -555,14 +615,22 @@ class SuggestionService extends EventEmitter {
|
|||
idleMs: context.idleMs,
|
||||
triggerDelayMs: config.triggerDelayMs,
|
||||
minPrefixChars: config.minPrefixChars,
|
||||
sinceLastRequestMs: now - this._lastRequestAt,
|
||||
sinceLastRequestMs: budget.sinceLastRequestMs,
|
||||
minIntervalMs: SUGGESTION_DEFAULTS.minIntervalMs,
|
||||
requestsThisMinute: this._minuteCount,
|
||||
requestsThisMinute: budget.requestsThisMinute,
|
||||
maxRequestsPerMinute: config.maxRequestsPerMinute,
|
||||
requestsToday: this._dayCount,
|
||||
requestsToday: budget.requestsToday,
|
||||
dailyBudget: config.dailyBudget
|
||||
})
|
||||
return decision.action === 'request'
|
||||
}
|
||||
}
|
||||
|
||||
/** 로컬 기억은 모델과 무관하다 — 같은 정책을 "모델 있음 · 표시 없음" 으로 평가한다. */
|
||||
private _canUseLocalMemory(context: TypingContext, config: PolicyConfig, now: number): boolean {
|
||||
return (
|
||||
decideSuggestion(
|
||||
this._buildPolicyInput(context, config, now, { modelAvailable: true, overlayVisible: false })
|
||||
).action === 'request'
|
||||
)
|
||||
}
|
||||
|
||||
private _collectMemoryHints(prefix: string, appName: string | null): LocalMemoryContext {
|
||||
|
|
@ -603,8 +671,7 @@ class SuggestionService extends EventEmitter {
|
|||
return false
|
||||
}
|
||||
return (
|
||||
current.prefix.replace(/\s+$/u, '').slice(-SUGGESTION_CONTEXT_MAX_CHARS) ===
|
||||
prefix.replace(/\s+$/u, '').slice(-SUGGESTION_CONTEXT_MAX_CHARS) &&
|
||||
normalizeSessionPrefix(current.prefix) === normalizeSessionPrefix(prefix) &&
|
||||
current.appName === context.appName &&
|
||||
current.windowTitle === context.windowTitle
|
||||
)
|
||||
|
|
@ -620,7 +687,7 @@ class SuggestionService extends EventEmitter {
|
|||
memory: LocalMemoryContext | null = null
|
||||
): boolean {
|
||||
if (!this._isCurrentFallbackContext(prefix, context, token)) return false
|
||||
const trimmedPrefix = prefix.slice(-SUGGESTION_CONTEXT_MAX_CHARS)
|
||||
const trimmedPrefix = normalizeSessionPrefix(prefix)
|
||||
const localMemory = memory ?? this._collectMemoryHints(trimmedPrefix, context.appName)
|
||||
const candidates = buildLocalSuggestionCandidates(trimmedPrefix, localMemory, maxCandidates, maxChars)
|
||||
if (candidates.length === 0) return false
|
||||
|
|
@ -634,6 +701,7 @@ class SuggestionService extends EventEmitter {
|
|||
this._anchorKind = context.anchorKind
|
||||
this._appName = context.appName
|
||||
this._windowTitle = context.windowTitle
|
||||
this._windowHandle = context.windowHandle ?? null
|
||||
this._generatedForPrefix = trimmedPrefix
|
||||
this._targetTotal = candidates.length
|
||||
this._lastSkipReason = null
|
||||
|
|
@ -645,7 +713,7 @@ class SuggestionService extends EventEmitter {
|
|||
phraseCount: localMemory.phraseHints.length,
|
||||
appPhraseCount: localMemory.appPhraseCount
|
||||
}
|
||||
this._record({
|
||||
this._recordSuggestion({
|
||||
prefix: trimmedPrefix,
|
||||
text: candidates[0],
|
||||
model: 'local-memory',
|
||||
|
|
@ -669,7 +737,7 @@ class SuggestionService extends EventEmitter {
|
|||
): Promise<void> {
|
||||
if (this._inFlight) {
|
||||
// 침묵하면 "왜 안 뜨는지" 를 알 수 없다 — 진단을 남긴다.
|
||||
logger.debug(`제안 건너뜀: 이미 생성 중 (${Date.now() - this._lastRequestAt}ms 경과)`)
|
||||
logger.debug(`제안 건너뜀: 이미 생성 중 (${Date.now() - this._budget.lastRequestAt}ms 경과)`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -679,7 +747,8 @@ class SuggestionService extends EventEmitter {
|
|||
try {
|
||||
|
||||
const startedAt = Date.now()
|
||||
const trimmedPrefix = prefix.slice(-SUGGESTION_CONTEXT_MAX_CHARS)
|
||||
// 세션 접두 — 프롬프트 입력이자 세션 유효성 비교의 기준 (normalizeSessionPrefix 정본)
|
||||
const trimmedPrefix = normalizeSessionPrefix(prefix)
|
||||
const model = this.resolveModel()
|
||||
if (!model) {
|
||||
const config = this.readPolicyConfig()
|
||||
|
|
@ -708,13 +777,10 @@ class SuggestionService extends EventEmitter {
|
|||
|
||||
this._inFlight = true
|
||||
this._generating = true
|
||||
this._lastRequestedPrefix = prefix.replace(/\s+$/u, '')
|
||||
this._lastRequestedPrefix = normalizeRequestPrefix(prefix)
|
||||
this._warmingUp = false
|
||||
this._provenance = null
|
||||
this._prevRequestAt = this._lastRequestAt
|
||||
this._lastRequestAt = Date.now()
|
||||
this._minuteCount += 1
|
||||
this._dayCount += 1
|
||||
this._budget.consume(Date.now())
|
||||
|
||||
// 후보가 오기 전에 빈 화면으로 기다리게 하지 않는다 — 즉시 자리를 잡고
|
||||
// "생성 중" 을 보여준 뒤 내용으로 채운다. 실측 5초 지연에서 특히 중요하다.
|
||||
|
|
@ -724,6 +790,7 @@ class SuggestionService extends EventEmitter {
|
|||
this._anchorKind = context.anchorKind
|
||||
this._appName = context.appName
|
||||
this._windowTitle = context.windowTitle
|
||||
this._windowHandle = context.windowHandle ?? null
|
||||
this._armVisibleTtl()
|
||||
this.emit('updated', this.getState())
|
||||
|
||||
|
|
@ -775,7 +842,7 @@ class SuggestionService extends EventEmitter {
|
|||
if (abort.signal.aborted) break
|
||||
raw += chunk
|
||||
// 최소 한 청크라도 도착했으면 모델이 메모리에 올라온 것 — 유지 시각을 갱신한다.
|
||||
this._modelWarmUntil = Date.now() + SUGGESTION_KEEP_ALIVE_MS - 30_000
|
||||
this._noteModelWarm(model)
|
||||
|
||||
// 도착하는 대로 오버레이에 흘려보낸다 — 사용자는 "계속 생성되는" 것을 본다.
|
||||
// IPC 과다 방출을 막기 위해 120ms 간격으로만 보낸다.
|
||||
|
|
@ -886,8 +953,7 @@ class SuggestionService extends EventEmitter {
|
|||
|
||||
const latencyMs = Date.now() - startedAt
|
||||
this._lastLatencyMs = latencyMs
|
||||
this._consecutiveFailures = 0
|
||||
this._cooldownUntil = 0
|
||||
this._budget.noteSuccess()
|
||||
this._partialText = ''
|
||||
this._candidates = candidates.map((candidate, index) => ({ text: candidate, rank: index }))
|
||||
this._activeIndex = 0
|
||||
|
|
@ -895,6 +961,7 @@ class SuggestionService extends EventEmitter {
|
|||
this._anchorKind = context.anchorKind
|
||||
this._appName = context.appName
|
||||
this._windowTitle = context.windowTitle
|
||||
this._windowHandle = context.windowHandle ?? null
|
||||
this._generatedForPrefix = trimmedPrefix
|
||||
this._targetTotal = SUGGESTION_DEFAULTS.maxCandidatesTotal
|
||||
this._lastSkipReason = null
|
||||
|
|
@ -906,7 +973,7 @@ class SuggestionService extends EventEmitter {
|
|||
appPhraseCount: memory.appPhraseCount
|
||||
}
|
||||
|
||||
this._record({ prefix: trimmedPrefix, text: candidates[0], model, latencyMs, candidateCount: candidates.length })
|
||||
this._recordSuggestion({ prefix: trimmedPrefix, text: candidates[0], model, latencyMs, candidateCount: candidates.length })
|
||||
logger.info(`제안 첫 후보 생성 (${latencyMs}ms, model=${model}) — 최대 ${SUGGESTION_DEFAULTS.maxCandidatesTotal}개까지 순차로 채운다`)
|
||||
this._armVisibleTtl()
|
||||
this.emit('updated', this.getState())
|
||||
|
|
@ -999,7 +1066,7 @@ class SuggestionService extends EventEmitter {
|
|||
for await (const chunk of stream) {
|
||||
if (abort.signal.aborted) break
|
||||
raw += chunk
|
||||
this._modelWarmUntil = Date.now() + SUGGESTION_KEEP_ALIVE_MS - 30_000
|
||||
this._noteModelWarm(model)
|
||||
if (raw.length >= MAX_RAW_OUTPUT_CHARS) break
|
||||
}
|
||||
if (abort.signal.aborted || token !== this._generationToken) return false
|
||||
|
|
@ -1013,7 +1080,7 @@ class SuggestionService extends EventEmitter {
|
|||
logger.debug(`제안 후보 추가 ${this._candidates.length}/${SUGGESTION_DEFAULTS.maxCandidatesTotal}`)
|
||||
this._armVisibleTtl()
|
||||
this.emit('updated', this.getState())
|
||||
this._record({
|
||||
this._recordSuggestion({
|
||||
prefix,
|
||||
text: candidateText,
|
||||
model,
|
||||
|
|
@ -1062,7 +1129,7 @@ class SuggestionService extends EventEmitter {
|
|||
|
||||
/** 마지막 생성 이후 흐른 시간 (ms). */
|
||||
get inFlightAgeMs(): number {
|
||||
return this._inFlight ? Date.now() - this._lastRequestAt : 0
|
||||
return this._inFlight ? Date.now() - this._budget.lastRequestAt : 0
|
||||
}
|
||||
|
||||
/** 응답 제한 (설정 → SSOT 기본값). */
|
||||
|
|
@ -1073,7 +1140,7 @@ class SuggestionService extends EventEmitter {
|
|||
|
||||
/** 현재 정책 기준의 접두. */
|
||||
private _currentPrefix(context: TypingContext): string {
|
||||
return context.prefix.replace(/\s+$/u, '')
|
||||
return normalizeRequestPrefix(context.prefix)
|
||||
}
|
||||
|
||||
/** 판단 입력을 서명이 바뀔 때만 남긴다. */
|
||||
|
|
@ -1108,11 +1175,7 @@ class SuggestionService extends EventEmitter {
|
|||
* (실측 신고). 임계 도달 시 쿨다운 동안은 요청 자체를 하지 않는다.
|
||||
*/
|
||||
private _noteFailure(reason: string): void {
|
||||
this._consecutiveFailures += 1
|
||||
if (this._consecutiveFailures < SUGGESTION_DEFAULTS.failureCooldownThreshold) return
|
||||
|
||||
this._cooldownUntil = Date.now() + SUGGESTION_DEFAULTS.failureCooldownMs
|
||||
this._consecutiveFailures = 0
|
||||
if (!this._budget.noteFailure(Date.now())) return
|
||||
logger.warn(
|
||||
`제안 생성 연속 실패 (${reason}) — ${Math.round(
|
||||
SUGGESTION_DEFAULTS.failureCooldownMs / 1000
|
||||
|
|
@ -1120,6 +1183,11 @@ class SuggestionService extends EventEmitter {
|
|||
)
|
||||
}
|
||||
|
||||
/** 이 모델이 방금 응답했다 — keep-alive 동안 메모리에 남아 있다고 본다. */
|
||||
private _noteModelWarm(model: string): void {
|
||||
this._warmth.noteWarm(model, Date.now() + SUGGESTION_KEEP_ALIVE_MS - SUGGESTION_KEEP_ALIVE_MARGIN_MS)
|
||||
}
|
||||
|
||||
private _armVisibleTtl(): void {
|
||||
if (this._ttlTimer) clearTimeout(this._ttlTimer)
|
||||
this._ttlTimer = setTimeout(() => {
|
||||
|
|
@ -1149,9 +1217,7 @@ class SuggestionService extends EventEmitter {
|
|||
// 취소된 요청은 속도 제한 예산을 쓰지 않았던 것으로 되돌린다.
|
||||
// (IME 조합 중 마지막 글자가 바뀌어 stale 로 잡히면, 취소가 예산을 갉아먹어
|
||||
// 정작 사용자가 멈췄을 때 rate-limited 로 막히던 문제)
|
||||
this._lastRequestAt = this._prevRequestAt
|
||||
this._minuteCount = Math.max(0, this._minuteCount - 1)
|
||||
this._dayCount = Math.max(0, this._dayCount - 1)
|
||||
this._budget.refund()
|
||||
// 보여줄 후보가 없으면 스피너만 남는다 — 오버레이를 지운다.
|
||||
if (this._candidates.length === 0) this.dismiss('stale')
|
||||
logger.debug(`제안 생성 취소 (${refresh}) — 다음 정상 입력 문맥에서만 재평가`)
|
||||
|
|
@ -1165,7 +1231,7 @@ class SuggestionService extends EventEmitter {
|
|||
*/
|
||||
private _watchdog(): void {
|
||||
if (!this._inFlight) return
|
||||
const age = Date.now() - this._lastRequestAt
|
||||
const age = Date.now() - this._budget.lastRequestAt
|
||||
if (age < this.readTimeoutMs() + 5000) return
|
||||
logger.warn(`제안 생성 플래그 누수 감지 (${age}ms) — 강제 해제`)
|
||||
this._generationToken += 1
|
||||
|
|
@ -1264,6 +1330,7 @@ class SuggestionService extends EventEmitter {
|
|||
const text = candidate.text
|
||||
const appName = this._appName
|
||||
const windowTitle = this._windowTitle
|
||||
const targetWindow = this._windowHandle
|
||||
|
||||
// 먼저 창을 닫고 생성을 멈춘다 — 수락은 즉시 반응해야 한다.
|
||||
this.dismiss('accepted')
|
||||
|
|
@ -1275,6 +1342,21 @@ class SuggestionService extends EventEmitter {
|
|||
logger.warn('수정자 키가 떼어지지 않은 채 제안을 삽입한다 (1.5초 초과)')
|
||||
}
|
||||
|
||||
// 붙여넣기(Ctrl+V)는 그 순간의 포그라운드 창으로 간다. 오버레이는 다음 스냅샷이 와야
|
||||
// 닫히므로, 그사이 다른 앱으로 옮겨 간 뒤 수락하면 엉뚱한 창에 삽입됐다 — 세션을 만든
|
||||
// 창이 아니면 삽입하지 않는다 (창을 알 수 없으면 기존처럼 진행한다).
|
||||
if (targetWindow !== null) {
|
||||
const currentWindow = this._foreground.currentWindowHandle()
|
||||
if (currentWindow !== null && currentWindow !== targetWindow) {
|
||||
logger.info(`제안 수락 취소: 포커스가 다른 창으로 옮겨 감 (app=${appName ?? '-'})`)
|
||||
this._lastSkipReason = 'stale'
|
||||
this.emit('state-changed', this.getState())
|
||||
return { ok: false, reason: 'stale' }
|
||||
}
|
||||
}
|
||||
|
||||
// 삽입 직후의 텍스트 스냅샷 diff 가 이 텍스트를 "타이핑" 으로 다시 학습하지 않도록 알린다.
|
||||
const cancelExpectedInsert = this._learning.expectProgrammaticInsert(text)
|
||||
const method = configGet('insertMethod')
|
||||
try {
|
||||
const result = await getTextInsertService().insertText(
|
||||
|
|
@ -1282,57 +1364,30 @@ class SuggestionService extends EventEmitter {
|
|||
method === 'keyboard' ? 'keyboard' : 'clipboard'
|
||||
)
|
||||
if (!result.success) {
|
||||
cancelExpectedInsert()
|
||||
logger.warn(`제안 삽입 실패 (method=${result.method}, len=${result.textLength})`)
|
||||
return { ok: false, reason: 'generation-failed' }
|
||||
}
|
||||
} catch (error) {
|
||||
cancelExpectedInsert()
|
||||
logger.warn(`제안 삽입 예외: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return { ok: false, reason: 'generation-failed' }
|
||||
}
|
||||
|
||||
logger.info(`제안 수락: ${text.length}자 삽입 (method=${method === 'keyboard' ? 'keyboard' : 'clipboard'}, app=${appName ?? '-'})`)
|
||||
this._markAccepted(text)
|
||||
this._repository.markAccepted(text)
|
||||
// 수락한 문장은 사용자 문체의 확실한 표본이다 (학습 동의 시에만 저장됨).
|
||||
getInputTelemetryService().recordExternalText(text, { appName, windowTitle, source: 'suggestion' })
|
||||
this._learning.recordAccepted(text, { appName, windowTitle })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
getHistory(limit = 50): SuggestionHistoryEntry[] {
|
||||
try {
|
||||
const db = getDatabase()
|
||||
return db
|
||||
.select()
|
||||
.from(suggestions)
|
||||
.orderBy(desc(suggestions.createdAt))
|
||||
.limit(Math.max(1, Math.min(limit, 200)))
|
||||
.all()
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
appName: row.appName,
|
||||
prefixText: row.prefixText,
|
||||
suggestionText: row.suggestionText,
|
||||
model: row.model,
|
||||
latencyMs: row.latencyMs,
|
||||
accepted: row.accepted,
|
||||
createdAt: row.createdAt
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.warn(`제안 이력 조회 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return []
|
||||
}
|
||||
return this._repository.list(limit)
|
||||
}
|
||||
|
||||
// ── 내부 ──────────────────────────────────────────────
|
||||
|
||||
private readPolicyConfig(): {
|
||||
triggerDelayMs: number
|
||||
minPrefixChars: number
|
||||
maxRequestsPerMinute: number
|
||||
dailyBudget: number
|
||||
maxCandidates: number
|
||||
maxChars: number
|
||||
excludedApps: string[]
|
||||
} {
|
||||
private readPolicyConfig(): PolicyConfig {
|
||||
return {
|
||||
triggerDelayMs: Math.max(
|
||||
SUGGESTION_DEFAULTS.minTriggerDelayMs,
|
||||
|
|
@ -1353,61 +1408,8 @@ class SuggestionService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/** 분/일 카운터 롤링. */
|
||||
private _rollCounters(now: number): void {
|
||||
if (now - this._minuteWindowStart >= 60000) {
|
||||
this._minuteWindowStart = now
|
||||
this._minuteCount = 0
|
||||
}
|
||||
const dayKey = new Date(now).toDateString()
|
||||
if (dayKey !== this._dayKey) {
|
||||
this._dayKey = dayKey
|
||||
this._dayCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
private _record(input: {
|
||||
prefix: string
|
||||
text: string
|
||||
model: string
|
||||
latencyMs: number
|
||||
candidateCount: number
|
||||
}): void {
|
||||
try {
|
||||
const db = getDatabase()
|
||||
db.insert(suggestions)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
appName: this._appName,
|
||||
prefixText: input.prefix.slice(-SUGGESTION_CONTEXT_MAX_CHARS),
|
||||
suggestionText: input.text,
|
||||
candidateCount: input.candidateCount,
|
||||
model: input.model,
|
||||
latencyMs: input.latencyMs,
|
||||
accepted: false,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
.run()
|
||||
} catch (error) {
|
||||
logger.warn(`제안 기록 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private _markAccepted(text: string): void {
|
||||
try {
|
||||
const db = getDatabase()
|
||||
const row = db
|
||||
.select({ id: suggestions.id })
|
||||
.from(suggestions)
|
||||
.where(eq(suggestions.suggestionText, text))
|
||||
.orderBy(desc(suggestions.createdAt))
|
||||
.limit(1)
|
||||
.get()
|
||||
if (!row) return
|
||||
db.update(suggestions).set({ accepted: true }).where(eq(suggestions.id, row.id)).run()
|
||||
} catch (error) {
|
||||
logger.warn(`수락 기록 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
private _recordSuggestion(input: Omit<SuggestionRecordInput, 'appName'>): void {
|
||||
this._repository.record({ ...input, appName: this._appName })
|
||||
}
|
||||
|
||||
/** 테스트/진단 — 현재 앱이 제외 대상인지. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
// src/main/services/suggestion/ModelWarmTracker.ts
|
||||
//
|
||||
// 제안 모델의 "메모리에 떠 있음" 상태를 모델별로 추적한다 (SRP).
|
||||
//
|
||||
// 온기는 모델에 묶인 사실이다. 숫자 하나로만 들고 있으면 설정에서 모델을 바꾼 직후에도
|
||||
// 새(콜드) 모델을 warm 으로 판정해, 콜드 로드(실측 11.4s)가 요청 타임아웃(8s)을 넘겨
|
||||
// 연속 실패 → 60초 쿨다운으로 이어졌다.
|
||||
//
|
||||
// 워밍업 실패도 여기서 기록한다 — 실패한 워밍업을 매 멈춤마다 다시 쏘지 않도록
|
||||
// 잠시 물러선다(backoff).
|
||||
|
||||
export class ModelWarmTracker {
|
||||
private _warmModel: string | null = null
|
||||
private _warmUntil = 0
|
||||
private _failedModel: string | null = null
|
||||
private _failedAt = 0
|
||||
|
||||
/** 이 모델이 `until` 까지 메모리에 남아 있다고 본다. 같은 모델의 실패 기록은 지운다. */
|
||||
noteWarm(model: string, until: number): void {
|
||||
this._warmModel = model
|
||||
this._warmUntil = until
|
||||
if (this._failedModel === model) {
|
||||
this._failedModel = null
|
||||
this._failedAt = 0
|
||||
}
|
||||
}
|
||||
|
||||
isWarm(model: string | null, now: number): boolean {
|
||||
return model !== null && model === this._warmModel && now < this._warmUntil
|
||||
}
|
||||
|
||||
/** 워밍업이 실패했다 (모델 미설치 · 서버 오류 등). */
|
||||
noteWarmUpFailure(model: string, now: number): void {
|
||||
this._failedModel = model
|
||||
this._failedAt = now
|
||||
}
|
||||
|
||||
/** 직전 워밍업 실패 뒤 `backoffMs` 가 지나지 않았으면 재시도를 미룬다. */
|
||||
isBackingOff(model: string | null, now: number, backoffMs: number): boolean {
|
||||
return model !== null && model === this._failedModel && now - this._failedAt < backoffMs
|
||||
}
|
||||
|
||||
/** 가장 최근 warm 으로 기록된 모델 (진단용). */
|
||||
get warmModel(): string | null {
|
||||
return this._warmModel
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._warmModel = null
|
||||
this._warmUntil = 0
|
||||
this._failedModel = null
|
||||
this._failedAt = 0
|
||||
}
|
||||
}
|
||||
169
apps/desktop/src/main/services/suggestion/SuggestionBudget.ts
Normal file
169
apps/desktop/src/main/services/suggestion/SuggestionBudget.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// src/main/services/suggestion/SuggestionBudget.ts
|
||||
//
|
||||
// 제안 요청 예산 — 최소 간격 · 분당/일일 카운트 · 연속 실패 쿨다운 (SRP).
|
||||
//
|
||||
// 이 클래스는 게이트가 아니다: 요청 여부의 정본은 core `decideSuggestion` 이고, 여기서는
|
||||
// 그 입력(snapshot)과 소비/환급/실패 기록만 맡는다. 모든 메서드가 `now` 를 인자로 받아
|
||||
// 가짜 시계로 단위 테스트할 수 있다.
|
||||
|
||||
import { SUGGESTION_DEFAULTS } from '@d3ro/core/input-intelligence'
|
||||
|
||||
export interface SuggestionBudgetSnapshot {
|
||||
sinceLastRequestMs: number
|
||||
requestsThisMinute: number
|
||||
requestsToday: number
|
||||
}
|
||||
|
||||
export interface SuggestionBudgetOptions {
|
||||
failureCooldownThreshold: number
|
||||
failureCooldownMs: number
|
||||
}
|
||||
|
||||
const MINUTE_MS = 60_000
|
||||
|
||||
function dayKeyOf(now: number): string {
|
||||
return new Date(now).toDateString()
|
||||
}
|
||||
|
||||
export class SuggestionBudget {
|
||||
private _lastRequestAt = 0
|
||||
/** 취소된 요청의 부담을 되돌리기 위해 직전 값을 기억한다. */
|
||||
private _prevRequestAt = 0
|
||||
private _minuteWindowStart = 0
|
||||
private _minuteCount = 0
|
||||
private _dayKey = ''
|
||||
private _dayCount = 0
|
||||
/** 마지막 소비가 속한 창 — 창이 바뀐 뒤의 환급이 새 창을 깎지 않도록. */
|
||||
private _consumedMinuteWindow = -1
|
||||
private _consumedDayKey = ''
|
||||
private _consecutiveFailures = 0
|
||||
private _cooldownUntil = 0
|
||||
|
||||
constructor(
|
||||
private readonly _options: SuggestionBudgetOptions = {
|
||||
failureCooldownThreshold: SUGGESTION_DEFAULTS.failureCooldownThreshold,
|
||||
failureCooldownMs: SUGGESTION_DEFAULTS.failureCooldownMs
|
||||
}
|
||||
) {}
|
||||
|
||||
get lastRequestAt(): number {
|
||||
return this._lastRequestAt
|
||||
}
|
||||
|
||||
get prevRequestAt(): number {
|
||||
return this._prevRequestAt
|
||||
}
|
||||
|
||||
get minuteCount(): number {
|
||||
return this._minuteCount
|
||||
}
|
||||
|
||||
get dayCount(): number {
|
||||
return this._dayCount
|
||||
}
|
||||
|
||||
get consecutiveFailures(): number {
|
||||
return this._consecutiveFailures
|
||||
}
|
||||
|
||||
get cooldownUntil(): number {
|
||||
return this._cooldownUntil
|
||||
}
|
||||
|
||||
/** 분/일 창을 굴린다. */
|
||||
roll(now: number): void {
|
||||
if (now - this._minuteWindowStart >= MINUTE_MS) {
|
||||
this._minuteWindowStart = now
|
||||
this._minuteCount = 0
|
||||
}
|
||||
const dayKey = dayKeyOf(now)
|
||||
if (dayKey !== this._dayKey) {
|
||||
this._dayKey = dayKey
|
||||
this._dayCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
/** 정책 입력으로 쓸 현재 값 (창을 먼저 굴린다). */
|
||||
snapshot(now: number): SuggestionBudgetSnapshot {
|
||||
this.roll(now)
|
||||
return {
|
||||
sinceLastRequestMs: now - this._lastRequestAt,
|
||||
requestsThisMinute: this._minuteCount,
|
||||
requestsToday: this._dayCount
|
||||
}
|
||||
}
|
||||
|
||||
/** 오늘 요청 수 (창을 먼저 굴린다). */
|
||||
requestsToday(now: number): number {
|
||||
this.roll(now)
|
||||
return this._dayCount
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 1건을 소비한다.
|
||||
*
|
||||
* 반드시 창을 먼저 굴린다 — 굴리지 않고 올리면 직후의 getState() 가 창을 굴리며
|
||||
* 방금 올린 카운트를 지워, 오래 쉰 뒤의 수동 요청이 예산에 잡히지 않았다.
|
||||
*/
|
||||
consume(now: number): void {
|
||||
this.roll(now)
|
||||
this._prevRequestAt = this._lastRequestAt
|
||||
this._lastRequestAt = now
|
||||
this._minuteCount += 1
|
||||
this._dayCount += 1
|
||||
this._consumedMinuteWindow = this._minuteWindowStart
|
||||
this._consumedDayKey = this._dayKey
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소된 요청의 부담을 되돌린다.
|
||||
*
|
||||
* 소비 이후 창이 바뀌었으면 그 창의 카운트는 이미 0부터 다시 셌으므로 건드리지 않는다.
|
||||
*/
|
||||
refund(): void {
|
||||
this._lastRequestAt = this._prevRequestAt
|
||||
if (this._consumedMinuteWindow === this._minuteWindowStart) {
|
||||
this._minuteCount = Math.max(0, this._minuteCount - 1)
|
||||
}
|
||||
if (this._consumedDayKey === this._dayKey) {
|
||||
this._dayCount = Math.max(0, this._dayCount - 1)
|
||||
}
|
||||
this._consumedMinuteWindow = -1
|
||||
this._consumedDayKey = ''
|
||||
}
|
||||
|
||||
/** 최소 간격 제한을 푼다 (분당·일일 상한은 그대로). */
|
||||
relaxInterval(now: number, minIntervalMs: number): void {
|
||||
this._lastRequestAt = Math.min(this._lastRequestAt, now - minIntervalMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 요청 시각을 직접 맞춘다.
|
||||
*
|
||||
* 진행 플래그 누수 감시(watchdog)의 경과 시간을 재현하는 진단·테스트 경로 전용이다.
|
||||
*/
|
||||
overrideLastRequestAt(at: number): void {
|
||||
this._lastRequestAt = at
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패를 기록한다. 임계에 도달하면 쿨다운을 시작하고 true 를 돌려준다.
|
||||
*/
|
||||
noteFailure(now: number): boolean {
|
||||
this._consecutiveFailures += 1
|
||||
if (this._consecutiveFailures < this._options.failureCooldownThreshold) return false
|
||||
this._cooldownUntil = now + this._options.failureCooldownMs
|
||||
this._consecutiveFailures = 0
|
||||
return true
|
||||
}
|
||||
|
||||
/** 성공하면 실패 누적과 쿨다운을 지운다. */
|
||||
noteSuccess(): void {
|
||||
this._consecutiveFailures = 0
|
||||
this._cooldownUntil = 0
|
||||
}
|
||||
|
||||
isCoolingDown(now: number): boolean {
|
||||
return now < this._cooldownUntil
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createTestDb } from '../../helpers/createTestDb'
|
||||
import { personalPhrases, phraseEdges, typingSamples } from '../../../src/main/db/schema'
|
||||
|
||||
const holder = vi.hoisted(() => ({
|
||||
db: null as unknown,
|
||||
config: new Map<string, unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }) } }))
|
||||
vi.mock('uiohook-napi', () => ({ uIOhook: { on: vi.fn(), removeListener: vi.fn() } }))
|
||||
vi.mock('../../../src/main/db', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../src/main/db')>()),
|
||||
getDatabase: () => holder.db
|
||||
}))
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: (key: string) => holder.config.get(key),
|
||||
configSet: (key: string, value: unknown) => holder.config.set(key, value)
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/global-input-hook', () => ({ acquireGlobalInputHook: () => () => undefined }))
|
||||
vi.mock('../../../src/main/services/KeyBindingService', () => ({ uiohookCodeToVk: () => null }))
|
||||
vi.mock('../../../src/main/services/UiaContextService', () => ({
|
||||
getUiaContextService: () => ({ isAvailable: () => false, lastReason: 'test', lastSuccessAt: 0 })
|
||||
}))
|
||||
vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null }))
|
||||
|
||||
import {
|
||||
getInputTelemetryService,
|
||||
resetInputTelemetryServiceForTests
|
||||
} from '../../../src/main/services/InputTelemetryService'
|
||||
import {
|
||||
getPersonalGraphService,
|
||||
resetPersonalGraphServiceForTests
|
||||
} from '../../../src/main/services/PersonalGraphService'
|
||||
|
||||
let testDb: ReturnType<typeof createTestDb>
|
||||
|
||||
interface TelemetryInternal {
|
||||
_foreground: { appName: string | null; windowTitle: string | null; hwnd: number | null }
|
||||
_applyTypedDelta(snapshot: { windowTitle: string | null; controlName?: string; controlType?: string; text: string }): void
|
||||
}
|
||||
|
||||
function snapshot(text: string) {
|
||||
return { windowTitle: '채널', controlName: '메시지', controlType: 'Edit', text }
|
||||
}
|
||||
|
||||
function typedSamples(): string[] {
|
||||
return testDb.db
|
||||
.select()
|
||||
.from(typingSamples)
|
||||
.all()
|
||||
.filter((row) => row.source === 'typed')
|
||||
.map((row) => row.text)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testDb = createTestDb()
|
||||
holder.db = testDb.db
|
||||
holder.config.clear()
|
||||
holder.config.set('inputLearnTypedText', true)
|
||||
holder.config.set('inputExcludedApps', [])
|
||||
resetInputTelemetryServiceForTests()
|
||||
resetPersonalGraphServiceForTests()
|
||||
const telemetry = getInputTelemetryService() as unknown as TelemetryInternal
|
||||
telemetry._foreground.appName = 'Slack.exe'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
testDb.close()
|
||||
})
|
||||
|
||||
describe('버그 3: 프로그램이 삽입한 텍스트를 타이핑으로 다시 학습하지 않는다', () => {
|
||||
const ACCEPTED = '다음 회의는 내일 오후 세 시에 하겠습니다'
|
||||
|
||||
it('예고된 제안 수락 삽입은 diff 에서 typed 로 학습하지 않는다', () => {
|
||||
const telemetry = getInputTelemetryService()
|
||||
const internal = telemetry as unknown as TelemetryInternal
|
||||
internal._applyTypedDelta(snapshot('안녕하세요 여러분 '))
|
||||
|
||||
telemetry.expectProgrammaticInsert(ACCEPTED)
|
||||
internal._applyTypedDelta(snapshot(`안녕하세요 여러분 ${ACCEPTED}`))
|
||||
|
||||
expect(typedSamples()).toEqual([])
|
||||
})
|
||||
|
||||
it('삽입 앞뒤로 직접 친 부분만 학습한다', () => {
|
||||
const telemetry = getInputTelemetryService()
|
||||
const internal = telemetry as unknown as TelemetryInternal
|
||||
internal._applyTypedDelta(snapshot('시작 '))
|
||||
|
||||
telemetry.expectProgrammaticInsert(ACCEPTED)
|
||||
internal._applyTypedDelta(snapshot(`시작 ${ACCEPTED} 확인 부탁드립니다`))
|
||||
|
||||
expect(typedSamples()).toEqual(['확인 부탁드립니다'])
|
||||
})
|
||||
|
||||
it('예고는 한 번만 소비된다 — 같은 문장을 나중에 직접 치면 학습한다', () => {
|
||||
const telemetry = getInputTelemetryService()
|
||||
const internal = telemetry as unknown as TelemetryInternal
|
||||
internal._applyTypedDelta(snapshot(''))
|
||||
internal._applyTypedDelta(snapshot('첫 줄 '))
|
||||
telemetry.expectProgrammaticInsert(ACCEPTED)
|
||||
internal._applyTypedDelta(snapshot(`첫 줄 ${ACCEPTED}`))
|
||||
internal._applyTypedDelta(snapshot(`첫 줄 ${ACCEPTED}\n${ACCEPTED}`))
|
||||
|
||||
expect(typedSamples()).toContain(ACCEPTED)
|
||||
})
|
||||
|
||||
it('삽입이 실패해 예고를 취소하면 이후 직접 친 같은 글은 학습한다', () => {
|
||||
const telemetry = getInputTelemetryService()
|
||||
const internal = telemetry as unknown as TelemetryInternal
|
||||
internal._applyTypedDelta(snapshot('앞 '))
|
||||
const cancel = telemetry.expectProgrammaticInsert(ACCEPTED)
|
||||
cancel()
|
||||
internal._applyTypedDelta(snapshot(`앞 ${ACCEPTED}`))
|
||||
|
||||
expect(typedSamples()).toEqual([ACCEPTED])
|
||||
})
|
||||
|
||||
it('키보드 방식으로 치는 도중 잡힌 앞부분도 학습하지 않고 나머지를 기다린다', () => {
|
||||
const telemetry = getInputTelemetryService()
|
||||
const internal = telemetry as unknown as TelemetryInternal
|
||||
internal._applyTypedDelta(snapshot(''))
|
||||
internal._applyTypedDelta(snapshot('x '))
|
||||
telemetry.expectProgrammaticInsert(ACCEPTED)
|
||||
internal._applyTypedDelta(snapshot(`x ${ACCEPTED.slice(0, 8)}`))
|
||||
internal._applyTypedDelta(snapshot(`x ${ACCEPTED}`))
|
||||
|
||||
expect(typedSamples()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('버그 9: 문구 삭제는 그 문장의 그래프 엣지도 지운다', () => {
|
||||
const ANCHOR = '오늘 회의 결과를 공유드립니다'
|
||||
const PRIVATE = '계좌 비밀번호는 따로 보내드리겠습니다'
|
||||
|
||||
it('삭제한 문장이 follows 이웃·통계·조회에 남지 않는다', () => {
|
||||
const graph = getPersonalGraphService()
|
||||
graph.indexText(`${ANCHOR}. ${PRIVATE}.`, { source: 'typed', appName: 'Slack.exe' })
|
||||
expect(graph.retrieveContext('오늘 회의 결과를').related).toContain(PRIVATE)
|
||||
|
||||
const row = testDb.db
|
||||
.select()
|
||||
.from(personalPhrases)
|
||||
.where(eq(personalPhrases.phrase, PRIVATE))
|
||||
.get()
|
||||
expect(row).toBeDefined()
|
||||
|
||||
expect(getInputTelemetryService().deletePhrase(row?.id ?? '')).toBe(true)
|
||||
|
||||
const edges = testDb.db.select().from(phraseEdges).all()
|
||||
expect(edges.filter((edge) => edge.fromPhrase === PRIVATE || edge.toPhrase === PRIVATE)).toEqual([])
|
||||
expect(graph.retrieveContext('오늘 회의 결과를').related).not.toContain(PRIVATE)
|
||||
expect(graph.getStats().topEdges.some((edge) => edge.to === PRIVATE || edge.from === PRIVATE)).toBe(false)
|
||||
expect(graph.query('오늘 회의').neighbors.some((neighbor) => neighbor.text === PRIVATE)).toBe(false)
|
||||
})
|
||||
|
||||
it('없는 id 삭제는 false 이고 다른 엣지를 건드리지 않는다', () => {
|
||||
const graph = getPersonalGraphService()
|
||||
graph.indexText(`${ANCHOR}. ${PRIVATE}.`, { source: 'typed', appName: 'Slack.exe' })
|
||||
const before = testDb.db.select().from(phraseEdges).all().length
|
||||
|
||||
expect(getInputTelemetryService().deletePhrase('missing')).toBe(false)
|
||||
expect(testDb.db.select().from(phraseEdges).all()).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('removePhrase 는 양방향 엣지를 모두 지우고 지운 수를 돌려준다', () => {
|
||||
const graph = getPersonalGraphService()
|
||||
graph.indexText(`${ANCHOR}. ${PRIVATE}. 확인 후 다시 연락드리겠습니다.`, { source: 'typed' })
|
||||
|
||||
const removed = graph.removePhrase(PRIVATE)
|
||||
|
||||
expect(removed).toBeGreaterThanOrEqual(2)
|
||||
const remaining = testDb.db.select().from(phraseEdges).all()
|
||||
expect(remaining.every((edge) => edge.fromPhrase !== PRIVATE && edge.toPhrase !== PRIVATE)).toBe(true)
|
||||
})
|
||||
})
|
||||
415
apps/desktop/tests/main/services/suggestion-redteam-r1-1.test.ts
Normal file
415
apps/desktop/tests/main/services/suggestion-redteam-r1-1.test.ts
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const config = vi.hoisted(() => ({
|
||||
suggestionEnabled: true as boolean,
|
||||
suggestionModelId: 'model-a' as string | null,
|
||||
llmModelId: 'model-a' as string | null,
|
||||
inputExcludedApps: [] as string[],
|
||||
suggestionTriggerDelayMs: 600,
|
||||
suggestionMinPrefixChars: 8,
|
||||
suggestionMaxRequestsPerMinute: 12,
|
||||
suggestionDailyBudget: 500,
|
||||
suggestionRequestTimeoutMs: 8000,
|
||||
inputLearnTypedText: false,
|
||||
inputTelemetryEnabled: false,
|
||||
suggestionOverlayInteractive: true,
|
||||
insertMethod: 'clipboard'
|
||||
}))
|
||||
const harness = vi.hoisted(() => ({
|
||||
isAvailable: vi.fn(() => true),
|
||||
streamGenerate: vi.fn(),
|
||||
insertText: vi.fn(async (text: string) => ({ success: true, method: 'clipboard', textLength: text.length })),
|
||||
continuations: [] as string[]
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: vi.fn((key: keyof typeof config) => config[key]),
|
||||
configSet: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||
getLocalLLMService: () => ({ isAvailable: harness.isAvailable, streamGenerate: harness.streamGenerate })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/InputTelemetryService', () => ({
|
||||
getInputTelemetryService: () => ({ listPhrases: () => [] })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/PersonalGraphService', () => ({
|
||||
getPersonalGraphService: () => ({
|
||||
retrieveContext: () => ({ continuations: harness.continuations, related: [] })
|
||||
})
|
||||
}))
|
||||
vi.mock('../../../src/main/services/TextInsertService', () => ({
|
||||
getTextInsertService: () => ({ insertText: harness.insertText })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/modifier-state', () => ({ waitForModifiersReleased: async () => true }))
|
||||
vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null }))
|
||||
vi.mock('../../../src/main/db', () => ({ getDatabase: vi.fn() }))
|
||||
vi.mock('../../../src/main/db/schema', () => ({ suggestions: {} }))
|
||||
|
||||
import { SuggestionService, type SuggestionServiceDeps } from '../../../src/main/services/SuggestionService'
|
||||
import { SuggestionBudget } from '../../../src/main/services/suggestion/SuggestionBudget'
|
||||
import { ModelWarmTracker } from '../../../src/main/services/suggestion/ModelWarmTracker'
|
||||
import type { TypingContext } from '../../../src/main/services/InputTelemetryService'
|
||||
|
||||
const SHORT_PREFIX = '오늘 회의에서 논의한 내용을 정리해서'
|
||||
const LONG_PREFIX = `${'가나다라 '.repeat(100)}${SHORT_PREFIX}`
|
||||
|
||||
function typingContext(overrides: Partial<TypingContext> = {}): TypingContext {
|
||||
const prefix = overrides.prefix ?? SHORT_PREFIX
|
||||
return {
|
||||
prefix,
|
||||
fullText: prefix,
|
||||
caretOffset: prefix.length,
|
||||
anchor: null,
|
||||
anchorKind: null,
|
||||
isPassword: false,
|
||||
isEditable: true,
|
||||
isComposing: false,
|
||||
hasSelection: false,
|
||||
available: true,
|
||||
appName: 'chrome.exe',
|
||||
windowTitle: 'Gmail',
|
||||
windowHandle: 101,
|
||||
idleMs: 2000,
|
||||
capturedAt: Date.now(),
|
||||
editedSinceFocus: true,
|
||||
typedRecently: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
interface Fakes {
|
||||
deps: SuggestionServiceDeps
|
||||
record: ReturnType<typeof vi.fn>
|
||||
foregroundHandle: { value: number | null }
|
||||
expectInsert: ReturnType<typeof vi.fn>
|
||||
cancelInsert: ReturnType<typeof vi.fn>
|
||||
recordAccepted: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function fakes(): Fakes {
|
||||
const record = vi.fn()
|
||||
const foregroundHandle = { value: 101 as number | null }
|
||||
const cancelInsert = vi.fn()
|
||||
const expectInsert = vi.fn(() => cancelInsert)
|
||||
const recordAccepted = vi.fn()
|
||||
return {
|
||||
record,
|
||||
foregroundHandle,
|
||||
expectInsert,
|
||||
cancelInsert,
|
||||
recordAccepted,
|
||||
deps: {
|
||||
repository: { record, markAccepted: vi.fn(), list: vi.fn(() => []) },
|
||||
budget: new SuggestionBudget(),
|
||||
warmth: new ModelWarmTracker(),
|
||||
foreground: { currentWindowHandle: () => foregroundHandle.value },
|
||||
learning: { expectProgrammaticInsert: expectInsert, recordAccepted }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function streamOf(text: string) {
|
||||
return (async function* () {
|
||||
yield text
|
||||
})()
|
||||
}
|
||||
|
||||
let services: SuggestionService[] = []
|
||||
function create(deps: SuggestionServiceDeps): SuggestionService {
|
||||
const service = new SuggestionService(deps)
|
||||
services.push(service)
|
||||
return service
|
||||
}
|
||||
|
||||
interface Internal {
|
||||
_generate(prefix: string, context: TypingContext, maxCandidates: number, maxChars: number): Promise<void>
|
||||
_warmUpPromise: Promise<void> | null
|
||||
_lastSkipReason: string | null
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
config.suggestionEnabled = true
|
||||
config.suggestionModelId = 'model-a'
|
||||
config.llmModelId = 'model-a'
|
||||
harness.isAvailable.mockReset()
|
||||
harness.isAvailable.mockReturnValue(true)
|
||||
harness.streamGenerate.mockReset()
|
||||
harness.insertText.mockClear()
|
||||
harness.continuations = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const service of services) service.dispose()
|
||||
services = []
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('버그 1: 400자 넘는 접두에서 세션이 stale 로 닫히지 않는다', () => {
|
||||
it('같은 긴 접두의 다음 스냅샷에서도 세션이 유지되고 재생성하지 않는다', async () => {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf('다음 단계도 확인하겠습니다.'))
|
||||
const { deps } = fakes()
|
||||
deps.warmth.noteWarm('model-a', Date.now() + 600_000)
|
||||
const service = create(deps)
|
||||
const internal = service as unknown as Internal
|
||||
const context = typingContext({ prefix: LONG_PREFIX })
|
||||
expect(LONG_PREFIX.length).toBeGreaterThan(400)
|
||||
|
||||
await internal._generate(LONG_PREFIX, context, 3, 160)
|
||||
expect(service.isVisible).toBe(true)
|
||||
|
||||
service.handleTypingContext(context)
|
||||
|
||||
expect(service.isVisible).toBe(true)
|
||||
expect(service.getState().lastSkipReason).not.toBe('stale')
|
||||
// 세션이 유지되므로 새 첫 요청(예산 소비)이 일어나지 않는다.
|
||||
expect(deps.budget.minuteCount).toBe(1)
|
||||
})
|
||||
|
||||
it('긴 접두에서도 이어 치면 세션을 끝낸다', async () => {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf('다음 단계도 확인하겠습니다.'))
|
||||
const { deps } = fakes()
|
||||
deps.warmth.noteWarm('model-a', Date.now() + 600_000)
|
||||
const service = create(deps)
|
||||
await (service as unknown as Internal)._generate(LONG_PREFIX, typingContext({ prefix: LONG_PREFIX }), 3, 160)
|
||||
|
||||
service.handleTypingContext(typingContext({ prefix: `${LONG_PREFIX} 그리고`, idleMs: 100 }))
|
||||
|
||||
expect(service.isVisible).toBe(false)
|
||||
expect(service.getState().lastSkipReason).toBe('stale')
|
||||
})
|
||||
})
|
||||
|
||||
describe('버그 2: 쿨다운 중 표시된 로컬 기억 세션', () => {
|
||||
function coolingService(): { service: SuggestionService; fake: Fakes } {
|
||||
const fake = fakes()
|
||||
fake.deps.budget.noteFailure(Date.now())
|
||||
fake.deps.budget.noteFailure(Date.now())
|
||||
expect(fake.deps.budget.isCoolingDown(Date.now())).toBe(true)
|
||||
harness.continuations = ['첫 번째 이어쓰기 문장입니다', '두 번째 이어쓰기 문장입니다']
|
||||
const service = create(fake.deps)
|
||||
service.handleTypingContext(typingContext())
|
||||
expect(service.getState()).toMatchObject({ visible: true, provenance: { mode: 'local-memory' } })
|
||||
expect(fake.record).toHaveBeenCalledTimes(1)
|
||||
return { service, fake }
|
||||
}
|
||||
|
||||
it('다음 후보 단축키(키 입력 직후 스냅샷)로 세션이 닫히지 않는다', () => {
|
||||
const { service } = coolingService()
|
||||
service.next()
|
||||
service.handleTypingContext(typingContext({ idleMs: 100 }))
|
||||
expect(service.getState()).toMatchObject({ visible: true, activeIndex: 1 })
|
||||
})
|
||||
|
||||
it('멈춘 상태의 주기 스냅샷이 선택을 초기화하거나 이력을 중복 기록하지 않는다', () => {
|
||||
const { service, fake } = coolingService()
|
||||
service.next()
|
||||
service.handleTypingContext(typingContext({ idleMs: 2000 }))
|
||||
service.handleTypingContext(typingContext({ idleMs: 2800 }))
|
||||
expect(service.getState().activeIndex).toBe(1)
|
||||
expect(fake.record).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('타이핑이 끊긴 뒤(not-typing)에도 읽는 중인 세션을 닫지 않는다', () => {
|
||||
const { service } = coolingService()
|
||||
service.handleTypingContext(typingContext({ typedRecently: false, idleMs: 9000 }))
|
||||
expect(service.isVisible).toBe(true)
|
||||
})
|
||||
|
||||
it('비밀번호 칸으로 옮기면 쿨다운 중에도 닫는다', () => {
|
||||
const { service } = coolingService()
|
||||
service.handleTypingContext(typingContext({ isPassword: true }))
|
||||
expect(service.isVisible).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('버그 4: 모델 변경 뒤 콜드 모델로 바로 생성하지 않는다', () => {
|
||||
it('이전 모델이 warm 이어도 새 모델은 워밍업부터 한다', async () => {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf('ok'))
|
||||
const { deps } = fakes()
|
||||
deps.warmth.noteWarm('model-a', Date.now() + 600_000)
|
||||
const service = create(deps)
|
||||
const internal = service as unknown as Internal
|
||||
const generateSpy = vi.spyOn(internal, '_generate')
|
||||
|
||||
config.suggestionModelId = 'model-b'
|
||||
service.handleTypingContext(typingContext())
|
||||
|
||||
expect(generateSpy).not.toHaveBeenCalled()
|
||||
expect(harness.streamGenerate).toHaveBeenCalledWith('hi', expect.objectContaining({ model: 'model-b', maxTokens: 1 }))
|
||||
expect(service.getState().lastSkipReason).toBe('model-unavailable')
|
||||
await internal._warmUpPromise
|
||||
expect(deps.warmth.isWarm('model-b', Date.now())).toBe(true)
|
||||
})
|
||||
|
||||
it('handleModelChanged 는 새 콜드 모델을 미리 워밍업한다', async () => {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf('ok'))
|
||||
const { deps } = fakes()
|
||||
deps.warmth.noteWarm('model-a', Date.now() + 600_000)
|
||||
const service = create(deps)
|
||||
|
||||
service.handleModelChanged()
|
||||
expect(harness.streamGenerate).not.toHaveBeenCalled()
|
||||
|
||||
config.suggestionModelId = 'model-b'
|
||||
service.handleModelChanged()
|
||||
await (service as unknown as Internal)._warmUpPromise
|
||||
expect(harness.streamGenerate).toHaveBeenCalledTimes(1)
|
||||
expect(harness.streamGenerate).toHaveBeenCalledWith('hi', expect.objectContaining({ model: 'model-b' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('버그 5: 실패한 워밍업은 실패로 센다', () => {
|
||||
it('워밍업 실패 뒤 다음 멈춤에서 워밍업을 바로 다시 쏘지 않고, 두 번째 실패로 쿨다운에 들어간다', async () => {
|
||||
vi.useFakeTimers()
|
||||
harness.streamGenerate.mockImplementation(() =>
|
||||
(async function* () {
|
||||
throw new Error('Ollama responded with 404')
|
||||
})()
|
||||
)
|
||||
const { deps } = fakes()
|
||||
const service = create(deps)
|
||||
const internal = service as unknown as Internal
|
||||
|
||||
service.handleTypingContext(typingContext())
|
||||
await internal._warmUpPromise
|
||||
expect(harness.streamGenerate).toHaveBeenCalledTimes(1)
|
||||
expect(deps.budget.consecutiveFailures).toBe(1)
|
||||
expect(service.getState().lastSkipReason).toBe('generation-failed')
|
||||
|
||||
// 800ms 뒤 샘플 — 실패 직후라 워밍업을 다시 쏘지 않는다.
|
||||
vi.advanceTimersByTime(800)
|
||||
service.handleTypingContext(typingContext())
|
||||
expect(harness.streamGenerate).toHaveBeenCalledTimes(1)
|
||||
expect(service.getState()).toMatchObject({ warmingUp: false, lastSkipReason: 'generation-failed' })
|
||||
|
||||
// 물러서기 시간이 지나면 한 번 더 시도하고, 그 실패로 쿨다운에 들어간다.
|
||||
vi.advanceTimersByTime(5000)
|
||||
service.handleTypingContext(typingContext())
|
||||
await internal._warmUpPromise
|
||||
expect(harness.streamGenerate).toHaveBeenCalledTimes(2)
|
||||
expect(deps.budget.isCoolingDown(Date.now())).toBe(true)
|
||||
expect(service.getState().coolingDown).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('버그 6: 수락 직전 대상 창 확인', () => {
|
||||
async function visibleService(fake: Fakes): Promise<SuggestionService> {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf('다음 단계도 확인하겠습니다.'))
|
||||
fake.deps.warmth.noteWarm('model-a', Date.now() + 600_000)
|
||||
const service = create(fake.deps)
|
||||
await (service as unknown as Internal)._generate(SHORT_PREFIX, typingContext({ windowHandle: 101 }), 3, 160)
|
||||
expect(service.isVisible).toBe(true)
|
||||
return service
|
||||
}
|
||||
|
||||
it('포커스가 다른 창으로 옮겨 갔으면 붙여넣지 않는다', async () => {
|
||||
const fake = fakes()
|
||||
const service = await visibleService(fake)
|
||||
fake.foregroundHandle.value = 202
|
||||
|
||||
const result = await service.accept()
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'stale' })
|
||||
expect(harness.insertText).not.toHaveBeenCalled()
|
||||
expect(fake.expectInsert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('같은 창이면 삽입하고, 삽입 예고와 수락 학습을 한 번씩 알린다', async () => {
|
||||
const fake = fakes()
|
||||
const service = await visibleService(fake)
|
||||
|
||||
const result = await service.accept()
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(harness.insertText).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.', 'clipboard')
|
||||
expect(fake.expectInsert).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.')
|
||||
expect(fake.recordAccepted).toHaveBeenCalledTimes(1)
|
||||
expect(fake.cancelInsert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('삽입이 실패하면 삽입 예고를 취소한다', async () => {
|
||||
const fake = fakes()
|
||||
const service = await visibleService(fake)
|
||||
harness.insertText.mockResolvedValueOnce({ success: false, method: 'clipboard', textLength: 0 })
|
||||
|
||||
const result = await service.accept()
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(fake.cancelInsert).toHaveBeenCalledTimes(1)
|
||||
expect(fake.recordAccepted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('창을 알 수 없으면(포그라운드 조회 실패) 기존처럼 삽입한다', async () => {
|
||||
const fake = fakes()
|
||||
const service = await visibleService(fake)
|
||||
fake.foregroundHandle.value = null
|
||||
|
||||
expect(await service.accept()).toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('SuggestionBudget', () => {
|
||||
it('consume 은 창을 먼저 굴린다 — 오래 쉰 뒤의 요청도 예산에 잡힌다', () => {
|
||||
const budget = new SuggestionBudget()
|
||||
const t0 = new Date(2026, 8, 28, 10, 0, 0).getTime()
|
||||
budget.consume(t0)
|
||||
const later = t0 + 120_000
|
||||
budget.consume(later)
|
||||
// 직후의 상태 조회(창 굴리기)가 방금 소비를 지우면 안 된다.
|
||||
expect(budget.snapshot(later + 10)).toMatchObject({ requestsThisMinute: 1, requestsToday: 2 })
|
||||
})
|
||||
|
||||
it('창이 바뀐 뒤의 환급은 새 창의 카운트를 깎지 않는다', () => {
|
||||
const budget = new SuggestionBudget()
|
||||
const t0 = new Date(2026, 8, 28, 10, 0, 0).getTime()
|
||||
budget.consume(t0)
|
||||
budget.consume(t0 + 61_000)
|
||||
budget.snapshot(t0 + 61_000)
|
||||
budget.consume(t0 + 62_000)
|
||||
budget.refund()
|
||||
expect(budget.snapshot(t0 + 62_000)).toMatchObject({ requestsThisMinute: 1 })
|
||||
expect(budget.lastRequestAt).toBe(t0 + 61_000)
|
||||
|
||||
const other = new SuggestionBudget()
|
||||
other.consume(t0)
|
||||
other.snapshot(t0 + 61_000)
|
||||
other.refund()
|
||||
expect(other.minuteCount).toBe(0)
|
||||
expect(other.dayCount).toBe(0)
|
||||
})
|
||||
|
||||
it('연속 실패 임계에서 쿨다운, 성공이면 초기화, relaxInterval 은 최소 간격만 푼다', () => {
|
||||
const budget = new SuggestionBudget({ failureCooldownThreshold: 2, failureCooldownMs: 60_000 })
|
||||
expect(budget.noteFailure(1000)).toBe(false)
|
||||
expect(budget.noteFailure(2000)).toBe(true)
|
||||
expect(budget.isCoolingDown(61_999)).toBe(true)
|
||||
expect(budget.isCoolingDown(62_000)).toBe(false)
|
||||
budget.noteSuccess()
|
||||
expect(budget.isCoolingDown(3000)).toBe(false)
|
||||
|
||||
budget.consume(100_000)
|
||||
budget.relaxInterval(101_000, 5000)
|
||||
expect(budget.snapshot(101_000)).toMatchObject({ sinceLastRequestMs: 5000, requestsThisMinute: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ModelWarmTracker', () => {
|
||||
it('온기와 워밍업 실패를 모델별로 기억한다', () => {
|
||||
const tracker = new ModelWarmTracker()
|
||||
tracker.noteWarm('a', 10_000)
|
||||
expect(tracker.isWarm('a', 9_999)).toBe(true)
|
||||
expect(tracker.isWarm('a', 10_000)).toBe(false)
|
||||
expect(tracker.isWarm('b', 5_000)).toBe(false)
|
||||
expect(tracker.isWarm(null, 5_000)).toBe(false)
|
||||
|
||||
tracker.noteWarmUpFailure('b', 1_000)
|
||||
expect(tracker.isBackingOff('b', 5_999, 5_000)).toBe(true)
|
||||
expect(tracker.isBackingOff('b', 6_000, 5_000)).toBe(false)
|
||||
expect(tracker.isBackingOff('a', 2_000, 5_000)).toBe(false)
|
||||
tracker.noteWarm('b', 20_000)
|
||||
expect(tracker.isBackingOff('b', 2_000, 5_000)).toBe(false)
|
||||
})
|
||||
})
|
||||
219
packages/core/__tests__/suggestion-redteam-r1-1.test.ts
Normal file
219
packages/core/__tests__/suggestion-redteam-r1-1.test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SUGGESTION_CONTEXT_MAX_CHARS,
|
||||
buildLocalSuggestionCandidates,
|
||||
decideSuggestionStep,
|
||||
matchesSessionPrefix,
|
||||
normalizeSessionPrefix,
|
||||
normalizeSuggestionText,
|
||||
parseSuggestionCandidates,
|
||||
sanitizeSuggestionLine,
|
||||
stripModelArtifacts,
|
||||
type SuggestionPolicyInput,
|
||||
type SuggestionStepInput
|
||||
} from '../src/input-intelligence'
|
||||
import * as suggestionText from '../src/suggestion-text'
|
||||
import * as terminalPrompt from '../src/terminal-prompt'
|
||||
import * as inputIntelligence from '../src/input-intelligence'
|
||||
|
||||
const LONG_PREFIX = `${'가'.repeat(450)} 회의 결과를 정리해서`
|
||||
|
||||
function policy(overrides: Partial<SuggestionPolicyInput> = {}): SuggestionPolicyInput {
|
||||
return {
|
||||
enabled: true,
|
||||
modelAvailable: true,
|
||||
overlayVisible: false,
|
||||
composing: false,
|
||||
hasSelection: false,
|
||||
isPassword: false,
|
||||
isEditable: true,
|
||||
appName: 'Slack.exe',
|
||||
excludedApps: [],
|
||||
editedSinceFocus: true,
|
||||
typedRecently: true,
|
||||
prefix: '오늘 회의 결과를 정리해서',
|
||||
idleMs: 2000,
|
||||
triggerDelayMs: 600,
|
||||
minPrefixChars: 8,
|
||||
sinceLastRequestMs: 60_000,
|
||||
minIntervalMs: 5000,
|
||||
requestsThisMinute: 0,
|
||||
maxRequestsPerMinute: 6,
|
||||
requestsToday: 0,
|
||||
dailyBudget: 500,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function step(overrides: Partial<SuggestionStepInput> = {}, policyOverrides: Partial<SuggestionPolicyInput> = {}) {
|
||||
return decideSuggestionStep({
|
||||
policy: policy(policyOverrides),
|
||||
sessionVisible: false,
|
||||
sessionPrefix: '',
|
||||
coolingDown: false,
|
||||
userDismissedQuiet: false,
|
||||
lastRequestedPrefix: '',
|
||||
modelWarm: true,
|
||||
warmUpBackoff: false,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('세션 접두 정규화 (400자 초과 입력창)', () => {
|
||||
it('세션이 저장한 400자 창과 잘리지 않은 현재 접두를 같은 세션으로 본다', () => {
|
||||
const stored = normalizeSessionPrefix(LONG_PREFIX)
|
||||
expect(stored).toHaveLength(SUGGESTION_CONTEXT_MAX_CHARS)
|
||||
expect(matchesSessionPrefix(stored, LONG_PREFIX)).toBe(true)
|
||||
expect(matchesSessionPrefix(stored, `${LONG_PREFIX} `)).toBe(true)
|
||||
})
|
||||
|
||||
it('긴 입력에서도 이어 치기는 세션 종료, 마지막 글자 IME 조합은 유지', () => {
|
||||
const stored = normalizeSessionPrefix(LONG_PREFIX)
|
||||
expect(matchesSessionPrefix(stored, `${LONG_PREFIX} 추가`)).toBe(false)
|
||||
expect(matchesSessionPrefix(stored, `${LONG_PREFIX.slice(0, -1)}사`)).toBe(true)
|
||||
})
|
||||
|
||||
it('decideSuggestionStep 은 400자 넘는 같은 접두로 표시 중 세션을 끝내지 않는다', () => {
|
||||
const result = step(
|
||||
{ sessionVisible: true, sessionPrefix: normalizeSessionPrefix(LONG_PREFIX), lastRequestedPrefix: LONG_PREFIX },
|
||||
{ prefix: LONG_PREFIX, overlayVisible: true }
|
||||
)
|
||||
expect(result).not.toMatchObject({ action: 'end-session' })
|
||||
expect(result).toMatchObject({ action: 'settle', then: 'record' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('decideSuggestionStep — 쿨다운 중 표시된 세션', () => {
|
||||
const visible = { sessionVisible: true, sessionPrefix: '오늘 회의 결과를 정리해서', coolingDown: true }
|
||||
|
||||
it('다음 후보 단축키(디바운스 구간)에서도 세션을 닫거나 다시 만들지 않는다', () => {
|
||||
expect(step(visible, { overlayVisible: true, idleMs: 100 })).toEqual({
|
||||
action: 'settle',
|
||||
reason: 'cooldown',
|
||||
tryLocalMemory: false,
|
||||
then: 'record'
|
||||
})
|
||||
})
|
||||
|
||||
it('멈춘 상태에서도 로컬 기억 세션을 다시 게시하지 않는다 (선택 초기화 · 이력 중복 방지)', () => {
|
||||
expect(step(visible, { overlayVisible: true, idleMs: 5000 })).toMatchObject({ tryLocalMemory: false, then: 'record' })
|
||||
})
|
||||
|
||||
it('타이핑이 끊겨도(not-typing) 읽는 중인 세션을 닫지 않는다', () => {
|
||||
expect(step(visible, { overlayVisible: true, typedRecently: false })).toMatchObject({ then: 'record' })
|
||||
})
|
||||
|
||||
it('빠른 실패 직후(최소 간격 안)에도 방금 게시한 폴백을 닫지 않는다', () => {
|
||||
expect(step(visible, { overlayVisible: true, sinceLastRequestMs: 100 })).toMatchObject({ then: 'record' })
|
||||
})
|
||||
|
||||
it('비밀번호 칸·선택 같은 하드 차단은 쿨다운 중에도 세션을 닫는다', () => {
|
||||
expect(step(visible, { overlayVisible: true, isPassword: true })).toMatchObject({
|
||||
reason: 'cooldown',
|
||||
tryLocalMemory: false,
|
||||
then: 'dismiss'
|
||||
})
|
||||
})
|
||||
|
||||
it('표시 중인 것이 없으면 로컬 기억 게시를 시도한다', () => {
|
||||
expect(step({ coolingDown: true })).toEqual({
|
||||
action: 'settle',
|
||||
reason: 'cooldown',
|
||||
tryLocalMemory: true,
|
||||
then: 'dismiss'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('decideSuggestionStep — 순서와 모델 온기', () => {
|
||||
it('정상 경로는 생성한다', () => {
|
||||
expect(step()).toEqual({ action: 'generate', prefix: '오늘 회의 결과를 정리해서' })
|
||||
})
|
||||
|
||||
it('콜드 모델은 워밍업, 워밍업 실패 직후에는 워밍업을 다시 쏘지 않는다', () => {
|
||||
expect(step({ modelWarm: false })).toMatchObject({ reason: 'model-unavailable', then: 'warm-up' })
|
||||
expect(step({ modelWarm: false, warmUpBackoff: true })).toMatchObject({
|
||||
reason: 'generation-failed',
|
||||
tryLocalMemory: true,
|
||||
then: 'record'
|
||||
})
|
||||
})
|
||||
|
||||
it('사용자 닫기 직후는 조용하다', () => {
|
||||
expect(step({ userDismissedQuiet: true })).toMatchObject({ reason: 'cooldown', then: 'record' })
|
||||
})
|
||||
|
||||
it('같은 텍스트로 이미 요청했으면 unchanged', () => {
|
||||
expect(
|
||||
step({ lastRequestedPrefix: '오늘 회의 결과를 정리해서' }, { overlayVisible: true })
|
||||
).toMatchObject({ reason: 'unchanged', then: 'record' })
|
||||
})
|
||||
|
||||
it('모델 서버가 없으면 로컬 기억을 먼저 시도하고 준비 중을 보여준다', () => {
|
||||
expect(step({}, { modelAvailable: false })).toMatchObject({
|
||||
reason: 'model-unavailable',
|
||||
tryLocalMemory: true,
|
||||
then: 'show-warming'
|
||||
})
|
||||
})
|
||||
|
||||
it('표시 중 세션의 접두가 자라면 세션을 끝낸다', () => {
|
||||
expect(
|
||||
step({ sessionVisible: true, sessionPrefix: '오늘 회의 결과를' }, { prefix: '오늘 회의 결과를 정리해서' })
|
||||
).toEqual({ action: 'end-session', reason: 'stale' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('후보 정제 — 문장 앞 숫자 보존', () => {
|
||||
it.each([
|
||||
['3 pm in the lobby', '3 pm in the lobby'],
|
||||
['10 minutes later I will call', '10 minutes later I will call'],
|
||||
['2026 roadmap is ready', '2026 roadmap is ready'],
|
||||
['3 시에 로비에서 봬요', '3 시에 로비에서 봬요']
|
||||
])('숫자로 시작하는 문장 %s 을 지우지 않는다', (input, expected) => {
|
||||
expect(sanitizeSuggestionLine(input)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['1. 첫 항목입니다', '첫 항목입니다'],
|
||||
['10) 열 번째 항목', '열 번째 항목'],
|
||||
['2] 괄호 번호', '괄호 번호'],
|
||||
['- 불릿 항목', '불릿 항목'],
|
||||
['• 점 불릿', '점 불릿']
|
||||
])('목록 표지 %s 는 걷어낸다', (input, expected) => {
|
||||
expect(sanitizeSuggestionLine(input)).toBe(expected)
|
||||
})
|
||||
|
||||
it('모델 출력 파싱도 앞 숫자를 보존한다', () => {
|
||||
expect(parseSuggestionCandidates('10 minutes later I will call', 'I will be there in')).toEqual([
|
||||
'10 minutes later I will call'
|
||||
])
|
||||
})
|
||||
|
||||
it('로컬 기억 힌트(사용자 자신의 글)는 모델용 정제를 거치지 않는다', () => {
|
||||
expect(
|
||||
buildLocalSuggestionCandidates('Let us meet at', {
|
||||
continuationHints: ['3 pm in the lobby', '"quoted" plan: ready'],
|
||||
relatedHints: [],
|
||||
phraseHints: []
|
||||
})
|
||||
).toEqual(['3 pm in the lobby', '"quoted" plan: ready'])
|
||||
})
|
||||
|
||||
it('stripModelArtifacts 는 지시문 되풀이를 버리고, normalizeSuggestionText 는 내용을 바꾸지 않는다', () => {
|
||||
expect(stripModelArtifacts('Suggestion: 이어집니다')).toBeNull()
|
||||
expect(stripModelArtifacts('{{prefix}}')).toBeNull()
|
||||
expect(normalizeSuggestionText(' 1. 그대로 둔다 ')).toBe('1. 그대로 둔다')
|
||||
expect(normalizeSuggestionText('a')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('모듈 분리 — 기존 import 경로 유지', () => {
|
||||
it('input-intelligence 가 분리된 모듈의 같은 함수를 다시 export 한다', () => {
|
||||
expect(inputIntelligence.sanitizeSuggestionLine).toBe(suggestionText.sanitizeSuggestionLine)
|
||||
expect(inputIntelligence.buildLocalSuggestionCandidates).toBe(suggestionText.buildLocalSuggestionCandidates)
|
||||
expect(inputIntelligence.endsSentence).toBe(suggestionText.endsSentence)
|
||||
expect(inputIntelligence.extractTerminalPromptPrefix).toBe(terminalPrompt.extractTerminalPromptPrefix)
|
||||
expect(inputIntelligence.SUGGESTION_MAX_OUTPUT_CHARS).toBe(160)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
217
packages/core/src/suggestion-text.ts
Normal file
217
packages/core/src/suggestion-text.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
// packages/core — 제안 텍스트 정본 (후보 정제 · 접두 정규화).
|
||||
//
|
||||
// 두 종류의 입력을 분리해 다룬다 (ISP):
|
||||
// 1) 신뢰할 수 없는 모델 출력 — 번호/불릿/따옴표/지시문 되풀이를 걷어내야 한다
|
||||
// (`stripModelArtifacts` → `normalizeSuggestionText`).
|
||||
// 2) 사용자가 학습시킨 로컬 기억 문구 — 사용자 자신의 글이므로 내용을 건드리지 않고
|
||||
// 공백과 길이만 정규화한다 (`normalizeSuggestionText`).
|
||||
// 두 입력이 한 정제 함수를 공유하던 시절, 모델 대응 규칙이 사용자의 "3 pm …" 같은
|
||||
// 문장 앞 숫자를 지웠다 — 이 분리가 그 회귀를 구조적으로 막는다.
|
||||
//
|
||||
// 순수 함수만 둔다 — Electron/Node 의존 금지. input-intelligence.ts 를 import 하지 않는다
|
||||
// (input-intelligence 가 이 모듈을 다시 export 하므로 순환이 생긴다).
|
||||
|
||||
/**
|
||||
* 프롬프트에 넣을 컨텍스트 길이 상한.
|
||||
*
|
||||
* 길이가 곧 로컬 추론 지연이므로 짧게 유지한다.
|
||||
*/
|
||||
export const SUGGESTION_CONTEXT_MAX_CHARS = 400
|
||||
/** 후보 문자열 길이 상한. */
|
||||
export const SUGGESTION_MAX_OUTPUT_CHARS = 160
|
||||
|
||||
/** 문장 종결 부호. */
|
||||
export const SENTENCE_TERMINATORS = /[.!?。!?…]/
|
||||
|
||||
/** 마지막 문장 종결 여부 — 참이면 직전 문장이 끝난 것이므로 제안을 지운다. */
|
||||
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'
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 접두 정규화
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 요청 접두 정규화 — 뒤쪽 공백만 걷어낸다 (길이는 자르지 않는다).
|
||||
*
|
||||
* 진행 중 요청의 연속 확장 판정(decideSuggestionRefresh)은 전체 접두로 비교해야 한다 —
|
||||
* 400자 창으로 자르면 한 글자만 쳐도 창이 밀려 "연속 확장" 이 깨진다.
|
||||
*/
|
||||
export function normalizeRequestPrefix(prefix: string): string {
|
||||
return prefix.replace(/\s+$/u, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 접두 정규화 — 뒤쪽 공백을 걷어내고 마지막 SUGGESTION_CONTEXT_MAX_CHARS 자만 남긴다.
|
||||
*
|
||||
* 세션은 이 창으로 만든 후보이므로, 표시 중인 세션의 유효성 비교는 양쪽 모두 이 함수로
|
||||
* 맞춰야 한다. 한쪽만 자르면 400자를 넘는 입력창에서 텍스트가 그대로여도 매 스냅샷마다
|
||||
* 세션이 stale 로 닫힌다.
|
||||
*/
|
||||
export function normalizeSessionPrefix(prefix: string): string {
|
||||
return normalizeRequestPrefix(prefix).slice(-SUGGESTION_CONTEXT_MAX_CHARS)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 후보 정제
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 목록 표지: 불릿(-, *, •)이거나, 1~3자리 번호 뒤에 목록 구두점(. ) ])이 붙은 것.
|
||||
*
|
||||
* 숫자만으로는 목록 표지로 보지 않는다 — "3 pm in the lobby", "10 minutes later",
|
||||
* "2026 roadmap" 의 숫자는 문장 내용이다.
|
||||
*/
|
||||
const LIST_MARKER_PATTERN = /^(?:[-*•]+[.)\]]?|\d{1,3}[.)\]])\s+/u
|
||||
const WRAPPING_QUOTES_PATTERN = /^["'“”‘’`]+|["'“”‘’`]+$/gu
|
||||
/** 모델이 지시문을 되풀이한 경우 (llm-prompts 회귀와 같은 부류) */
|
||||
const INSTRUCTION_ECHO_PATTERN = /^(suggestion|completion|candidate|output|answer|다음 문장)\s*[::]/iu
|
||||
const TEMPLATE_ECHO_PATTERN = /^\{\{.*\}\}$/u
|
||||
|
||||
/**
|
||||
* 모델 출력 한 줄에서 모델 특유의 부산물을 걷어낸다.
|
||||
*
|
||||
* 번호/불릿/따옴표를 지우고, 지시문 되풀이·템플릿 누출은 버린다(null).
|
||||
* 길이 제한은 하지 않는다 — normalizeSuggestionText 가 맡는다.
|
||||
*/
|
||||
export function stripModelArtifacts(line: string): string | null {
|
||||
let text = line.trim()
|
||||
if (!text) return null
|
||||
text = text.replace(LIST_MARKER_PATTERN, '')
|
||||
text = text.replace(WRAPPING_QUOTES_PATTERN, '')
|
||||
text = text.replace(/\s+/gu, ' ').trim()
|
||||
if (INSTRUCTION_ECHO_PATTERN.test(text)) return null
|
||||
if (TEMPLATE_ECHO_PATTERN.test(text)) return null
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* 후보 텍스트 정규화 — 공백을 한 칸으로 모으고 길이를 제한한다.
|
||||
*
|
||||
* 내용은 바꾸지 않는다 (사용자 자신의 문구에 안전). 2자 미만이면 null.
|
||||
*/
|
||||
export function normalizeSuggestionText(
|
||||
text: string,
|
||||
maxChars = SUGGESTION_MAX_OUTPUT_CHARS
|
||||
): string | null {
|
||||
let normalized = text.replace(/\s+/gu, ' ').trim()
|
||||
if (normalized.length < 2) return null
|
||||
if (normalized.length > maxChars) {
|
||||
normalized = normalized.slice(0, maxChars)
|
||||
const lastSpace = normalized.lastIndexOf(' ')
|
||||
if (lastSpace > maxChars * 0.6) normalized = normalized.slice(0, lastSpace)
|
||||
normalized = normalized.trim()
|
||||
}
|
||||
return normalized.length >= 2 ? normalized : null
|
||||
}
|
||||
|
||||
/** 모델 출력 한 줄 정제: 번호/불릿/따옴표/지시문 제거 + 길이 제한. */
|
||||
export function sanitizeSuggestionLine(line: string, maxChars = SUGGESTION_MAX_OUTPUT_CHARS): string | null {
|
||||
const stripped = stripModelArtifacts(line)
|
||||
return stripped === null ? null : normalizeSuggestionText(stripped, maxChars)
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델 출력에서 후보 목록을 뽑아 정제한다.
|
||||
*
|
||||
* gemma 계열 소형 모델은 번호/따옴표/머리말을 붙이기 쉬우므로 여기서 걷어낸다.
|
||||
* 접두를 그대로 되풀이하는 후보는 버린다 (ghost text 로 쓸 수 없음).
|
||||
*/
|
||||
export function parseSuggestionCandidates(
|
||||
raw: string,
|
||||
prefix: string,
|
||||
maxCandidates = 3,
|
||||
maxChars = SUGGESTION_MAX_OUTPUT_CHARS
|
||||
): string[] {
|
||||
if (!raw) return []
|
||||
const prefixTail = normalizeRequestPrefix(prefix).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 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 = normalizeRequestPrefix(prefix)
|
||||
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 = normalizeSuggestionText(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 = normalizeSuggestionText(raw, safeMaxChars)
|
||||
if (candidate === null) continue
|
||||
const overlap = suffixPrefixOverlap(normalizedPrefix, candidate)
|
||||
if (overlap >= 2) {
|
||||
append(normalizeSuggestionText(candidate.slice(overlap), safeMaxChars))
|
||||
} else if (acceptsWholePhrase) {
|
||||
append(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
49
packages/core/src/terminal-prompt.ts
Normal file
49
packages/core/src/terminal-prompt.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// packages/core — 터미널 화면에서 "지금 치고 있는 입력 줄" 을 뽑는다.
|
||||
//
|
||||
// 터미널은 화면 버퍼 전체가 입력창으로 읽힌다(상태줄·명령·출력 포함). 제안은 입력 줄만
|
||||
// 접두로 써야 하므로 그 파싱을 한곳에 둔다. 순수 함수만 둔다 — 다른 core 모듈을
|
||||
// import 하지 않는다 (input-intelligence 가 이 모듈을 다시 export 한다).
|
||||
|
||||
/** 상자·블록·도형·기타 기호·딩뱃 — 터미널 UI/상태줄의 지문이다. */
|
||||
export const NON_PROSE_GLYPH_PATTERN = /[←-⇿─-➿⬀-⯿]/u
|
||||
|
||||
/**
|
||||
* 입력 줄의 시작 표시: 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue