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

@ -0,0 +1,86 @@
// 서버 쓰기 제약·LLM 입력 한도 정책(순수 함수) — 경계값은 서버 migration·llm-contract 와 같다.
import { describe, expect, it } from 'vitest'
import { INSTRUCTION_LIMITS, instructionLimitViolation, validateInstructionDraft } from '../src/instruction-policy'
import { MEMO_TAG_MAX_LENGTH, memoTagKey, normalizeMemoTag, validateMemoTag } from '../src/memo-tag-policy'
import { dictionaryIdentityKey, findDictionaryIdentityConflict } from '../src/dictionary-policy'
import { LLM_PROXY_CHAT_LIMITS, singleTurnLimitViolation } from '../src/llm-chat'
import {
joinCondensedSections,
planTranscriptCondense,
selectTranscriptExcerpts,
} from '../src/meeting-llm-input'
describe('instruction-policy', () => {
it('빈 이름·빈 프롬프트는 거절한다(서버 btrim 1자 이상)', () => {
expect(validateInstructionDraft({ name: ' ', prompt: 'p' })).toEqual({ field: 'name', reason: 'empty' })
expect(validateInstructionDraft({ name: 'n', prompt: '\n' })).toEqual({ field: 'prompt', reason: 'empty' })
expect(validateInstructionDraft({ description: '' })).toBeNull()
})
it('길이 경계값 0/1/max/max+1 (코드 포인트)', () => {
expect(validateInstructionDraft({ name: 'n', prompt: 'p' })).toBeNull()
expect(validateInstructionDraft({ name: 'n'.repeat(INSTRUCTION_LIMITS.name) })).toBeNull()
expect(validateInstructionDraft({ name: 'n'.repeat(INSTRUCTION_LIMITS.name + 1) })).toMatchObject({ field: 'name', reason: 'too-long' })
expect(instructionLimitViolation({ prompt: 'p'.repeat(INSTRUCTION_LIMITS.prompt + 1) })).toBe('prompt')
expect(instructionLimitViolation({ description: '😀'.repeat(INSTRUCTION_LIMITS.description) })).toBeNull()
})
})
describe('memo-tag-policy', () => {
it('정규화·자연키는 서버와 같다', () => {
expect(normalizeMemoTag(' Work Notes ')).toBe('work notes')
expect(memoTagKey('h1', ' A B')).toBe('h1|a b')
})
it('1~80자 경계', () => {
expect(validateMemoTag(' ')).toBe('empty')
expect(validateMemoTag('t')).toBeNull()
expect(validateMemoTag('t'.repeat(MEMO_TAG_MAX_LENGTH))).toBeNull()
expect(validateMemoTag('t'.repeat(MEMO_TAG_MAX_LENGTH + 1))).toBe('too-long')
})
})
describe('dictionary identity', () => {
it('대소문자·앞뒤 공백만 다른 단어는 같은 단어다(자기 자신 제외)', () => {
expect(dictionaryIdentityKey(' API ')).toBe('api')
const rows = [{ id: '1', word: 'API' }, { id: '2', word: 'Ärger' }]
expect(findDictionaryIdentityConflict('api', rows)?.id).toBe('1')
expect(findDictionaryIdentityConflict('ärger', rows)?.id).toBe('2')
expect(findDictionaryIdentityConflict('API', rows, '1')).toBeNull()
})
})
describe('singleTurnLimitViolation', () => {
it('서버처럼 앞뒤 공백을 빼고 세며, 경계는 통과', () => {
const max = LLM_PROXY_CHAT_LIMITS.maxMessageChars
expect(singleTurnLimitViolation({ message: ` ${'x'.repeat(max)} ` })).toBeNull()
expect(singleTurnLimitViolation({ message: 'x'.repeat(max + 1) })).toBe('message')
expect(singleTurnLimitViolation({ message: 'q', system: 's'.repeat(LLM_PROXY_CHAT_LIMITS.maxSystemChars + 1) })).toBe('system')
})
})
describe('meeting-llm-input', () => {
const lines = Array.from({ length: 200 }, (_, i) => `[00:${String(i % 60).padStart(2, '0')}] 발언 ${i} 내용입니다`)
const transcript = lines.join('\n')
it('한도 안이면 나누지 않고, 넘으면 줄 경계 조각으로 나눈다', () => {
expect(planTranscriptCondense('짧은 전사', 100)).toEqual([])
const chunks = planTranscriptCondense(transcript, 1_000, 1_000)
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.join('\n')).toBe(transcript)
for (const chunk of chunks) expect(chunk.length).toBeLessThanOrEqual(1_000)
expect(joinCondensedSections(['a', 'b'])).toBe('### 1/2\na\n\n### 2/2\nb')
})
it('채팅 발췌: 전체가 들어가면 그대로, 아니면 질문과 관련된 구간을 시간 순으로 한도 안에', () => {
expect(selectTranscriptExcerpts('짧다', '질문', 100)).toMatchObject({ text: '짧다', complete: true })
const withAnswer = [...lines]
withAnswer[190] = '[03:10] 출시 담당자는 김민수로 결정했습니다'
const excerpt = selectTranscriptExcerpts(withAnswer.join('\n'), '출시 담당자는 누구로 결정했나요?', 800, 200)
expect(excerpt.complete).toBe(false)
expect(excerpt.text.length).toBeLessThanOrEqual(800)
expect(excerpt.text).toContain('김민수')
expect(excerpt.includedChunks).toBeLessThan(excerpt.totalChunks)
})
})