From b2be9f7c7b9f4e87f2ea6e17dd0c4bdc48e3da0e Mon Sep 17 00:00:00 2001 From: Jaeho Date: Wed, 3 Jun 2026 23:51:35 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(ai):=20Gemini=20=EC=9E=84=EB=B2=A0?= =?UTF-8?q?=EB=94=A9=20429=20=EC=99=84=ED=99=94=20(=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EB=B6=84=ED=95=A0=20+=20=EB=B0=B1=EC=98=A4=ED=94=84=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GeminiEmbeddingProvider 가 전체 청크를 한 요청에 보내 분당 토큰 한도(429 RESOURCE_EXHAUSTED)에 쉽게 걸렸다. EMBEDDING_BATCH_SIZE 단위로 쪼개 순차 호출하고, RESOURCE_EXHAUSTED 만 지수 백오프로 재시도한다(상한 30s). 소진 시 GEMINI_RATE_LIMITED, 그 외 오류는 GEMINI_FAILED 로 즉시 실패시킨다. - EMBEDDING_MAX_RETRIES / EMBEDDING_RETRY_BASE_DELAY_SEC 설정 추가 - build_embedding_provider · runner 배선, 단위 테스트 4건 추가 Co-Authored-By: Claude Opus 4.8 --- ai/.env.example | 2 + ai/CLAUDE.md | 2 + ai/src/ai_server/config/settings.py | 4 + ai/src/ai_server/messaging/runner.py | 3 + ai/src/ai_server/rag/embedder.py | 109 +++++++++++++++++++++------ ai/tests/test_embedder.py | 102 ++++++++++++++++++++++++- docs/environment.md | 4 +- 7 files changed, 203 insertions(+), 23 deletions(-) diff --git a/ai/.env.example b/ai/.env.example index 4fd31f57..73c30d3b 100644 --- a/ai/.env.example +++ b/ai/.env.example @@ -45,6 +45,8 @@ EMBEDDING_DIM=1536 EMBEDDING_CHUNK_SIZE=1000 EMBEDDING_CHUNK_OVERLAP=200 EMBEDDING_BATCH_SIZE=32 +EMBEDDING_MAX_RETRIES=5 # 429(RESOURCE_EXHAUSTED) 지수 백오프 재시도 횟수 +EMBEDDING_RETRY_BASE_DELAY_SEC=2.0 # 재시도 기본 지연(초). delay=base*2^attempt (최대 30s) GEMINI_API_KEY= diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index e3b7a7cf..edf12a5d 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -325,6 +325,8 @@ docker run --env-file .env -p 8000:8000 stackup-ai - 콜백: `callback.questions` (`kind=POOL|FOLLOWUP`) - **임베딩 본 구현** (`rag/`): `MarkdownChunker` + `GeminiEmbeddingProvider` (1536d, `gemini-embedding-001`). 운영/개발 default 는 gemini, 테스트는 `MockEmbeddingProvider`. + - 청크를 `EMBEDDING_BATCH_SIZE`(기본 32) 단위로 쪼개 순차 호출 — 한 요청에 몰면 분당 토큰 한도(429 `RESOURCE_EXHAUSTED`)에 걸린다. + - 429 는 지수 백오프(`EMBEDDING_MAX_RETRIES`/`EMBEDDING_RETRY_BASE_DELAY_SEC`, 상한 30s)로 재시도. 소진 시 `GEMINI_RATE_LIMITED`(retriable), 그 외 오류는 `GEMINI_FAILED`(retriable)로 즉시 실패. - **스토리지 추상화** (`storage/`): `S3Storage`(기본) / `LocalFilesystemStorage`. `STORAGE_BACKEND` 토글. - **LLM 호출 로깅 본 구현** (`observability/llm_logging_callback.py`, US-30): LangChain `AsyncCallbackHandler` 가 토큰/latency 측정 → Core `/api/internal/ai-logs` POST. diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 9adf36d9..092ef5ed 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -134,7 +134,11 @@ class Settings(BaseSettings): embedding_dim: int = 1536 embedding_chunk_size: int = 1000 embedding_chunk_overlap: int = 200 + # 한 임베딩 요청당 청크 수. 크면 분당 토큰 한도(429)에 걸리기 쉬우니 적당히 쪼갠다. embedding_batch_size: int = 32 + # 429(RESOURCE_EXHAUSTED) 시 지수 백오프 재시도 횟수·기본 지연(초). + embedding_max_retries: int = 5 + embedding_retry_base_delay_sec: float = 2.0 # PDF Vision (이미지/스캔 PDF 폴백 — 게이트웨이 멀티모달) pdf_vision_max_pages: int = 5 diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index ff1489fe..02448a61 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -96,6 +96,9 @@ def __init__(self, settings: Settings) -> None: dim=settings.embedding_dim, model=settings.embedding_model, gemini_api_key=settings.gemini_api_key, + batch_size=settings.embedding_batch_size, + 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) diff --git a/ai/src/ai_server/rag/embedder.py b/ai/src/ai_server/rag/embedder.py index 0c79adb3..9f2fc44e 100644 --- a/ai/src/ai_server/rag/embedder.py +++ b/ai/src/ai_server/rag/embedder.py @@ -1,9 +1,15 @@ from __future__ import annotations +import asyncio import hashlib +import random import struct from typing import Protocol +import structlog + +log = structlog.get_logger(__name__) + class EmbeddingError(Exception): def __init__(self, *, code: str, message: str, retriable: bool) -> None: @@ -13,6 +19,16 @@ def __init__(self, *, code: str, message: str, retriable: bool) -> None: self.retriable = retriable +# Gemini 가 한도 초과(429)일 때만 백오프 재시도한다. 다른 오류(인증·잘못된 입력 등)는 +# 재시도해도 동일하므로 즉시 실패시킨다. SDK ClientError 는 .code(HTTP)·.status 를 노출한다. +def _is_rate_limited(exc: Exception) -> bool: + if getattr(exc, "code", None) == 429: + return True + if getattr(exc, "status", None) == "RESOURCE_EXHAUSTED": + return True + return "RESOURCE_EXHAUSTED" in str(exc) + + # 구현체는 바꿔서 사용할 수 있음 class EmbeddingProvider(Protocol): @property @@ -61,7 +77,20 @@ def _embed_one(self, text: str) -> list[float]: # Gemini Embedding 을 사용합니다. # 이건 충대키로 안되니 키 발급 필요함 class GeminiEmbeddingProvider: - def __init__(self, *, api_key: str, model: str, dim: int) -> None: + # 한 요청에 너무 많은 청크를 담으면 분당 토큰 한도(TPM)에 걸려 429 가 난다. + # batch_size 로 쪼개 순차 호출하고, 429 는 지수 백오프로 재시도한다. + _MAX_BACKOFF_SEC = 30.0 + + def __init__( + self, + *, + api_key: str, + model: str, + dim: int, + batch_size: int = 32, + max_retries: int = 5, + retry_base_delay_sec: float = 2.0, + ) -> None: if not api_key: raise ValueError("GEMINI_API_KEY 누락 — provider=gemini 사용 불가") if dim <= 0: @@ -71,6 +100,9 @@ def __init__(self, *, api_key: str, model: str, dim: int) -> None: self._client = genai.Client(api_key=api_key) self._model = model self._dim = dim + self._batch_size = max(1, batch_size) + self._max_retries = max(0, max_retries) + self._retry_base_delay = max(0.0, retry_base_delay_sec) @property def dim(self) -> int: @@ -87,25 +119,50 @@ async def embed( return [] from google.genai import types as genai_types - try: - resp = await self._client.aio.models.embed_content( - model=self._model, - contents=texts, - config=genai_types.EmbedContentConfig( - # 인덱싱은 RETRIEVAL_DOCUMENT, 검색 쿼리는 RETRIEVAL_QUERY 로 - # 분리해야 Gemini embedding 의 코사인 정합도가 최적화된다. - task_type=task_type, - output_dimensionality=self._dim, - ), - ) - except Exception as exc: - raise EmbeddingError( - code="GEMINI_FAILED", - message=f"Gemini embedding 호출 실패: {exc}", - retriable=True, - ) from exc - - return [list(e.values) for e in resp.embeddings] + config = genai_types.EmbedContentConfig( + # 인덱싱은 RETRIEVAL_DOCUMENT, 검색 쿼리는 RETRIEVAL_QUERY 로 + # 분리해야 Gemini embedding 의 코사인 정합도가 최적화된다. + task_type=task_type, + output_dimensionality=self._dim, + ) + + vectors: list[list[float]] = [] + for start in range(0, len(texts), self._batch_size): + batch = texts[start : start + self._batch_size] + resp = await self._embed_batch_with_retry(batch, config) + vectors.extend(list(e.values) for e in resp.embeddings) + return vectors + + async def _embed_batch_with_retry(self, batch: list[str], config: object) -> object: + attempt = 0 + while True: + try: + return await self._client.aio.models.embed_content( + model=self._model, + contents=batch, + config=config, + ) + except Exception as exc: + rate_limited = _is_rate_limited(exc) + if rate_limited and attempt < self._max_retries: + delay = min( + self._retry_base_delay * (2**attempt), self._MAX_BACKOFF_SEC + ) + delay += random.uniform(0.0, self._retry_base_delay * 0.1) + log.warning( + "embed.gemini.rate_limited", + attempt=attempt + 1, + max_retries=self._max_retries, + delay_sec=round(delay, 2), + ) + await asyncio.sleep(delay) + attempt += 1 + continue + raise EmbeddingError( + code="GEMINI_RATE_LIMITED" if rate_limited else "GEMINI_FAILED", + message=f"Gemini embedding 호출 실패: {exc}", + retriable=True, + ) from exc def build_embedding_provider( @@ -114,11 +171,21 @@ def build_embedding_provider( dim: int, model: str, gemini_api_key: str = "", + batch_size: int = 32, + max_retries: int = 5, + retry_base_delay_sec: float = 2.0, ) -> EmbeddingProvider: if provider == "mock": return MockEmbeddingProvider(dim=dim, model=model) if provider == "gemini": - return GeminiEmbeddingProvider(api_key=gemini_api_key, model=model, dim=dim) + return GeminiEmbeddingProvider( + api_key=gemini_api_key, + model=model, + dim=dim, + batch_size=batch_size, + max_retries=max_retries, + retry_base_delay_sec=retry_base_delay_sec, + ) if provider == "openai": raise NotImplementedError("openai embedding provider 미구현 — 후속 PR에서 추가") if provider == "ollama": diff --git a/ai/tests/test_embedder.py b/ai/tests/test_embedder.py index afc53c61..fb6f99ed 100644 --- a/ai/tests/test_embedder.py +++ b/ai/tests/test_embedder.py @@ -120,7 +120,9 @@ async def test_gemini_embed_returns_vector_list_from_sdk_response() -> None: @pytest.mark.asyncio -async def test_gemini_embed_task_type_defaults_to_document_and_overrides_to_query() -> None: +async def test_gemini_embed_task_type_defaults_to_document_and_overrides_to_query() -> ( + None +): from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -183,5 +185,103 @@ async def test_gemini_embed_wraps_sdk_exception_as_retriable() -> None: with pytest.raises(EmbeddingError) as exc_info: await emb.embed(["x"]) + # 한도 초과가 아닌 일반 오류는 재시도하지 않고 즉시 실패. assert exc_info.value.code == "GEMINI_FAILED" assert exc_info.value.retriable is True + assert fake_aio.models.embed_content.await_count == 1 + + +@pytest.mark.asyncio +async def test_gemini_embed_splits_into_batches() -> None: + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + + from ai_server.rag.embedder import GeminiEmbeddingProvider + + # 배치마다 입력 개수만큼 벡터를 돌려주도록 모사. + def _resp_for(*, contents, **_kwargs): + return SimpleNamespace( + embeddings=[ + SimpleNamespace(values=[float(i)]) for i in range(len(contents)) + ] + ) + + fake_aio = MagicMock() + fake_aio.models.embed_content = AsyncMock(side_effect=_resp_for) + fake_client = MagicMock() + fake_client.aio = fake_aio + + with patch("google.genai.Client", return_value=fake_client): + emb = GeminiEmbeddingProvider(api_key="fake", model="m", dim=1, batch_size=2) + + out = await emb.embed(["a", "b", "c", "d", "e"]) + assert len(out) == 5 + # 5개 / batch_size 2 → 3회 호출 (2 + 2 + 1) + assert fake_aio.models.embed_content.await_count == 3 + assert [ + len(call.kwargs["contents"]) + for call in fake_aio.models.embed_content.await_args_list + ] == [2, 2, 1] + + +@pytest.mark.asyncio +async def test_gemini_embed_retries_on_429_then_succeeds() -> None: + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + + from ai_server.rag.embedder import GeminiEmbeddingProvider + + class _RateLimited(Exception): + code = 429 + status = "RESOURCE_EXHAUSTED" + + ok = SimpleNamespace(embeddings=[SimpleNamespace(values=[0.5])]) + fake_aio = MagicMock() + fake_aio.models.embed_content = AsyncMock(side_effect=[_RateLimited("429"), ok]) + fake_client = MagicMock() + fake_client.aio = fake_aio + + with patch("google.genai.Client", return_value=fake_client): + emb = GeminiEmbeddingProvider( + api_key="fake", + model="m", + dim=1, + max_retries=3, + retry_base_delay_sec=0.0, # 테스트는 즉시 재시도. + ) + + out = await emb.embed(["x"]) + assert out == [[0.5]] + assert fake_aio.models.embed_content.await_count == 2 + + +@pytest.mark.asyncio +async def test_gemini_embed_gives_up_after_max_retries_on_429() -> None: + from unittest.mock import AsyncMock, MagicMock, patch + + from ai_server.rag.embedder import EmbeddingError, GeminiEmbeddingProvider + + class _RateLimited(Exception): + code = 429 + status = "RESOURCE_EXHAUSTED" + + fake_aio = MagicMock() + fake_aio.models.embed_content = AsyncMock(side_effect=_RateLimited("429")) + fake_client = MagicMock() + fake_client.aio = fake_aio + + with patch("google.genai.Client", return_value=fake_client): + emb = GeminiEmbeddingProvider( + api_key="fake", + model="m", + dim=1, + max_retries=2, + retry_base_delay_sec=0.0, + ) + + with pytest.raises(EmbeddingError) as exc_info: + await emb.embed(["x"]) + assert exc_info.value.code == "GEMINI_RATE_LIMITED" + assert exc_info.value.retriable is True + # 최초 1 + 재시도 2 = 3회. + assert fake_aio.models.embed_content.await_count == 3 diff --git a/docs/environment.md b/docs/environment.md index c1c861de..114adc67 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -136,7 +136,9 @@ EMBEDDING_MODEL=gemini-embedding-001 EMBEDDING_DIM=1536 # DB 컬럼 차원과 일치 필수 EMBEDDING_CHUNK_SIZE=1000 EMBEDDING_CHUNK_OVERLAP=200 -EMBEDDING_BATCH_SIZE=32 +EMBEDDING_BATCH_SIZE=32 # 한 요청당 청크 수 (작을수록 429 회피, 호출 수↑) +EMBEDDING_MAX_RETRIES=5 # 429(RESOURCE_EXHAUSTED) 지수 백오프 재시도 횟수 +EMBEDDING_RETRY_BASE_DELAY_SEC=2.0 # delay = base*2^attempt + jitter (상한 30s) # ===== Markdown 산출물 키 템플릿 ===== ANALYZED_RESUME_MD_KEY_TEMPLATE=analyzed/resume/{resume_id}/summary.md From cb931309fe2a06598bafad1ccadcf3d31d5b8c02 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Wed, 3 Jun 2026 23:51:55 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(workspace):=20=EC=9E=90=EB=A3=8C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20UI=EB=A5=BC=20=EB=93=9C=EB=A1=AD=EC=A1=B4?= =?UTF-8?q?=C2=B7=EC=B9=B4=EB=93=9C=C2=B7=EB=AA=A8=EB=8B=AC=EB=A1=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=94=94=EC=9E=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가로 막대형 나열을 카드 그리드로 바꾸고, 이력서 업로드는 드래그앤드롭 드롭존으로, 분석 결과는 카드 클릭 시 모달 상세로 전환했다. 보조 텍스트 대비를 WCAG AA 로 높이고(fg-muted/subtle 한 단계 진하게), 홈 히어로의 면접 시작 CTA 를 강조했다. - 재사용 Modal 프리미티브 신설 (shared/ui/Modal): Esc·백드롭 닫기, 스크롤 잠금, 포커스 복원, z-modal - 이력서/레포 목록·분석 결과·통계 카드 스타일 통일 (rounded-2xl + shadow-sm) - 디자인 토큰·인벤토리 문서 갱신 Co-Authored-By: Claude Opus 4.8 --- docs/design-system.md | 14 +- frontend/src/app/styles/global.css | 26 ++ frontend/src/app/styles/tokens.css | 5 +- .../src/features/analysis/ui/DocumentList.tsx | 233 ++++++++++++++---- .../src/features/history/ui/ScoreTrend.tsx | 4 +- .../src/features/history/ui/StatsSummary.tsx | 2 +- frontend/src/features/repo/ui/RepoList.tsx | 110 ++++++--- frontend/src/features/repo/ui/RepoPicker.tsx | 128 ++++++++-- .../src/features/resume/ui/ResumeList.tsx | 100 +++++--- .../src/features/resume/ui/ResumeUploader.tsx | 61 ++++- frontend/src/pages/Workspace/ui/HomeView.tsx | 119 +++++++-- .../src/pages/Workspace/ui/ResumesView.tsx | 8 +- frontend/src/shared/ui/Modal/Modal.tsx | 101 ++++++++ frontend/src/shared/ui/Modal/index.ts | 2 + frontend/src/shared/ui/index.ts | 2 + 15 files changed, 743 insertions(+), 172 deletions(-) create mode 100644 frontend/src/shared/ui/Modal/Modal.tsx create mode 100644 frontend/src/shared/ui/Modal/index.ts diff --git a/docs/design-system.md b/docs/design-system.md index 5284f772..cbafd6c9 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -37,9 +37,9 @@ | `sage-50` | `#e8e7e1` | 가장 밝은 컴포넌트 배경 (= `surface`) | | `sage-100` | `#d4cfcb` | 분리선 / 보더 (= `border`) | | `sage-200` | `#c9ccc8` | 비활성 텍스트 / 보조 배경 (= `border-strong`, `fg-disabled`) | -| `sage-300` | `#b4bdaf` | 보조 텍스트 (= `fg-subtle`) | -| `sage-400` | `#a0a89d` | 보조 텍스트 강조 (= `fg-muted`) | -| `sage-500` | `#626e5c` | **Primary**, 활성 / 포커스 | +| `sage-300` | `#b4bdaf` | 비활성 보조 / placeholder | +| `sage-400` | `#a0a89d` | 보조 텍스트 (= `fg-subtle`) | +| `sage-500` | `#626e5c` | **Primary**, 활성 / 포커스, 본문 보조 텍스트 (= `fg-muted`) | | `sage-600` | `#3e4739` | Primary hover | | `sage-700` | `#2b3625` | Primary pressed / 강조 컴포넌트 | | `sage-800` | `#1f271b` | 주요 헤딩 (= `fg-strong`) | @@ -61,8 +61,8 @@ Tailwind 사용: `bg-sage-{n}`, `text-sage-{n}`, `border-sage-{n}`. | `--color-border-strong` | `sage-200` | `border-border-strong` | | `--color-fg` | `sage-950` | `text-fg` | | `--color-fg-strong` | `sage-800` | `text-fg-strong` | -| `--color-fg-muted` | `sage-400` | `text-fg-muted` | -| `--color-fg-subtle` | `sage-300` | `text-fg-subtle` | +| `--color-fg-muted` | `sage-500` | `text-fg-muted` | +| `--color-fg-subtle` | `sage-400` | `text-fg-subtle` | | `--color-fg-disabled` | `sage-200` | `text-fg-disabled` | | `--color-fg-on-primary` | `white` | `text-fg-on-primary` | | `--color-primary` | `sage-500` | `bg-primary`, `text-primary` | @@ -255,7 +255,7 @@ Tailwind v4 기본 `--spacing: 0.25rem` (= 4px) 사용. `p-4` = `16px`. ### Feedback - `Toast` — 4종 (success / info / warning / error), 우상단 stack, 4초 자동 dismiss, `z-toast`. -- `Modal` — `sm / md / lg / fullscreen`, focus trap 필수, `z-modal` + `z-modal-backdrop`. +- `Modal` ✅ — `shared/ui/Modal`. `title` + `children` + 옵션 `footer` 슬롯, Esc·백드롭 클릭으로 닫힘, `z-modal`, body 스크롤 잠금 + 포커스 복원. (전체 focus trap 은 추후) - `Drawer` — 우측 슬라이드, 세션 설정 등. - `Popover`, `Tooltip` — 키보드 접근 가능. - `ConfirmDialog` — 파괴적 액션(삭제, 회원 탈퇴) 전용. @@ -281,7 +281,7 @@ Tailwind v4 기본 `--spacing: 0.25rem` (= 4px) 사용. `p-4` = `16px`. | 도메인 상태 | 시각 컬러 | 토큰 | 컴포넌트 예 | |---|---|---|---| -| `READY` / `PENDING` / `QUEUED` | neutral | `text-fg-muted` (sage-400) | 회색 Badge | +| `READY` / `PENDING` / `QUEUED` | neutral | `text-fg-muted` (sage-500) | 회색 Badge | | `IN_PROGRESS` / `PROCESSING` / `ANALYZING` | warning | `bg-warning-50 text-warning-700` | 노란 Badge + spinner | | `COMPLETED` / `ANALYZED` / `ACTIVE` | success | `bg-success-50 text-success-700` | 초록 Badge | | `INTERRUPTED` | warning | `bg-warning-50 text-warning-700` | 노란 Badge (느낌표 아이콘) | diff --git a/frontend/src/app/styles/global.css b/frontend/src/app/styles/global.css index b2899d23..951c0338 100644 --- a/frontend/src/app/styles/global.css +++ b/frontend/src/app/styles/global.css @@ -103,6 +103,32 @@ 50%, 100% { opacity: 0; } } + @keyframes modal-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + @keyframes modal-pop { + from { + opacity: 0; + transform: translateY(12px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + .anim-modal-backdrop { + animation: modal-fade var(--duration-normal) var(--ease-decelerate) both; + } + .anim-modal-panel { + animation: modal-pop var(--duration-normal) var(--ease-decelerate) both; + } + .anim-hero-rise { animation: hero-rise 0.8s var(--ease-decelerate) both; } diff --git a/frontend/src/app/styles/tokens.css b/frontend/src/app/styles/tokens.css index e1a67264..c76544c4 100644 --- a/frontend/src/app/styles/tokens.css +++ b/frontend/src/app/styles/tokens.css @@ -41,8 +41,9 @@ --color-fg: var(--color-sage-950); --color-fg-strong: var(--color-sage-800); - --color-fg-muted: var(--color-sage-400); - --color-fg-subtle: var(--color-sage-300); + /* 본문 위 보조 텍스트가 밝은 표면에서 충분히 읽히도록 한 단계씩 진하게 (WCAG AA). */ + --color-fg-muted: var(--color-sage-500); + --color-fg-subtle: var(--color-sage-400); --color-fg-disabled: var(--color-sage-200); --color-fg-on-primary: var(--color-white); diff --git a/frontend/src/features/analysis/ui/DocumentList.tsx b/frontend/src/features/analysis/ui/DocumentList.tsx index c993a759..f23f84b2 100644 --- a/frontend/src/features/analysis/ui/DocumentList.tsx +++ b/frontend/src/features/analysis/ui/DocumentList.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { isApiError } from '@/shared/api' -import { StatusBadge, type StatusTone } from '@/shared/ui' -import { fetchDocument, type DocumentFilter } from '../api/analysis' +import { Modal, StatusBadge, type StatusTone } from '@/shared/ui' +import type { DocumentFilter } from '../api/analysis' import { useDocuments } from '../model/useDocuments' import type { AnalysisSourceType, @@ -22,6 +22,8 @@ const SOURCE_LABEL: Record = { WEB: '웹', } +const TECH_PREVIEW_COUNT = 4 + type Props = { filter?: DocumentFilter // 클라이언트에서 소스 유형으로 한정 (탭별 분석 결과 분리용). @@ -30,6 +32,7 @@ type Props = { export function DocumentList({ filter = {}, sourceType }: Props) { const { data = [], isPending, isError, error } = useDocuments(filter) + const [activeId, setActiveId] = useState(null) if (isPending) { return

분석 결과를 불러오는 중…

@@ -49,50 +52,61 @@ export function DocumentList({ filter = {}, sourceType }: Props) { if (docs.length === 0) { const subject = sourceType ? SOURCE_LABEL[sourceType] : '이력서·레포' return ( -
-

아직 분석된 문서가 없습니다.

-

+

+

아직 분석된 문서가 없습니다.

+

{subject} 분석이 완료되면 요약과 기술 스택이 여기에 표시됩니다.

) } + const activeDoc = docs.find((doc) => doc.id === activeId) ?? null + return ( -
    - {docs.map((doc) => ( - - ))} -
+ <> +
    + {docs.map((doc) => ( + setActiveId(doc.id)} + /> + ))} +
+ + setActiveId(null)} + title={activeDoc ? `${SOURCE_LABEL[activeDoc.sourceType]} 분석 결과` : ''} + > + {activeDoc ? : null} + + ) } -function DocumentRow({ doc }: { doc: AnalyzedDocument }) { +function DocumentCard({ + doc, + onOpen, +}: { + doc: AnalyzedDocument + onOpen: () => void +}) { const meta = STATUS_META[doc.analysisStatus] - const [opening, setOpening] = useState(false) - const [openError, setOpenError] = useState(null) - - const openSource = async () => { - setOpenError(null) - setOpening(true) - try { - const detail = await fetchDocument(doc.id) - if (detail.documentDownloadUrl) { - window.open(detail.documentDownloadUrl, '_blank', 'noreferrer') - } else { - setOpenError('다운로드 URL을 가져오지 못했습니다.') - } - } catch (e) { - setOpenError(isApiError(e) ? e.message : '원문을 열지 못했습니다.') - } finally { - setOpening(false) - } - } + const clickable = doc.analysisStatus === 'ANALYZED' + const extraTech = doc.techStack.length - TECH_PREVIEW_COUNT - return ( -
  • -
    - + const body = ( + <> +
    + + + + {SOURCE_LABEL[doc.sourceType]} {meta.label} @@ -101,39 +115,158 @@ function DocumentRow({ doc }: { doc: AnalyzedDocument }) { {doc.analysisStatus === 'ANALYZED' ? ( <> {doc.summary ? ( -

    {doc.summary}

    +

    + {doc.summary} +

    ) : null} {doc.techStack.length > 0 ? ( -
      - {doc.techStack.map((tech) => ( +
        + {doc.techStack.slice(0, TECH_PREVIEW_COUNT).map((tech) => (
      • {tech}
      • ))} + {extraTech > 0 ? ( +
      • +{extraTech}
      • + ) : null}
      ) : null} - - {openError ? ( -

      {openError}

      - ) : null} ) : null} + {doc.analysisStatus === 'PROCESSING' ? ( +

      + 분석이 완료되면 요약과 기술 스택이 표시됩니다. +

      + ) : null} + {doc.analysisStatus === 'FAILED' ? ( -

      +

      {doc.errorMessage ?? '분석에 실패했습니다.'}

      ) : null} + + ) + + if (!clickable) { + return ( +
    • + {body} +
    • + ) + } + + return ( +
    • +
    • ) } + +function DocumentDetail({ doc }: { doc: AnalyzedDocument }) { + return ( +
      + {doc.summary ? ( +
      +

      + 요약 +

      +

      + {doc.summary} +

      +
      + ) : null} + + {doc.techStack.length > 0 ? ( +
      +

      + 기술 스택 +

      +
        + {doc.techStack.map((tech) => ( +
      • + {tech} +
      • + ))} +
      +
      + ) : null} +
      + ) +} + +function SourceIcon({ source }: { source: AnalysisSourceType }) { + if (source === 'REPOSITORY') { + return ( + + + + + + + ) + } + if (source === 'WEB') { + return ( + + + + + ) + } + return ( + + + + + + ) +} diff --git a/frontend/src/features/history/ui/ScoreTrend.tsx b/frontend/src/features/history/ui/ScoreTrend.tsx index 1ba5affe..101c6d3f 100644 --- a/frontend/src/features/history/ui/ScoreTrend.tsx +++ b/frontend/src/features/history/ui/ScoreTrend.tsx @@ -8,7 +8,7 @@ export function ScoreTrend({ stats }: { stats: UserStats }) { if (points.length === 0) { return ( -
      +
      점수 추이

      아직 채점된 면접이 없어요.

      @@ -16,7 +16,7 @@ export function ScoreTrend({ stats }: { stats: UserStats }) { } return ( -
      +
      종합 점수 추이 (최근 {points.length}회)
      {points.map((r) => { diff --git a/frontend/src/features/history/ui/StatsSummary.tsx b/frontend/src/features/history/ui/StatsSummary.tsx index 33eb9fc1..c31e79d7 100644 --- a/frontend/src/features/history/ui/StatsSummary.tsx +++ b/frontend/src/features/history/ui/StatsSummary.tsx @@ -4,7 +4,7 @@ import type { UserStats } from '../api/historyApi' export function StatsSummary({ stats }: { stats: UserStats }) { const a = stats.averages return ( -
      +
      diff --git a/frontend/src/features/repo/ui/RepoList.tsx b/frontend/src/features/repo/ui/RepoList.tsx index 13b9286b..86dcd86e 100644 --- a/frontend/src/features/repo/ui/RepoList.tsx +++ b/frontend/src/features/repo/ui/RepoList.tsx @@ -33,9 +33,11 @@ export function RepoList() { } if (data.length === 0) { return ( -
      -

      아직 등록된 레포지토리가 없습니다.

      -

      +

      +

      + 아직 등록된 레포지토리가 없습니다. +

      +

      위에서 GitHub 레포를 가져오면 분석이 자동으로 시작됩니다.

      @@ -43,9 +45,9 @@ export function RepoList() { } return ( -
        +
          {data.map((repo) => ( - -
          - - {repo.repoFullName} - -

          - {showProgress ? progress.message : (repo.defaultBranch ?? 'main')} -

          -
          -
          - {meta.label} - +
        • + + + + +
          + + +
          + {meta.label} + + {showProgress ? progress.message : (repo.defaultBranch ?? 'main')} + +
        • ) } + +function RepoIcon() { + return ( + + + + + + + ) +} + +function TrashIcon() { + return ( + + + + ) +} diff --git a/frontend/src/features/repo/ui/RepoPicker.tsx b/frontend/src/features/repo/ui/RepoPicker.tsx index 666c101a..02ff8ebf 100644 --- a/frontend/src/features/repo/ui/RepoPicker.tsx +++ b/frontend/src/features/repo/ui/RepoPicker.tsx @@ -27,56 +27,80 @@ export function RepoPicker() { } return ( -
          -
          -

          GitHub에서 레포 가져오기

          +
          +
          + + + +
          +

          + GitHub에서 레포 가져오기 +

          +

          + 연결된 GitHub 계정의 레포지토리를 등록할 수 있습니다. +

          +
          {open ? ( -
          +
          {error ? ( -

          {error}

          +

          + {error} +

          ) : null} {candidates.isPending ? ( -

          불러오는 중…

          +

          + 불러오는 중… +

          ) : candidates.isError ? ( -

          +

          {isApiError(candidates.error) ? candidates.error.message : 'GitHub 레포를 불러오지 못했습니다.'}

          ) : candidates.data.length === 0 ? ( -

          표시할 레포가 없습니다.

          +

          + 표시할 레포가 없습니다. +

          ) : ( -
            +
              {candidates.data.map((repo) => (
            • -
              -

              + + + + + {repo.fullName} - {repo.isPrivate ? ( - - private - - ) : null} -

              -
              + + {repo.isPrivate ? ( + + + private + + ) : null} + @@ -85,23 +109,23 @@ export function RepoPicker() {
            )} -
            +
            - {page} + {page} 페이지
            @@ -109,3 +133,53 @@ export function RepoPicker() {
          ) } + +function GithubIcon() { + return ( + + + + ) +} + +function RepoIcon() { + return ( + + + + + + + ) +} + +function LockIcon() { + return ( + + + + + ) +} diff --git a/frontend/src/features/resume/ui/ResumeList.tsx b/frontend/src/features/resume/ui/ResumeList.tsx index 0450f9e6..f20e0599 100644 --- a/frontend/src/features/resume/ui/ResumeList.tsx +++ b/frontend/src/features/resume/ui/ResumeList.tsx @@ -27,20 +27,13 @@ export function ResumeList() { ) } if (data.length === 0) { - return ( -
          -

          아직 등록된 이력서가 없습니다.

          -

          - PDF 이력서를 업로드하면 분석이 자동으로 시작됩니다. -

          -
          - ) + return null } return ( -
            +
              {data.map((resume) => ( - -
              -

              - {resume.originalFilename} -

              -

              - {showProgress ? progress.message : formatFileSize(resume.fileSize)} -

              -
              -
              - {meta.label} - +
            • + + + + +
              +
              +

              + {resume.originalFilename} +

              + +
              + +
              + {meta.label} + + {showProgress ? progress.message : formatFileSize(resume.fileSize)} + +
            • ) } + +function FileIcon() { + return ( + + + + + + ) +} + +function TrashIcon() { + return ( + + + + ) +} diff --git a/frontend/src/features/resume/ui/ResumeUploader.tsx b/frontend/src/features/resume/ui/ResumeUploader.tsx index 04ad1cc9..af55c353 100644 --- a/frontend/src/features/resume/ui/ResumeUploader.tsx +++ b/frontend/src/features/resume/ui/ResumeUploader.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from 'react' import { isApiError } from '@/shared/api' +import { Spinner } from '@/shared/ui/Spinner' import { useUploadResume } from '../model/useResumes' const MAX_FILE_SIZE = 20 * 1024 * 1024 @@ -7,6 +8,7 @@ const MAX_FILE_SIZE = 20 * 1024 * 1024 export function ResumeUploader() { const inputRef = useRef(null) const [error, setError] = useState(null) + const [dragging, setDragging] = useState(false) const upload = useUploadResume() const handleFile = (file: File | undefined) => { @@ -30,7 +32,7 @@ export function ResumeUploader() { } return ( -
              +
              inputRef.current?.click()} - className="rounded-lg bg-primary px-4 py-2 text-button text-fg-on-primary transition-colors hover:bg-primary-hover disabled:opacity-60" + onDragOver={(e) => { + e.preventDefault() + if (!dragging) setDragging(true) + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault() + setDragging(false) + handleFile(e.dataTransfer.files?.[0]) + }} + className={[ + 'group flex w-full flex-col items-center justify-center gap-4 rounded-2xl border-2 border-dashed px-6 py-12 text-center transition-colors duration-fast', + dragging + ? 'border-primary bg-sage-50' + : 'border-border-strong bg-surface-raised hover:border-primary hover:bg-surface', + 'disabled:cursor-not-allowed disabled:opacity-60', + ].join(' ')} > - {upload.isPending ? '업로드 중…' : 'PDF 업로드'} + + {upload.isPending ? : } + + + + {upload.isPending + ? '업로드 중…' + : 'PDF 이력서를 끌어다 놓거나 클릭해 업로드'} + + + 최대 20MB · PDF 형식만 지원 + + - {error ?

              {error}

              : null} + {error ? ( +

              {error}

              + ) : null}
              ) } + +function UploadIcon() { + return ( + + + + + ) +} diff --git a/frontend/src/pages/Workspace/ui/HomeView.tsx b/frontend/src/pages/Workspace/ui/HomeView.tsx index 6cc1ec21..05f18b23 100644 --- a/frontend/src/pages/Workspace/ui/HomeView.tsx +++ b/frontend/src/pages/Workspace/ui/HomeView.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react' import { Link } from 'react-router-dom' import { ScoreTrend, @@ -23,21 +24,29 @@ export function HomeView() { 면접 모드와 직군을 고르면 AI가 질문을 생성하고, 실시간으로 답변을 주고받습니다.

              -
              +
              - - - + + 자료 준비하기 + + → + +
      @@ -46,16 +55,19 @@ export function HomeView() { to="/workspace/resumes" title="이력서" description="이력서를 업로드하고 분석 결과를 확인하세요." + icon={} /> } /> } />
      @@ -76,28 +88,103 @@ function QuickLink({ to, title, description, + icon, }: { to: string title: string description: string + icon: ReactNode }) { return ( +
      + + {icon} + + + → + +

      {title}

      {description}

      - - → - ) } + +function PlayIcon() { + return ( + + + + ) +} + +function ResumeIcon() { + return ( + + + + + + ) +} + +function RepoIcon() { + return ( + + + + + + + ) +} + +function HistoryIcon() { + return ( + + + + + ) +} diff --git a/frontend/src/pages/Workspace/ui/ResumesView.tsx b/frontend/src/pages/Workspace/ui/ResumesView.tsx index ccb769cc..94c33e1c 100644 --- a/frontend/src/pages/Workspace/ui/ResumesView.tsx +++ b/frontend/src/pages/Workspace/ui/ResumesView.tsx @@ -4,13 +4,15 @@ import { DocumentList } from '@/features/analysis' export function ResumesView() { return ( -
      +
      } > - +
      + + +
      void + title: ReactNode + children: ReactNode + footer?: ReactNode +} + +export function Modal({ open, onClose, title, children, footer }: ModalProps) { + const panelRef = useRef(null) + const titleId = useId() + + useEffect(() => { + if (!open) return + + const previouslyFocused = document.activeElement as HTMLElement | null + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKey) + + const prevOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + panelRef.current?.focus() + + return () => { + document.removeEventListener('keydown', onKey) + document.body.style.overflow = prevOverflow + previouslyFocused?.focus?.() + } + }, [open, onClose]) + + if (!open) return null + + return createPortal( +
      + + + +
      {children}
      + + {footer ? ( +
      {footer}
      + ) : null} +
      +
      , + document.body, + ) +} + +function CloseIcon() { + return ( + + + + ) +} diff --git a/frontend/src/shared/ui/Modal/index.ts b/frontend/src/shared/ui/Modal/index.ts new file mode 100644 index 00000000..a528f6ba --- /dev/null +++ b/frontend/src/shared/ui/Modal/index.ts @@ -0,0 +1,2 @@ +export { Modal } from './Modal' +export type { ModalProps } from './Modal' diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index 2b1b6b2f..c8b626a9 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -1,2 +1,4 @@ export { StatusBadge } from './StatusBadge' export type { StatusBadgeProps, StatusTone } from './StatusBadge' +export { Modal } from './Modal' +export type { ModalProps } from './Modal' From 0668dcb7d00a871a87e637d3d5f0db7f389de46c Mon Sep 17 00:00:00 2001 From: Jaeho Date: Wed, 3 Jun 2026 23:52:04 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(workspace):=20=EC=9D=B4=EB=A0=A5?= =?UTF-8?q?=EC=84=9C=C2=B7=EB=A0=88=ED=8F=AC=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=8B=9C=20=EB=B6=84=EC=84=9D=20=EA=B2=B0=EA=B3=BC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A6=89=EC=8B=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 삭제 뮤테이션이 자기 목록(resumes/repositories)만 무효화하고 documents 쿼리는 그대로 두어, 항목을 지워도 분석 결과 카드가 화면에 남았다. onSuccess 에서 ['documents'] 도 무효화한다. 삭제는 클라이언트 액션이라 SSE 가 오지 않으므로 직접 무효화가 필요하다(analysis feature 직접 import 는 FSD 동일 레이어 금지라 키 리터럴 사용). Co-Authored-By: Claude Opus 4.8 --- frontend/src/features/repo/model/useRepositories.ts | 3 +++ frontend/src/features/resume/model/useResumes.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/frontend/src/features/repo/model/useRepositories.ts b/frontend/src/features/repo/model/useRepositories.ts index 9902d5e3..8aa82e24 100644 --- a/frontend/src/features/repo/model/useRepositories.ts +++ b/frontend/src/features/repo/model/useRepositories.ts @@ -49,6 +49,9 @@ export function useDeleteRepository() { mutationFn: deleteRepository, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: repoKeys.registered }) + // 분석 결과(documents)는 analysis feature 소유라 직접 import 하지 않고(FSD 동일레이어 금지) + // 키 리터럴 ['documents'] 로 무효화 — 삭제는 클라이언트 액션이라 SSE 가 오지 않는다. + void queryClient.invalidateQueries({ queryKey: ['documents'] }) }, }) } diff --git a/frontend/src/features/resume/model/useResumes.ts b/frontend/src/features/resume/model/useResumes.ts index 36f2fc4b..99134dfd 100644 --- a/frontend/src/features/resume/model/useResumes.ts +++ b/frontend/src/features/resume/model/useResumes.ts @@ -29,6 +29,9 @@ export function useDeleteResume() { mutationFn: deleteResume, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: resumeKeys.all }) + // 분석 결과(documents)는 analysis feature 소유라 직접 import 하지 않고(FSD 동일레이어 금지) + // 키 리터럴 ['documents'] 로 무효화 — 삭제는 클라이언트 액션이라 SSE 가 오지 않는다. + void queryClient.invalidateQueries({ queryKey: ['documents'] }) }, }) }