release: ship v1.5.0 with on-device writing suggestions
Adds next-sentence suggestions while typing, weekly input insights and a personal phrase memory to the desktop app, and fixes custom instructions so they process the text instead of inserting the instruction's own wording. Local model requests are now bounded and individually cancellable. Bumps the product version to 1.5.0 (Android/iOS build 1050000), refreshes the landing and web download links, and records the new INPUT feature rows and the open verification gaps in the infrastructure map.
This commit is contained in:
parent
99f06c253c
commit
5c11ee2fde
104 changed files with 14410 additions and 174 deletions
123
packages/core/__tests__/personal-graph.test.ts
Normal file
123
packages/core/__tests__/personal-graph.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// packages/core/__tests__/personal-graph.test.ts
|
||||
// 개인 그래프 순수 함수 테스트 — 용어 추출/문장 분해/관계/순위.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SHARES_TERMS_MIN_SIMILARITY,
|
||||
buildSequenceEdges,
|
||||
continuationsFrom,
|
||||
extractTerms,
|
||||
jaccard,
|
||||
rankRelated,
|
||||
splitSentences
|
||||
} from '../src/personal-graph'
|
||||
|
||||
describe('extractTerms', () => {
|
||||
it('소문자화하고 조사/불용어를 걷어낸다', () => {
|
||||
const terms = extractTerms('오늘 회의에서 논의한 내용을 정리해서 공유드립니다')
|
||||
expect(terms).toContain('회의에서')
|
||||
expect(terms).toContain('정리해서')
|
||||
expect(terms).not.toContain('그리고')
|
||||
})
|
||||
|
||||
it('영어 불용어를 제거한다', () => {
|
||||
const terms = extractTerms('Thanks for the quick turnaround on the report')
|
||||
expect(terms).toContain('quick')
|
||||
expect(terms).toContain('turnaround')
|
||||
expect(terms).toContain('report')
|
||||
expect(terms).not.toContain('the')
|
||||
})
|
||||
|
||||
it('빈도 순으로 상위 limit 개만 돌려준다', () => {
|
||||
const terms = extractTerms('배포 배포 배포 점검 점검 테스트', 2)
|
||||
expect(terms).toEqual(['배포', '점검'])
|
||||
})
|
||||
|
||||
it('1자 토큰은 버린다', () => {
|
||||
expect(extractTerms('a b c 배포')).toEqual(['배포'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitSentences', () => {
|
||||
it('종결 부호와 개행으로 나누고 짧은 조각은 버린다', () => {
|
||||
const sentences = splitSentences('오늘 회의는 여기서 끝. 짧음. 내일 일정을 공유드릴게요!')
|
||||
expect(sentences).toContain('오늘 회의는 여기서 끝')
|
||||
expect(sentences).toContain('내일 일정을 공유드릴게요')
|
||||
expect(sentences).not.toContain('짧음')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSequenceEdges', () => {
|
||||
it('인접한 문장 쌍만 만든다', () => {
|
||||
const edges = buildSequenceEdges(['A 문장입니다', 'B 문장입니다', 'C 문장입니다'])
|
||||
expect(edges).toEqual([
|
||||
{ from: 'A 문장입니다', to: 'B 문장입니다' },
|
||||
{ from: 'B 문장입니다', to: 'C 문장입니다' }
|
||||
])
|
||||
})
|
||||
|
||||
it('같은 문장이 반복되면 만들지 않는다', () => {
|
||||
expect(buildSequenceEdges(['같은 문장입니다', '같은 문장입니다'])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('jaccard', () => {
|
||||
it('겹침 비율을 계산한다', () => {
|
||||
expect(jaccard(['a', 'b'], ['a', 'b'])).toBe(1)
|
||||
expect(jaccard(['a', 'b'], ['c', 'd'])).toBe(0)
|
||||
expect(jaccard(['a', 'b', 'c'], ['a', 'b', 'd'])).toBeCloseTo(0.5, 5)
|
||||
})
|
||||
|
||||
it('빈 집합은 0 이다', () => {
|
||||
expect(jaccard([], ['a'])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('continuationsFrom', () => {
|
||||
it('꼬리 뒤에 실제로 이어 쓴 텍스트만 뽑는다', () => {
|
||||
const out = continuationsFrom(
|
||||
['오늘 회의에서 논의한 내용을 정리해서 공유드립니다', '관계 없는 문장입니다'],
|
||||
'논의한 내용을'
|
||||
)
|
||||
expect(out).toEqual(['정리해서 공유드립니다'])
|
||||
})
|
||||
|
||||
it('꼬리가 너무 짧으면 아무것도 만들지 않는다', () => {
|
||||
expect(continuationsFrom(['아무 문장'], '아무')).toEqual([])
|
||||
})
|
||||
|
||||
it('길이를 제한하고 중복을 없앤다', () => {
|
||||
const out = continuationsFrom(
|
||||
['접두 뒤에 이어지는 아주 긴 문장이 계속 이어집니다 그리고 더 이어집니다', '접두 뒤에 이어지는 아주 긴 문장이 계속 이어집니다'],
|
||||
'접두 뒤에',
|
||||
2,
|
||||
20
|
||||
)
|
||||
expect(out).toHaveLength(1)
|
||||
expect(out[0].length).toBeLessThanOrEqual(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rankRelated', () => {
|
||||
it('관계 가중치와 용어 유사도, 최근성을 함께 본다', () => {
|
||||
const now = Date.now()
|
||||
const ranked = rankRelated(
|
||||
['회의', '일정'],
|
||||
[
|
||||
{ text: '회의 일정을 공유드립니다', terms: ['회의', '일정'], weight: 0, lastUsedAt: now },
|
||||
{ text: '전혀 상관 없는 문장입니다', terms: ['바다', '산'], weight: 0, lastUsedAt: now },
|
||||
{ text: '강하게 이어지는 문장입니다', terms: [], weight: 10, lastUsedAt: null }
|
||||
],
|
||||
3
|
||||
)
|
||||
// 관련도 순: 강한 엣지 → 용어 유사 + 최근성 → 무관(마지막)
|
||||
expect(ranked[0]).toBe('강하게 이어지는 문장입니다')
|
||||
expect(ranked[1]).toBe('회의 일정을 공유드립니다')
|
||||
expect(ranked[2]).toBe('전혀 상관 없는 문장입니다')
|
||||
})
|
||||
|
||||
it('용어 유사도 임계값 상수는 0과 1 사이이다', () => {
|
||||
expect(SHARES_TERMS_MIN_SIMILARITY).toBeGreaterThan(0)
|
||||
expect(SHARES_TERMS_MIN_SIMILARITY).toBeLessThan(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@d3ro/core",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸",
|
||||
"license": "MIT",
|
||||
|
|
@ -34,6 +34,10 @@
|
|||
"types": "./src/constants.ts",
|
||||
"default": "./src/constants.ts"
|
||||
},
|
||||
"./input-intelligence": {
|
||||
"types": "./src/input-intelligence.ts",
|
||||
"default": "./src/input-intelligence.ts"
|
||||
},
|
||||
"./entitlement": {
|
||||
"types": "./src/entitlement.ts",
|
||||
"default": "./src/entitlement.ts"
|
||||
|
|
|
|||
|
|
@ -192,6 +192,20 @@ export enum ErrorCode {
|
|||
QuotaExceeded = 861,
|
||||
TierRequired = 862,
|
||||
|
||||
// === Input Intelligence (970-989) ===
|
||||
/** UIA 브리지(사이드카 /uia/focus)를 쓸 수 없음 */
|
||||
UiaBridgeUnavailable = 970,
|
||||
/** 입력 텔레메트리 수집 시작 실패 */
|
||||
InputTelemetryStartFailed = 971,
|
||||
/** 입력 텔레메트리 설정/저장 실패 */
|
||||
InputTelemetryConfigFailed = 972,
|
||||
/** 다음 문장 제안 생성 실패 */
|
||||
SuggestionGenerationFailed = 973,
|
||||
/** 제안 접수/삽입 실패 */
|
||||
SuggestionAcceptFailed = 974,
|
||||
/** 제안 기능 비활성 상태에서의 호출 */
|
||||
SuggestionDisabled = 975,
|
||||
|
||||
// === System / Window (900-999) ===
|
||||
WindowCreationFailed = 900,
|
||||
WindowNotFound = 901,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
export * from './types'
|
||||
export * from './keybinding'
|
||||
export * from './input-intelligence'
|
||||
export * from './personal-graph'
|
||||
export * from './errors'
|
||||
export * from './ipc-channels'
|
||||
export * from './constants'
|
||||
|
|
|
|||
1113
packages/core/src/input-intelligence.ts
Normal file
1113
packages/core/src/input-intelligence.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -527,6 +527,50 @@ export const IPC_CHANNELS = {
|
|||
GET_SUBSCRIPTION_STATUS: 'payment:getSubscriptionStatus',
|
||||
CANCEL_SUBSCRIPTION: 'payment:cancelSubscription',
|
||||
},
|
||||
|
||||
// ── Input telemetry (수집·동의·리포트) ──
|
||||
INPUT_TELEMETRY: {
|
||||
GET_STATE: 'inputTelemetry:getState',
|
||||
SET_ENABLED: 'inputTelemetry:setEnabled',
|
||||
SET_PAUSED: 'inputTelemetry:setPaused',
|
||||
GET_SUMMARY: 'inputTelemetry:getSummary',
|
||||
GET_PRIVACY_RECEIPT: 'inputTelemetry:getPrivacyReceipt',
|
||||
GET_PHRASES: 'inputTelemetry:getPhrases',
|
||||
DELETE_PHRASE: 'inputTelemetry:deletePhrase',
|
||||
CLEAR_ALL: 'inputTelemetry:clearAll',
|
||||
GET_GRAPH: 'inputTelemetry:getGraph',
|
||||
QUERY_GRAPH: 'inputTelemetry:queryGraph',
|
||||
// Main → Renderer events
|
||||
ACTIVITY: 'inputTelemetry:activity',
|
||||
STATE_CHANGED: 'inputTelemetry:stateChanged',
|
||||
},
|
||||
|
||||
// ── Next-sentence suggestion (ghost text) ──
|
||||
SUGGESTION: {
|
||||
GET_STATE: 'suggestion:getState',
|
||||
SET_CONFIG: 'suggestion:setConfig',
|
||||
REQUEST_NOW: 'suggestion:requestNow',
|
||||
ACCEPT: 'suggestion:accept',
|
||||
NEXT: 'suggestion:next',
|
||||
PREV: 'suggestion:prev',
|
||||
DISMISS: 'suggestion:dismiss',
|
||||
GET_HISTORY: 'suggestion:getHistory',
|
||||
// Main → Renderer events
|
||||
UPDATED: 'suggestion:updated',
|
||||
CLEARED: 'suggestion:cleared',
|
||||
STATE_CHANGED: 'suggestion:stateChanged',
|
||||
},
|
||||
|
||||
// ── Popup Internal Channels (SuggestionOverlay) ──
|
||||
POPUP_SUGGESTION: {
|
||||
SHOW: 'suggestionPopup:show',
|
||||
UPDATE: 'suggestionPopup:update',
|
||||
HIDE: 'suggestionPopup:hide',
|
||||
ACCEPT: 'suggestionPopup:accept',
|
||||
DISMISS: 'suggestionPopup:dismiss',
|
||||
ACCEPTED: 'suggestionPopup:accepted',
|
||||
DISMISSED: 'suggestionPopup:dismissed',
|
||||
},
|
||||
} as const
|
||||
|
||||
// 타입 유틸리티: 채널명 유니온 추출
|
||||
|
|
|
|||
|
|
@ -677,9 +677,13 @@ export type KeyBindingActionId =
|
|||
| 'caption'
|
||||
| 'history-popup'
|
||||
| 'command-popup'
|
||||
| 'suggestion-accept'
|
||||
| 'suggestion-next'
|
||||
| 'suggestion-prev'
|
||||
| 'suggestion-dismiss'
|
||||
|
||||
/** 액션 그룹 (설정 화면 섹션) */
|
||||
export type KeyBindingActionGroup = 'voice' | 'window'
|
||||
export type KeyBindingActionGroup = 'voice' | 'window' | 'input'
|
||||
|
||||
export interface KeyBindingActionSpec {
|
||||
id: KeyBindingActionId
|
||||
|
|
@ -771,6 +775,44 @@ export const KEYBINDING_ACTIONS: readonly KeyBindingActionSpec[] = Object.freeze
|
|||
holdMode: false,
|
||||
doublePress: false,
|
||||
defaultBindings: [kb(0x43 /* C */, { ctrl: true, shift: true })]
|
||||
},
|
||||
{
|
||||
id: 'suggestion-accept',
|
||||
group: 'input',
|
||||
labelKey: 'keybinding.action.suggestionAccept',
|
||||
descriptionKey: 'keybinding.action.suggestionAccept.desc',
|
||||
holdMode: false,
|
||||
doublePress: false,
|
||||
// 사용자 요청으로 Ctrl+Alt+화살표 계열로 통일했다:
|
||||
// 오른쪽 수락 / 아래 다음 후보 / 위 이전 후보 / 왼쪽 닫기.
|
||||
defaultBindings: [kb(VK.ArrowRight, { ctrl: true, alt: true })]
|
||||
},
|
||||
{
|
||||
id: 'suggestion-next',
|
||||
group: 'input',
|
||||
labelKey: 'keybinding.action.suggestionNext',
|
||||
descriptionKey: 'keybinding.action.suggestionNext.desc',
|
||||
holdMode: false,
|
||||
doublePress: false,
|
||||
defaultBindings: [kb(VK.ArrowDown, { ctrl: true, alt: true })]
|
||||
},
|
||||
{
|
||||
id: 'suggestion-prev',
|
||||
group: 'input',
|
||||
labelKey: 'keybinding.action.suggestionPrev',
|
||||
descriptionKey: 'keybinding.action.suggestionPrev.desc',
|
||||
holdMode: false,
|
||||
doublePress: false,
|
||||
defaultBindings: [kb(VK.ArrowUp, { ctrl: true, alt: true })]
|
||||
},
|
||||
{
|
||||
id: 'suggestion-dismiss',
|
||||
group: 'input',
|
||||
labelKey: 'keybinding.action.suggestionDismiss',
|
||||
descriptionKey: 'keybinding.action.suggestionDismiss.desc',
|
||||
holdMode: false,
|
||||
doublePress: false,
|
||||
defaultBindings: [kb(VK.ArrowLeft, { ctrl: true, alt: true })]
|
||||
}
|
||||
])
|
||||
|
||||
|
|
@ -1035,6 +1077,67 @@ export function detectBindingConflicts(
|
|||
return conflicts
|
||||
}
|
||||
|
||||
/** 저장된 전체 단축키 설정에서 사용 불가·중복 바인딩을 수집한 결과. */
|
||||
export interface KeyBindingAuditIssue {
|
||||
kind: 'invalid' | 'conflict'
|
||||
actionId: KeyBindingActionId
|
||||
bindingIndex: number
|
||||
binding: KeyBinding
|
||||
reasonKey: string | null
|
||||
conflictActionIds: KeyBindingActionId[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 바인딩 맵을 한 번에 검수한다.
|
||||
*
|
||||
* hold/double-press 예외는 detectBindingConflicts()의 기존 계약을 그대로 따른다.
|
||||
*/
|
||||
export function auditKeyBindingMap(map: Readonly<KeyBindingMap>): KeyBindingAuditIssue[] {
|
||||
const issues: KeyBindingAuditIssue[] = []
|
||||
const reportedPairs = new Set<string>()
|
||||
|
||||
for (const action of KEYBINDING_ACTIONS) {
|
||||
const bindings = map[action.id] ?? []
|
||||
for (const [bindingIndex, binding] of bindings.entries()) {
|
||||
const validation = validateBinding(binding)
|
||||
if (!validation.valid) {
|
||||
issues.push({
|
||||
kind: 'invalid',
|
||||
actionId: action.id,
|
||||
bindingIndex,
|
||||
binding: { ...binding },
|
||||
reasonKey: validation.reasonKey,
|
||||
conflictActionIds: []
|
||||
})
|
||||
}
|
||||
|
||||
const conflictActionIds = detectBindingConflicts(action.id, binding, map)
|
||||
.map((conflict) => conflict.actionId)
|
||||
.filter((otherActionId) => action.id.localeCompare(otherActionId) < 0)
|
||||
.filter((otherActionId) => {
|
||||
const pairKey = `${bindingKey(binding)}:${action.id}:${otherActionId}`
|
||||
if (reportedPairs.has(pairKey)) return false
|
||||
reportedPairs.add(pairKey)
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
|
||||
if (conflictActionIds.length > 0) {
|
||||
issues.push({
|
||||
kind: 'conflict',
|
||||
actionId: action.id,
|
||||
bindingIndex,
|
||||
binding: { ...binding },
|
||||
reasonKey: null,
|
||||
conflictActionIds
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 검색 (드롭다운 필터)
|
||||
// ============================================================
|
||||
|
|
|
|||
222
packages/core/src/personal-graph.ts
Normal file
222
packages/core/src/personal-graph.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// packages/core — 개인 그래프(관계형 개인화) 정본.
|
||||
//
|
||||
// 왜 그래프인가:
|
||||
// n-gram 문자열 조회(1단계)는 "같은 꼬리 뒤에 이어 쓴 문장" 만 찾는다. 하지만 사용자의
|
||||
// 글은 관계로 이어진다 — 어떤 문장은 늘 다른 문장 뒤에 오고("follows"), 어떤 문장들은
|
||||
// 같은 용어를 공유한다("shares_terms"). 그 관계를 저장해 두면 접두가 조금 달라도
|
||||
// 관련 문맥을 끌어올 수 있다.
|
||||
//
|
||||
// 전부 순수 함수로 두어 LLM/DB 없이 검증 가능하게 한다.
|
||||
|
||||
import { countWords } from './input-intelligence'
|
||||
import type { PhraseSource } from './input-intelligence'
|
||||
|
||||
// 렌더러/프리로드도 같은 계약을 쓴다 (main 모듈을 import 할 수 없다).
|
||||
export type { PhraseSource }
|
||||
|
||||
/** 그래프 노드 = 사용자의 문장 하나. */
|
||||
export interface GraphNode {
|
||||
text: string
|
||||
terms: string[]
|
||||
source: PhraseSource
|
||||
appName: string | null
|
||||
count: number
|
||||
lastUsedAt: number | null
|
||||
}
|
||||
|
||||
export type GraphEdgeKind = 'follows' | 'shares_terms'
|
||||
|
||||
/** 그래프 엣지 = 두 문장의 관계. */
|
||||
export interface GraphEdge {
|
||||
from: string
|
||||
to: string
|
||||
kind: GraphEdgeKind
|
||||
weight: number
|
||||
}
|
||||
|
||||
/** 그래프 통계 (지식베이스 그래프 탭). */
|
||||
export interface PersonalGraphStats {
|
||||
nodes: number
|
||||
followsEdges: number
|
||||
sharesTermsEdges: number
|
||||
/** 가장 강한 follows 엣지 (A → B) */
|
||||
topEdges: Array<{ from: string; to: string; kind: string; weight: number }>
|
||||
/** 최근 노드 */
|
||||
recentNodes: Array<{ text: string; terms: string[]; count: number; appName: string | null }>
|
||||
}
|
||||
|
||||
/** 특정 텍스트 주변 그래프 조회 결과. */
|
||||
export interface PersonalGraphQuery {
|
||||
anchors: Array<{ text: string; terms: string[]; count: number }>
|
||||
neighbors: Array<{ text: string; kind: string; weight: number }>
|
||||
}
|
||||
|
||||
export interface GraphContext {
|
||||
/** 접두 꼬리 뒤에 실제로 이어 쓴 텍스트 (가장 강한 신호) */
|
||||
continuations: string[]
|
||||
/** 관계로 끌어온 관련 문장 (follows 우선, 그다음 용어 공유) */
|
||||
related: string[]
|
||||
}
|
||||
|
||||
/** 용어 추출에서 제외할 불용어 (한/영 최소 집합). */
|
||||
const STOPWORDS: ReadonlySet<string> = new Set([
|
||||
'그리고',
|
||||
'그러나',
|
||||
'하지만',
|
||||
'그래서',
|
||||
'저는',
|
||||
'제가',
|
||||
'이거',
|
||||
'그거',
|
||||
'저거',
|
||||
'있습니다',
|
||||
'합니다',
|
||||
'입니다',
|
||||
'the',
|
||||
'and',
|
||||
'for',
|
||||
'with',
|
||||
'that',
|
||||
'this',
|
||||
'from',
|
||||
'have',
|
||||
'will',
|
||||
'your',
|
||||
'you',
|
||||
'are',
|
||||
'was',
|
||||
'were',
|
||||
'not',
|
||||
'but'
|
||||
])
|
||||
|
||||
/** 최소 길이 (1~2자 토큰은 노이즈가 많다). */
|
||||
const MIN_TERM_CHARS = 2
|
||||
|
||||
/**
|
||||
* 문장에서 비교용 용어를 뽑는다.
|
||||
*
|
||||
* 소문자화 → 문자/숫자 경계로 분리 → 불용어/짧은 토큰 제거 → 빈도 순.
|
||||
*/
|
||||
export function extractTerms(text: string, limit = 8): string[] {
|
||||
const tokens = text
|
||||
.toLowerCase()
|
||||
.split(/[^0-9a-z\uac00-\ud7a3\u3040-\u30ff\u4e00-\u9fff]+/u)
|
||||
.filter((token) => token.length >= MIN_TERM_CHARS)
|
||||
.filter((token) => !STOPWORDS.has(token))
|
||||
|
||||
const frequency = new Map<string, number>()
|
||||
for (const token of tokens) {
|
||||
frequency.set(token, (frequency.get(token) ?? 0) + 1)
|
||||
}
|
||||
|
||||
return [...frequency.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, limit)
|
||||
.map(([token]) => token)
|
||||
}
|
||||
|
||||
/** 문장 단위로 쪼갠다 (종결 부호 + 개행). 2단어 이상만 남긴다. */
|
||||
export function splitSentences(text: string): string[] {
|
||||
return text
|
||||
.split(/[.!?。!?…\n]+/u)
|
||||
.map((segment) => segment.replace(/\s+/gu, ' ').trim())
|
||||
.filter((segment) => segment.length >= 4 && countWords(segment) >= 2)
|
||||
}
|
||||
|
||||
/** 한 텍스트 안에서 인접한 문장 쌍 (follows 엣지의 근거). */
|
||||
export function buildSequenceEdges(sentences: readonly string[]): Array<{ from: string; to: string }> {
|
||||
const edges: Array<{ from: string; to: string }> = []
|
||||
for (let index = 0; index + 1 < sentences.length; index += 1) {
|
||||
const from = sentences[index]
|
||||
const to = sentences[index + 1]
|
||||
if (from === to) continue
|
||||
edges.push({ from, to })
|
||||
}
|
||||
return edges
|
||||
}
|
||||
|
||||
/** 자카드 유사도 — 두 용어 집합이 얼마나 겹치는가 (0~1). */
|
||||
export function jaccard(a: readonly string[], b: readonly string[]): number {
|
||||
if (a.length === 0 || b.length === 0) return 0
|
||||
const setA = new Set(a)
|
||||
const setB = new Set(b)
|
||||
let intersection = 0
|
||||
for (const term of setA) {
|
||||
if (setB.has(term)) intersection += 1
|
||||
}
|
||||
const union = setA.size + setB.size - intersection
|
||||
return union === 0 ? 0 : intersection / union
|
||||
}
|
||||
|
||||
/** 용어 공유 엣지로 볼 최소 자카드. */
|
||||
export const SHARES_TERMS_MIN_SIMILARITY = 0.34
|
||||
|
||||
/**
|
||||
* 접두 꼬리 뒤에 실제로 이어 쓴 텍스트를 뽑는다.
|
||||
*
|
||||
* `texts` 는 꼬리를 포함하는 후보 문장들(최신순)이다.
|
||||
*/
|
||||
export function continuationsFrom(
|
||||
texts: readonly string[],
|
||||
tail: string,
|
||||
limit = 3,
|
||||
maxChars = 60
|
||||
): string[] {
|
||||
const needle = tail.trim()
|
||||
if (needle.length < 4) return []
|
||||
|
||||
const out: string[] = []
|
||||
for (const text of texts) {
|
||||
const index = text.indexOf(needle)
|
||||
if (index < 0) continue
|
||||
const continuation = text
|
||||
.slice(index + needle.length)
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim()
|
||||
if (continuation.length < 3) continue
|
||||
const clipped = continuation.slice(0, maxChars)
|
||||
if (out.includes(clipped)) continue
|
||||
out.push(clipped)
|
||||
if (out.length >= limit) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface RelatedCandidate {
|
||||
text: string
|
||||
terms: readonly string[]
|
||||
/** 엣지 가중치 합 (follows 가중치가 더 크게 반영된다) */
|
||||
weight: number
|
||||
lastUsedAt: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 관련 문장 순위를 매긴다.
|
||||
*
|
||||
* 점수 = 엣지 가중치 + 용어 유사도 + 최근성 보너스.
|
||||
* 이미 접두에 포함된 문장(후보 자신)은 제외한다.
|
||||
*/
|
||||
export function rankRelated(
|
||||
anchorTerms: readonly string[],
|
||||
candidates: readonly RelatedCandidate[],
|
||||
limit = 4
|
||||
): string[] {
|
||||
const scored = candidates
|
||||
.map((candidate) => {
|
||||
const similarity = jaccard(anchorTerms, candidate.terms)
|
||||
const recency = candidate.lastUsedAt
|
||||
? Math.max(0, 1 - (Date.now() - candidate.lastUsedAt) / (7 * 86400000))
|
||||
: 0
|
||||
return { text: candidate.text, score: candidate.weight * 1.0 + similarity * 2.0 + recency * 0.5 }
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
const out: string[] = []
|
||||
for (const item of scored) {
|
||||
if (out.includes(item.text)) continue
|
||||
out.push(item.text)
|
||||
if (out.length >= limit) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -501,6 +501,55 @@ export interface AppConfig {
|
|||
updateDeviceId: string
|
||||
/** 사용자가 건너뛴 버전 (강제 업데이트에는 적용되지 않음) */
|
||||
skippedUpdateVersion: string | null
|
||||
/**
|
||||
* 입력 텔레메트리 (키/마우스 집계) 수집.
|
||||
*
|
||||
* 기본값은 false — 옵트인. 수집 항목은 카운터/거리/집계뿐이고
|
||||
* 키 내용은 저장하지 않는다 (ActivityWatch aw-watcher-input 과 동일한
|
||||
* 데이터 최소화 정책).
|
||||
*/
|
||||
inputTelemetryEnabled: boolean
|
||||
/** 동의는 유지한 채 수집만 시 중단 */
|
||||
inputTelemetryPaused: boolean
|
||||
/**
|
||||
* 타이한 실제 텍스트 학습 (UIA 로 포커스 입력창을 읽어 개인 문구 생성).
|
||||
*
|
||||
* 기본값 false — 텔레메트리보다 한 단계 더 강한 동의가 필요하다.
|
||||
* 비밀번호 필드(IsPassword)는 항상 제외된다.
|
||||
*/
|
||||
inputLearnTypedText: boolean
|
||||
/** 수집/학습 제외 (실행 파일명, 예: 'KeePassXC.exe') */
|
||||
inputExcludedApps: string[]
|
||||
/** 다음 문장 제안 (ghost text) 활성 */
|
||||
suggestionEnabled: boolean
|
||||
/** 제안 전용 Ollama 모델 (null → llmModelId 사용) */
|
||||
suggestionModelId: string | null
|
||||
/** 타이핑 정지 후 요청까지 지연 (ms) */
|
||||
suggestionTriggerDelayMs: number
|
||||
/** 제안 요청 최소 접두 길이 (문자) */
|
||||
suggestionMinPrefixChars: number
|
||||
/** 분당 최대 요청 수 (과금/부하 방지) */
|
||||
suggestionMaxRequestsPerMinute: number
|
||||
/** 일일 요청 예산 */
|
||||
suggestionDailyBudget: number
|
||||
/** 오버레이 클릭 허용 (false → 완전 클릭 통과) */
|
||||
suggestionOverlayInteractive: boolean
|
||||
/**
|
||||
* 제안 응답 제한 (ms).
|
||||
*
|
||||
* 실측(gemma4:e4b, 이 개발 머신): 콜드 첫 요청 24.7초 / 워 4.9초(32토큰).
|
||||
* 느린 하드웨어에서 생성이 UI 를 붙잡지 않도록 상한을 다.
|
||||
*/
|
||||
suggestionRequestTimeoutMs: number
|
||||
/**
|
||||
* 제안/텔레메트리 튜닝 값의 개정판.
|
||||
*
|
||||
* electron-store 는 기본값을 설정 파일에 함께 써버리므로, 기본값을 바꿔도
|
||||
* 기존 사용자 파일에는 옛 값이 굳어 있다(실측: 트리거 1000ms 가 남아
|
||||
* 조합 게이트 기준이 2000ms 로 계산돼 제안이 영영 안 떴다). 기본값을
|
||||
* 바꿀 때마다 ConfigService 의 개정판을 올려 1회 마이그레이션한다.
|
||||
*/
|
||||
suggestionTuningRevision: number
|
||||
}
|
||||
|
||||
export interface ConfigGetParams {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue