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
48 changes: 19 additions & 29 deletions docs/event-stream.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ WS /realtime/sessions/{sessionId}/audio # RT3 실시간 음성 답변 스트

### WS 라이브 면접 (RT1)
- 경로 `WS /realtime/sessions/{id}` (SSE `/realtime/stream/*` 와 다른 path → Upgrade 분기 없음). 인증 동일 `?access_token=`.
- 서버→클라 프레임(JSON): `{ "id": <messageId>, "event": <eventType>, "data": <payload> }` (session 채널 fan-out을 그대로 전달 — 질문/꼬리질문/세션상태).
- 서버→클라 프레임(JSON): `{ "id": <eventId>, "event": "<SseEventType 대문자>", "data": {"data": <payload>, "traceId": "..."} }` (session 채널 fan-out을 그대로 전달 — 질문/꼬리질문/세션상태). `event` 는 enum 이름(`SESSION_MESSAGE`/`SESSION_STATE`/`FEEDBACK_READY`), **소문자 `session.message` 아님**. `SESSION_MESSAGE` 의 payload 는 **messageId 뿐** → 본문은 `GET …/messages` 로 조회(§3.3).
- 클라→서버: `{ "type": "answer", "content": "...", "idempotencyKey"?: "..." }`. RealTime이 Core 내부 REST(`POST /api/internal/sessions/{id}/messages`)로 프록시 → 답변 INSERT + `generate.followup` 발행.
- 인증: 쿼리 토큰 `?access_token=<stream-token>` (EventSource/WS 헤더 한계 우회). RealTime `internal/auth`가 HS256(키=`SHA-256(JWT_SECRET)`)로 검증.
- 권한 (리소스 스코프): 토큰은 `resourceType`(`USER`/`SESSION`)·`resourceId` claim을 담는다. 소유권은 **발급 시점**에 Core가 검증하고(USER=`POST /api/auth/stream-token` 본인, SESSION=`POST /api/sessions/{id}/stream-token` 소유권 체크 후 발급), RealTime은 path 리소스와 토큰 리소스의 일치만 확인한다(불일치 → 403). 이로써 RealTime은 PG 무접근으로 소유권을 판정한다. `documents/{id}` 채널 스코프는 MVP deferred(인증 토큰만).
Expand All @@ -40,29 +40,25 @@ WS /realtime/sessions/{sessionId}/audio # RT3 실시간 음성 답변 스트

## 2. 이벤트 포맷

> - `event` 이름은 **`SseEventType` enum 이름(대문자)**: `DOC_STATE`·`REPO_STATE`·`SESSION_MESSAGE`·`SESSION_STATE`·`FEEDBACK_READY`·`ERROR`·`KEEP_ALIVE` (+ AI 가 직접 발행하는 `ANALYSIS_PROGRESS`). **`session.message` 같은 소문자 점표기가 아니다.** (RealTime `bridge/dispatcher.go` `Type: env.Payload.EventType` → `sse.go`/`ws.go` 가 그대로 전달.)
> - `data` 봉투는 `{"data": <payload>, "traceId": "..."}` 다 (`realtime/CLAUDE.md §8`). payload 필드는 camelCase.
> - 클라는 SSE `addEventListener(<ENUM_NAME>, …)` / WS `frame.event === '<ENUM_NAME>'` 로 매칭한다.

표준 SSE 프레임:
```
event: <eventName>
event: SESSION_MESSAGE
id: <eventId>
data: <JSON>
data: {"data": <payload>, "traceId": "..."}

```

`<JSON>`:
```json
{
"type": "DOC_STATE",
"payload": { ... },
"timestamp": "2026-04-28T15:00:00Z",
"traceId": "..."
}
```
WS(RT1)는 같은 내용을 JSON 한 줄 프레임으로: `{ "id": <eventId>, "event": "SESSION_MESSAGE", "data": {"data": <payload>, "traceId": "..."} }`.

---

## 3. 이벤트 카탈로그

### 3.1 분석 상태 (`event: doc.state`)
### 3.1 분석 상태 (`event: DOC_STATE`)
```json
{
"type": "DOC_STATE",
Expand All @@ -78,7 +74,7 @@ data: <JSON>
- `state` ∈ `QUEUED | PROCESSING | COMPLETED | FAILED`
- `progress` 0.0~1.0 (옵션)

### 3.2 레포 분석 (`event: repo.state`)
### 3.2 레포 분석 (`event: REPO_STATE`)
```json
{
"type": "REPO_STATE",
Expand Down Expand Up @@ -112,22 +108,15 @@ data: <JSON>
- **발행 경로**: AI 서버 → (`stackup.realtime` exchange, `realtime.user.notify`) → RealTime → user 채널 SSE. Core 를 거치지 않는다(진행 정보는 영속 대상이 아님). 종료 상태만 기존대로 AI → Core 콜백 → REPO_STATE/DOC_STATE 로 전달.
- 프론트는 `ANALYSIS_PROGRESS` 수신 시 진행 store 갱신(쿼리 무효화 X), `REPO_STATE`/`DOC_STATE`(종료) 수신 시 진행 store clear + 목록 쿼리 무효화.

### 3.3 세션 메시지 푸시 (`event: session.message`)
### 3.3 세션 메시지 푸시 (`event: SESSION_MESSAGE`)

**`data` 는 변경된 메시지 본문이 아니라 messageId(숫자)만 담는 "변경 알림"이다.** Core `QuestionsCallbackService` 가 `RealtimeNotifyEvent.session(id, SESSION_MESSAGE, message.getId())` 로 발행하기 때문. 클라는 이 이벤트를 받으면 `GET /api/sessions/{id}/messages` 로 본문을 조회한다(Workspace 의 분석 SSE→쿼리 무효화와 동일 패턴). 질문·꼬리질문·STT transcript 반영 등 메시지 시퀀스가 바뀔 때마다 발행.

```json
{
"type": "SESSION_MESSAGE",
"payload": {
"sessionId": 99,
"messageId": 503,
"role": "INTERVIEWER",
"content": "왜 그 시점에 ...",
"parentMessageId": 502,
"sequenceNumber": 7
}
}
{ "data": 503, "traceId": "..." }
```

### 3.4 세션 상태 (`event: session.state`)
### 3.4 세션 상태 (`event: SESSION_STATE`)
```json
{
"type": "SESSION_STATE",
Expand All @@ -139,7 +128,7 @@ data: <JSON>
}
```

### 3.5 피드백 생성 완료 (`event: feedback.ready`)
### 3.5 피드백 생성 완료 (`event: FEEDBACK_READY`)
```json
{
"type": "FEEDBACK_READY",
Expand All @@ -151,7 +140,8 @@ data: <JSON>
}
```

### 3.6 에러 (`event: error`)
### 3.6 에러 (`event: ERROR`)
> WS 전송 계층 에러는 `event: "error"`(소문자) 프레임으로도 올 수 있다(`ws.go writeError`). 도메인 ERROR 이벤트는 `ERROR`.
```json
{
"type": "ERROR",
Expand Down
15 changes: 9 additions & 6 deletions frontend/src/features/interview/model/interviewEvent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import { describe, it, expect } from 'vitest'
import { interviewEventAction } from './interviewEvent'

describe('interviewEventAction', () => {
it('session.message → 메시지 refetch', () => {
expect(interviewEventAction('session.message')).toEqual({ kind: 'refetch-messages' })
it('SESSION_MESSAGE → 메시지 refetch', () => {
expect(interviewEventAction('SESSION_MESSAGE')).toEqual({ kind: 'refetch-messages' })
})
it('session.state → 세션 refetch', () => {
expect(interviewEventAction('session.state')).toEqual({ kind: 'refetch-session' })
it('SESSION_STATE → 세션 refetch', () => {
expect(interviewEventAction('SESSION_STATE')).toEqual({ kind: 'refetch-session' })
})
it('feedback.ready → 피드백 리다이렉트', () => {
expect(interviewEventAction('feedback.ready')).toEqual({ kind: 'redirect-feedback' })
it('FEEDBACK_READY → 피드백 리다이렉트', () => {
expect(interviewEventAction('FEEDBACK_READY')).toEqual({ kind: 'redirect-feedback' })
})
it('소문자 점표기(문서상 예시)는 매칭되지 않는다', () => {
expect(interviewEventAction('session.message')).toEqual({ kind: 'ignore' })
})
it('알 수 없는 이벤트는 무시', () => {
expect(interviewEventAction('whatever')).toEqual({ kind: 'ignore' })
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/features/interview/model/interviewEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ export type InterviewAction =

export function interviewEventAction(event: string): InterviewAction {
switch (event) {
case 'session.message':
case 'SESSION_MESSAGE':
return { kind: 'refetch-messages' }
case 'session.state':
case 'SESSION_STATE':
return { kind: 'refetch-session' }
case 'feedback.ready':
case 'FEEDBACK_READY':
return { kind: 'redirect-feedback' }
default:
return { kind: 'ignore' }
Expand Down
22 changes: 17 additions & 5 deletions frontend/src/pages/InterviewSession/ui/InterviewSessionPage.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import { useParams } from 'react-router-dom'
import { SiteNav } from '@/widgets/site-nav'
import { SiteFooter } from '@/widgets/site-footer'
import { LiveInterview } from '@/features/interview'

export default function InterviewSessionPage() {
const { id } = useParams<{ id: string }>()
const sessionId = Number(id)
if (!Number.isFinite(sessionId) || sessionId <= 0) {
return <div className="px-4 py-16 text-center text-fg-muted">잘못된 세션입니다.</div>
}
const valid = Number.isFinite(sessionId) && sessionId > 0

return (
<div className="mx-auto flex h-screen max-w-content flex-col">
<LiveInterview sessionId={sessionId} />
<div className="flex min-h-svh flex-col bg-bg text-fg">
<SiteNav />
<main className="mx-auto w-full max-w-content flex-1 px-6 py-6 lg:px-12">
{valid ? (
// 라이브 채팅은 고정 높이 카드로 — 내부에서 thread 스크롤 + composer 하단 고정.
<div className="flex h-[70svh] min-h-120 flex-col overflow-hidden rounded-xl border border-border bg-surface-raised shadow-sm">
<LiveInterview sessionId={sessionId} />
</div>
) : (
<p className="py-16 text-center text-fg-muted">잘못된 세션입니다.</p>
)}
</main>
<SiteFooter />
</div>
)
}
32 changes: 23 additions & 9 deletions frontend/src/pages/InterviewSetup/ui/InterviewSetupPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useNavigate } from 'react-router-dom'
import { SiteNav } from '@/widgets/site-nav'
import { SiteFooter } from '@/widgets/site-footer'
import { useDocuments } from '@/features/analysis'
import { InterviewSetupForm, useCreateSession } from '@/features/interview'
import type { DocOption } from '@/features/interview'
Expand All @@ -17,14 +19,26 @@ export default function InterviewSetupPage() {
}))

return (
<InterviewSetupForm
documents={options}
isSubmitting={createSession.isPending}
onCreate={(req) =>
createSession.mutate(req, {
onSuccess: (session) => navigate(`/sessions/${session.id}`),
})
}
/>
<div className="flex min-h-svh flex-col bg-bg text-fg">
<SiteNav />
<main className="flex-1">
<div className="mx-auto max-w-readable px-4 pt-10">
<h1 className="text-h4 text-fg">새 모의 면접</h1>
<p className="mt-2 text-body text-fg-muted">
모드와 직군을 선택하면 AI가 맞춤 질문을 생성합니다.
</p>
</div>
<InterviewSetupForm
documents={options}
isSubmitting={createSession.isPending}
onCreate={(req) =>
createSession.mutate(req, {
onSuccess: (session) => navigate(`/sessions/${session.id}`),
})
}
/>
</main>
<SiteFooter />
</div>
)
}
22 changes: 14 additions & 8 deletions frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import { Link, useParams } from 'react-router-dom'
import { SiteNav } from '@/widgets/site-nav'
import { SiteFooter } from '@/widgets/site-footer'
import { Button } from '@/shared/ui/Button'

// 피드백 리포트 화면(features/feedback)은 아직 범위 밖. feedback.ready 리다이렉트가
// 빈 화면으로 끝나지 않도록 임시 안내 스텁을 제공한다.
export default function SessionFeedbackPage() {
const { id } = useParams<{ id: string }>()
return (
<div className="mx-auto flex max-w-readable flex-col items-center gap-4 px-4 py-16 text-center">
<h1 className="text-h4 text-fg">피드백 준비 완료</h1>
<p className="text-body text-fg-muted">
세션 #{id}의 면접이 종료되어 피드백이 준비되었습니다. 상세 리포트 화면은 준비 중입니다.
</p>
<Link to="/workspace">
<Button variant="secondary">워크스페이스로</Button>
</Link>
<div className="flex min-h-svh flex-col bg-bg text-fg">
<SiteNav />
<main className="mx-auto flex w-full max-w-content flex-1 flex-col items-center justify-center gap-4 px-6 py-16 text-center lg:px-12">
<h1 className="text-h4 text-fg">피드백 준비 완료</h1>
<p className="text-body text-fg-muted">
세션 #{id}의 면접이 종료되어 피드백이 준비되었습니다. 상세 리포트 화면은 준비 중입니다.
</p>
<Link to="/workspace">
<Button variant="secondary">워크스페이스로</Button>
</Link>
</main>
<SiteFooter />
</div>
)
}