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
4 changes: 2 additions & 2 deletions frontend/src/features/analysis/ui/DocumentList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { isApiError } from '@/shared/api'
import { EmptyState, Modal, StatusBadge, type StatusTone } from '@/shared/ui'
import { EmptyState, ListSkeleton, Modal, StatusBadge, type StatusTone } from '@/shared/ui'
import { DOCUMENT_SOURCE_LABEL as SOURCE_LABEL } from '@/domain/rag'
import type { DocumentFilter } from '../api/analysis'
import { useDocuments } from '../model/useDocuments'
Expand Down Expand Up @@ -30,7 +30,7 @@ export function DocumentList({ filter = {}, sourceType }: Props) {
const [activeId, setActiveId] = useState<number | null>(null)

if (isPending) {
return <p className="text-body text-fg-muted">분석 결과를 불러오는 중…</p>
return <ListSkeleton label="분석 결과를 불러오는 중…" />
}
if (isError) {
return (
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/features/cover-letter/ui/CoverLetterList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { isApiError } from '@/shared/api'
import { useAnalysisProgress } from '@/shared/hooks'
import { ConfirmDialog, EmptyState, StatusBadge, type StatusTone } from '@/shared/ui'
import { ConfirmDialog, EmptyState, ListSkeleton, StatusBadge, type StatusTone } from '@/shared/ui'
import { useCoverLetters, useDeleteCoverLetter } from '../model/useCoverLetters'
import type { CoverLetter, CoverLetterStatus } from '../model/types'

Expand All @@ -17,7 +17,7 @@ export function CoverLetterList() {
const remove = useDeleteCoverLetter()

if (isPending) {
return <p className="text-body text-fg-muted">자소서를 불러오는 중…</p>
return <ListSkeleton label="자소서를 불러오는 중…" />
}
if (isError) {
return (
Expand Down
220 changes: 109 additions & 111 deletions frontend/src/features/feedback/ui/FeedbackReport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,91 +94,54 @@ export function FeedbackReport({
<ScoreBar label="전달력" score={feedback.communicationScore} />
</section>

{jobFit && (
{/* 핵심 서사: 강점 → 개선 → 키워드 → 학습방향 (점수 직후로 끌어올려 스캔성↑) */}
{feedback.strengthsSummary && (
<section className="flex flex-col gap-2">
<h2 className="text-h6 text-fg">직무 적합도</h2>
<p className="text-caption text-fg-subtle">
채용공고(JD) 요구 대비 적합도·갭 평가입니다. 종합 점수에는 반영되지 않습니다.
<h2 className="text-h6 text-fg">강점</h2>
<p className="whitespace-pre-wrap text-body text-fg-muted">
<HighlightedText text={feedback.strengthsSummary} terms={highlightTerms} />
</p>
<div className="flex flex-col gap-1.5">
<ScoreBar label="직무 적합도" score={jobFit.score} />
{jobFit.detail && (
<p className="whitespace-pre-wrap pl-1 text-caption text-fg-muted">
<HighlightedText text={jobFit.detail} terms={highlightTerms} />
</p>
)}
{(jobFit.strength || jobFit.weakness) && (
<div className="flex flex-col gap-0.5 pl-1 text-caption text-fg-muted">
{jobFit.strength && <span>충족 · {jobFit.strength}</span>}
{jobFit.weakness && <span>갭 · {jobFit.weakness}</span>}
</div>
)}
{jobFit.scoreRationale && (
<p className="pl-1 text-caption text-fg-subtle">
점수 근거 · {jobFit.scoreRationale}
</p>
)}
</div>
</section>
)}

{roleUnderstanding && (
{feedback.weaknessesSummary && (
<section className="flex flex-col gap-2">
<h2 className="text-h6 text-fg">직무 이해도</h2>
<p className="text-caption text-fg-subtle">
직무가 무엇을 하는 자리인지에 대한 이해·지원동기 평가입니다. 종합 점수에는 반영되지
않습니다.
<h2 className="text-h6 text-fg">개선할 점</h2>
<p className="whitespace-pre-wrap text-body text-fg-muted">
<HighlightedText text={feedback.weaknessesSummary} terms={highlightTerms} />
</p>
<div className="flex flex-col gap-1.5">
<ScoreBar label="직무 이해도" score={roleUnderstanding.score} />
{roleUnderstanding.detail && (
<p className="whitespace-pre-wrap pl-1 text-caption text-fg-muted">
<HighlightedText text={roleUnderstanding.detail} terms={highlightTerms} />
</p>
)}
{(roleUnderstanding.strength || roleUnderstanding.weakness) && (
<div className="flex flex-col gap-0.5 pl-1 text-caption text-fg-muted">
{roleUnderstanding.strength && <span>강점 · {roleUnderstanding.strength}</span>}
{roleUnderstanding.weakness && <span>보완 · {roleUnderstanding.weakness}</span>}
</div>
)}
{roleUnderstanding.scoreRationale && (
<p className="pl-1 text-caption text-fg-subtle">
점수 근거 · {roleUnderstanding.scoreRationale}
</p>
)}
</div>
</section>
)}

{selfIntro && (
{feedback.improvementKeywords && feedback.improvementKeywords.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="text-h6 text-fg">자기소개 첫인상</h2>
<p className="text-caption text-fg-subtle">
전달력·구성·직무적합성 평가입니다. 종합 점수에는 반영되지 않습니다.
</p>
<div className="flex flex-col gap-1.5">
<ScoreBar label="첫인상" score={selfIntro.score} />
{selfIntro.detail && (
<p className="whitespace-pre-wrap pl-1 text-caption text-fg-muted">
<HighlightedText text={selfIntro.detail} terms={highlightTerms} />
</p>
)}
{(selfIntro.strength || selfIntro.weakness) && (
<div className="flex flex-col gap-0.5 pl-1 text-caption text-fg-muted">
{selfIntro.strength && <span>강점 · {selfIntro.strength}</span>}
{selfIntro.weakness && <span>보완 · {selfIntro.weakness}</span>}
</div>
)}
{selfIntro.scoreRationale && (
<p className="pl-1 text-caption text-fg-subtle">
점수 근거 · {selfIntro.scoreRationale}
</p>
)}
<h2 className="text-h6 text-fg">다음에 채울 키워드</h2>
<div className="flex flex-wrap gap-2">
{feedback.improvementKeywords.map((kw) => (
<StatusBadge key={kw} tone="info">
{kw}
</StatusBadge>
))}
</div>
</section>
)}

{feedback.studyPlan && feedback.studyPlan.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="text-h6 text-fg">학습 방향</h2>
<ul className="flex flex-col gap-1.5">
{feedback.studyPlan.map((step, i) => (
<li key={i} className="flex gap-2 text-body text-fg-muted">
<span aria-hidden className="text-primary">›</span>
<span className="whitespace-pre-wrap">
<HighlightedText text={step} terms={highlightTerms} />
</span>
</li>
))}
</ul>
</section>
)}

{interviewerPanel.length > 0 && (
<section className="flex flex-col gap-3">
<h2 className="text-h6 text-fg">면접관 패널 평가</h2>
Expand Down Expand Up @@ -206,50 +169,85 @@ export function FeedbackReport({
</section>
)}

{feedback.strengthsSummary && (
<section className="flex flex-col gap-2">
<h2 className="text-h6 text-fg">강점</h2>
<p className="whitespace-pre-wrap text-body text-fg-muted">
<HighlightedText text={feedback.strengthsSummary} terms={highlightTerms} />
</p>
</section>
)}
{/* 추가 평가 — 종합 점수에 미반영. 면책은 그룹 헤더에서 한 번만. */}
{(jobFit || roleUnderstanding || selfIntro) && (
<section className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 className="text-h6 text-fg">추가 평가</h2>
<p className="text-caption text-fg-subtle">
아래 항목은 종합 점수에 반영되지 않는 참고용 평가입니다.
</p>
</div>

{feedback.weaknessesSummary && (
<section className="flex flex-col gap-2">
<h2 className="text-h6 text-fg">개선할 점</h2>
<p className="whitespace-pre-wrap text-body text-fg-muted">
<HighlightedText text={feedback.weaknessesSummary} terms={highlightTerms} />
</p>
</section>
)}
{jobFit && (
<div className="flex flex-col gap-1.5">
<h3 className="text-body font-semibold text-fg-strong">직무 적합도</h3>
<p className="pl-1 text-caption text-fg-subtle">채용공고(JD) 요구 대비 적합도·갭</p>
<ScoreBar label="직무 적합도" score={jobFit.score} />
{jobFit.detail && (
<p className="whitespace-pre-wrap pl-1 text-caption text-fg-muted">
<HighlightedText text={jobFit.detail} terms={highlightTerms} />
</p>
)}
{(jobFit.strength || jobFit.weakness) && (
<div className="flex flex-col gap-0.5 pl-1 text-caption text-fg-muted">
{jobFit.strength && <span>충족 · {jobFit.strength}</span>}
{jobFit.weakness && <span>갭 · {jobFit.weakness}</span>}
</div>
)}
{jobFit.scoreRationale && (
<p className="pl-1 text-caption text-fg-subtle">점수 근거 · {jobFit.scoreRationale}</p>
)}
</div>
)}

{feedback.improvementKeywords && feedback.improvementKeywords.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="text-h6 text-fg">다음에 채울 키워드</h2>
<div className="flex flex-wrap gap-2">
{feedback.improvementKeywords.map((kw) => (
<StatusBadge key={kw} tone="info">
{kw}
</StatusBadge>
))}
</div>
</section>
)}
{roleUnderstanding && (
<div className="flex flex-col gap-1.5">
<h3 className="text-body font-semibold text-fg-strong">직무 이해도</h3>
<p className="pl-1 text-caption text-fg-subtle">직무에 대한 이해·지원동기</p>
<ScoreBar label="직무 이해도" score={roleUnderstanding.score} />
{roleUnderstanding.detail && (
<p className="whitespace-pre-wrap pl-1 text-caption text-fg-muted">
<HighlightedText text={roleUnderstanding.detail} terms={highlightTerms} />
</p>
)}
{(roleUnderstanding.strength || roleUnderstanding.weakness) && (
<div className="flex flex-col gap-0.5 pl-1 text-caption text-fg-muted">
{roleUnderstanding.strength && <span>강점 · {roleUnderstanding.strength}</span>}
{roleUnderstanding.weakness && <span>보완 · {roleUnderstanding.weakness}</span>}
</div>
)}
{roleUnderstanding.scoreRationale && (
<p className="pl-1 text-caption text-fg-subtle">
점수 근거 · {roleUnderstanding.scoreRationale}
</p>
)}
</div>
)}

{feedback.studyPlan && feedback.studyPlan.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="text-h6 text-fg">학습 방향</h2>
<ul className="flex flex-col gap-1.5">
{feedback.studyPlan.map((step, i) => (
<li key={i} className="flex gap-2 text-body text-fg-muted">
<span aria-hidden className="text-primary">›</span>
<span className="whitespace-pre-wrap">
<HighlightedText text={step} terms={highlightTerms} />
</span>
</li>
))}
</ul>
{selfIntro && (
<div className="flex flex-col gap-1.5">
<h3 className="text-body font-semibold text-fg-strong">자기소개 첫인상</h3>
<p className="pl-1 text-caption text-fg-subtle">전달력·구성·직무적합성</p>
<ScoreBar label="첫인상" score={selfIntro.score} />
{selfIntro.detail && (
<p className="whitespace-pre-wrap pl-1 text-caption text-fg-muted">
<HighlightedText text={selfIntro.detail} terms={highlightTerms} />
</p>
)}
{(selfIntro.strength || selfIntro.weakness) && (
<div className="flex flex-col gap-0.5 pl-1 text-caption text-fg-muted">
{selfIntro.strength && <span>강점 · {selfIntro.strength}</span>}
{selfIntro.weakness && <span>보완 · {selfIntro.weakness}</span>}
</div>
)}
{selfIntro.scoreRationale && (
<p className="pl-1 text-caption text-fg-subtle">
점수 근거 · {selfIntro.scoreRationale}
</p>
)}
</div>
)}
</section>
)}
</div>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/features/history/ui/SessionHistoryList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'
import { EmptyState } from '@/shared/ui'
import { EmptyState, ListSkeleton } from '@/shared/ui'
import { useSessions } from '../model/useHistory'
import { SessionCard } from './SessionCard'

Expand All @@ -15,7 +15,7 @@ export function SessionHistoryList() {
} = useSessions()

if (isLoading) {
return <p className="py-8 text-center text-body text-fg-muted">불러오는 중…</p>
return <ListSkeleton count={4} label="면접 기록을 불러오는 중…" className="py-2" />
}
if (isError) {
return (
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/features/interview/model/optimistic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export function pendingAnswers(
return pending
}

// 아직 서버에 반영 전인 낙관적 답변은 '전송 중'(CREATED)으로 표시한다.
// 서버 메시지(COMPLETED)가 도착하면 pendingAnswers 가 이 항목을 소진해 자연 대체된다.
export function toOptimisticMessage(answer: OptimisticAnswer): Message {
return { role: 'INTERVIEWEE', content: answer.content, status: 'COMPLETED' } as Message
return { role: 'INTERVIEWEE', content: answer.content, status: 'CREATED' } as Message
}
29 changes: 29 additions & 0 deletions frontend/src/features/interview/model/useLiveInterview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode =
const { end } = useSessionLifecycle(sessionId)

const [optimistic, setOptimistic] = useState<OptimisticAnswer[]>([])
// 전송 실패로 롤백된 답변 본문 — 컴포저가 입력창을 복원하는 데 사용(nonce 로 매 실패마다 트리거).
const [restoreDraft, setRestoreDraft] = useState<{ content: string; nonce: number } | null>(null)
const [connection, setConnection] = useState<ConnectionStatus>('connecting')
const [deltaBuffer, setDeltaBuffer] = useState<Record<number, string>>({})
// 라이브 세그먼트 오디오가 지금 재생 중인 메시지(아바타·질문 카드의 '말하는 중' 표시용).
Expand All @@ -46,6 +48,12 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode =
// 소켓 onEvent 클로저에서 최신 모드를 읽기 위한 ref. 텍스트 모드는 음성 자동재생 안 함.
const deliveryModeRef = useRef(deliveryMode)
deliveryModeRef.current = deliveryMode

// 텍스트 답변은 WS fire-and-forget 라 ack 가 없다. 제출 후 일정 시간 내 서버 반영이
// 안 되면 stuck 된 낙관적 답변을 롤백한다. tempId 별 타이머 + 최신 optimistic 미러 ref.
const optimisticRef = useRef<OptimisticAnswer[]>([])
optimisticRef.current = optimistic
const answerTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
if (queueRef.current === null) {
queueRef.current = createSegmentQueue(async (url) => {
if (typeof Audio === 'undefined') return
Expand Down Expand Up @@ -164,15 +172,35 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode =
},
})

const ANSWER_ACK_TIMEOUT_MS = 10_000

const submitAnswer = useCallback(
(content: string) => {
const tempId = crypto.randomUUID()
setOptimistic((prev) => [...prev, { tempId, content }])
socketSubmit(content, tempId)
// ack 타임아웃: 시간 내 서버 반영(낙관적 소진)이 없으면 롤백 + 입력 복원 + 안내.
const timer = setTimeout(() => {
answerTimers.current.delete(tempId)
if (!optimisticRef.current.some((o) => o.tempId === tempId)) return // 이미 반영됨
setOptimistic((prev) => prev.filter((o) => o.tempId !== tempId))
setRestoreDraft({ content, nonce: Date.now() })
toast.error('답변 전송에 실패했어요. 입력을 복원했으니 다시 시도해 주세요.')
}, ANSWER_ACK_TIMEOUT_MS)
answerTimers.current.set(tempId, timer)
},
[socketSubmit],
)

// 언마운트 시 남은 ack 타이머 정리(언마운트 후 setState/toast 방지).
useEffect(() => {
const timers = answerTimers.current
return () => {
timers.forEach((t) => clearTimeout(t))
timers.clear()
}
}, [])

// 음성 답변은 REST 업로드(multipart). 성공 시 placeholder 메시지를
// 받으므로 목록을 무효화해 "음성 인식 중…" 버블을 띄운다.
// 이후 STT 완료는 callback.voice → SESSION_MESSAGE SSE 로 갱신된다.
Expand Down Expand Up @@ -216,6 +244,7 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode =
turn: currentTurn(items),
connection,
submitAnswer,
restoreDraft,
submitVoice,
voiceUploading: voiceMutation.isPending,
voiceError: voiceMutation.isError,
Expand Down
Loading