fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the 2026-09-28 design overhaul, committed as one unit with their tests. - desktop main: STT timeouts and sidecar, voice recording store, sync (credentials, audio, knowledge reindex, push gates), runtime provisioner, update policy, AltGr keybindings, voice-command policy, dictionary file codec/limits, meeting transcript condensing and a local recording ledger so interrupted-session recovery only closes meetings this device recorded (a phone's live meeting is left alone). - mobile: login CSRF via implicit token callbacks rejected, account deletion/retention, durable queue retention, knowledge realtime without unfiltered DELETE, meeting re-record failure paths, cloud STT client, preferences store/resync. - core: text chunking splits long unbroken transcripts to fit, template field policy, dictionary limits, meeting markdown inline handling. - server: payple webhook policy and cancellation order scope, meeting document generation quota, team RPC null-role guard, unified LLM quota in-flight accounting, knowledge chunk vector index, meeting re-record failure paths (migrations 20260929*). - ci: portable/runtime feed gates, update-policy schema, Forgejo file delete and alias planning. Four older tests are updated to the new contracts rather than the old behavior: token-pair auth callbacks are rejected, knowledge realtime no longer subscribes to DELETE, long transcript lines are split, and meeting recovery requires the local recording ledger for empty rows.
This commit is contained in:
parent
2428ede03d
commit
ba9ef9741e
161 changed files with 17056 additions and 2379 deletions
|
|
@ -2,10 +2,65 @@
|
|||
// 루트 barrel은 공유 계약(타입 + 클라이언트 팩토리)만 공개해야 한다.
|
||||
// 앱마다 따로 소유하는 저장소·쿼터·STT 호출 헬퍼가 '정본처럼' 다시 노출되지 않도록 고정한다.
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import * as apiClient from '../src/index'
|
||||
import type { TranscribeAudioResult } from '../src/index'
|
||||
|
||||
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
interface ExportConditions {
|
||||
types?: string
|
||||
default?: string
|
||||
}
|
||||
|
||||
interface PackageManifest {
|
||||
main?: string
|
||||
types?: string
|
||||
exports: Record<string, ExportConditions | string>
|
||||
}
|
||||
|
||||
function readManifest(): PackageManifest {
|
||||
return JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf8')) as PackageManifest
|
||||
}
|
||||
|
||||
function exportTargets(entry: ExportConditions | string): string[] {
|
||||
return typeof entry === 'string' ? [entry] : Object.values(entry).filter((v): v is string => typeof v === 'string')
|
||||
}
|
||||
|
||||
describe('@d3ro/api-client exports map', () => {
|
||||
it('publishes exactly the shared-contract subpaths', () => {
|
||||
expect(Object.keys(readManifest().exports).sort()).toEqual([
|
||||
'.',
|
||||
'./client',
|
||||
'./supabase-browser',
|
||||
'./supabase-server',
|
||||
])
|
||||
})
|
||||
|
||||
it('does not publish app-owned runtime policy subpaths', () => {
|
||||
const subpaths = Object.keys(readManifest().exports)
|
||||
for (const removed of ['./auth', './meetings', './history', './usage', './transcribe']) {
|
||||
expect(subpaths, removed).not.toContain(removed)
|
||||
}
|
||||
})
|
||||
|
||||
it('points every declared target (exports, main, types) at an existing file', () => {
|
||||
const manifest = readManifest()
|
||||
const targets = [
|
||||
...Object.values(manifest.exports).flatMap(exportTargets),
|
||||
...(manifest.main ? [manifest.main] : []),
|
||||
...(manifest.types ? [manifest.types] : []),
|
||||
]
|
||||
expect(targets.length).toBeGreaterThan(0)
|
||||
for (const target of targets) {
|
||||
expect(existsSync(resolve(PACKAGE_ROOT, target)), target).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('@d3ro/api-client root surface', () => {
|
||||
it('exposes only the client factory runtime values', () => {
|
||||
expect(Object.keys(apiClient).sort()).toEqual([
|
||||
|
|
|
|||
|
|
@ -19,22 +19,6 @@
|
|||
"types": "./src/client.ts",
|
||||
"default": "./src/client.ts"
|
||||
},
|
||||
"./auth": {
|
||||
"types": "./src/auth.ts",
|
||||
"default": "./src/auth.ts"
|
||||
},
|
||||
"./meetings": {
|
||||
"types": "./src/meetings.ts",
|
||||
"default": "./src/meetings.ts"
|
||||
},
|
||||
"./history": {
|
||||
"types": "./src/history.ts",
|
||||
"default": "./src/history.ts"
|
||||
},
|
||||
"./usage": {
|
||||
"types": "./src/usage.ts",
|
||||
"default": "./src/usage.ts"
|
||||
},
|
||||
"./supabase-browser": {
|
||||
"types": "./src/supabase-browser.ts",
|
||||
"default": "./src/supabase-browser.ts"
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
// packages/api-client/src/auth.ts
|
||||
// Supabase Auth 래퍼. OAuth, session, 로그아웃.
|
||||
|
||||
import type { Session, User, Provider } from '@supabase/supabase-js'
|
||||
import type { D3roSupabaseClient } from './client'
|
||||
|
||||
export type { Session, User }
|
||||
|
||||
export interface SignInOptions {
|
||||
provider: Extract<Provider, 'google' | 'github' | 'apple'>
|
||||
redirectTo: string
|
||||
}
|
||||
|
||||
export async function signInWithOAuth(
|
||||
client: D3roSupabaseClient,
|
||||
options: SignInOptions
|
||||
): Promise<{ url: string | null; error: string | null }> {
|
||||
const { data, error } = await client.auth.signInWithOAuth({
|
||||
provider: options.provider,
|
||||
options: {
|
||||
redirectTo: options.redirectTo
|
||||
}
|
||||
})
|
||||
return {
|
||||
url: data?.url ?? null,
|
||||
error: error?.message ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOut(client: D3roSupabaseClient): Promise<{ error: string | null }> {
|
||||
const { error } = await client.auth.signOut()
|
||||
return { error: error?.message ?? null }
|
||||
}
|
||||
|
||||
export async function getSession(client: D3roSupabaseClient): Promise<Session | null> {
|
||||
const { data } = await client.auth.getSession()
|
||||
return data.session ?? null
|
||||
}
|
||||
|
||||
export async function getUser(client: D3roSupabaseClient): Promise<User | null> {
|
||||
const { data } = await client.auth.getUser()
|
||||
return data.user ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 변경 구독. 반환된 함수를 호출하면 구독 해제.
|
||||
*/
|
||||
export function onAuthStateChange(
|
||||
client: D3roSupabaseClient,
|
||||
callback: (session: Session | null) => void
|
||||
): () => void {
|
||||
const { data } = client.auth.onAuthStateChange((_event, session) => {
|
||||
callback(session)
|
||||
})
|
||||
return () => {
|
||||
data.subscription.unsubscribe()
|
||||
}
|
||||
}
|
||||
113
packages/core/__tests__/dictionary-limits-redteam-r3-26.test.ts
Normal file
113
packages/core/__tests__/dictionary-limits-redteam-r3-26.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// packages/core/__tests__/dictionary-limits-redteam-r3-26.test.ts
|
||||
// 사전 쓰기 정책이 서버 트리거(normalize_dictionary_word, 마이그레이션 20260821000005)의
|
||||
// 길이 제한(단어 120·발음 200 코드 포인트)을 로컬에서 먼저 거부하는지 검증한다.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { D3ROError, ErrorCode } from '../src/errors'
|
||||
import {
|
||||
DICTIONARY_LIMITS,
|
||||
buildDictionaryUpdatePatch,
|
||||
dictionaryWriteProblemError,
|
||||
validateDictionaryDraft
|
||||
} from '../src/dictionary-policy'
|
||||
|
||||
describe('DICTIONARY_LIMITS', () => {
|
||||
it('서버 트리거와 같은 값이다', () => {
|
||||
expect(DICTIONARY_LIMITS).toEqual({ word: 120, pronunciation: 200 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateDictionaryDraft', () => {
|
||||
it('정상 입력을 정규화한다', () => {
|
||||
expect(validateDictionaryDraft({ word: ' GPT ', pronunciation: ' 지피티 ' })).toEqual({
|
||||
ok: true,
|
||||
draft: { word: 'GPT', pronunciation: '지피티' }
|
||||
})
|
||||
expect(validateDictionaryDraft({ word: 'GPT', pronunciation: ' ' })).toEqual({
|
||||
ok: true,
|
||||
draft: { word: 'GPT', pronunciation: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('빈 단어를 거부한다', () => {
|
||||
expect(validateDictionaryDraft({ word: ' ' })).toEqual({ ok: false, reason: 'empty-word' })
|
||||
})
|
||||
|
||||
it('정확히 120자 단어·200자 발음은 받아들인다 (앞뒤 공백 제외)', () => {
|
||||
const result = validateDictionaryDraft({
|
||||
word: ` ${'가'.repeat(120)} `,
|
||||
pronunciation: ` ${'a'.repeat(200)} `
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('121자 단어를 too-long 으로 거부한다', () => {
|
||||
expect(validateDictionaryDraft({ word: 'a'.repeat(121) })).toEqual({
|
||||
ok: false,
|
||||
reason: 'too-long',
|
||||
field: 'word',
|
||||
max: 120
|
||||
})
|
||||
})
|
||||
|
||||
it('201자 발음을 too-long 으로 거부한다', () => {
|
||||
expect(validateDictionaryDraft({ word: 'ok', pronunciation: 'b'.repeat(201) })).toEqual({
|
||||
ok: false,
|
||||
reason: 'too-long',
|
||||
field: 'pronunciation',
|
||||
max: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('코드 포인트로 센다 — 서로게이트 쌍 이모지 120개는 허용', () => {
|
||||
const emoji = '\u{1F600}'.repeat(120) // UTF-16 길이 240
|
||||
expect(validateDictionaryDraft({ word: emoji }).ok).toBe(true)
|
||||
expect(validateDictionaryDraft({ word: `${emoji}\u{1F600}` }).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDictionaryUpdatePatch 길이 제한', () => {
|
||||
it('121자 단어로 바꾸는 편집을 거부한다', () => {
|
||||
expect(buildDictionaryUpdatePatch({ id: 'a', word: 'w'.repeat(121) })).toEqual({
|
||||
ok: false,
|
||||
reason: 'too-long',
|
||||
field: 'word',
|
||||
max: 120
|
||||
})
|
||||
})
|
||||
|
||||
it('201자 발음으로 바꾸는 편집을 거부한다', () => {
|
||||
expect(buildDictionaryUpdatePatch({ id: 'a', pronunciation: 'p'.repeat(201) })).toEqual({
|
||||
ok: false,
|
||||
reason: 'too-long',
|
||||
field: 'pronunciation',
|
||||
max: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('제한 이내 편집과 발음 지우기는 그대로 통과한다', () => {
|
||||
expect(
|
||||
buildDictionaryUpdatePatch({ id: 'a', word: 'w'.repeat(120), pronunciation: 'p'.repeat(200) })
|
||||
).toEqual({ ok: true, patch: { word: 'w'.repeat(120), pronunciation: 'p'.repeat(200) } })
|
||||
expect(buildDictionaryUpdatePatch({ id: 'a', pronunciation: null })).toEqual({
|
||||
ok: true,
|
||||
patch: { pronunciation: null }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('dictionaryWriteProblemError', () => {
|
||||
it('too-long 은 field·max 를 details 로 담은 DictionaryImportInvalidFormat 이다', () => {
|
||||
const err = dictionaryWriteProblemError({ reason: 'too-long', field: 'pronunciation', max: 200 })
|
||||
expect(err).toBeInstanceOf(D3ROError)
|
||||
expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat)
|
||||
expect(err.details).toEqual({ reason: 'too-long', field: 'pronunciation', max: 200 })
|
||||
})
|
||||
|
||||
it('empty-word 는 기존 메시지를 유지한다', () => {
|
||||
const err = dictionaryWriteProblemError({ reason: 'empty-word' })
|
||||
expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat)
|
||||
expect(err.message).toBe('Dictionary word is empty')
|
||||
expect(err.details).toEqual({ reason: 'empty-word', field: 'word' })
|
||||
})
|
||||
})
|
||||
100
packages/core/__tests__/input-suggestion-redteam-r3-1.test.ts
Normal file
100
packages/core/__tests__/input-suggestion-redteam-r3-1.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// 제안 정책 회귀 (r3-1):
|
||||
// - 케어렛을 모르는 필드에서 문서 중간을 고쳤으면 제안을 닫는다 (caret-unknown)
|
||||
// - 단어를 끝낸 뒤(뒤 공백) 로컬 기억이 단어 조각을 붙이지 않는다
|
||||
// - 학습 제외 앱은 출처와 무관하게 학습하지 않는다
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
decideSuggestion,
|
||||
isEditAtTextEnd,
|
||||
shouldLearnFromApp,
|
||||
shouldPersistSuggestionPrefix,
|
||||
type SuggestionPolicyInput
|
||||
} from '../src/input-intelligence'
|
||||
import { buildLocalSuggestionCandidateEntries, joinSuggestion, normalizeSessionPrefix } from '../src/suggestion-text'
|
||||
|
||||
function policy(overrides: Partial<SuggestionPolicyInput> = {}): SuggestionPolicyInput {
|
||||
return {
|
||||
enabled: true,
|
||||
modelAvailable: true,
|
||||
overlayVisible: false,
|
||||
composing: false,
|
||||
hasSelection: false,
|
||||
isPassword: false,
|
||||
isEditable: true,
|
||||
appName: 'notepad.exe',
|
||||
excludedApps: [],
|
||||
editedSinceFocus: true,
|
||||
typedRecently: true,
|
||||
prefix: '오늘 회의에서 논의한 내용을 정리해서',
|
||||
idleMs: 2000,
|
||||
triggerDelayMs: 600,
|
||||
minPrefixChars: 8,
|
||||
sinceLastRequestMs: 60_000,
|
||||
minIntervalMs: 5000,
|
||||
requestsThisMinute: 0,
|
||||
maxRequestsPerMinute: 12,
|
||||
requestsToday: 0,
|
||||
dailyBudget: 500,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('케어렛 신뢰도', () => {
|
||||
it('문서 끝에서 쓰거나 지운 편집만 끝 편집으로 본다', () => {
|
||||
expect(isEditAtTextEnd('abc', 'abcd')).toBe(true)
|
||||
expect(isEditAtTextEnd('abc', 'ab')).toBe(true)
|
||||
expect(isEditAtTextEnd('abc', 'abc')).toBe(true)
|
||||
expect(isEditAtTextEnd('첫 문장. 둘째 문장.', '첫 문장이. 둘째 문장.')).toBe(false)
|
||||
expect(isEditAtTextEnd('aXb', 'ab')).toBe(false)
|
||||
})
|
||||
|
||||
it('caretReliable=false 면 요청하지 않고 떠 있는 세션도 닫는다', () => {
|
||||
expect(decideSuggestion(policy({ caretReliable: false }))).toEqual({ action: 'clear', reason: 'caret-unknown' })
|
||||
expect(decideSuggestion(policy({ caretReliable: false, overlayVisible: true }))).toEqual({
|
||||
action: 'clear',
|
||||
reason: 'caret-unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('생략하거나 true 면 기존처럼 요청한다', () => {
|
||||
expect(decideSuggestion(policy()).action).toBe('request')
|
||||
expect(decideSuggestion(policy({ caretReliable: true })).action).toBe('request')
|
||||
})
|
||||
})
|
||||
|
||||
describe('단어를 끝낸 뒤 로컬 기억 이음새', () => {
|
||||
const hints = (phrase: string) => ({ continuationHints: [], relatedHints: [], phraseHints: [phrase] })
|
||||
|
||||
it("'내일 회의 ' 뒤에 '록 공유드립니다' 조각을 만들지 않는다", () => {
|
||||
const raw = '내일 회의 '
|
||||
const entries = buildLocalSuggestionCandidateEntries(normalizeSessionPrefix(raw), hints('회의록 공유드립니다'), 3, 160, {
|
||||
wordFinished: true
|
||||
})
|
||||
expect(entries.map((entry) => entry.text)).not.toContain('록 공유드립니다')
|
||||
})
|
||||
|
||||
it("'check the ' 뒤에 'n we can ship' 조각을 만들지 않는다 (원문을 넘기면 자동 판단)", () => {
|
||||
const entries = buildLocalSuggestionCandidateEntries('check the ', hints('then we can ship'), 3, 160)
|
||||
expect(entries.map((entry) => entry.text)).not.toContain('n we can ship')
|
||||
})
|
||||
|
||||
it('단어 중간에서 멈췄으면 기존처럼 붙인다', () => {
|
||||
const entries = buildLocalSuggestionCandidateEntries('내일 회의', hints('회의록 공유드립니다'), 3, 160)
|
||||
expect(entries[0]).toEqual({ text: '록 공유드립니다', attachToPrefix: true })
|
||||
expect('내일 회의' + joinSuggestion('내일 회의', entries[0].text, 'attach')).toBe('내일 회의록 공유드립니다')
|
||||
})
|
||||
})
|
||||
|
||||
describe('학습 제외 앱 정본', () => {
|
||||
it('터미널·에디터는 출처와 무관하게 학습하지 않는다', () => {
|
||||
expect(shouldLearnFromApp('WindowsTerminal.exe')).toBe(false)
|
||||
expect(shouldLearnFromApp('Code.exe')).toBe(false)
|
||||
expect(shouldLearnFromApp('KakaoTalk.exe')).toBe(true)
|
||||
expect(shouldLearnFromApp(null)).toBe(true)
|
||||
})
|
||||
|
||||
it('제안 접두 저장도 같은 규칙을 쓴다', () => {
|
||||
expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: 'WindowsTerminal.exe' })).toBe(false)
|
||||
expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: 'KakaoTalk.exe' })).toBe(true)
|
||||
})
|
||||
})
|
||||
286
packages/core/__tests__/keybinding-altgr-redteam-r3-8.test.ts
Normal file
286
packages/core/__tests__/keybinding-altgr-redteam-r3-8.test.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
// packages/core/__tests__/keybinding-altgr-redteam-r3-8.test.ts
|
||||
// 레드팀 r3-8 회귀 테스트 — AltGr 배열(독일어·프랑스어·폴란드어·북유럽).
|
||||
//
|
||||
// Windows 는 AltGr 를 "가짜 LCtrl 눌림 → RAlt 눌림"(같은 타임스탬프)으로 보내고, libuiohook 은
|
||||
// 가짜 LCtrl 로 Ctrl 수정자를 켠다. 그래서
|
||||
// 1) '@'(AltGr+Q) 를 칠 때마다 Ctrl+RightAlt('command') 가 발동해 음성 세션이 열렸고,
|
||||
// 2) 기본 dictation(RightAlt 단독) · hands-free(더블탭) 는 영영 발동하지 않았다.
|
||||
// 데스크톱 어댑터와 같은 순서(AltGrPressDetector → noteKeyDown → keyDown)로 흘려 검증한다.
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
ALTGR_CHORD_GRACE_MS,
|
||||
AltGrPressDetector,
|
||||
ChordStateMachine
|
||||
} from '../src/keybinding-runtime'
|
||||
import type { ChordBindingEntry, ChordClock, ChordTriggerEvent } from '../src/keybinding-runtime'
|
||||
import { KEYBINDING_ACTIONS, VK, bindingKey, createDefaultBindingMap } from '../src/keybinding'
|
||||
|
||||
const VK_Q = 0x51
|
||||
|
||||
// ============================================================
|
||||
// 가짜 시계
|
||||
// ============================================================
|
||||
|
||||
class FakeClock implements ChordClock {
|
||||
private _now = 1_000
|
||||
private _timers: { at: number; callback: () => void; active: boolean }[] = []
|
||||
|
||||
now(): number {
|
||||
return this._now
|
||||
}
|
||||
|
||||
schedule(callback: () => void, delayMs: number): () => void {
|
||||
const timer = { at: this._now + delayMs, callback, active: true }
|
||||
this._timers.push(timer)
|
||||
return () => {
|
||||
timer.active = false
|
||||
}
|
||||
}
|
||||
|
||||
advance(ms: number): void {
|
||||
const target = this._now + ms
|
||||
for (;;) {
|
||||
const due = this._timers
|
||||
.filter((t) => t.active && t.at <= target)
|
||||
.sort((a, b) => a.at - b.at)[0]
|
||||
if (due === undefined) break
|
||||
this._now = due.at
|
||||
due.active = false
|
||||
due.callback()
|
||||
}
|
||||
this._timers = this._timers.filter((t) => t.active)
|
||||
this._now = target
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 어댑터 흉내 (KeyBindingService._handleKeyDown / _handleKeyUp 와 같은 순서)
|
||||
// ============================================================
|
||||
|
||||
interface Mods {
|
||||
ctrl?: boolean
|
||||
alt?: boolean
|
||||
}
|
||||
|
||||
function defaultEntries(): ChordBindingEntry[] {
|
||||
const map = createDefaultBindingMap()
|
||||
return KEYBINDING_ACTIONS.flatMap((spec) =>
|
||||
map[spec.id].map((binding) => ({
|
||||
actionId: spec.id,
|
||||
holdMode: spec.holdMode,
|
||||
doublePress: spec.doublePress,
|
||||
binding
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
class Harness {
|
||||
readonly clock = new FakeClock()
|
||||
readonly events: string[] = []
|
||||
readonly detector = new AltGrPressDetector()
|
||||
readonly machine = new ChordStateMachine({
|
||||
clock: this.clock,
|
||||
onTrigger: (e: ChordTriggerEvent) => this.events.push(`${e.actionId}:${e.type}`)
|
||||
})
|
||||
|
||||
constructor() {
|
||||
this.machine.setBindings(defaultEntries())
|
||||
}
|
||||
|
||||
private _key(vk: number, mods: Mods, isAltGr: boolean): string {
|
||||
return bindingKey({
|
||||
device: 'keyboard',
|
||||
code: vk,
|
||||
ctrl: (mods.ctrl ?? false) && !isAltGr,
|
||||
alt: mods.alt ?? false,
|
||||
shift: false,
|
||||
meta: false
|
||||
})
|
||||
}
|
||||
|
||||
down(vk: number, mods: Mods = {}): void {
|
||||
const isAltGr = this.detector.keyDown(vk, this.clock.now())
|
||||
this.machine.noteKeyDown(vk)
|
||||
this.machine.keyDown(this._key(vk, mods, isAltGr), { altGr: isAltGr })
|
||||
}
|
||||
|
||||
up(vk: number, mods: Mods = {}): void {
|
||||
this.detector.keyUp(vk)
|
||||
this.machine.keyUp(this._key(vk, mods, false), vk)
|
||||
}
|
||||
|
||||
/** AltGr 누름: 가짜 LCtrl → RAlt (같은 타임스탬프, RAlt 에는 Ctrl+Alt 가 실린다) */
|
||||
altGrDown(): void {
|
||||
this.down(VK.CtrlLeft, { ctrl: true })
|
||||
this.down(VK.AltRight, { ctrl: true, alt: true })
|
||||
}
|
||||
|
||||
/** AltGr 놓음: 가짜 LCtrl ↑ → RAlt ↑ */
|
||||
altGrUp(): void {
|
||||
this.up(VK.CtrlLeft, { alt: true })
|
||||
this.up(VK.AltRight)
|
||||
}
|
||||
|
||||
wait(ms: number): void {
|
||||
this.clock.advance(ms)
|
||||
}
|
||||
}
|
||||
|
||||
describe('AltGr 배열 — 문자 입력은 음성 트리거를 발동하지 않는다', () => {
|
||||
let h: Harness
|
||||
|
||||
beforeEach(() => {
|
||||
h = new Harness()
|
||||
})
|
||||
|
||||
it("AltGr+Q('@') 를 치면 어떤 트리거도 나가지 않는다", () => {
|
||||
h.altGrDown()
|
||||
h.wait(40)
|
||||
h.down(VK_Q, { ctrl: true, alt: true })
|
||||
h.up(VK_Q, { ctrl: true, alt: true })
|
||||
h.wait(20)
|
||||
h.altGrUp()
|
||||
h.wait(1_000)
|
||||
expect(h.events).toEqual([])
|
||||
})
|
||||
|
||||
it("AltGr 를 700ms 넘게 누른 채 '{' '}' 를 쳐도 트리거가 나가지 않는다", () => {
|
||||
h.altGrDown()
|
||||
h.wait(100)
|
||||
h.down(0x37, { ctrl: true, alt: true }) // 7 → '{'
|
||||
h.up(0x37, { ctrl: true, alt: true })
|
||||
h.wait(300)
|
||||
h.down(0x30, { ctrl: true, alt: true }) // 0 → '}'
|
||||
h.up(0x30, { ctrl: true, alt: true })
|
||||
h.wait(400)
|
||||
h.altGrUp()
|
||||
expect(h.events).toEqual([])
|
||||
})
|
||||
|
||||
it('좌표계로 옮기지 못한 문자 키(vk=null, 예: VK_OEM_102 "<")도 보류를 취소한다', () => {
|
||||
h.altGrDown()
|
||||
h.wait(30)
|
||||
h.detector.keyDown(null, h.clock.now())
|
||||
h.machine.noteKeyDown(null)
|
||||
h.wait(30)
|
||||
h.altGrUp()
|
||||
h.wait(1_000)
|
||||
expect(h.events).toEqual([])
|
||||
})
|
||||
|
||||
it('판별기가 AltGr 를 놓쳐도(타임스탬프 불일치) 문자 키가 Ctrl+RightAlt 보류를 취소한다', () => {
|
||||
h.down(VK.CtrlLeft, { ctrl: true })
|
||||
h.wait(5)
|
||||
h.down(VK.AltRight, { ctrl: true, alt: true }) // 'command' 로 보임 → AltGr 형태 보류
|
||||
h.down(VK_Q, { ctrl: true, alt: true })
|
||||
h.up(VK_Q, { ctrl: true, alt: true })
|
||||
h.up(VK.CtrlLeft, { alt: true })
|
||||
h.up(VK.AltRight)
|
||||
h.wait(1_000)
|
||||
expect(h.events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AltGr 배열 — 기본 dictation · hands-free 가 발동한다', () => {
|
||||
let h: Harness
|
||||
|
||||
beforeEach(() => {
|
||||
h = new Harness()
|
||||
})
|
||||
|
||||
it('AltGr 를 누르고 있으면 유예 뒤 dictation 이 pressed, 놓으면 released', () => {
|
||||
h.altGrDown()
|
||||
expect(h.events).toEqual([])
|
||||
h.wait(ALTGR_CHORD_GRACE_MS)
|
||||
expect(h.events).toEqual(['dictation:pressed'])
|
||||
h.wait(1_500)
|
||||
h.altGrUp()
|
||||
expect(h.events).toEqual(['dictation:pressed', 'dictation:released'])
|
||||
})
|
||||
|
||||
it('AltGr auto-repeat(가짜 LCtrl + RAlt 반복)는 누름을 다시 트리거하지 않는다', () => {
|
||||
h.altGrDown()
|
||||
h.wait(ALTGR_CHORD_GRACE_MS)
|
||||
h.wait(500)
|
||||
h.altGrDown()
|
||||
h.wait(33)
|
||||
h.altGrDown()
|
||||
h.altGrUp()
|
||||
expect(h.events).toEqual(['dictation:pressed', 'dictation:released'])
|
||||
})
|
||||
|
||||
it('AltGr 더블탭은 hands-free 를 켠다', () => {
|
||||
h.altGrDown()
|
||||
h.wait(60)
|
||||
h.altGrUp()
|
||||
h.wait(120)
|
||||
h.altGrDown()
|
||||
h.wait(60)
|
||||
h.altGrUp()
|
||||
expect(h.events).toEqual([
|
||||
'dictation:pressed',
|
||||
'dictation:released',
|
||||
'hands-free:pressed',
|
||||
'hands-free:released'
|
||||
])
|
||||
})
|
||||
|
||||
it("'@' 직후 AltGr 를 탭해도 hands-free 로 세지 않는다 (취소된 누름은 첫 탭이 아니다)", () => {
|
||||
h.altGrDown()
|
||||
h.down(VK_Q, { ctrl: true, alt: true })
|
||||
h.up(VK_Q, { ctrl: true, alt: true })
|
||||
h.altGrUp()
|
||||
h.wait(100)
|
||||
h.altGrDown()
|
||||
h.wait(60)
|
||||
h.altGrUp()
|
||||
expect(h.events).toEqual(['dictation:pressed', 'dictation:released'])
|
||||
})
|
||||
|
||||
it('진짜 LCtrl 을 먼저 누르고 AltGr 를 누르면 command(Ctrl+RightAlt) 다', () => {
|
||||
h.down(VK.CtrlLeft, { ctrl: true })
|
||||
h.wait(80)
|
||||
h.altGrDown() // Windows 는 LCtrl 이 눌려 있어도 가짜 LCtrl 을 보낸다
|
||||
h.wait(ALTGR_CHORD_GRACE_MS)
|
||||
expect(h.events).toEqual(['command:pressed'])
|
||||
h.up(VK.AltRight, { ctrl: true })
|
||||
h.up(VK.CtrlLeft)
|
||||
expect(h.events).toEqual(['command:pressed', 'command:released'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AltGrPressDetector', () => {
|
||||
it('같은 타임스탬프의 LCtrl 직후 RAlt 만 AltGr 다', () => {
|
||||
const d = new AltGrPressDetector()
|
||||
expect(d.keyDown(VK.CtrlLeft, 10)).toBe(false)
|
||||
expect(d.keyDown(VK.AltRight, 10)).toBe(true)
|
||||
})
|
||||
|
||||
it('타임스탬프가 다르거나 사이에 다른 키가 끼면 AltGr 가 아니다', () => {
|
||||
const a = new AltGrPressDetector()
|
||||
a.keyDown(VK.CtrlLeft, 10)
|
||||
expect(a.keyDown(VK.AltRight, 26)).toBe(false)
|
||||
|
||||
const b = new AltGrPressDetector()
|
||||
b.keyDown(VK.CtrlLeft, 10)
|
||||
b.keyDown(VK_Q, 10)
|
||||
expect(b.keyDown(VK.AltRight, 10)).toBe(false)
|
||||
})
|
||||
|
||||
it('RAlt 를 놓으면 다음 누름은 새로 판정한다', () => {
|
||||
const d = new AltGrPressDetector()
|
||||
d.keyDown(VK.CtrlLeft, 10)
|
||||
expect(d.keyDown(VK.AltRight, 10)).toBe(true)
|
||||
d.keyUp(VK.CtrlLeft)
|
||||
d.keyUp(VK.AltRight)
|
||||
expect(d.keyDown(VK.AltRight, 500)).toBe(false)
|
||||
})
|
||||
|
||||
it('reset 은 상태를 비운다', () => {
|
||||
const d = new AltGrPressDetector()
|
||||
d.keyDown(VK.CtrlLeft, 10)
|
||||
d.reset()
|
||||
expect(d.keyDown(VK.AltRight, 10)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
// packages/core/__tests__/meeting-markdown-inline-redteam-r3-27.test.ts
|
||||
// 회귀: DOCX 내보내기가 인라인 강조(**x**)를 별표째 찍어 PDF 와 결과가 달랐다.
|
||||
// 이제 두 경로가 parseInlineRuns 하나로 굵기를 해석해야 한다.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import JSZip from 'jszip'
|
||||
import { markdownToDocx } from '../src/utils/markdown-to-docx'
|
||||
import { markdownToSimpleHtml, parseInlineRuns } from '../src/utils/meeting-markdown'
|
||||
|
||||
interface RunSpan {
|
||||
text: string
|
||||
bold: boolean
|
||||
}
|
||||
|
||||
/** document.xml 에서 본문 run(텍스트·굵기)만 뽑는다. */
|
||||
async function docxRuns(markdown: string): Promise<RunSpan[]> {
|
||||
const buffer = await markdownToDocx(markdown, 'T')
|
||||
const zip = await JSZip.loadAsync(buffer)
|
||||
const file = zip.file('word/document.xml')
|
||||
if (!file) throw new Error('document.xml missing')
|
||||
const xml = await file.async('string')
|
||||
const runs: RunSpan[] = []
|
||||
for (const match of xml.matchAll(/<w:r>([\s\S]*?)<\/w:r>/g)) {
|
||||
const body = match[1]
|
||||
const text = [...body.matchAll(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g)].map((m) => m[1]).join('')
|
||||
const bold = /<w:b\/>|<w:b w:val="(?:true|1|on)"\/>/.test(body)
|
||||
runs.push({ text, bold })
|
||||
}
|
||||
// 첫 run 은 문서 제목('T')
|
||||
return runs.slice(1)
|
||||
}
|
||||
|
||||
function boldTexts(runs: readonly RunSpan[]): string[] {
|
||||
return runs.filter((r) => r.bold).map((r) => r.text)
|
||||
}
|
||||
|
||||
function htmlBoldTexts(html: string): string[] {
|
||||
return [...html.matchAll(/<strong>([\s\S]*?)<\/strong>/g)].map((m) => m[1])
|
||||
}
|
||||
|
||||
describe('parseInlineRuns', () => {
|
||||
it('강조 표기를 벗기고 굵은 조각으로 나눈다', () => {
|
||||
expect(parseInlineRuns('**결정**: 3월 출시')).toEqual([
|
||||
{ text: '결정', bold: true },
|
||||
{ text: ': 3월 출시', bold: false },
|
||||
])
|
||||
expect(parseInlineRuns('a **b** c **d**')).toEqual([
|
||||
{ text: 'a ', bold: false },
|
||||
{ text: 'b', bold: true },
|
||||
{ text: ' c ', bold: false },
|
||||
{ text: 'd', bold: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('짝이 없는 별표·빈 문자열은 그대로 둔다', () => {
|
||||
expect(parseInlineRuns('2 ** 3')).toEqual([{ text: '2 ** 3', bold: false }])
|
||||
expect(parseInlineRuns('****')).toEqual([{ text: '****', bold: false }])
|
||||
expect(parseInlineRuns('')).toEqual([])
|
||||
})
|
||||
|
||||
it('HTML 렌더링은 기존 출력과 같다 (이스케이프 후 <strong>)', () => {
|
||||
expect(markdownToSimpleHtml('- **A** <b> & "c"')).toBe(
|
||||
'<ul>\n<li><strong>A</strong> <b> & "c"</li>\n</ul>',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DOCX 인라인 강조 (PDF 와 동일)', () => {
|
||||
it("'- **A** b' 는 DOCX 와 HTML 에서 같은 굵은 조각을 만든다", async () => {
|
||||
const md = '- **A** b'
|
||||
const runs = await docxRuns(md)
|
||||
expect(runs.map((r) => r.text).join('')).toBe('A b')
|
||||
expect(boldTexts(runs)).toEqual(htmlBoldTexts(markdownToSimpleHtml(md)))
|
||||
expect(boldTexts(runs)).toEqual(['A'])
|
||||
})
|
||||
|
||||
it('제목·체크박스·문단·표 셀에 별표가 남지 않는다', async () => {
|
||||
const md = [
|
||||
'## **결정** 사항',
|
||||
'- [x] **담당**: 윤',
|
||||
'**요약** 줄',
|
||||
'',
|
||||
'| **담당** | 기한 |',
|
||||
'|---|---|',
|
||||
'| **윤** | 3월 |',
|
||||
].join('\n')
|
||||
const runs = await docxRuns(md)
|
||||
const allText = runs.map((r) => r.text).join('')
|
||||
expect(allText).not.toContain('**')
|
||||
expect(allText).toContain('☑ 담당: 윤')
|
||||
// 표 머리글은 전체가 굵다 — 기한도 굵음
|
||||
expect(boldTexts(runs)).toEqual(['결정', '담당', '요약', '담당', '기한', '윤'])
|
||||
expect(htmlBoldTexts(markdownToSimpleHtml(md))).toEqual(['결정', '담당', '요약', '담당', '윤'])
|
||||
})
|
||||
})
|
||||
|
|
@ -83,9 +83,13 @@ describe('chunkTranscriptByLines / looksTruncatedRewrite', () => {
|
|||
expect(chunkTranscriptByLines(text, 26)).toEqual(['[00:01] aaaa\n[00:02] bbbb', '[00:03] cccc'])
|
||||
})
|
||||
|
||||
it('한도보다 긴 한 줄은 그 줄 하나로 한 조각이 된다', () => {
|
||||
// redteam r3-3: 줄바꿈 없는 긴 전사도 한도 이하 조각으로 나눈다(이전: 긴 한 줄을 통째로 한 조각 — 한도를 넘겼다).
|
||||
it('한도보다 긴 한 줄도 한도 이하 조각으로 나뉘고, 내용은 빠지지 않는다', () => {
|
||||
const long = `[00:01] ${'x'.repeat(50)}`
|
||||
expect(chunkTranscriptByLines(`${long}\n[00:02] y`, 20)).toEqual([long, '[00:02] y'])
|
||||
const chunks = chunkTranscriptByLines(`${long}\n[00:02] y`, 20)
|
||||
expect(chunks.every((c) => c.length <= 20)).toBe(true)
|
||||
expect(chunks.join('').split('x').length - 1).toBe(50)
|
||||
expect(chunks[chunks.length - 1]).toBe('[00:02] y')
|
||||
})
|
||||
|
||||
it('시각 줄이 크게 줄었거나 비었으면 잘린 것으로 본다', () => {
|
||||
|
|
|
|||
59
packages/core/__tests__/template-field-policy.test.ts
Normal file
59
packages/core/__tests__/template-field-policy.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
findTemplateFieldIdProblem,
|
||||
nextTemplateFieldId,
|
||||
nextTemplateFieldNumber,
|
||||
templateFieldIdProblems,
|
||||
} from '../src/template-field-policy'
|
||||
|
||||
const ids = (...values: string[]) => values.map((id) => ({ id }))
|
||||
|
||||
describe('templateFieldIdProblems', () => {
|
||||
it('유효한 id는 문제가 없다', () => {
|
||||
expect(templateFieldIdProblems(ids('a', 'b', 'c'))).toEqual([null, null, null])
|
||||
expect(findTemplateFieldIdProblem(ids('a', 'b'))).toBeNull()
|
||||
})
|
||||
|
||||
it('빈 id와 공백뿐인 id는 empty', () => {
|
||||
expect(templateFieldIdProblems(ids('a', '', ' '))).toEqual([null, 'empty', 'empty'])
|
||||
})
|
||||
|
||||
it('겹치는 id는 모든 행이 duplicate', () => {
|
||||
expect(templateFieldIdProblems(ids('field3', 'x', 'field3'))).toEqual(['duplicate', null, 'duplicate'])
|
||||
})
|
||||
|
||||
it('id는 정확히 비교한다(자리표시자 키가 정확 일치이므로)', () => {
|
||||
expect(templateFieldIdProblems(ids('a', ' a', 'A'))).toEqual([null, null, null])
|
||||
})
|
||||
|
||||
it('findTemplateFieldIdProblem은 첫 문제의 위치와 id를 돌려준다', () => {
|
||||
expect(findTemplateFieldIdProblem(ids('a', 'b', 'b'))).toEqual({ index: 1, id: 'b', problem: 'duplicate' })
|
||||
expect(findTemplateFieldIdProblem(ids('a', ''))).toEqual({ index: 1, id: '', problem: 'empty' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextTemplateFieldId', () => {
|
||||
it('빈 목록이면 field1', () => {
|
||||
expect(nextTemplateFieldId([])).toBe('field1')
|
||||
})
|
||||
|
||||
it('중간 필드를 지운 뒤에도 기존 id를 다시 만들지 않는다 (회귀: 개수 기반 번호)', () => {
|
||||
// [field1, field2, field3]에서 field1을 지움 → 개수+1 = field3 (중복)
|
||||
expect(nextTemplateFieldId(ids('field2', 'field3'))).toBe('field4')
|
||||
})
|
||||
|
||||
it('사용자가 이름을 바꾼 필드도 번호 계산에 넣는다', () => {
|
||||
expect(nextTemplateFieldNumber(ids('recipient', 'field7', 'body'))).toBe(8)
|
||||
expect(nextTemplateFieldId(ids('recipient', 'body'))).toBe('field1')
|
||||
})
|
||||
|
||||
it('추가·삭제를 섞어 반복해도 id가 겹치지 않는다', () => {
|
||||
const fields = ids('field1')
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
fields.push({ id: nextTemplateFieldId(fields) })
|
||||
fields.push({ id: nextTemplateFieldId(fields) })
|
||||
fields.splice(i % fields.length, 1)
|
||||
expect(findTemplateFieldIdProblem(fields)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
107
packages/core/__tests__/transcript-chunking-redteam-r3-3.test.ts
Normal file
107
packages/core/__tests__/transcript-chunking-redteam-r3-3.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// 줄바꿈 없는 긴 전사(자막·파일 전사 히스토리, 폰 회의 raw_transcript)도 한도 이하 조각으로 나뉘어야 한다.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { splitTextToFit } from '../src/text-chunking'
|
||||
import { chunkTranscriptByLines, looksTruncatedRewrite } from '../src/meeting-transcript'
|
||||
import {
|
||||
MEETING_MAP_CHUNK_CHARS,
|
||||
planTranscriptCondense,
|
||||
selectTranscriptExcerpts,
|
||||
} from '../src/meeting-llm-input'
|
||||
|
||||
/** 자막 세션 fullText 처럼 세그먼트를 공백으로 이어 붙인 한 줄 전사 (약 20,000자) */
|
||||
function singleLineTranscript(targetChars: number): string {
|
||||
const sentences: string[] = []
|
||||
let length = 0
|
||||
for (let i = 0; length < targetChars; i++) {
|
||||
const sentence =
|
||||
i === 437 ? '출시 담당자는 김민수로 결정했습니다.' : `오늘 회의의 ${i}번째 안건에 대해 이야기를 나눴습니다.`
|
||||
sentences.push(sentence)
|
||||
length += sentence.length + 1
|
||||
}
|
||||
return sentences.join(' ')
|
||||
}
|
||||
|
||||
describe('splitTextToFit', () => {
|
||||
it('들어가면 그대로 둔다', () => {
|
||||
expect(splitTextToFit(' 짧은 문장. ', 100)).toEqual(['짧은 문장.'])
|
||||
expect(splitTextToFit(' ', 100)).toEqual([])
|
||||
})
|
||||
|
||||
it('문장 경계를 먼저 쓴다', () => {
|
||||
expect(splitTextToFit('첫 문장입니다. 둘째 문장입니다. 셋째', 20)).toEqual(['첫 문장입니다. 둘째 문장입니다.', '셋째'])
|
||||
})
|
||||
|
||||
it('소수점은 문장 경계가 아니다', () => {
|
||||
const pieces = splitTextToFit('매출은 3.5 억 원 증가 예상입니다', 12)
|
||||
expect(pieces.every((p) => p.length <= 12)).toBe(true)
|
||||
expect(pieces.join(' ')).toBe('매출은 3.5 억 원 증가 예상입니다')
|
||||
expect(pieces.some((p) => p.endsWith('3.'))).toBe(false)
|
||||
})
|
||||
|
||||
it('문장 경계가 너무 앞에 있으면 공백에서 자른다', () => {
|
||||
expect(splitTextToFit('네. 그러면 다음 주 월요일까지 초안을 보내겠습니다', 20)).toEqual([
|
||||
'네. 그러면 다음 주 월요일까지',
|
||||
'초안을 보내겠습니다',
|
||||
])
|
||||
})
|
||||
|
||||
it('전각 문장 부호는 뒤에 공백이 없어도 경계다', () => {
|
||||
expect(splitTextToFit('今日は晴れです。明日は雨です。', 9)).toEqual(['今日は晴れです。', '明日は雨です。'])
|
||||
})
|
||||
|
||||
it('경계가 없으면 글자 수로 자르고 서로게이트 쌍은 가르지 않는다', () => {
|
||||
expect(splitTextToFit('x'.repeat(25), 10)).toEqual(['x'.repeat(10), 'x'.repeat(10), 'x'.repeat(5)])
|
||||
const emoji = `${'a'.repeat(4)}😀${'b'.repeat(4)}`
|
||||
const pieces = splitTextToFit(emoji, 5)
|
||||
expect(pieces.join('')).toBe(emoji)
|
||||
expect(pieces.every((p) => p.length <= 5)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chunkTranscriptByLines — 줄바꿈 없는 긴 줄', () => {
|
||||
it('단일 줄 20,000자 전사도 한도 이하 조각 여러 개로 나뉜다', () => {
|
||||
const transcript = singleLineTranscript(20_000)
|
||||
expect(transcript.includes('\n')).toBe(false)
|
||||
const chunks = chunkTranscriptByLines(transcript, 1_500)
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(14)
|
||||
expect(chunks.every((c) => c.length <= 1_500)).toBe(true)
|
||||
// 내용은 잃지 않는다(조각 경계의 공백만 줄바꿈으로 바뀐다)
|
||||
expect(chunks.join(' ').replace(/\s+/g, ' ')).toBe(transcript)
|
||||
// 문장 경계에서 끊었다
|
||||
expect(chunks.every((c) => c.endsWith('.'))).toBe(true)
|
||||
})
|
||||
|
||||
it('긴 시각 줄을 자르면 조각마다 [MM:SS] [화자] 머리를 붙인다', () => {
|
||||
const line = `[12:34] [화자 1] ${'가나다라 마바사. '.repeat(40).trim()}`
|
||||
const chunks = chunkTranscriptByLines(`[00:01] 짧은 줄\n${line}`, 120)
|
||||
expect(chunks.every((c) => c.length <= 120)).toBe(true)
|
||||
const pieces = chunks.flatMap((c) => c.split('\n')).slice(1)
|
||||
expect(pieces.length).toBeGreaterThan(1)
|
||||
expect(pieces.every((p) => p.startsWith('[12:34] [화자 1] '))).toBe(true)
|
||||
})
|
||||
|
||||
it('다시 쓰기 조각 하나를 그대로 돌려받으면 잘린 것으로 보지 않는다', () => {
|
||||
const chunks = chunkTranscriptByLines(singleLineTranscript(20_000), 1_500)
|
||||
for (const chunk of chunks) expect(looksTruncatedRewrite(chunk, chunk)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('meeting-llm-input — 단일 줄 전사', () => {
|
||||
it('planTranscriptCondense 는 프록시 한도 안쪽 조각 여러 개를 낸다', () => {
|
||||
const transcript = singleLineTranscript(20_000)
|
||||
const chunks = planTranscriptCondense(transcript, 8_000)
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(4)
|
||||
expect(chunks.every((c) => c.length <= MEETING_MAP_CHUNK_CHARS)).toBe(true)
|
||||
})
|
||||
|
||||
it('selectTranscriptExcerpts 는 질문과 관련된 구간을 실제로 넣는다(빈 발췌 금지)', () => {
|
||||
const transcript = singleLineTranscript(20_000)
|
||||
const excerpt = selectTranscriptExcerpts(transcript, '출시 담당자는 누구로 결정했나요?', 7_000)
|
||||
expect(excerpt.complete).toBe(false)
|
||||
expect(excerpt.totalChunks).toBeGreaterThan(1)
|
||||
expect(excerpt.includedChunks).toBeGreaterThan(0)
|
||||
expect(excerpt.text.length).toBeGreaterThan(0)
|
||||
expect(excerpt.text.length).toBeLessThanOrEqual(7_000)
|
||||
expect(excerpt.text).toContain('출시 담당자는 김민수로 결정했습니다.')
|
||||
})
|
||||
})
|
||||
|
|
@ -26,6 +26,10 @@
|
|||
"types": "./src/errors.ts",
|
||||
"default": "./src/errors.ts"
|
||||
},
|
||||
"./voice-error-message": {
|
||||
"types": "./src/voice-error-message.ts",
|
||||
"default": "./src/voice-error-message.ts"
|
||||
},
|
||||
"./ipc-channels": {
|
||||
"types": "./src/ipc-channels.ts",
|
||||
"default": "./src/ipc-channels.ts"
|
||||
|
|
|
|||
|
|
@ -25,10 +25,19 @@ export const CAPTION_STREAMING_DEFAULTS = {
|
|||
/** 다음 인식에 넘기는 앞 문맥 길이 (자) */
|
||||
contextChars: 200,
|
||||
/** 문맥 다듬기 결과가 원문에서 이 비율 이상 바뀌면 버린다 */
|
||||
refineMaxChangeRatio: 0.35
|
||||
refineMaxChangeRatio: 0.35,
|
||||
/** 인식이 연속 실패하면 첫 재시도까지 기다리는 시간 (실패마다 두 배) */
|
||||
failureBackoffBaseMs: 500,
|
||||
/** 연속 실패 재시도 간격 상한 */
|
||||
failureBackoffMaxMs: 8000,
|
||||
/** 인식이 계속 실패하는 동안 들고 있을 오디오 상한 — 넘는 앞부분은 버린다 */
|
||||
failureRetainMs: 24000
|
||||
} as const
|
||||
|
||||
export type CaptionTickAction = 'finalize' | 'force-commit' | 'partial' | 'trim-idle' | 'wait'
|
||||
/**
|
||||
* - drop-overflow: 인식이 계속 실패해 버퍼가 상한을 넘었다 — 앞부분을 버린다(무한 증가·전체 재전송 방지).
|
||||
*/
|
||||
export type CaptionTickAction = 'finalize' | 'force-commit' | 'partial' | 'trim-idle' | 'drop-overflow' | 'wait'
|
||||
|
||||
export interface CaptionTickInput {
|
||||
/** 확정되지 않은 오디오 길이 */
|
||||
|
|
@ -41,6 +50,20 @@ export interface CaptionTickInput {
|
|||
sincePartialMs: number
|
||||
/** 인식이 진행 중인가 (한 트랙에 한 건만) */
|
||||
busy: boolean
|
||||
/** 연속 인식 실패 수 (성공하면 0). 생략하면 0 */
|
||||
consecutiveFailures?: number
|
||||
/** 마지막 실패 이후 지난 시간 */
|
||||
sinceFailureMs?: number
|
||||
}
|
||||
|
||||
/** 연속 실패 n 번 뒤 다음 인식까지 기다릴 시간 (지수 백오프). 실패가 없으면 0. */
|
||||
export function captionFailureBackoffMs(
|
||||
consecutiveFailures: number,
|
||||
opts: Partial<typeof CAPTION_STREAMING_DEFAULTS> = {}
|
||||
): number {
|
||||
if (consecutiveFailures <= 0) return 0
|
||||
const o = { ...CAPTION_STREAMING_DEFAULTS, ...opts }
|
||||
return Math.min(o.failureBackoffMaxMs, o.failureBackoffBaseMs * 2 ** Math.min(consecutiveFailures - 1, 16))
|
||||
}
|
||||
|
||||
/** 매 틱마다 무엇을 할지 정한다. */
|
||||
|
|
@ -51,6 +74,13 @@ export function decideCaptionTick(
|
|||
const o = { ...CAPTION_STREAMING_DEFAULTS, ...opts }
|
||||
if (input.busy) return 'wait'
|
||||
if (!input.hasVoice) return input.bufferMs > o.idleKeepMs ? 'trim-idle' : 'wait'
|
||||
// 인식이 계속 실패하면(사이드카 다운·모델 로드 실패) 버퍼를 자르지 못한다 — 매 틱 전체 버퍼로
|
||||
// 재시도하지 않고 물러서며, 상한을 넘은 앞부분은 버린다.
|
||||
const failures = input.consecutiveFailures ?? 0
|
||||
if (failures > 0) {
|
||||
if (input.bufferMs > o.failureRetainMs) return 'drop-overflow'
|
||||
if ((input.sinceFailureMs ?? Number.MAX_SAFE_INTEGER) < captionFailureBackoffMs(failures, o)) return 'wait'
|
||||
}
|
||||
if (input.bufferMs < o.minAudioMs) return 'wait'
|
||||
if (input.sinceVoiceMs >= o.silenceCommitMs) return 'finalize'
|
||||
if (input.bufferMs >= o.maxBufferMs) return 'force-commit'
|
||||
|
|
|
|||
|
|
@ -2,12 +2,38 @@
|
|||
// 사용자 사전 편집 정책 — 순수 함수만 둔다 (DB/IPC/UI 의존 없음).
|
||||
// 데스크톱 DictionaryService(쓰기 규칙)와 DictionaryPage(폼 → 요청 변환)가 같은 규칙을 공유한다.
|
||||
|
||||
import { ErrorCode } from './errors'
|
||||
import { D3ROError, ErrorCode } from './errors'
|
||||
import { charLength } from './instruction-policy'
|
||||
import type { DictionaryAddParams, DictionaryEntry, DictionaryUpdateParams } from './types'
|
||||
|
||||
export type DictionaryCategory = DictionaryEntry['category']
|
||||
|
||||
/** 단어를 정규화한다. 공백뿐이면 null (= 유효하지 않음). */
|
||||
/**
|
||||
* 서버 쓰기 제약 — 마이그레이션 20260821000005 의 normalize_dictionary_word 트리거와 같다:
|
||||
* char_length(btrim(word)) ≤ 120, char_length(btrim(pronunciation)) ≤ 200 (코드 포인트 기준).
|
||||
* 로컬이 이보다 긴 값을 받아들이면 서버가 22023 으로 거부하고, 아웃박스가 재시도 끝에 항목을 영구 보류해
|
||||
* 다른 기기로 동기화되지 않는다. 모바일 사전 서비스·서버 포터빌리티 가져오기도 같은 값을 쓴다.
|
||||
*/
|
||||
export const DICTIONARY_LIMITS = { word: 120, pronunciation: 200 } as const
|
||||
|
||||
export type DictionaryLimitField = keyof typeof DICTIONARY_LIMITS
|
||||
|
||||
/** 사전 항목을 저장할 수 없는 이유 */
|
||||
export type DictionaryWriteProblem =
|
||||
| { reason: 'empty-word' }
|
||||
| { reason: 'too-long'; field: DictionaryLimitField; max: number }
|
||||
|
||||
/** 정규화한 값이 서버 길이 제한을 넘으면 그 문제, 아니면 null. */
|
||||
export function dictionaryLengthProblem(
|
||||
field: DictionaryLimitField,
|
||||
normalized: string | null | undefined
|
||||
): DictionaryWriteProblem | null {
|
||||
if (normalized === null || normalized === undefined) return null
|
||||
const max = DICTIONARY_LIMITS[field]
|
||||
return charLength(normalized) > max ? { reason: 'too-long', field, max } : null
|
||||
}
|
||||
|
||||
/** 단어를 정규화한다. 공백뿐이면 null (= 유효하지 않음). 길이 제한은 validateDictionaryDraft 가 본다. */
|
||||
export function normalizeDictionaryWord(raw: string): string | null {
|
||||
const word = raw.trim()
|
||||
return word.length > 0 ? word : null
|
||||
|
|
@ -57,9 +83,9 @@ export interface DictionaryUpdatePatch {
|
|||
|
||||
export type DictionaryUpdatePatchResult =
|
||||
| { ok: true; patch: DictionaryUpdatePatch }
|
||||
| { ok: false; reason: 'empty-word' }
|
||||
| ({ ok: false } & DictionaryWriteProblem)
|
||||
|
||||
/** 갱신 요청을 검증·정규화해 쓰기 패치로 바꾼다. */
|
||||
/** 갱신 요청을 검증·정규화해 쓰기 패치로 바꾼다. 준 필드만 검사한다(빈 단어·서버 길이 초과 거부). */
|
||||
export function buildDictionaryUpdatePatch(
|
||||
params: DictionaryUpdateParams
|
||||
): DictionaryUpdatePatchResult {
|
||||
|
|
@ -67,14 +93,66 @@ export function buildDictionaryUpdatePatch(
|
|||
if (params.word !== undefined) {
|
||||
const word = normalizeDictionaryWord(params.word)
|
||||
if (word === null) return { ok: false, reason: 'empty-word' }
|
||||
const tooLong = dictionaryLengthProblem('word', word)
|
||||
if (tooLong) return { ok: false, ...tooLong }
|
||||
patch.word = word
|
||||
}
|
||||
const pronunciation = normalizeDictionaryPronunciation(params.pronunciation)
|
||||
if (pronunciation !== undefined) patch.pronunciation = pronunciation
|
||||
if (pronunciation !== undefined) {
|
||||
const tooLong = dictionaryLengthProblem('pronunciation', pronunciation)
|
||||
if (tooLong) return { ok: false, ...tooLong }
|
||||
patch.pronunciation = pronunciation
|
||||
}
|
||||
if (params.category !== undefined) patch.category = params.category
|
||||
return { ok: true, patch }
|
||||
}
|
||||
|
||||
/** 새 항목의 정규화된 쓰기 값 */
|
||||
export interface DictionaryDraft {
|
||||
word: string
|
||||
pronunciation: string | null
|
||||
}
|
||||
|
||||
export type DictionaryDraftResult =
|
||||
| { ok: true; draft: DictionaryDraft }
|
||||
| ({ ok: false } & DictionaryWriteProblem)
|
||||
|
||||
/**
|
||||
* 새 항목(추가·가져오기)을 검증·정규화한다 (순수 함수).
|
||||
* 빈 단어와 서버 길이 제한을 넘는 단어·발음을 거부한다 — 서버 트리거와 같은 기준.
|
||||
*/
|
||||
export function validateDictionaryDraft(input: {
|
||||
word: string
|
||||
pronunciation?: string | null
|
||||
}): DictionaryDraftResult {
|
||||
const word = normalizeDictionaryWord(input.word)
|
||||
if (word === null) return { ok: false, reason: 'empty-word' }
|
||||
const wordProblem = dictionaryLengthProblem('word', word)
|
||||
if (wordProblem) return { ok: false, ...wordProblem }
|
||||
const pronunciation = normalizeDictionaryPronunciation(input.pronunciation) ?? null
|
||||
const pronunciationProblem = dictionaryLengthProblem('pronunciation', pronunciation)
|
||||
if (pronunciationProblem) return { ok: false, ...pronunciationProblem }
|
||||
return { ok: true, draft: { word, pronunciation } }
|
||||
}
|
||||
|
||||
/**
|
||||
* 쓰기 문제를 IPC 로 넘길 D3ROError 로 바꾼다.
|
||||
* details 의 field·max 로 UI 가 어떤 필드가 몇 자를 넘었는지 보여줄 수 있다.
|
||||
*/
|
||||
export function dictionaryWriteProblemError(problem: DictionaryWriteProblem): D3ROError {
|
||||
if (problem.reason === 'empty-word') {
|
||||
return new D3ROError(ErrorCode.DictionaryImportInvalidFormat, 'Dictionary word is empty', {
|
||||
reason: problem.reason,
|
||||
field: 'word'
|
||||
})
|
||||
}
|
||||
return new D3ROError(
|
||||
ErrorCode.DictionaryImportInvalidFormat,
|
||||
`Dictionary ${problem.field} exceeds ${problem.max} characters`,
|
||||
{ reason: problem.reason, field: problem.field, max: problem.max }
|
||||
)
|
||||
}
|
||||
|
||||
/** SQLite UNIQUE 제약 위반 여부 (better-sqlite3 오류 메시지 기준). */
|
||||
export function isUniqueConstraintViolation(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
|
|
|||
|
|
@ -187,6 +187,23 @@ export const PASTE_INSERTION_THRESHOLD_CHARS = 200
|
|||
* 텍스트가 되므로 키코드 재구성은 원리적으로 불가능하다. 커밋된 텍스트를 읽어
|
||||
* diff 하는 방식만이 CJK 를 포함해 정확하다.
|
||||
*/
|
||||
/**
|
||||
* 두 텍스트 사이의 편집이 문서 끝에서 일어났는가 (케어렛을 모를 때 "커서 = 문서 끝" 가정의 근거).
|
||||
*
|
||||
* computeTypedDelta 와 같은 공통 접두/접미 분해를 쓴다. 바뀐 구간 뒤에 남은 공통 접미가 없으면
|
||||
* 끝에서 쓰거나 지운 것이다. 같으면(편집 없음) true.
|
||||
*/
|
||||
export function isEditAtTextEnd(prevText: string, nextText: string): boolean {
|
||||
if (prevText === nextText) return true
|
||||
let prefix = 0
|
||||
const maxPrefix = Math.min(prevText.length, nextText.length)
|
||||
while (prefix < maxPrefix && prevText[prefix] === nextText[prefix]) prefix += 1
|
||||
const maxSuffix = Math.min(prevText.length - prefix, nextText.length - prefix)
|
||||
// 공통 접미가 한 글자라도 남으면 편집 지점 뒤에 기존 텍스트가 있다 — 문서 중간 편집이다.
|
||||
if (maxSuffix <= 0) return true
|
||||
return prevText[prevText.length - 1] !== nextText[nextText.length - 1]
|
||||
}
|
||||
|
||||
export function computeTypedDelta(
|
||||
prevText: string,
|
||||
nextText: string,
|
||||
|
|
@ -399,6 +416,11 @@ export type SuggestionSkipReason =
|
|||
| 'cooldown'
|
||||
/** 포커스만 옮겨 왔을 뿐 이 필드에서 실제로 타이핑하지 않았다 (마우스 클릭 등) */
|
||||
| 'not-typing'
|
||||
/**
|
||||
* 케어렛 위치를 알 수 없고(예: Notepad — ValuePattern 만 제공) 마지막 편집도 문서 끝이 아니었다.
|
||||
* 접두가 "문서 끝" 이라 실제 커서 위치와 무관하다 — 수락하면 문서 중간에 엉뚱한 문장이 붙는다.
|
||||
*/
|
||||
| 'caret-unknown'
|
||||
|
||||
export type SuggestionDecision =
|
||||
| { action: 'request'; prefix: string }
|
||||
|
|
@ -423,6 +445,11 @@ export interface SuggestionPolicyInput {
|
|||
typedRecently: boolean
|
||||
/** 어렛 앞 스트 */
|
||||
prefix: string
|
||||
/**
|
||||
* prefix 가 실제 커서 앞 텍스트라고 믿을 수 있는가. 케어렛 오프셋을 알면 true, 모르면
|
||||
* 마지막 편집이 문서 끝에서 일어났을 때만 true. 생략하면 true(호환).
|
||||
*/
|
||||
caretReliable?: boolean
|
||||
idleMs: number
|
||||
triggerDelayMs: number
|
||||
minPrefixChars: number
|
||||
|
|
@ -448,6 +475,9 @@ export function decideSuggestion(input: SuggestionPolicyInput): SuggestionDecisi
|
|||
if (input.appName && isAppExcluded(input.appName, input.excludedApps)) {
|
||||
return { action: 'clear', reason: 'excluded-app' }
|
||||
}
|
||||
// 케어렛을 모르는 필드에서 문서 중간을 고쳤다 — 접두(문서 끝)가 커서 위치와 무관하다.
|
||||
// 떠 있는 세션도 닫는다: 수락하면 실제 커서(문서 중간)에 문서 끝의 이어 쓰기가 붙는다.
|
||||
if (input.caretReliable === false) return { action: 'clear', reason: 'caret-unknown' }
|
||||
// 포커스만 옮겨 왔을 뿐(마우스 클릭 등) 이 필드에서 아무것도 치지 않았으면 제안하지 않는다.
|
||||
//
|
||||
// 유휴 판정이 키보드 기준이라, 필드에 이미 차 있던 텍스트로 클릭만 해도 (마지막
|
||||
|
|
@ -761,7 +791,17 @@ export function shouldPersistSuggestionPrefix(input: {
|
|||
appName: string | null
|
||||
}): boolean {
|
||||
if (!input.learnTypedText) return false
|
||||
return !(input.appName && isAppExcluded(input.appName, LEARNING_EXCLUDED_APPS))
|
||||
return shouldLearnFromApp(input.appName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 앱에서 나온 텍스트를 개인 코퍼스(문구·그래프·표본)에 넣어도 되는가 — 학습 제외 앱의 정본.
|
||||
*
|
||||
* 출처(타이핑·받아쓰기·수락한 제안)와 무관하다: 터미널·에디터·에이전트 허브에 보낸 개발 지시는
|
||||
* 어떤 경로로 들어왔든 KakaoTalk 제안으로 새면 안 된다. 앱을 모르면(null) 학습한다.
|
||||
*/
|
||||
export function shouldLearnFromApp(appName: string | null | undefined): boolean {
|
||||
return !(appName && isAppExcluded(appName, LEARNING_EXCLUDED_APPS))
|
||||
}
|
||||
|
||||
/** 공백을 뺀 글자 중 문자(모든 언어)가 이 비율 이상이어야 문장으로 본다. */
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
// 한 액션에 여러 바인딩이 붙고, 여러 액션이 한 바인딩을 공유하기 때문이다.
|
||||
|
||||
import { TIMING } from './constants'
|
||||
import { bindingKey, isModifierKeyCode, normalizeBinding } from './keybinding'
|
||||
import { VK, bindingKey, isModifierKeyCode, normalizeBinding } from './keybinding'
|
||||
import type { BindingDevice, KeyBinding, KeyBindingActionId } from './keybinding'
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -55,6 +55,18 @@ export interface ChordReleasedComponent {
|
|||
code: number
|
||||
}
|
||||
|
||||
/** keyDown 의 부가 정보 (어댑터가 관측한 눌림의 성격). */
|
||||
export interface ChordKeyDownOptions {
|
||||
/**
|
||||
* 이 눌림이 AltGr(가짜 LCtrl + RightAlt)이다 — AltGrPressDetector 판정.
|
||||
*
|
||||
* AltGr 배열(독일어·프랑스어·폴란드어·북유럽 등)에서 오른쪽 Alt 는 문자 입력(@, €, {, \)의
|
||||
* 시작이다. 바인딩 모양과 무관하게 이 누름으로 발동하는 트리거를 보류해, 이어서 문자 키가
|
||||
* 눌리면(noteKeyDown) 취소한다.
|
||||
*/
|
||||
altGr?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간 포트. 실제 구현은 Date.now / setTimeout, 테스트는 가짜 타이머를 넣는다.
|
||||
* schedule 은 취소 함수를 돌려준다 — 타이머 핸들 타입을 core 가 알 필요가 없다.
|
||||
|
|
@ -266,8 +278,23 @@ export class ChordStateMachine {
|
|||
|
||||
// ── 입력 ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 바인딩 여부와 무관하게 모든 키보드 눌림을 알린다 (keyDown 보다 먼저 호출).
|
||||
*
|
||||
* 비수정자 키가 눌렸다 = 보류 중인 AltGr 형태 트리거는 조합이나 문자 입력(AltGr+Q='@')의
|
||||
* 일부였다 → 취소한다. 바인딩된 키만 보면 바인딩되지 않은 문자 키가 보류를 끝내지 못해,
|
||||
* 문자 하나를 칠 때마다 보류 트리거가 발동한다.
|
||||
*
|
||||
* vk 가 null 이면 어댑터가 정본 좌표계로 옮기지 못한 키다. 수정자 키는 모두 옮길 수
|
||||
* 있으므로 비수정자로 본다. 수정자 키(가짜 LCtrl · Shift 추가 등)는 보류를 건드리지 않는다.
|
||||
*/
|
||||
noteKeyDown(vk: number | null): void {
|
||||
if (vk !== null && isModifierKeyCode(vk)) return
|
||||
this._cancelPendingExcept(null)
|
||||
}
|
||||
|
||||
/** 키/버튼 눌림. eventKey 는 이벤트의 bindingKey. */
|
||||
keyDown(eventKey: string): void {
|
||||
keyDown(eventKey: string, options: ChordKeyDownOptions = {}): void {
|
||||
const entries = this._byKey.get(eventKey)
|
||||
if (entries === undefined || entries.length === 0) return
|
||||
|
||||
|
|
@ -312,7 +339,7 @@ export class ChordStateMachine {
|
|||
|
||||
for (const trigger of triggers) {
|
||||
this._debug(`Key binding pressed: "${trigger.entry.actionId}" (double=${isDoublePress})`)
|
||||
if (trigger.entry.altGrShaped) {
|
||||
if (trigger.entry.altGrShaped || options.altGr === true) {
|
||||
this._deferPressed(trigger)
|
||||
} else {
|
||||
this._emitPressed(trigger)
|
||||
|
|
@ -393,13 +420,19 @@ export class ChordStateMachine {
|
|||
}, this._altGrGraceMs)
|
||||
}
|
||||
|
||||
private _cancelPendingExcept(eventKey: string): void {
|
||||
/** 보류 중인 트리거를 취소한다. eventKey 가 주어지면 그 바인딩의 보류는 남긴다. */
|
||||
private _cancelPendingExcept(eventKey: string | null): void {
|
||||
for (const trigger of this._pending) {
|
||||
if (trigger.key === eventKey) continue
|
||||
trigger.cancelTimer?.()
|
||||
trigger.cancelTimer = null
|
||||
trigger.status = 'cancelled'
|
||||
this._pending.delete(trigger)
|
||||
// 취소된 누름은 더블프레스의 첫 탭이 아니다 — '@' 를 치고 곧바로 오른쪽 Alt 를
|
||||
// 탭하면 hands-free 가 켜지는 일을 막는다.
|
||||
if (this._lastPress.get(trigger.key) === trigger.pressedAt) {
|
||||
this._lastPress.delete(trigger.key)
|
||||
}
|
||||
this._debug(`AltGr 형태 보류 트리거 취소 (다른 키가 이어짐): "${trigger.entry.actionId}"`)
|
||||
}
|
||||
}
|
||||
|
|
@ -434,3 +467,66 @@ export class ChordStateMachine {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AltGr 눌림 판별 (Windows)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Windows AltGr 판별기 (순수 정책).
|
||||
*
|
||||
* AltGr 배열에서 오른쪽 Alt 를 누르면 Windows 는 가짜 LCtrl 눌림을 먼저 보내고 곧바로
|
||||
* RAlt 눌림을 보낸다. 두 이벤트의 타임스탬프는 같다. 후킹 라이브러리(libuiohook)는 이 가짜
|
||||
* LCtrl 을 걸러내지 않고 Ctrl 수정자를 켜므로, RAlt 눌림이 `Ctrl+RightAlt` 로 보인다 —
|
||||
* 기본 'command' 바인딩과 겹치고 기본 'dictation'(RightAlt 단독)과는 절대 맞지 않는다.
|
||||
*
|
||||
* 판정: RAlt 눌림 직전 이벤트가 같은 타임스탬프의 LCtrl 눌림이고, 그 LCtrl 이 그 전까지
|
||||
* 놓여 있었다면 AltGr 다. 사용자가 LCtrl 을 먼저 누르고 있었다면(Ctrl+AltGr) 진짜 Ctrl 이다.
|
||||
* AltGr 를 누른 채 반복되는 RAlt 눌림(auto-repeat)도 같은 AltGr 로 본다.
|
||||
*
|
||||
* 후킹 이벤트에 스캔 코드(0x21D)·주입 플래그가 없어 타임스탬프로만 판별한다. 그래서 진짜
|
||||
* LCtrl 과 RAlt 를 같은 시계 틱 안에 함께 누르면 AltGr 로 오인할 수 있다 — 결과는 보류가
|
||||
* 붙은 RightAlt 단독 눌림이므로 안전한 쪽으로 틀린다.
|
||||
*/
|
||||
export class AltGrPressDetector {
|
||||
private _ctrlLeftDown = false
|
||||
private _altGrHeld = false
|
||||
/** 직전 키보드 눌림 (가짜 LCtrl 짝 판정용) */
|
||||
private _last: { vk: number | null; time: number; ctrlLeftWasDown: boolean } | null = null
|
||||
|
||||
/**
|
||||
* 키보드 눌림을 관측한다. 모든 눌림을 넣어야 한다(좌표계로 옮기지 못한 키는 vk=null).
|
||||
* 반환값: 이 눌림이 AltGr(가짜 LCtrl + RAlt)인가.
|
||||
*/
|
||||
keyDown(vk: number | null, time: number): boolean {
|
||||
const previous = this._last
|
||||
this._last = { vk, time, ctrlLeftWasDown: this._ctrlLeftDown }
|
||||
|
||||
if (vk === VK.CtrlLeft) {
|
||||
this._ctrlLeftDown = true
|
||||
return false
|
||||
}
|
||||
if (vk !== VK.AltRight) return false
|
||||
|
||||
if (this._altGrHeld) return true
|
||||
this._altGrHeld =
|
||||
previous !== null &&
|
||||
previous.vk === VK.CtrlLeft &&
|
||||
previous.time === time &&
|
||||
!previous.ctrlLeftWasDown
|
||||
return this._altGrHeld
|
||||
}
|
||||
|
||||
/** 키보드 놓임을 관측한다. */
|
||||
keyUp(vk: number | null): void {
|
||||
if (vk === VK.CtrlLeft) this._ctrlLeftDown = false
|
||||
if (vk === VK.AltRight) this._altGrHeld = false
|
||||
}
|
||||
|
||||
/** 모든 상태를 비운다 (후킹 중지 등). */
|
||||
reset(): void {
|
||||
this._ctrlLeftDown = false
|
||||
this._altGrHeld = false
|
||||
this._last = null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const EXCERPT_SEPARATOR = '\n…\n'
|
|||
|
||||
/**
|
||||
* 전사가 budgetChars 를 넘으면 구간 요약용 조각으로 나눈다. 넘지 않으면 빈 배열(나눌 필요 없음).
|
||||
* 한 줄이 조각 크기보다 길면 그 줄 하나가 한 조각이 된다(줄 중간은 자르지 않는다).
|
||||
* 조각은 모두 조각 크기 이하다 — 줄바꿈 없는 긴 줄은 문장 경계 → 공백 → 글자 수 순으로 자른다.
|
||||
*/
|
||||
export function planTranscriptCondense(
|
||||
transcript: string,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
// 데스크톱은 전사를 `[MM:SS] [화자] 내용` 줄로 저장하고, Supabase `transcripts` 는 구간 행으로 저장한다
|
||||
// (모바일은 구간이 있으면 구간을 우선 표시한다). 두 표현을 이 한 곳에서만 바꾼다.
|
||||
|
||||
import { splitTextToFit } from './text-chunking'
|
||||
|
||||
export interface TranscriptLine {
|
||||
/** 회의 시작 기준 ms. 시각 표기가 없는 줄은 앞 줄의 시각을 이어받는다(처음이면 0). */
|
||||
timestampMs: number
|
||||
|
|
@ -113,20 +115,40 @@ export function applySpeakerLabels(
|
|||
})
|
||||
}
|
||||
|
||||
/** 줄 앞의 `[MM:SS] [화자] ` 머리 — 긴 줄을 자를 때 이어지는 조각에도 붙여 시각·화자를 잃지 않는다 */
|
||||
const LINE_PREFIX = /^\[\d{1,3}:\d{2}\]\s*(?:\[[^\]]+\]\s*)?/
|
||||
|
||||
/**
|
||||
* 전사를 줄 경계에서 끊어 LLM 한 번에 보낼 크기의 조각들로 나눈다.
|
||||
* 한 줄이 maxChars 보다 길면 그 줄 하나로 한 조각을 만든다(줄 중간은 자르지 않는다).
|
||||
* maxChars 보다 긴 한 줄을 maxChars 이하 줄들로 자른다(문장 경계 → 공백 → 글자 수).
|
||||
* `[MM:SS] [화자]` 머리가 있으면 조각마다 붙인다 — 단, 머리가 한도의 절반을 넘으면 붙이지 않는다.
|
||||
*/
|
||||
function splitLongLine(line: string, maxChars: number): string[] {
|
||||
if (line.length <= maxChars) return [line]
|
||||
const prefix = LINE_PREFIX.exec(line)?.[0] ?? ''
|
||||
if (!prefix || prefix.length * 2 > maxChars) return splitTextToFit(line, maxChars)
|
||||
const body = line.slice(prefix.length)
|
||||
return splitTextToFit(body, maxChars - prefix.length).map((piece) => `${prefix}${piece}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 전사를 줄 경계에서 끊어 LLM 한 번에 보낼 크기(maxChars 이하)의 조각들로 나눈다.
|
||||
* 한 줄이 maxChars 보다 길면(줄바꿈 없는 자막·파일·폰 전사) 그 줄을 문장 경계 → 공백 → 글자 수
|
||||
* 순으로 잘라 여러 줄로 만든다 — 예전엔 줄 하나가 통째로 한 조각이 되어 LLM 입력·출력 한도를 넘었다.
|
||||
* 빈 줄은 버린다.
|
||||
*/
|
||||
export function chunkTranscriptByLines(text: string | null | undefined, maxChars: number): string[] {
|
||||
if (!text) return []
|
||||
const limit = Math.max(1, Math.floor(maxChars))
|
||||
const chunks: string[] = []
|
||||
let current: string[] = []
|
||||
let size = 0
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = raw.trimEnd()
|
||||
if (!line.trim()) continue
|
||||
if (current.length > 0 && size + line.length + 1 > maxChars) {
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.map((raw) => raw.trimEnd())
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.flatMap((line) => splitLongLine(line, limit))
|
||||
for (const line of lines) {
|
||||
if (current.length > 0 && size + line.length + 1 > limit) {
|
||||
chunks.push(current.join('\n'))
|
||||
current = []
|
||||
size = 0
|
||||
|
|
|
|||
|
|
@ -188,6 +188,14 @@ export function buildLocalSuggestionCandidates(
|
|||
return buildLocalSuggestionCandidateEntries(prefix, hints, limit, maxChars).map((entry) => entry.text)
|
||||
}
|
||||
|
||||
export interface LocalSuggestionBuildOptions {
|
||||
/**
|
||||
* 사용자가 친 원문이 공백으로 끝났는가(단어를 끝냈는가). 생략하면 prefix 인자의 뒤 공백으로 판단한다.
|
||||
* true 면 접두 꼬리와 문구 머리가 단어 중간에서 겹치는 후보를 버린다.
|
||||
*/
|
||||
wordFinished?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* buildLocalSuggestionCandidates 와 같은 후보를, 접두와의 이음새 정보와 함께 돌려준다.
|
||||
*
|
||||
|
|
@ -198,8 +206,12 @@ export function buildLocalSuggestionCandidateEntries(
|
|||
prefix: string,
|
||||
hints: LocalSuggestionHints,
|
||||
limit = 3,
|
||||
maxChars = SUGGESTION_MAX_OUTPUT_CHARS
|
||||
maxChars = SUGGESTION_MAX_OUTPUT_CHARS,
|
||||
options: LocalSuggestionBuildOptions = {}
|
||||
): LocalSuggestionCandidate[] {
|
||||
// 커서 앞 원문이 공백으로 끝나면 단어가 끝났다 — 단어 중간 이어 쓰기(attach)는 조각이 된다
|
||||
// ("내일 회의 " + "록 공유드립니다"). 호출자가 뒤 공백을 걷어낸 접두를 넘기면 명시적으로 알린다.
|
||||
const wordFinished = options.wordFinished ?? /\s$/u.test(prefix)
|
||||
const safeLimit = Math.max(0, Math.floor(limit))
|
||||
const safeMaxChars = Math.max(0, Math.floor(maxChars))
|
||||
if (safeLimit === 0 || safeMaxChars < 2) return []
|
||||
|
|
@ -232,7 +244,9 @@ export function buildLocalSuggestionCandidateEntries(
|
|||
const overlap = suffixPrefixOverlap(normalizedPrefix, candidate)
|
||||
if (overlap >= 2) {
|
||||
const remainder = candidate.slice(overlap)
|
||||
append(normalizeSuggestionText(remainder, safeMaxChars), !/^\s/u.test(remainder))
|
||||
const midWord = !/^\s/u.test(remainder)
|
||||
if (midWord && wordFinished) continue
|
||||
append(normalizeSuggestionText(remainder, safeMaxChars), midWord)
|
||||
} else if (acceptsWholePhrase) {
|
||||
append(candidate)
|
||||
}
|
||||
|
|
@ -282,7 +296,10 @@ function firstCodePoint(text: string): string {
|
|||
* - 이음새 양쪽 어느 쪽도 띄어 쓰지 않는 문자 체계(중·일·태 등, CJK 구두점)가 아니다.
|
||||
*/
|
||||
export function joinSuggestion(rawPrefix: string, candidate: string, joint: SuggestionJoint = 'separate'): string {
|
||||
if (!candidate || joint === 'attach') return candidate
|
||||
if (!candidate) return candidate
|
||||
// 단어 중간 이어 쓰기는 공백 없이 붙인다. 원문이 이미 공백으로 끝났으면 이어 붙일 단어가 없다
|
||||
// — separate 규칙으로 간다(공백 뒤라 결과는 후보 그대로).
|
||||
if (joint === 'attach' && !/\s$/u.test(rawPrefix)) return candidate
|
||||
const before = lastCodePoint(rawPrefix)
|
||||
const after = firstCodePoint(candidate)
|
||||
if (!before || !after) return candidate
|
||||
|
|
|
|||
60
packages/core/src/template-field-policy.ts
Normal file
60
packages/core/src/template-field-policy.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// packages/core/src/template-field-policy.ts
|
||||
// 받아쓰기 템플릿 필드 id 규칙 — 편집 UI(TemplateSection)와 도메인 서비스(DictationTemplateService)가 함께 쓰는 정본.
|
||||
//
|
||||
// 필드 id는 세션에서 받아쓴 값을 담는 키(fieldValues[id])이자 출력 포맷의 {{id}} 자리표시자 키다.
|
||||
// 그래서 한 템플릿 안에서 비어 있지 않고 서로 달라야 한다. 겹치면 나중 필드 값이 앞 필드 값을 덮어써
|
||||
// 출력에서 앞 값이 조용히 사라진다.
|
||||
|
||||
import type { TemplateField } from './types'
|
||||
|
||||
/** 필드 하나의 id 문제. 공백뿐인 id는 비어 있는 것으로 본다. */
|
||||
export type TemplateFieldIdProblem = 'empty' | 'duplicate'
|
||||
|
||||
/** 새 필드 id 접두사 — `field1`, `field2`, ... */
|
||||
export const TEMPLATE_FIELD_ID_PREFIX = 'field'
|
||||
|
||||
const GENERATED_ID = new RegExp(`^${TEMPLATE_FIELD_ID_PREFIX}(\\d+)$`)
|
||||
|
||||
/**
|
||||
* 필드마다 id 문제를 돌려준다(입력과 같은 길이, 문제 없으면 null).
|
||||
* 같은 id를 가진 필드는 모두 'duplicate'로 표시한다 — 편집 UI가 겹치는 행을 전부 강조할 수 있게.
|
||||
*/
|
||||
export function templateFieldIdProblems(
|
||||
fields: ReadonlyArray<Pick<TemplateField, 'id'>>,
|
||||
): Array<TemplateFieldIdProblem | null> {
|
||||
const counts = new Map<string, number>()
|
||||
for (const field of fields) counts.set(field.id, (counts.get(field.id) ?? 0) + 1)
|
||||
return fields.map((field) => {
|
||||
if (field.id.trim().length === 0) return 'empty'
|
||||
return (counts.get(field.id) ?? 0) > 1 ? 'duplicate' : null
|
||||
})
|
||||
}
|
||||
|
||||
/** 첫 번째 id 문제와 그 위치. 모든 id가 유효하면 null. */
|
||||
export function findTemplateFieldIdProblem(
|
||||
fields: ReadonlyArray<Pick<TemplateField, 'id'>>,
|
||||
): { index: number; id: string; problem: TemplateFieldIdProblem } | null {
|
||||
const problems = templateFieldIdProblems(fields)
|
||||
const index = problems.findIndex((problem) => problem !== null)
|
||||
if (index === -1) return null
|
||||
return { index, id: fields[index].id, problem: problems[index] as TemplateFieldIdProblem }
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 필드와 겹치지 않는 새 필드 번호 — `fieldN` 형식 id 중 가장 큰 N + 1.
|
||||
* (사용자가 이름을 `field7`로 바꾼 필드도 세므로 `field7`은 다시 나오지 않는다.)
|
||||
* 필드 개수에서 번호를 만들면 중간 필드를 지운 뒤 이미 있는 id가 다시 나온다.
|
||||
*/
|
||||
export function nextTemplateFieldNumber(fields: ReadonlyArray<Pick<TemplateField, 'id'>>): number {
|
||||
let max = 0
|
||||
for (const field of fields) {
|
||||
const match = GENERATED_ID.exec(field.id)
|
||||
if (match) max = Math.max(max, Number(match[1]))
|
||||
}
|
||||
return max + 1
|
||||
}
|
||||
|
||||
/** 기존 필드와 겹치지 않는 새 필드 id. */
|
||||
export function nextTemplateFieldId(fields: ReadonlyArray<Pick<TemplateField, 'id'>>): string {
|
||||
return `${TEMPLATE_FIELD_ID_PREFIX}${nextTemplateFieldNumber(fields)}`
|
||||
}
|
||||
80
packages/core/src/text-chunking.ts
Normal file
80
packages/core/src/text-chunking.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// packages/core/src/text-chunking.ts
|
||||
// 줄바꿈이 없는 긴 텍스트를 한도 안의 조각으로 자르는 순수 정책(정본).
|
||||
//
|
||||
// 자막·파일 전사 히스토리(`segments.join(' ')`)와 폰 회의 전사(STT 결과 한 문단)는 줄바꿈이 없다.
|
||||
// 줄 경계로만 나누면 전체가 조각 하나가 되어 LLM 입력 한도·출력 토큰 한도를 넘는다. 그래서
|
||||
// 문장 경계 → 공백 → 글자 수 순으로 내려가며 자른다.
|
||||
//
|
||||
// 순수 함수만 둔다 — Electron/Node 의존 금지. 다른 core 모듈을 import 하지 않는다(순환 방지).
|
||||
|
||||
/** 문장을 끝내는 부호 (라틴·CJK·말줄임) */
|
||||
const SENTENCE_END = new Set(['.', '?', '!', '…', '。', '?', '!'])
|
||||
/** 뒤에 공백이 없어도 문장 경계로 보는 전각 부호 (중국어·일본어는 띄어 쓰지 않는다) */
|
||||
const FULL_WIDTH_SENTENCE_END = new Set(['。', '?', '!'])
|
||||
/** 문장 끝 부호 뒤에 붙을 수 있는 닫는 따옴표·괄호 */
|
||||
const CLOSING_MARKS = new Set(['"', "'", '”', '’', ')', ']', '」', '』', ')'])
|
||||
const WHITESPACE = /\s/u
|
||||
|
||||
/** 문장 경계를 이 비율보다 앞에서만 찾으면 너무 짧은 조각이 되므로 공백 경계를 대신 쓴다 */
|
||||
const MIN_SENTENCE_FILL_RATIO = 0.5
|
||||
|
||||
function isHighSurrogate(code: number): boolean {
|
||||
return code >= 0xd800 && code <= 0xdbff
|
||||
}
|
||||
|
||||
/**
|
||||
* window(= text 앞 maxChars 자) 안에서 마지막 문장 경계의 끝 위치를 찾는다(없으면 0).
|
||||
* 경계: 문장 끝 부호(+닫는 부호) 뒤가 공백이거나 텍스트 끝, 또는 전각 문장 부호.
|
||||
* "3.5" 처럼 부호 뒤에 바로 글자가 오면 경계가 아니다.
|
||||
*/
|
||||
function lastSentenceBoundary(text: string, limit: number): number {
|
||||
let best = 0
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const ch = text[i]
|
||||
if (!SENTENCE_END.has(ch)) continue
|
||||
let end = i + 1
|
||||
while (end < limit && CLOSING_MARKS.has(text[end])) end++
|
||||
const next = text[end]
|
||||
if (FULL_WIDTH_SENTENCE_END.has(ch) || next === undefined || WHITESPACE.test(next)) {
|
||||
best = end
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function lastWhitespace(text: string, limit: number): number {
|
||||
for (let i = limit; i > 0; i--) {
|
||||
if (WHITESPACE.test(text[i] ?? '')) return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** text(길이 > maxChars)의 첫 조각을 끊을 위치 — 문장 경계 → 공백 → 글자 수 순. */
|
||||
function findCut(text: string, maxChars: number): number {
|
||||
const sentence = lastSentenceBoundary(text, maxChars)
|
||||
if (sentence >= Math.ceil(maxChars * MIN_SENTENCE_FILL_RATIO)) return sentence
|
||||
const space = lastWhitespace(text, maxChars)
|
||||
if (space > 0) return space
|
||||
if (sentence > 0) return sentence
|
||||
// 글자 수로 자른다 — 서로게이트 쌍(이모지 등) 가운데는 피한다
|
||||
const hard = isHighSurrogate(text.charCodeAt(maxChars - 1)) ? maxChars - 1 : maxChars
|
||||
return Math.max(1, hard)
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트를 maxChars 이하 조각들로 자른다. 들어가면 [text] 그대로(앞뒤 공백만 정리).
|
||||
* 자른 조각의 앞뒤 공백은 버리고, 빈 조각은 내지 않는다.
|
||||
*/
|
||||
export function splitTextToFit(text: string, maxChars: number): string[] {
|
||||
const limit = Math.max(1, Math.floor(maxChars))
|
||||
const pieces: string[] = []
|
||||
let rest = text.trim()
|
||||
while (rest.length > limit) {
|
||||
const cut = findCut(rest, limit)
|
||||
const piece = rest.slice(0, cut).trim()
|
||||
if (piece) pieces.push(piece)
|
||||
rest = rest.slice(cut).trim()
|
||||
}
|
||||
if (rest) pieces.push(rest)
|
||||
return pieces
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
import {
|
||||
CHECKBOX_CHECKED,
|
||||
CHECKBOX_UNCHECKED,
|
||||
parseInlineRuns,
|
||||
parseMarkdownBlocks,
|
||||
type MarkdownBlock,
|
||||
} from './meeting-markdown'
|
||||
|
|
@ -27,9 +28,20 @@ const HEADING_LEVELS = {
|
|||
3: HeadingLevel.HEADING_3,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 블록 텍스트를 TextRun 목록으로 바꾼다. 인라인 강조 해석은 HTML 경로와 같은
|
||||
* parseInlineRuns 를 쓰므로 PDF/DOCX 굵기가 일치한다. forceBold 는 표 머리글처럼
|
||||
* 블록 전체가 굵어야 할 때 쓴다. 빈 텍스트도 문단 구조가 유지되게 빈 run 하나를 둔다.
|
||||
*/
|
||||
function inlineTextRuns(text: string, forceBold = false): TextRun[] {
|
||||
const runs = parseInlineRuns(text)
|
||||
if (runs.length === 0) return [new TextRun({ text: '', bold: forceBold })]
|
||||
return runs.map((run) => new TextRun({ text: run.text, bold: forceBold || run.bold }))
|
||||
}
|
||||
|
||||
function tableCell(text: string, bold: boolean): TableCell {
|
||||
return new TableCell({
|
||||
children: [new Paragraph({ children: [new TextRun({ text, bold })] })],
|
||||
children: [new Paragraph({ children: inlineTextRuns(text, bold) })],
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -39,15 +51,18 @@ export function blocksToDocxElements(blocks: readonly MarkdownBlock[]): Array<Pa
|
|||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
elements.push(new Paragraph({ text: block.text, heading: HEADING_LEVELS[block.level] }))
|
||||
elements.push(new Paragraph({ children: inlineTextRuns(block.text), heading: HEADING_LEVELS[block.level] }))
|
||||
break
|
||||
case 'bullet':
|
||||
elements.push(new Paragraph({ text: block.text, bullet: { level: 0 } }))
|
||||
elements.push(new Paragraph({ children: inlineTextRuns(block.text), bullet: { level: 0 } }))
|
||||
break
|
||||
case 'checkbox':
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
text: `${block.checked ? CHECKBOX_CHECKED : CHECKBOX_UNCHECKED} ${block.text}`,
|
||||
children: [
|
||||
new TextRun(`${block.checked ? CHECKBOX_CHECKED : CHECKBOX_UNCHECKED} `),
|
||||
...inlineTextRuns(block.text),
|
||||
],
|
||||
bullet: { level: 0 },
|
||||
}),
|
||||
)
|
||||
|
|
@ -55,7 +70,7 @@ export function blocksToDocxElements(blocks: readonly MarkdownBlock[]): Array<Pa
|
|||
case 'paragraph':
|
||||
// 줄마다 문단 하나 — 한 줄짜리 항목이 한 문단으로 합쳐지지 않게 한다
|
||||
for (const line of block.lines) {
|
||||
elements.push(new Paragraph({ children: [new TextRun(line)] }))
|
||||
elements.push(new Paragraph({ children: inlineTextRuns(line) }))
|
||||
}
|
||||
break
|
||||
case 'table': {
|
||||
|
|
|
|||
|
|
@ -158,6 +158,36 @@ export function parseMarkdownBlocks(markdown: string | null | undefined): Markdo
|
|||
return blocks
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 인라인 파서 (순수)
|
||||
// ============================================================
|
||||
|
||||
/** 블록 텍스트 안의 인라인 조각. 강조 표기(**)는 벗겨지고 bold 로만 남는다. */
|
||||
export interface InlineRun {
|
||||
text: string
|
||||
bold: boolean
|
||||
}
|
||||
|
||||
const INLINE_BOLD = /\*\*(.+?)\*\*/g
|
||||
|
||||
/**
|
||||
* 인라인 강조(**굵게**)를 조각으로 나눈다 — HTML/DOCX 렌더러가 공유하는 단일 정본.
|
||||
* 두 내보내기가 같은 문서를 같은 굵기로 그리도록 해석은 여기 한 곳에서만 한다.
|
||||
* 빈 조각은 만들지 않으므로 빈 문자열은 빈 배열이 된다.
|
||||
*/
|
||||
export function parseInlineRuns(text: string): InlineRun[] {
|
||||
const runs: InlineRun[] = []
|
||||
let cursor = 0
|
||||
for (const match of text.matchAll(INLINE_BOLD)) {
|
||||
const start = match.index ?? 0
|
||||
if (start > cursor) runs.push({ text: text.slice(cursor, start), bold: false })
|
||||
runs.push({ text: match[1], bold: true })
|
||||
cursor = start + match[0].length
|
||||
}
|
||||
if (cursor < text.length) runs.push({ text: text.slice(cursor), bold: false })
|
||||
return runs
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HTML 렌더링 (PDF 생성용)
|
||||
// ============================================================
|
||||
|
|
@ -174,9 +204,11 @@ export function escapeHtml(text: string): string {
|
|||
return text.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch)
|
||||
}
|
||||
|
||||
/** 이스케이프 후 인라인 강조(**굵게**)만 태그로 바꾼다. */
|
||||
/** 인라인 조각을 이스케이프하고 굵은 조각만 <strong> 으로 감싼다. */
|
||||
function renderInline(text: string): string {
|
||||
return escapeHtml(text).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
return parseInlineRuns(text)
|
||||
.map((run) => (run.bold ? `<strong>${escapeHtml(run.text)}</strong>` : escapeHtml(run.text)))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export const CHECKBOX_UNCHECKED = '☐'
|
||||
|
|
|
|||
67
packages/core/src/voice-error-message.ts
Normal file
67
packages/core/src/voice-error-message.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// packages/core/src/voice-error-message.ts
|
||||
// 받아쓰기 오류 코드 → 사용자 문구 종류의 단일 분류(순수 함수, i18n 의존 없음).
|
||||
//
|
||||
// VoiceModeService 의 D3ROError.message 는 로그용 영문("Audio capture failed: …")이다. 이 분류가 없으면
|
||||
// 녹음 팁·메인 창 알림이 각자 코드를 해석하거나 영문 원문을 그대로 보인다(2026-09-28 감사: 팁 분기가
|
||||
// 오류 코드를 받지 못해 한 번도 타지 않았다). 문구 종류(VoiceErrorMessageId)를 여기서 한 번 정하고,
|
||||
// 표면(main 팁 / 렌더러 알림)은 종류 → 번역 키를 Record 로 매핑한다 — 종류가 늘면 매핑 누락이 타입 오류가 된다.
|
||||
|
||||
import { ErrorCode } from './errors'
|
||||
|
||||
export type VoiceErrorMessageId =
|
||||
| 'modelMissing'
|
||||
| 'engine'
|
||||
| 'micOpen'
|
||||
| 'micLost'
|
||||
| 'noSpeech'
|
||||
| 'tooShort'
|
||||
| 'transcription'
|
||||
| 'captionActive'
|
||||
| 'quota'
|
||||
| 'llmFailed'
|
||||
|
||||
export function classifyVoiceError(code: number | undefined | null): VoiceErrorMessageId | null {
|
||||
switch (code) {
|
||||
case ErrorCode.STTModelNotFound:
|
||||
return 'modelMissing'
|
||||
case ErrorCode.STTEngineNotInstalled:
|
||||
case ErrorCode.STTModelLoadFailed:
|
||||
case ErrorCode.STTModelNotLoaded:
|
||||
case ErrorCode.STTSidecarSpawnFailed:
|
||||
case ErrorCode.STTSidecarCrashed:
|
||||
case ErrorCode.STTSidecarCommunicationFailed:
|
||||
return 'engine'
|
||||
case ErrorCode.AudioDeviceNotFound:
|
||||
case ErrorCode.AudioDeviceAccessDenied:
|
||||
case ErrorCode.AudioDeviceBusy:
|
||||
case ErrorCode.AudioCaptureStartFailed:
|
||||
case ErrorCode.AudioNoPermission:
|
||||
return 'micOpen'
|
||||
case ErrorCode.AudioCaptureFailed:
|
||||
case ErrorCode.AudioStreamError:
|
||||
return 'micLost'
|
||||
case ErrorCode.STTNoAudioData:
|
||||
return 'noSpeech'
|
||||
case ErrorCode.STTAudioTooShort:
|
||||
return 'tooShort'
|
||||
case ErrorCode.STTTranscriptionFailed:
|
||||
case ErrorCode.STTTranscriptionTimeout:
|
||||
return 'transcription'
|
||||
case ErrorCode.CaptionAlreadyActive:
|
||||
return 'captionActive'
|
||||
case ErrorCode.QuotaExceeded:
|
||||
return 'quota'
|
||||
case ErrorCode.LLMProcessingFailed:
|
||||
case ErrorCode.ChainExecutionFailed:
|
||||
return 'llmFailed'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 이 종류의 오류를 사용자가 설정에서 고칠 수 있으면 그 탭 */
|
||||
export function voiceErrorSettingsTab(id: VoiceErrorMessageId | null): 'stt' | 'audio' | null {
|
||||
if (id === 'modelMissing') return 'stt'
|
||||
if (id === 'micOpen') return 'audio'
|
||||
return null
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue