fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway
This commit is contained in:
parent
3a46437f28
commit
ddc78546f0
62 changed files with 4786 additions and 648 deletions
|
|
@ -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: "변경 없음"
|
||||
|
|
|
|||
49
packages/core/src/instruction-policy.ts
Normal file
49
packages/core/src/instruction-policy.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
26
packages/core/src/meeting-live.ts
Normal file
26
packages/core/src/meeting-live.ts
Normal 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'
|
||||
99
packages/core/src/meeting-llm-input.ts
Normal file
99
packages/core/src/meeting-llm-input.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
28
packages/core/src/memo-tag-policy.ts
Normal file
28
packages/core/src/memo-tag-policy.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue