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
|
|
@ -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 })
|
||||
}
|
||||
|
||||
/** 테스트/진단 — 현재 앱이 제외 대상인지. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue