Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/event-stream.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ WS(RT1)는 같은 내용을 JSON 한 줄 프레임으로: `{ "id": <eventId>, "e
```

- `messageId`: Core 가 답변 직후 선INSERT 한 INTERVIEWER **placeholder** 메시지 id(content=`"(생성 중)"`, status=`CREATED`). 생성 시 Core 가 `SESSION_MESSAGE(placeholderId)` 를 1회 발행하므로 프론트 목록에 placeholder 버블이 먼저 뜬다.
- `seq`: 0부터 단조 증가. `text`: 이번 델타에서 **추가된 조각**(누적 아님). 프론트는 placeholder 버블에 append.
- `seq`: 0부터 단조 증가. `text`: 이번 델타에서 **추가된 조각**(누적 아님). 발행은 순서대로지만 브로커→WS fan-out 경로가 프레임 순서·전달을 보장하지는 않으므로, 프론트는 도착 순서가 아닌 `seq` 로 재조립한다(연속 prefix 만 표시, 중복은 멱등 — `streamingBuffer.ts`). 유실분은 종료 reconcile 이 정본으로 덮는다.
- 흐름: 답변 → (Core) placeholder INSERT + `SESSION_MESSAGE` → (AI) `astream` 으로 `SESSION_MESSAGE_DELTA` 연속 발행 → (AI) `callback.questions(FOLLOWUP, followupMessageId)` → (Core) placeholder UPDATE(content/COMPLETED) + `SESSION_MESSAGE`(종료) → 프론트 `GET …/messages` 재조회로 정본 reconcile.
- `answer_intent=DONT_KNOW` 면 AI 가 델타를 **발행하지 않고**, Core 가 placeholder 삭제 후 다음 일반질문으로 진행한다. 이때 프론트는 placeholder 를 "생각 중"으로만 표시하다 일반질문으로 교체.

Expand Down
22 changes: 0 additions & 22 deletions frontend/src/features/interview/lib/useTypewriter.test.ts

This file was deleted.

33 changes: 0 additions & 33 deletions frontend/src/features/interview/lib/useTypewriter.ts

This file was deleted.

60 changes: 56 additions & 4 deletions frontend/src/features/interview/model/streamingBuffer.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,65 @@
import { describe, it, expect } from 'vitest'
import { applyDelta, isStreamingMessage, FOLLOWUP_GENERATING_TEXT } from './streamingBuffer'
import type { DeltaBuffer } from './streamingBuffer'
import {
applyDelta,
bufferedText,
isStreamingMessage,
FOLLOWUP_GENERATING_TEXT,
} from './streamingBuffer'

describe('streamingBuffer', () => {
it('applyDelta 는 messageId별로 누적한다', () => {
let buf: Record<number, string> = {}
it('정상 순서 프레임을 누적한다', () => {
let buf: DeltaBuffer = {}
buf = applyDelta(buf, { messageId: 5, seq: 0, text: '안녕' })
buf = applyDelta(buf, { messageId: 5, seq: 1, text: '하세요' })
expect(buf[5]).toBe('안녕하세요')
expect(bufferedText(buf, 5)).toBe('안녕하세요')
})

it('순서가 뒤바뀐 프레임은 seq 로 재조립한다', () => {
let buf: DeltaBuffer = {}
buf = applyDelta(buf, { messageId: 7, seq: 0, text: '동시성 ' })
buf = applyDelta(buf, { messageId: 7, seq: 2, text: '어떻게 해결했나요?' })
buf = applyDelta(buf, { messageId: 7, seq: 1, text: '문제를 ' })
expect(bufferedText(buf, 7)).toBe('동시성 문제를 어떻게 해결했나요?')
})

it('중복 재전달 프레임은 멱등 — 한 번만 반영된다', () => {
let buf: DeltaBuffer = {}
buf = applyDelta(buf, { messageId: 5, seq: 0, text: '안녕' })
buf = applyDelta(buf, { messageId: 5, seq: 0, text: '안녕' })
expect(bufferedText(buf, 5)).toBe('안녕')
})

it('갭 뒤 프레임은 갭이 채워질 때까지 표시하지 않는다', () => {
let buf: DeltaBuffer = {}
buf = applyDelta(buf, { messageId: 5, seq: 0, text: 'A' })
buf = applyDelta(buf, { messageId: 5, seq: 2, text: 'C' })
expect(bufferedText(buf, 5)).toBe('A')
})

it('갭이 채워지면 보류된 프레임까지 이어서 표시한다', () => {
let buf: DeltaBuffer = {}
buf = applyDelta(buf, { messageId: 5, seq: 0, text: 'A' })
buf = applyDelta(buf, { messageId: 5, seq: 2, text: 'C' })
buf = applyDelta(buf, { messageId: 5, seq: 1, text: 'B' })
expect(bufferedText(buf, 5)).toBe('ABC')
})

it('seq 0 이 도착하기 전에는 undefined — placeholder 표시를 유지한다', () => {
let buf: DeltaBuffer = {}
buf = applyDelta(buf, { messageId: 5, seq: 1, text: 'B' })
expect(bufferedText(buf, 5)).toBeUndefined()
})

it('메시지별로 격리 누적한다', () => {
let buf: DeltaBuffer = {}
buf = applyDelta(buf, { messageId: 1, seq: 0, text: '하나' })
buf = applyDelta(buf, { messageId: 2, seq: 0, text: '둘' })
expect(bufferedText(buf, 1)).toBe('하나')
expect(bufferedText(buf, 2)).toBe('둘')
expect(bufferedText(buf, 3)).toBeUndefined()
})

it('isStreamingMessage 는 sentinel content + 버퍼 유무로 판별', () => {
expect(isStreamingMessage({ content: FOLLOWUP_GENERATING_TEXT }, undefined)).toBe(true)
expect(isStreamingMessage({ content: FOLLOWUP_GENERATING_TEXT }, '안녕')).toBe(true)
Expand Down
30 changes: 23 additions & 7 deletions frontend/src/features/interview/model/streamingBuffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,29 @@ export { FOLLOWUP_GENERATING_TEXT }

export type DeltaPayload = { messageId: number; seq: number; text: string }

// messageId -> 누적 텍스트. seq 는 순서 보조(현재는 단순 append, gap 은 종료 reconcile 로 자기치유).
export function applyDelta(
buffer: Record<number, string>,
delta: DeltaPayload,
): Record<number, string> {
const prev = buffer[delta.messageId] ?? ''
return { ...buffer, [delta.messageId]: prev + delta.text }
// messageId -> (seq -> 조각). 발행측(AI followup consumer)은 seq 를 0부터 1씩 증가시키지만,
// WS 경로는 프레임 순서를 보장하지 않으므로 도착 순서가 아닌 seq 로 재조립한다.
export type DeltaBuffer = Record<number, Record<number, string>>

// 중복 seq(재전달)는 멱등 — 첫 조각을 유지한다.
export function applyDelta(buffer: DeltaBuffer, delta: DeltaPayload): DeltaBuffer {
const parts = buffer[delta.messageId]
if (parts?.[delta.seq] !== undefined) return buffer
return { ...buffer, [delta.messageId]: { ...parts, [delta.seq]: delta.text } }
}

// seq 0부터 연속으로 이어진 구간만 join 한다. 갭 뒤에 도착한 조각은 갭이 채워질 때까지
// 표시하지 않는다 — 프레임이 정말 유실되면 종료 시 콜백 정본(reconcile)이 덮는다.
// seq 0 이 아직 없으면 undefined — 호출부가 placeholder 표시를 유지하게 한다.
export function bufferedText(
buffer: DeltaBuffer,
messageId: number | undefined,
): string | undefined {
const parts = buffer[messageId ?? -1]
if (!parts || parts[0] === undefined) return undefined
let text = ''
for (let seq = 0; parts[seq] !== undefined; seq++) text += parts[seq]
return text
}

export function isStreamingMessage(
Expand Down
13 changes: 7 additions & 6 deletions frontend/src/features/interview/model/useLiveInterview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { useInterviewSocket } from './useInterviewSocket'
import { interviewEventAction } from './interviewEvent'
import { pendingAnswers, toOptimisticMessage } from './optimistic'
import type { OptimisticAnswer } from './optimistic'
import { applyDelta, isStreamingMessage, FOLLOWUP_GENERATING_TEXT } from './streamingBuffer'
import type { DeltaPayload } from './streamingBuffer'
import { applyDelta, bufferedText, isStreamingMessage, FOLLOWUP_GENERATING_TEXT } from './streamingBuffer'
import type { DeltaBuffer, DeltaPayload } from './streamingBuffer'
import type { DeliveryMode } from './useDeliveryMode'

export type ThreadItem = Message & { key: string; streaming?: boolean }
Expand All @@ -38,7 +38,7 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode =
const [optimistic, setOptimistic] = useState<OptimisticAnswer[]>([])
// 전송 실패로 롤백된 답변 본문 — 컴포저가 입력창을 복원하는 데 사용(nonce 로 매 실패마다 트리거).
const [restoreDraft, setRestoreDraft] = useState<{ content: string; nonce: number } | null>(null)
const [deltaBuffer, setDeltaBuffer] = useState<Record<number, string>>({})
const [deltaBuffer, setDeltaBuffer] = useState<DeltaBuffer>({})
// 라이브 세그먼트 오디오가 지금 재생 중인 메시지(아바타·질문 카드의 '말하는 중' 표시용).
const [speakingAudio, setSpeakingAudio] = useState<{ msgId: number | null; playing: boolean }>({
msgId: null,
Expand Down Expand Up @@ -81,9 +81,9 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode =
)
const pending = pendingAnswers(optimistic, serverMessages)

// 스트리밍 중인 메시지는 deltaBuffer의 누적 텍스트로 content를 오버라이드한다.
// 스트리밍 중인 메시지는 deltaBuffer의 seq 재조립 텍스트로 content를 오버라이드한다.
const mergedMessages = serverMessages.map((m) => {
const buffered = deltaBuffer[m.id ?? -1]
const buffered = bufferedText(deltaBuffer, m.id)
if (buffered !== undefined && isStreamingMessage(m, buffered)) {
return { ...m, content: buffered, streaming: true as const }
}
Expand Down Expand Up @@ -194,7 +194,8 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode =
navigate(`/sessions/${sessionId}/feedback`)
} else if (action.kind === 'append-delta') {
const payload = (frame.data as { data?: DeltaPayload } | undefined)?.data
if (payload && typeof payload.messageId === 'number') {
// seq 재조립은 비정상 seq 조각을 조용히 영구 드롭하므로 seq 타입까지 방어 검증한다.
if (payload && typeof payload.messageId === 'number' && typeof payload.seq === 'number') {
setDeltaBuffer((b) => applyDelta(b, payload))
}
} else if (action.kind === 'queue-audio') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function ConversationThread({
<div ref={containerRef} className="flex h-full flex-col gap-3 overflow-y-auto px-4 py-6">
{items.map((item) =>
isQuestion(item) ? (
<QuestionBubble key={item.key} message={item} autoPlay={mode === 'voice' && item.key === lastQuestionKey} streaming={item.streaming} />
<QuestionBubble key={item.key} message={item} autoPlay={mode === 'voice' && item.key === lastQuestionKey} />
) : (
<AnswerBubble key={item.key} message={item} />
),
Expand Down
6 changes: 2 additions & 4 deletions frontend/src/features/interview/ui/live/QuestionBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { StatusBadge } from '@/shared/ui/StatusBadge'
import { categoryLabel } from '../../lib/categoryLabel'
import { useTtsPlayback } from '../../lib/media/useTtsPlayback'
import { FOLLOWUP_GENERATING_TEXT } from '../../model/streamingBuffer'
import { useTypewriter } from '../../lib/useTypewriter'
import { useSetQuestionBookmark } from '../../model/useBookmarks'

function StarIcon({ filled }: { filled: boolean }) {
Expand Down Expand Up @@ -34,12 +33,10 @@ function PlayIcon({ playing }: { playing: boolean }) {
export function QuestionBubble({
message,
autoPlay = false,
streaming = false,
bookmarkable = false,
}: {
message: Message
autoPlay?: boolean
streaming?: boolean
/** 오답노트 표시 버튼 노출. 라이브 중엔 끄고(집중 방해) 종료 세션 기록에서만 켠다. */
bookmarkable?: boolean
}) {
Expand All @@ -48,7 +45,8 @@ export function QuestionBubble({
const hasMeta = Boolean(label || message.targetEvidence)
const ttsReady = message.ttsStatus === 'SUCCEEDED'
const isSentinel = message.content === FOLLOWUP_GENERATING_TEXT
const shownText = useTypewriter(message.content ?? '', !!streaming && !isSentinel)
// 델타가 이미 토큰 단위로 도착하므로 재애니메이션 없이 그대로 표시한다(실스트림 = 애니메이션).
const shownText = message.content ?? ''

const { playing, toggle, audioNode } = useTtsPlayback({
sessionId: message.sessionId,
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/features/interview/ui/live/StageQuestion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useState } from 'react'
import type { Message } from '@/domain/session'
import { categoryLabel } from '../../lib/categoryLabel'
import { useTtsPlayback } from '../../lib/media/useTtsPlayback'
import { useTypewriter } from '../../lib/useTypewriter'
import type { DeliveryMode } from '../../model/useDeliveryMode'

function PlayIcon({ playing }: { playing: boolean }) {
Expand Down Expand Up @@ -49,7 +48,8 @@ export function StageQuestion({
const ttsPending = ttsStatus === 'PENDING'
const ttsFailed = ttsStatus === 'FAILED'
const voiceMode = mode === 'voice'
const shownText = useTypewriter(question.content ?? '', !!streaming)
// 델타가 이미 토큰 단위로 도착하므로 재애니메이션 없이 그대로 표시한다(실스트림 = 애니메이션).
const shownText = question.content ?? ''

// 음성 모드여도 TTS 가 실패했으면 텍스트로 폴백한다.
const listenOnly = voiceMode && !ttsFailed
Expand All @@ -67,7 +67,7 @@ export function StageQuestion({
return (
// 질문은 WebSocket 으로 비동기 도착한다. live region 이 없으면 스크린리더 사용자는
// 새 질문이 왔다는 사실 자체를 모른 채 기다리게 된다 — 면접의 핵심 흐름이라
// 알림이 필수다. 타이핑 효과로 글자가 이어 붙는 동안 계속 읽지 않도록,
// 알림이 필수다. 델타 스트리밍으로 글자가 이어 붙는 동안 계속 읽지 않도록,
// 스트리밍이 끝난 뒤에만 한 번 알리게 aria-busy 로 묶는다.
<div
role="region"
Expand Down
Loading