fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent 3a46437f28
commit ddc78546f0
62 changed files with 4786 additions and 648 deletions

View file

@ -13,6 +13,26 @@ export function normalizeDictionaryWord(raw: string): string | null {
return word.length > 0 ? word : null
}
/**
* 단어의 식별 키 — 같은 카테고리에서 이 키가 같으면 같은 단어다.
* 서버 유일 인덱스 `(user_id, lower(btrim(word)), category)` 와 같은 규칙(대소문자 무시).
* 로컬이 'API' 와 'api' 를 따로 받아들이면, 동기화 때 서버 충돌(23505)로 로컬 행이 서버 행으로
* 대체되며 사용자가 넣은 표기와 발음이 조용히 사라진다.
*/
export function dictionaryIdentityKey(word: string): string {
return word.trim().toLowerCase()
}
/** 같은 카테고리의 기존 단어 중 식별 키가 같은 첫 항목 (excludeId 는 자기 자신 제외용) */
export function findDictionaryIdentityConflict<T extends { id: string; word: string }>(
word: string,
candidates: readonly T[],
excludeId?: string
): T | null {
const key = dictionaryIdentityKey(word)
return candidates.find((c) => c.id !== excludeId && dictionaryIdentityKey(c.word) === key) ?? null
}
/**
* 발음 입력을 정규화한다.
* - undefined → undefined: "변경 없음"

View file

@ -0,0 +1,49 @@
// packages/core/src/instruction-policy.ts
// 사용자 LLM 명령어의 서버 쓰기 제약 — 서비스·동기화 어댑터·편집 폼이 같은 정본을 쓴다.
// 값은 서버 custom_instructions CHECK 와 같다(마이그레이션 20260821000005):
// char_length(btrim(name)) BETWEEN 1 AND 80, description ≤ 240, char_length(btrim(prompt)) BETWEEN 1 AND 4000
export const INSTRUCTION_LIMITS = { name: 80, description: 240, prompt: 4000 } as const
export type InstructionLimitField = keyof typeof INSTRUCTION_LIMITS
/** Postgres char_length 와 같은 코드 포인트 수 */
export function charLength(value: string): number {
return [...value].length
}
/** 서버 제한을 넘는 첫 필드. 없으면 null. (이름·프롬프트는 앞뒤 공백을 빼고 센다 — 서버 btrim 과 같다) */
export function instructionLimitViolation(input: {
name?: string
description?: string
prompt?: string
}): InstructionLimitField | null {
if (input.name !== undefined && charLength(input.name.trim()) > INSTRUCTION_LIMITS.name) return 'name'
if (input.description !== undefined && charLength(input.description.trim()) > INSTRUCTION_LIMITS.description) {
return 'description'
}
if (input.prompt !== undefined && charLength(input.prompt.trim()) > INSTRUCTION_LIMITS.prompt) return 'prompt'
return null
}
/** 저장할 수 없는 이유 */
export type InstructionDraftProblem =
| { field: 'name' | 'prompt'; reason: 'empty' }
| { field: InstructionLimitField; reason: 'too-long'; max: number }
/**
* 명령어 초안(생성 또는 부분 수정)을 서버 제약으로 검사한다 (순수 함수).
* 수정(부분 패치)에서는 준 필드만 검사한다 — 빈 이름·빈 프롬프트로 바꾸는 것도 막는다
* (예전엔 생성만 빈 값을 막아, 편집으로 비운 프롬프트가 서버 CHECK 에 계속 거부돼 동기화가 멈췄다).
*/
export function validateInstructionDraft(input: {
name?: string
description?: string
prompt?: string
}): InstructionDraftProblem | null {
if (input.name !== undefined && input.name.trim().length === 0) return { field: 'name', reason: 'empty' }
if (input.prompt !== undefined && input.prompt.trim().length === 0) return { field: 'prompt', reason: 'empty' }
const field = instructionLimitViolation(input)
if (field !== null) return { field, reason: 'too-long', max: INSTRUCTION_LIMITS[field] }
return null
}

View file

@ -142,3 +142,21 @@ export function fitChatRequest(request: ChatRequest, limits: Readonly<ChatReques
if (fittedSystem !== undefined) fitted.system = fittedSystem
return fitted
}
/** 단일 턴 요청(generate/processText)에서 한도를 넘는 부분. */
export type SingleTurnLimitViolation = 'system' | 'message'
/**
* 단일 턴 요청이 한도를 넘는지 (순수 함수). 서버(parseLlmRequest)처럼 앞뒤 공백을 빼고 센다.
* 채팅과 달리 잘라 보내지 않는다 — 자유 생성 입력(회의 전사·요약 대상)을 조용히 자르면
* 결과가 입력의 앞부분만 반영하고도 완전한 것처럼 보인다. 호출자가 나누거나(map-reduce) 실패를 알린다.
*/
export function singleTurnLimitViolation(
input: { system?: string; message: string },
limits: Readonly<ChatRequestLimits> = LLM_PROXY_CHAT_LIMITS,
): SingleTurnLimitViolation | null {
const system = input.system?.trim()
if (system && system.length > limits.maxSystemChars) return 'system'
if (input.message.trim().length > limits.maxMessageChars) return 'message'
return null
}

View file

@ -0,0 +1,26 @@
// packages/core/src/meeting-live.ts
// 진행 중인 회의 녹음·회의 채팅을 렌더러가 다시 불러올 때 쓰는 계약 타입.
// (IPC: 'meetingMode:getLiveSession', 'meetingChat:getHistory')
import type { CaptionSegment, MeetingMemo, MeetingModeState } from './types'
/** 진행 중(녹음·처리 중)인 회의의 실시간 전사와 메모 */
export interface MeetingLiveSession {
sessionId: string
state: MeetingModeState
startedAt: number
elapsedMs: number
segments: CaptionSegment[]
memos: MeetingMemo[]
}
/** 회의 채팅 한 턴 (main 이 회의마다 보관하는 대화) */
export interface MeetingChatHistoryTurn {
role: 'user' | 'assistant'
content: string
}
/** 회의 채팅 IPC 채널 — 회의 하나의 대화를 조회한다 */
export const MEETING_CHAT_GET_HISTORY_CHANNEL = 'meetingChat:getHistory'
/** 진행 중인 회의 녹음의 세그먼트·메모를 조회한다 */
export const MEETING_MODE_GET_LIVE_SESSION_CHANNEL = 'meetingMode:getLiveSession'

View file

@ -0,0 +1,99 @@
// packages/core/src/meeting-llm-input.ts
// 긴 회의 전사를 LLM 입력 한도 안에 넣는 순수 정책.
//
// llm-proxy 는 메시지·system 하나당 8,000자를 넘으면 400 으로 거부하고, 채팅의 system 은 앞부분만
// 남기고 잘린다. 30분 넘는 회의 전사는 쉽게 이 한도를 넘어, 문서 생성·요약이 실패하고 채팅은 회의
// 앞부분만 보고 "확인되지 않습니다"라고 답했다. 여기서는
// - 문서·요약: 구간별로 줄인 뒤(map) 합쳐 한 번에 보낸다(reduce) — 조각 계획만 정한다.
// - 채팅: 질문과 관련 있는 구간만 골라 한도 안에 넣고, 잘렸다는 사실을 함께 돌려준다.
import { chunkTranscriptByLines } from './meeting-transcript'
/** 구간 요약(map) 한 번에 보낼 전사 조각 크기 — 프록시 메시지 한도(8,000자) 안쪽 */
export const MEETING_MAP_CHUNK_CHARS = 6_000
/** 채팅 발췌를 고를 때의 조각 크기 */
export const MEETING_EXCERPT_CHUNK_CHARS = 1_200
const EXCERPT_SEPARATOR = '\n…\n'
/**
* 전사가 budgetChars 를 넘으면 구간 요약용 조각으로 나눈다. 넘지 않으면 빈 배열(나눌 필요 없음).
* 한 줄이 조각 크기보다 길면 그 줄 하나가 한 조각이 된다(줄 중간은 자르지 않는다).
*/
export function planTranscriptCondense(
transcript: string,
budgetChars: number,
chunkChars: number = MEETING_MAP_CHUNK_CHARS,
): string[] {
if (transcript.trim().length <= budgetChars) return []
return chunkTranscriptByLines(transcript, Math.max(1, Math.min(chunkChars, budgetChars)))
}
/** 구간 요약들을 순서 표시와 함께 이어 붙인다(reduce 입력). */
export function joinCondensedSections(sections: readonly string[]): string {
const total = sections.length
return sections.map((section, i) => `### ${i + 1}/${total}\n${section.trim()}`).join('\n\n')
}
/** 문자·숫자만 남긴 소문자 문자 bigram — 조사가 붙은 한국어("결정은"/"결정을")도 겹치게 한다. */
function bigrams(text: string): Set<string> {
const normalized = text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ')
const grams = new Set<string>()
for (const word of normalized.split(' ')) {
const chars = [...word]
if (chars.length === 1) grams.add(chars[0])
for (let i = 0; i + 1 < chars.length; i++) grams.add(chars[i] + chars[i + 1])
}
return grams
}
export interface TranscriptExcerpt {
text: string
/** 전사를 통째로 넣었는가 (false 면 일부 구간만 넣었다) */
complete: boolean
includedChunks: number
totalChunks: number
}
/**
* 채팅 질문에 쓸 전사를 budgetChars 안으로 고른다.
* - 전사 전체가 들어가면 그대로 쓴다.
* - 아니면 질문과 겹치는 bigram 이 많은 구간부터 넣고(동점이면 앞 구간), 시간 순으로 되돌려 이어 붙인다.
*/
export function selectTranscriptExcerpts(
transcript: string,
question: string,
budgetChars: number,
chunkChars: number = MEETING_EXCERPT_CHUNK_CHARS,
): TranscriptExcerpt {
const text = transcript.trim()
if (text.length <= budgetChars) {
return { text, complete: true, includedChunks: text ? 1 : 0, totalChunks: text ? 1 : 0 }
}
const chunks = chunkTranscriptByLines(text, Math.max(1, Math.min(chunkChars, budgetChars)))
const query = bigrams(question)
const scored = chunks.map((chunk, index) => {
const grams = bigrams(chunk)
let score = 0
for (const gram of query) if (grams.has(gram)) score++
return { index, chunk, score }
})
scored.sort((a, b) => b.score - a.score || a.index - b.index)
const chosen: Array<{ index: number; chunk: string }> = []
let used = 0
for (const candidate of scored) {
const cost = candidate.chunk.length + (chosen.length > 0 ? EXCERPT_SEPARATOR.length : 0)
if (used + cost > budgetChars) continue
chosen.push(candidate)
used += cost
}
chosen.sort((a, b) => a.index - b.index)
return {
text: chosen.map((c) => c.chunk).join(EXCERPT_SEPARATOR),
complete: chosen.length === chunks.length,
includedChunks: chosen.length,
totalChunks: chunks.length,
}
}

View file

@ -0,0 +1,28 @@
// packages/core/src/memo-tag-policy.ts
// 메모 태그 정규화·식별 규칙 — 도메인 서비스(MemoService)와 동기화 어댑터가 함께 쓰는 정본.
// (예전엔 동기화 모듈에 있어 도메인 서비스가 sync 인프라에 의존했다)
/** 서버 memo_tags CHECK 와 같은 태그 최대 길이(코드 포인트) — 마이그레이션 20260821000024 */
export const MEMO_TAG_MAX_LENGTH = 80
const MEMO_TAG_KEY_SEPARATOR = '|'
/** 서버 정규화와 같다: trim → 연속 공백 1칸 → 소문자. */
export function normalizeMemoTag(tag: string): string {
return tag.trim().replace(/\s+/g, ' ').toLowerCase()
}
/** 기록 하나에서 태그를 식별하는 자연키 `${historyId}|${정규화 태그}` */
export function memoTagKey(historyId: string, tag: string): string {
return `${historyId}${MEMO_TAG_KEY_SEPARATOR}${normalizeMemoTag(tag)}`
}
export type MemoTagProblem = 'empty' | 'too-long'
/** 정규화한 태그를 서버 제약으로 검사한다. 문제 없으면 null. */
export function validateMemoTag(tag: string): MemoTagProblem | null {
const normalized = normalizeMemoTag(tag)
if (normalized.length === 0) return 'empty'
if ([...normalized].length > MEMO_TAG_MAX_LENGTH) return 'too-long'
return null
}