diff --git a/ai/src/ai_server/chain/prompts/rerank.py b/ai/src/ai_server/chain/prompts/rerank.py deleted file mode 100644 index b5df409b..00000000 --- a/ai/src/ai_server/chain/prompts/rerank.py +++ /dev/null @@ -1,22 +0,0 @@ -# 검색 후보 리랭킹 프롬프트. -# 하이브리드 검색이 가져온 후보 청크들을 쿼리와 함께 한 번에 모델에 넣어, -# 관련도 높은 순으로 인덱스를 재정렬한다 (cross-encoder 대용, 호출 1회). - -SYSTEM_PROMPT = ( - "당신은 검색 결과 재정렬기(reranker)입니다. " - "주어진 쿼리에 대해 후보 청크들의 관련도를 평가하고, " - "가장 관련 높은 순서로 인덱스를 정렬하세요.\n" - "- 관련도는 쿼리의 의도에 대한 답이 청크에 실제로 담겨 있는 정도로 판단합니다.\n" - "- 관련 없는 청크는 결과에서 제외해도 됩니다.\n" - "- 응답은 반드시 지정된 JSON 스키마(ranked_indices)를 따릅니다." -) - -HUMAN_PROMPT = ( - "쿼리:\n{query}\n\n" - "후보 청크 (각 [i] 인덱스):\n" - "---\n" - "{candidates}\n" - "---\n\n" - "관련도가 높은 순으로 최대 {top_k}개의 인덱스를 ranked_indices 에 담아 반환하세요.\n" - "{format_instructions}" -) diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 092ef5ed..7f51c3a7 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -38,9 +38,8 @@ class Settings(BaseSettings): ai_realtime_exchange: str = "stackup.realtime" ai_realtime_routing_user: str = "realtime.user.notify" feedback_rag_top_k: int = 5 - # 리랭킹: 하이브리드 검색으로 후보 N개(candidate_k)를 가져와 LLM 으로 재정렬 후 top_k 주입. - rerank_enabled: bool = True - rerank_candidate_k: int = 20 + # RAG 컨텍스트 구성(임베딩+검색) 전체 상한. 초과 시 (none) 으로 폴백해 첫 토큰을 막지 않는다. + followup_rag_timeout_sec: float = 1.5 # 질문 풀 초기 크기. Core 의 applyPool 이 questions[0] 만 INSERT 하므로 1 로 고정해 토큰 낭비 차단. # 후속 작업에서 풀 저장 도입 시 늘리기 (예: 5). questions_initial_pool_size: int = 1 diff --git a/ai/src/ai_server/messaging/consumers/feedback_consumer.py b/ai/src/ai_server/messaging/consumers/feedback_consumer.py index 24be44d7..d61dd1cc 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -15,7 +15,6 @@ VoiceAnalysisSummary, ) from ai_server.rag.embedder import EmbeddingProvider -from ai_server.rag.reranker import NoopReranker, Reranker, rerank_hits log = structlog.get_logger(__name__) @@ -41,8 +40,6 @@ def __init__( core_client: CoreClient, embedder: EmbeddingProvider | None = None, rag_top_k: int = 5, - reranker: Reranker | None = None, - candidate_k: int = 20, ) -> None: self._generator = generator self._publisher = publisher @@ -51,8 +48,6 @@ def __init__( self._core = core_client self._embedder = embedder self._rag_top_k = rag_top_k - self._reranker = reranker or NoopReranker() - self._candidate_k = max(candidate_k, rag_top_k) async def handle(self, message: AbstractIncomingMessage) -> None: async with message.process(requeue=False): @@ -148,16 +143,13 @@ async def _build_rag_context(self, req: GenerateFeedbackRequest) -> str: query_embedding=query_vec, query_text=last_answer, document_ids=req.context_document_ids, - top_k=self._candidate_k, + top_k=self._rag_top_k, ) except Exception as exc: log.warn("feedback.rag.failed", error=str(exc), session_id=req.session_id) return "(none)" if not hits: return "(none)" - hits = await rerank_hits( - self._reranker, query=last_answer, hits=hits, top_k=self._rag_top_k - ) return "\n---\n".join( f"[doc#{h.document_id} chunk#{h.chunk_index}] {h.chunk_text}" for h in hits ) diff --git a/ai/src/ai_server/messaging/consumers/followup_consumer.py b/ai/src/ai_server/messaging/consumers/followup_consumer.py index 4dc6606f..cd40f094 100644 --- a/ai/src/ai_server/messaging/consumers/followup_consumer.py +++ b/ai/src/ai_server/messaging/consumers/followup_consumer.py @@ -20,7 +20,6 @@ GenerateFollowupRequest, ) from ai_server.rag.embedder import EmbeddingProvider -from ai_server.rag.reranker import NoopReranker, Reranker, rerank_hits log = structlog.get_logger(__name__) @@ -43,13 +42,12 @@ def __init__( core_client: CoreClient | None = None, embedder: EmbeddingProvider | None = None, rag_top_k: int = 5, - reranker: Reranker | None = None, - candidate_k: int = 20, streaming_generator: StreamingFollowupGenerator | None = None, session_notifier: SessionRealtimeNotifier | None = None, tts=None, storage=None, tts_voice: str = "", + rag_timeout_sec: float = 1.5, ) -> None: self._generator = generator self._publisher = publisher @@ -58,13 +56,12 @@ def __init__( self._core = core_client self._embedder = embedder self._rag_top_k = rag_top_k - self._reranker = reranker or NoopReranker() - self._candidate_k = max(candidate_k, rag_top_k) self._streaming = streaming_generator self._notifier = session_notifier self._tts = tts self._storage = storage self._tts_voice = tts_voice + self._rag_timeout_sec = rag_timeout_sec async def handle(self, message: AbstractIncomingMessage) -> None: async with message.process(requeue=False): @@ -233,7 +230,19 @@ async def _synth_segment( async def _build_rag_context(self, req: GenerateFollowupRequest) -> str: if not self._core or not self._embedder or not req.context_document_ids: return "(none)" + try: + return await asyncio.wait_for( + self._do_build_rag_context(req), timeout=self._rag_timeout_sec + ) + except asyncio.TimeoutError: + log.warning( + "followup.rag.timeout", + session_id=req.session_id, + timeout_sec=self._rag_timeout_sec, + ) + return "(none)" + async def _do_build_rag_context(self, req: GenerateFollowupRequest) -> str: query = f"{req.previous_question}\n\n{req.answer_text}" try: query_vec = ( @@ -243,7 +252,7 @@ async def _build_rag_context(self, req: GenerateFollowupRequest) -> str: query_embedding=query_vec, query_text=query, document_ids=req.context_document_ids, - top_k=self._candidate_k, + top_k=self._rag_top_k, ) except Exception as exc: log.warn("followup.rag.failed", error=str(exc), session_id=req.session_id) @@ -251,9 +260,6 @@ async def _build_rag_context(self, req: GenerateFollowupRequest) -> str: if not hits: return "(none)" - hits = await rerank_hits( - self._reranker, query=query, hits=hits, top_k=self._rag_top_k - ) return "\n---\n".join( f"[doc#{h.document_id} chunk#{h.chunk_index}] {h.chunk_text}" for h in hits ) diff --git a/ai/src/ai_server/messaging/consumers/questions_consumer.py b/ai/src/ai_server/messaging/consumers/questions_consumer.py index caad4fcd..82875b14 100644 --- a/ai/src/ai_server/messaging/consumers/questions_consumer.py +++ b/ai/src/ai_server/messaging/consumers/questions_consumer.py @@ -14,7 +14,6 @@ QuestionPoolCallbackPayload, ) from ai_server.rag.embedder import EmbeddingProvider -from ai_server.rag.reranker import NoopReranker, Reranker, rerank_hits log = structlog.get_logger(__name__) @@ -31,8 +30,6 @@ def __init__( core_client: CoreClient | None = None, embedder: EmbeddingProvider | None = None, rag_top_k: int = 5, - reranker: Reranker | None = None, - candidate_k: int = 20, ) -> None: self._generator = generator self._publisher = publisher @@ -44,8 +41,6 @@ def __init__( self._core = core_client self._embedder = embedder self._rag_top_k = rag_top_k - self._reranker = reranker or NoopReranker() - self._candidate_k = max(candidate_k, rag_top_k) async def handle(self, message: AbstractIncomingMessage) -> None: async with message.process(requeue=False): @@ -138,7 +133,7 @@ async def _build_context(self, req: GenerateQuestionsRequest) -> str: query_embedding=query_vec, query_text=query, document_ids=document_ids, - top_k=self._candidate_k, + top_k=self._rag_top_k, ) except Exception as exc: log.warn("questions.rag.failed", error=str(exc), session_id=req.session_id) @@ -146,9 +141,6 @@ async def _build_context(self, req: GenerateQuestionsRequest) -> str: if not hits: return base_context - hits = await rerank_hits( - self._reranker, query=query, hits=hits, top_k=self._rag_top_k - ) rag_context = "\n---\n".join( f"[doc#{h.document_id} chunk#{h.chunk_index}] {h.chunk_text}" for h in hits ) diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index 5bbb368f..9217323d 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -32,7 +32,6 @@ from ai_server.messaging.connection import RabbitConnection from ai_server.rag.chunker import MarkdownChunker from ai_server.rag.embedder import build_embedding_provider -from ai_server.rag.reranker import build_reranker from ai_server.messaging.consumers.feedback_consumer import FeedbackConsumer from ai_server.messaging.consumers.followup_consumer import FollowupConsumer from ai_server.messaging.consumers.questions_consumer import QuestionsConsumer @@ -102,7 +101,6 @@ def __init__(self, settings: Settings) -> None: max_retries=settings.embedding_max_retries, retry_base_delay_sec=settings.embedding_retry_base_delay_sec, ) - reranker = build_reranker(settings, core_client=core_client) vision_pdf_reader = build_vision_pdf_reader(settings, core_client=core_client) # 이력서 PDF @@ -182,8 +180,6 @@ def __init__(self, settings: Settings) -> None: initial_pool_size=settings.questions_initial_pool_size, core_client=core_client, embedder=embedder, - reranker=reranker, - candidate_k=settings.rerank_candidate_k, ) # 꼬리질문 생성 (US-19) @@ -206,13 +202,12 @@ def __init__(self, settings: Settings) -> None: callback_routing_key=settings.ai_callback_routing_questions, core_client=core_client, embedder=embedder, - reranker=reranker, - candidate_k=settings.rerank_candidate_k, streaming_generator=streaming_followup_generator, session_notifier=session_notifier, tts=tts, storage=storage, tts_voice=settings.openai_tts_voice, + rag_timeout_sec=settings.followup_rag_timeout_sec, ) # 종합 피드백 생성 (US-24) @@ -226,8 +221,6 @@ def __init__(self, settings: Settings) -> None: callback_routing_key=settings.ai_callback_routing_feedback, core_client=core_client, embedder=embedder, - reranker=reranker, - candidate_k=settings.rerank_candidate_k, rag_top_k=settings.feedback_rag_top_k, ) diff --git a/ai/src/ai_server/rag/reranker.py b/ai/src/ai_server/rag/reranker.py deleted file mode 100644 index 2668705e..00000000 --- a/ai/src/ai_server/rag/reranker.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import annotations - -from typing import Protocol - -import structlog -from langchain_core.output_parsers import PydanticOutputParser -from langchain_core.prompts import ChatPromptTemplate -from langchain_core.runnables import Runnable -from pydantic import BaseModel, Field - -from ai_server.chain.prompts.rerank import HUMAN_PROMPT, SYSTEM_PROMPT -from ai_server.config.settings import Settings -from ai_server.core.client import CoreClient -from ai_server.observability.llm_logging_callback import CoreAiLogCallback - -log = structlog.get_logger(__name__) - - -class RerankResult(BaseModel): - ranked_indices: list[int] = Field( - default_factory=list, - description="relevant candidate indices, most relevant first", - ) - - -class Reranker(Protocol): - async def rerank( - self, *, query: str, candidates: list[str], top_k: int - ) -> list[int]: ... - - -async def rerank_hits( - reranker: Reranker, *, query: str, hits: list, top_k: int -) -> list: - """검색 hit 리스트(.chunk_text 보유)를 query 기준으로 재정렬해 top_k 반환.""" - if not hits: - return list(hits) - indices = await reranker.rerank( - query=query, candidates=[h.chunk_text for h in hits], top_k=top_k - ) - return [hits[i] for i in indices] - - -def _identity(n: int, top_k: int) -> list[int]: - return list(range(min(top_k, n))) - - -# 리랭킹 비활성/미주입 시 검색 순서를 그대로 사용하는 passthrough. -class NoopReranker: - async def rerank( - self, *, query: str, candidates: list[str], top_k: int - ) -> list[int]: - return _identity(len(candidates), top_k) - - -# LLM 으로 후보를 한 번에 재정렬한다. 실패 시 검색 순서로 graceful degrade. -class LlmReranker: - def __init__(self, chain: Runnable) -> None: - self._chain = chain - - async def rerank( - self, *, query: str, candidates: list[str], top_k: int - ) -> list[int]: - if not candidates: - return [] - numbered = "\n\n".join(f"[{i}] {c}" for i, c in enumerate(candidates)) - try: - result = await self._chain.ainvoke( - {"query": query, "candidates": numbered, "top_k": top_k} - ) - seen: set[int] = set() - ordered: list[int] = [] - for i in result.ranked_indices: - if 0 <= i < len(candidates) and i not in seen: - seen.add(i) - ordered.append(i) - if not ordered: - raise ValueError("리랭커가 유효한 인덱스를 반환하지 않음") - return ordered[:top_k] - except Exception as exc: # noqa: BLE001 - log.warning("rerank.failed_fallback_to_search_order", error=str(exc)) - return _identity(len(candidates), top_k) - - -def build_reranker( - settings: Settings, core_client: CoreClient | None = None -) -> Reranker: - if not settings.rerank_enabled: - return NoopReranker() - - from langchain_openai import ChatOpenAI - - parser = PydanticOutputParser(pydantic_object=RerankResult) - prompt = ChatPromptTemplate.from_messages( - [ - ("system", SYSTEM_PROMPT), - ("human", HUMAN_PROMPT), - ] - ).partial(format_instructions=parser.get_format_instructions()) - - callbacks = [] - if core_client is not None: - callbacks.append( - CoreAiLogCallback( - core_client=core_client, - request_type="rerank", - default_model=settings.llm_flash_model, - ) - ) - - llm = ChatOpenAI( - model=settings.llm_flash_model, - temperature=0.0, - api_key=settings.llm_api_key or None, - base_url=settings.llm_base_url, - max_tokens=settings.llm_flash_max_tokens, - callbacks=callbacks, - ) - return LlmReranker(prompt | llm | parser) diff --git a/ai/tests/test_followup_consumer.py b/ai/tests/test_followup_consumer.py index 2abebacd..9873a6f3 100644 --- a/ai/tests/test_followup_consumer.py +++ b/ai/tests/test_followup_consumer.py @@ -168,7 +168,7 @@ async def _stream(*, on_question_token, **kwargs): call = core.search_embeddings.await_args assert call.kwargs["query_embedding"] == [0.1, 0.2, 0.3] assert call.kwargs["document_ids"] == [7] - assert call.kwargs["top_k"] == 20 # candidate_k (리랭크 후보 수) + assert call.kwargs["top_k"] == 3 # rag_top_k (direct vector search) assert call.kwargs["query_text"] # 하이브리드 검색: 쿼리 텍스트 동봉 assert ( "Outbox rows are inserted in the same transaction" in received_kwargs["context"] @@ -480,3 +480,111 @@ async def test_callback_includes_answer_message_id(): payload = publisher.publish.await_args.kwargs["payload"] assert payload.answer_message_id == 502 # _envelope 의 answerMessageId assert payload.followup_message_id == 503 # _envelope 의 followupMessageId + + +# --------------------------------------------------------------------------- +# 꼬리질문 RAG 저지연: top_k 직접 검색, timeout 폴백 +# --------------------------------------------------------------------------- + + +def _make_req() -> "GenerateFollowupRequest": + from ai_server.model.messages.followup import GenerateFollowupRequest + + return GenerateFollowupRequest( + session_id=99, + parent_message_id=501, + answer_message_id=502, + followup_message_id=503, + previous_question="결제 outbox 어떻게 구현?", + answer_text="RabbitMQ로 보냈습니다.", + mode="TECHNICAL", + job_category="BACKEND", + context_document_ids=[1], + ) + + +def _make_hit(document_id: int = 1, chunk_index: int = 0, chunk_text: str = "x"): + from types import SimpleNamespace + + return SimpleNamespace( + document_id=document_id, chunk_index=chunk_index, chunk_text=chunk_text + ) + + +@pytest.mark.asyncio +async def test_rag_searches_top_k_directly(): + """search_embeddings 는 rag_top_k 로 직접 검색하고, 청크 텍스트가 결과에 포함된다.""" + from ai_server.messaging.consumers.followup_consumer import FollowupConsumer + from ai_server.messaging.idempotency import LruIdempotencyStore + + hit = _make_hit(chunk_text="이 청크가 반환돼야 한다") + + embedder = MagicMock() + embedder.embed = AsyncMock(return_value=[[0.0]]) + + core = MagicMock() + core.search_embeddings = AsyncMock(return_value=[hit]) + + generator = MagicMock() + generator.generate = AsyncMock() + publisher = MagicMock() + publisher.publish = AsyncMock() + + consumer = FollowupConsumer( + generator=generator, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.questions", + core_client=core, + embedder=embedder, + rag_top_k=5, + ) + + req = _make_req() + result = await consumer._build_rag_context(req) + + # 청크 텍스트가 결과에 포함돼야 한다 + assert "이 청크가 반환돼야 한다" in result + # rag_top_k 로 직접 검색 + call_kwargs = core.search_embeddings.await_args.kwargs + assert call_kwargs["top_k"] == 5 # rag_top_k + + +@pytest.mark.asyncio +async def test_rag_timeout_returns_none(): + """rag_timeout_sec 초과 시 _build_rag_context 가 '(none)' 을 반환한다.""" + import asyncio + + from ai_server.messaging.consumers.followup_consumer import FollowupConsumer + from ai_server.messaging.idempotency import LruIdempotencyStore + + async def _slow_embed(texts, *, task_type=""): + await asyncio.sleep(0.1) # 타임아웃(0.01s) 보다 훨씬 길다 + return [[0.0]] + + embedder = MagicMock() + embedder.embed = _slow_embed + + core = MagicMock() + core.search_embeddings = AsyncMock(return_value=[]) + + generator = MagicMock() + generator.generate = AsyncMock() + publisher = MagicMock() + publisher.publish = AsyncMock() + + consumer = FollowupConsumer( + generator=generator, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.questions", + core_client=core, + embedder=embedder, + rag_top_k=5, + rag_timeout_sec=0.01, + ) + + req = _make_req() + result = await consumer._build_rag_context(req) + + assert result == "(none)" diff --git a/ai/tests/test_questions_consumer.py b/ai/tests/test_questions_consumer.py index 275c126a..cd54d0b1 100644 --- a/ai/tests/test_questions_consumer.py +++ b/ai/tests/test_questions_consumer.py @@ -261,7 +261,7 @@ async def test_consumer_injects_initial_rag_chunks_when_available(): call = core.search_embeddings.await_args assert call.kwargs["query_embedding"] == [0.1, 0.2] assert call.kwargs["document_ids"] == [1, 2] - assert call.kwargs["top_k"] == 20 # candidate_k (리랭크 후보 수) + assert call.kwargs["top_k"] == 2 # rag_top_k (direct vector search) assert call.kwargs["query_text"] # 하이브리드 검색: 쿼리 텍스트 동봉 context = generator.generate.await_args.kwargs["context"] assert "Outbox table uses status and retry count" in context diff --git a/ai/tests/test_reranker.py b/ai/tests/test_reranker.py deleted file mode 100644 index f5094417..00000000 --- a/ai/tests/test_reranker.py +++ /dev/null @@ -1,76 +0,0 @@ -from dataclasses import dataclass - -import pytest - -from ai_server.rag.reranker import ( - LlmReranker, - NoopReranker, - RerankResult, - rerank_hits, -) - - -@dataclass -class _Hit: - chunk_text: str - - -class _FakeChain: - def __init__(self, result=None, *, raises: Exception | None = None) -> None: - self._result = result - self._raises = raises - - async def ainvoke(self, _inputs): - if self._raises is not None: - raise self._raises - return self._result - - -@pytest.mark.asyncio -async def test_noop_reranker_returns_identity_truncated() -> None: - r = NoopReranker() - assert await r.rerank(query="q", candidates=["a", "b", "c"], top_k=2) == [0, 1] - - -@pytest.mark.asyncio -async def test_llm_reranker_reorders_by_returned_indices() -> None: - chain = _FakeChain(RerankResult(ranked_indices=[2, 0])) - r = LlmReranker(chain) - assert await r.rerank(query="q", candidates=["a", "b", "c"], top_k=2) == [2, 0] - - -@pytest.mark.asyncio -async def test_llm_reranker_filters_out_of_range_and_dupes() -> None: - chain = _FakeChain(RerankResult(ranked_indices=[1, 1, 9, -1, 0])) - r = LlmReranker(chain) - # 중복/범위밖 제거 후 순서 유지 - assert await r.rerank(query="q", candidates=["a", "b", "c"], top_k=5) == [1, 0] - - -@pytest.mark.asyncio -async def test_llm_reranker_falls_back_to_search_order_on_failure() -> None: - chain = _FakeChain(raises=RuntimeError("llm down")) - r = LlmReranker(chain) - assert await r.rerank(query="q", candidates=["a", "b", "c"], top_k=2) == [0, 1] - - -@pytest.mark.asyncio -async def test_llm_reranker_empty_indices_falls_back() -> None: - chain = _FakeChain(RerankResult(ranked_indices=[])) - r = LlmReranker(chain) - assert await r.rerank(query="q", candidates=["a", "b"], top_k=2) == [0, 1] - - -@pytest.mark.asyncio -async def test_rerank_hits_maps_indices_back_to_hits() -> None: - chain = _FakeChain(RerankResult(ranked_indices=[2, 0])) - r = LlmReranker(chain) - hits = [_Hit("zero"), _Hit("one"), _Hit("two")] - out = await rerank_hits(r, query="q", hits=hits, top_k=2) - assert [h.chunk_text for h in out] == ["two", "zero"] - - -@pytest.mark.asyncio -async def test_rerank_hits_empty_returns_empty() -> None: - out = await rerank_hits(NoopReranker(), query="q", hits=[], top_k=5) - assert out == [] diff --git a/frontend/src/features/interview/lib/useTypewriter.test.ts b/frontend/src/features/interview/lib/useTypewriter.test.ts new file mode 100644 index 00000000..a40c4e16 --- /dev/null +++ b/frontend/src/features/interview/lib/useTypewriter.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useTypewriter } from './useTypewriter' + +describe('useTypewriter', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('enabled=false 면 전체 텍스트 즉시', () => { + const { result } = renderHook(() => useTypewriter('안녕하세요', false)) + expect(result.current).toBe('안녕하세요') + }) + + it('enabled 면 점진적으로 드러나 결국 전체에 도달', () => { + const { result } = renderHook(() => useTypewriter('가나다라마바사아자차', true)) + // 처음엔 일부만(또는 비어있음) + const initial = result.current.length + act(() => { vi.advanceTimersByTime(35 * 12) }) + expect(result.current).toBe('가나다라마바사아자차') + expect(initial).toBeLessThan('가나다라마바사아자차'.length) + }) +}) diff --git a/frontend/src/features/interview/lib/useTypewriter.ts b/frontend/src/features/interview/lib/useTypewriter.ts new file mode 100644 index 00000000..c6ee7509 --- /dev/null +++ b/frontend/src/features/interview/lib/useTypewriter.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef, useState } from 'react' + +// 스트리밍 텍스트를 일정 속도로 점진 표시(타자기). enabled=false면 전체 즉시 반환. +// fullText 가 늘어나면 따라가고, 남은 양에 비례해 step 을 키워 길고 빠른 스트림은 지연 없이 따라잡는다. +export function useTypewriter(fullText: string, enabled: boolean): string { + const [count, setCount] = useState(enabled ? 0 : fullText.length) + const targetRef = useRef(fullText) + targetRef.current = fullText + + useEffect(() => { + if (!enabled) { + setCount(targetRef.current.length) + return + } + const id = setInterval(() => { + setCount((c) => { + const target = targetRef.current.length + if (c >= target) return c + const remaining = target - c + const step = Math.max(2, Math.ceil(remaining / 6)) + return Math.min(target, c + step) + }) + }, 35) + return () => clearInterval(id) + }, [enabled]) + + const shown = enabled ? Math.min(count, fullText.length) : fullText.length + return fullText.slice(0, shown) +} diff --git a/frontend/src/features/interview/model/useLiveInterview.ts b/frontend/src/features/interview/model/useLiveInterview.ts index 2d70a915..14ba87f0 100644 --- a/frontend/src/features/interview/model/useLiveInterview.ts +++ b/frontend/src/features/interview/model/useLiveInterview.ts @@ -15,7 +15,7 @@ import type { OptimisticAnswer } from './optimistic' import { applyDelta, isStreamingMessage, FOLLOWUP_GENERATING_TEXT } from './streamingBuffer' import type { DeltaPayload } from './streamingBuffer' -export type ThreadItem = Message & { key: string } +export type ThreadItem = Message & { key: string; streaming?: boolean } export type ConnectionStatus = 'connecting' | 'open' | 'closed' export function useLiveInterview(sessionId: number) { @@ -58,7 +58,7 @@ export function useLiveInterview(sessionId: number) { const mergedMessages = serverMessages.map((m) => { const buffered = deltaBuffer[m.id ?? -1] if (buffered !== undefined && isStreamingMessage(m, buffered)) { - return { ...m, content: buffered } + return { ...m, content: buffered, streaming: true as const } } return m }) diff --git a/frontend/src/features/interview/ui/live/ConversationThread.tsx b/frontend/src/features/interview/ui/live/ConversationThread.tsx index 93b2120c..9317ff3c 100644 --- a/frontend/src/features/interview/ui/live/ConversationThread.tsx +++ b/frontend/src/features/interview/ui/live/ConversationThread.tsx @@ -27,7 +27,7 @@ export function ConversationThread({
{items.map((item) => isQuestion(item) ? ( - + ) : ( ), diff --git a/frontend/src/features/interview/ui/live/InterviewStage.tsx b/frontend/src/features/interview/ui/live/InterviewStage.tsx index 5082d4bf..9450562f 100644 --- a/frontend/src/features/interview/ui/live/InterviewStage.tsx +++ b/frontend/src/features/interview/ui/live/InterviewStage.tsx @@ -119,7 +119,7 @@ export function InterviewStage({ {awaitingQuestion || !currentQuestion ? ( ) : ( - + )}
diff --git a/frontend/src/features/interview/ui/live/QuestionBubble.tsx b/frontend/src/features/interview/ui/live/QuestionBubble.tsx index f543659f..f77b6a6c 100644 --- a/frontend/src/features/interview/ui/live/QuestionBubble.tsx +++ b/frontend/src/features/interview/ui/live/QuestionBubble.tsx @@ -3,6 +3,7 @@ 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' function PlayIcon({ playing }: { playing: boolean }) { return ( @@ -15,13 +16,17 @@ function PlayIcon({ playing }: { playing: boolean }) { export function QuestionBubble({ message, autoPlay = false, + streaming = false, }: { message: Message autoPlay?: boolean + streaming?: boolean }) { const label = categoryLabel(message.category) 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 { playing, toggle, audioNode } = useTtsPlayback({ sessionId: message.sessionId, @@ -42,14 +47,14 @@ export function QuestionBubble({ )}
- {message.content === FOLLOWUP_GENERATING_TEXT ? ( + {isSentinel ? ( ) : ( -

{message.content}

+

{shownText}

)} {ttsReady && (
diff --git a/frontend/src/features/interview/ui/live/StageQuestion.tsx b/frontend/src/features/interview/ui/live/StageQuestion.tsx index c9ad8886..153b5bb6 100644 --- a/frontend/src/features/interview/ui/live/StageQuestion.tsx +++ b/frontend/src/features/interview/ui/live/StageQuestion.tsx @@ -1,6 +1,7 @@ import type { Message } from '@/domain/session' import { categoryLabel } from '../../lib/categoryLabel' import { useTtsPlayback } from '../../lib/media/useTtsPlayback' +import { useTypewriter } from '../../lib/useTypewriter' function PlayIcon({ playing }: { playing: boolean }) { return ( @@ -11,9 +12,10 @@ function PlayIcon({ playing }: { playing: boolean }) { } // 면접관이 지금 막 던진 한 질문에만 집중시키는 카드. -export function StageQuestion({ question, segmented = false }: { question: Message; segmented?: boolean }) { +export function StageQuestion({ question, segmented = false, streaming = false }: { question: Message; segmented?: boolean; streaming?: boolean }) { const label = categoryLabel(question.category) const ttsReady = question.ttsStatus === 'SUCCEEDED' + const shownText = useTypewriter(question.content ?? '', !!streaming) const { playing, toggle, audioNode } = useTtsPlayback({ sessionId: question.sessionId, @@ -40,7 +42,7 @@ export function StageQuestion({ question, segmented = false }: { question: Messa

- {question.content} + {shownText}

{ttsReady && (