Skip to content

Commit 128180f

Browse files
authored
Merge pull request #83 from Team-StackUp/design/workspace-improvement
Design/workspace improvement
2 parents 5dbaf93 + 0668dcb commit 128180f

24 files changed

Lines changed: 952 additions & 195 deletions

File tree

ai/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ EMBEDDING_DIM=1536
4545
EMBEDDING_CHUNK_SIZE=1000
4646
EMBEDDING_CHUNK_OVERLAP=200
4747
EMBEDDING_BATCH_SIZE=32
48+
EMBEDDING_MAX_RETRIES=5 # 429(RESOURCE_EXHAUSTED) 지수 백오프 재시도 횟수
49+
EMBEDDING_RETRY_BASE_DELAY_SEC=2.0 # 재시도 기본 지연(초). delay=base*2^attempt (최대 30s)
4850

4951
GEMINI_API_KEY=
5052

ai/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,8 @@ docker run --env-file .env -p 8000:8000 stackup-ai
325325
- 콜백: `callback.questions` (`kind=POOL|FOLLOWUP`)
326326
- **임베딩 본 구현** (`rag/`): `MarkdownChunker` + `GeminiEmbeddingProvider` (1536d, `gemini-embedding-001`).
327327
운영/개발 default 는 gemini, 테스트는 `MockEmbeddingProvider`.
328+
- 청크를 `EMBEDDING_BATCH_SIZE`(기본 32) 단위로 쪼개 순차 호출 — 한 요청에 몰면 분당 토큰 한도(429 `RESOURCE_EXHAUSTED`)에 걸린다.
329+
- 429 는 지수 백오프(`EMBEDDING_MAX_RETRIES`/`EMBEDDING_RETRY_BASE_DELAY_SEC`, 상한 30s)로 재시도. 소진 시 `GEMINI_RATE_LIMITED`(retriable), 그 외 오류는 `GEMINI_FAILED`(retriable)로 즉시 실패.
328330
- **스토리지 추상화** (`storage/`): `S3Storage`(기본) / `LocalFilesystemStorage`. `STORAGE_BACKEND` 토글.
329331
- **LLM 호출 로깅 본 구현** (`observability/llm_logging_callback.py`, US-30):
330332
LangChain `AsyncCallbackHandler` 가 토큰/latency 측정 → Core `/api/internal/ai-logs` POST.

ai/src/ai_server/config/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,11 @@ class Settings(BaseSettings):
134134
embedding_dim: int = 1536
135135
embedding_chunk_size: int = 1000
136136
embedding_chunk_overlap: int = 200
137+
# 한 임베딩 요청당 청크 수. 크면 분당 토큰 한도(429)에 걸리기 쉬우니 적당히 쪼갠다.
137138
embedding_batch_size: int = 32
139+
# 429(RESOURCE_EXHAUSTED) 시 지수 백오프 재시도 횟수·기본 지연(초).
140+
embedding_max_retries: int = 5
141+
embedding_retry_base_delay_sec: float = 2.0
138142

139143
# PDF Vision (이미지/스캔 PDF 폴백 — 게이트웨이 멀티모달)
140144
pdf_vision_max_pages: int = 5

ai/src/ai_server/messaging/runner.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ def __init__(self, settings: Settings) -> None:
9696
dim=settings.embedding_dim,
9797
model=settings.embedding_model,
9898
gemini_api_key=settings.gemini_api_key,
99+
batch_size=settings.embedding_batch_size,
100+
max_retries=settings.embedding_max_retries,
101+
retry_base_delay_sec=settings.embedding_retry_base_delay_sec,
99102
)
100103
reranker = build_reranker(settings, core_client=core_client)
101104
vision_pdf_reader = build_vision_pdf_reader(settings, core_client=core_client)

ai/src/ai_server/rag/embedder.py

Lines changed: 88 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import hashlib
5+
import random
46
import struct
57
from typing import Protocol
68

9+
import structlog
10+
11+
log = structlog.get_logger(__name__)
12+
713

814
class EmbeddingError(Exception):
915
def __init__(self, *, code: str, message: str, retriable: bool) -> None:
@@ -13,6 +19,16 @@ def __init__(self, *, code: str, message: str, retriable: bool) -> None:
1319
self.retriable = retriable
1420

1521

22+
# Gemini 가 한도 초과(429)일 때만 백오프 재시도한다. 다른 오류(인증·잘못된 입력 등)는
23+
# 재시도해도 동일하므로 즉시 실패시킨다. SDK ClientError 는 .code(HTTP)·.status 를 노출한다.
24+
def _is_rate_limited(exc: Exception) -> bool:
25+
if getattr(exc, "code", None) == 429:
26+
return True
27+
if getattr(exc, "status", None) == "RESOURCE_EXHAUSTED":
28+
return True
29+
return "RESOURCE_EXHAUSTED" in str(exc)
30+
31+
1632
# 구현체는 바꿔서 사용할 수 있음
1733
class EmbeddingProvider(Protocol):
1834
@property
@@ -61,7 +77,20 @@ def _embed_one(self, text: str) -> list[float]:
6177
# Gemini Embedding 을 사용합니다.
6278
# 이건 충대키로 안되니 키 발급 필요함
6379
class GeminiEmbeddingProvider:
64-
def __init__(self, *, api_key: str, model: str, dim: int) -> None:
80+
# 한 요청에 너무 많은 청크를 담으면 분당 토큰 한도(TPM)에 걸려 429 가 난다.
81+
# batch_size 로 쪼개 순차 호출하고, 429 는 지수 백오프로 재시도한다.
82+
_MAX_BACKOFF_SEC = 30.0
83+
84+
def __init__(
85+
self,
86+
*,
87+
api_key: str,
88+
model: str,
89+
dim: int,
90+
batch_size: int = 32,
91+
max_retries: int = 5,
92+
retry_base_delay_sec: float = 2.0,
93+
) -> None:
6594
if not api_key:
6695
raise ValueError("GEMINI_API_KEY 누락 — provider=gemini 사용 불가")
6796
if dim <= 0:
@@ -71,6 +100,9 @@ def __init__(self, *, api_key: str, model: str, dim: int) -> None:
71100
self._client = genai.Client(api_key=api_key)
72101
self._model = model
73102
self._dim = dim
103+
self._batch_size = max(1, batch_size)
104+
self._max_retries = max(0, max_retries)
105+
self._retry_base_delay = max(0.0, retry_base_delay_sec)
74106

75107
@property
76108
def dim(self) -> int:
@@ -87,25 +119,50 @@ async def embed(
87119
return []
88120
from google.genai import types as genai_types
89121

90-
try:
91-
resp = await self._client.aio.models.embed_content(
92-
model=self._model,
93-
contents=texts,
94-
config=genai_types.EmbedContentConfig(
95-
# 인덱싱은 RETRIEVAL_DOCUMENT, 검색 쿼리는 RETRIEVAL_QUERY 로
96-
# 분리해야 Gemini embedding 의 코사인 정합도가 최적화된다.
97-
task_type=task_type,
98-
output_dimensionality=self._dim,
99-
),
100-
)
101-
except Exception as exc:
102-
raise EmbeddingError(
103-
code="GEMINI_FAILED",
104-
message=f"Gemini embedding 호출 실패: {exc}",
105-
retriable=True,
106-
) from exc
107-
108-
return [list(e.values) for e in resp.embeddings]
122+
config = genai_types.EmbedContentConfig(
123+
# 인덱싱은 RETRIEVAL_DOCUMENT, 검색 쿼리는 RETRIEVAL_QUERY 로
124+
# 분리해야 Gemini embedding 의 코사인 정합도가 최적화된다.
125+
task_type=task_type,
126+
output_dimensionality=self._dim,
127+
)
128+
129+
vectors: list[list[float]] = []
130+
for start in range(0, len(texts), self._batch_size):
131+
batch = texts[start : start + self._batch_size]
132+
resp = await self._embed_batch_with_retry(batch, config)
133+
vectors.extend(list(e.values) for e in resp.embeddings)
134+
return vectors
135+
136+
async def _embed_batch_with_retry(self, batch: list[str], config: object) -> object:
137+
attempt = 0
138+
while True:
139+
try:
140+
return await self._client.aio.models.embed_content(
141+
model=self._model,
142+
contents=batch,
143+
config=config,
144+
)
145+
except Exception as exc:
146+
rate_limited = _is_rate_limited(exc)
147+
if rate_limited and attempt < self._max_retries:
148+
delay = min(
149+
self._retry_base_delay * (2**attempt), self._MAX_BACKOFF_SEC
150+
)
151+
delay += random.uniform(0.0, self._retry_base_delay * 0.1)
152+
log.warning(
153+
"embed.gemini.rate_limited",
154+
attempt=attempt + 1,
155+
max_retries=self._max_retries,
156+
delay_sec=round(delay, 2),
157+
)
158+
await asyncio.sleep(delay)
159+
attempt += 1
160+
continue
161+
raise EmbeddingError(
162+
code="GEMINI_RATE_LIMITED" if rate_limited else "GEMINI_FAILED",
163+
message=f"Gemini embedding 호출 실패: {exc}",
164+
retriable=True,
165+
) from exc
109166

110167

111168
def build_embedding_provider(
@@ -114,11 +171,21 @@ def build_embedding_provider(
114171
dim: int,
115172
model: str,
116173
gemini_api_key: str = "",
174+
batch_size: int = 32,
175+
max_retries: int = 5,
176+
retry_base_delay_sec: float = 2.0,
117177
) -> EmbeddingProvider:
118178
if provider == "mock":
119179
return MockEmbeddingProvider(dim=dim, model=model)
120180
if provider == "gemini":
121-
return GeminiEmbeddingProvider(api_key=gemini_api_key, model=model, dim=dim)
181+
return GeminiEmbeddingProvider(
182+
api_key=gemini_api_key,
183+
model=model,
184+
dim=dim,
185+
batch_size=batch_size,
186+
max_retries=max_retries,
187+
retry_base_delay_sec=retry_base_delay_sec,
188+
)
122189
if provider == "openai":
123190
raise NotImplementedError("openai embedding provider 미구현 — 후속 PR에서 추가")
124191
if provider == "ollama":

ai/tests/test_embedder.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,9 @@ async def test_gemini_embed_returns_vector_list_from_sdk_response() -> None:
120120

121121

122122
@pytest.mark.asyncio
123-
async def test_gemini_embed_task_type_defaults_to_document_and_overrides_to_query() -> None:
123+
async def test_gemini_embed_task_type_defaults_to_document_and_overrides_to_query() -> (
124+
None
125+
):
124126
from types import SimpleNamespace
125127
from unittest.mock import AsyncMock, MagicMock, patch
126128

@@ -183,5 +185,103 @@ async def test_gemini_embed_wraps_sdk_exception_as_retriable() -> None:
183185

184186
with pytest.raises(EmbeddingError) as exc_info:
185187
await emb.embed(["x"])
188+
# 한도 초과가 아닌 일반 오류는 재시도하지 않고 즉시 실패.
186189
assert exc_info.value.code == "GEMINI_FAILED"
187190
assert exc_info.value.retriable is True
191+
assert fake_aio.models.embed_content.await_count == 1
192+
193+
194+
@pytest.mark.asyncio
195+
async def test_gemini_embed_splits_into_batches() -> None:
196+
from types import SimpleNamespace
197+
from unittest.mock import AsyncMock, MagicMock, patch
198+
199+
from ai_server.rag.embedder import GeminiEmbeddingProvider
200+
201+
# 배치마다 입력 개수만큼 벡터를 돌려주도록 모사.
202+
def _resp_for(*, contents, **_kwargs):
203+
return SimpleNamespace(
204+
embeddings=[
205+
SimpleNamespace(values=[float(i)]) for i in range(len(contents))
206+
]
207+
)
208+
209+
fake_aio = MagicMock()
210+
fake_aio.models.embed_content = AsyncMock(side_effect=_resp_for)
211+
fake_client = MagicMock()
212+
fake_client.aio = fake_aio
213+
214+
with patch("google.genai.Client", return_value=fake_client):
215+
emb = GeminiEmbeddingProvider(api_key="fake", model="m", dim=1, batch_size=2)
216+
217+
out = await emb.embed(["a", "b", "c", "d", "e"])
218+
assert len(out) == 5
219+
# 5개 / batch_size 2 → 3회 호출 (2 + 2 + 1)
220+
assert fake_aio.models.embed_content.await_count == 3
221+
assert [
222+
len(call.kwargs["contents"])
223+
for call in fake_aio.models.embed_content.await_args_list
224+
] == [2, 2, 1]
225+
226+
227+
@pytest.mark.asyncio
228+
async def test_gemini_embed_retries_on_429_then_succeeds() -> None:
229+
from types import SimpleNamespace
230+
from unittest.mock import AsyncMock, MagicMock, patch
231+
232+
from ai_server.rag.embedder import GeminiEmbeddingProvider
233+
234+
class _RateLimited(Exception):
235+
code = 429
236+
status = "RESOURCE_EXHAUSTED"
237+
238+
ok = SimpleNamespace(embeddings=[SimpleNamespace(values=[0.5])])
239+
fake_aio = MagicMock()
240+
fake_aio.models.embed_content = AsyncMock(side_effect=[_RateLimited("429"), ok])
241+
fake_client = MagicMock()
242+
fake_client.aio = fake_aio
243+
244+
with patch("google.genai.Client", return_value=fake_client):
245+
emb = GeminiEmbeddingProvider(
246+
api_key="fake",
247+
model="m",
248+
dim=1,
249+
max_retries=3,
250+
retry_base_delay_sec=0.0, # 테스트는 즉시 재시도.
251+
)
252+
253+
out = await emb.embed(["x"])
254+
assert out == [[0.5]]
255+
assert fake_aio.models.embed_content.await_count == 2
256+
257+
258+
@pytest.mark.asyncio
259+
async def test_gemini_embed_gives_up_after_max_retries_on_429() -> None:
260+
from unittest.mock import AsyncMock, MagicMock, patch
261+
262+
from ai_server.rag.embedder import EmbeddingError, GeminiEmbeddingProvider
263+
264+
class _RateLimited(Exception):
265+
code = 429
266+
status = "RESOURCE_EXHAUSTED"
267+
268+
fake_aio = MagicMock()
269+
fake_aio.models.embed_content = AsyncMock(side_effect=_RateLimited("429"))
270+
fake_client = MagicMock()
271+
fake_client.aio = fake_aio
272+
273+
with patch("google.genai.Client", return_value=fake_client):
274+
emb = GeminiEmbeddingProvider(
275+
api_key="fake",
276+
model="m",
277+
dim=1,
278+
max_retries=2,
279+
retry_base_delay_sec=0.0,
280+
)
281+
282+
with pytest.raises(EmbeddingError) as exc_info:
283+
await emb.embed(["x"])
284+
assert exc_info.value.code == "GEMINI_RATE_LIMITED"
285+
assert exc_info.value.retriable is True
286+
# 최초 1 + 재시도 2 = 3회.
287+
assert fake_aio.models.embed_content.await_count == 3

docs/design-system.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@
3737
| `sage-50` | `#e8e7e1` | 가장 밝은 컴포넌트 배경 (= `surface`) |
3838
| `sage-100` | `#d4cfcb` | 분리선 / 보더 (= `border`) |
3939
| `sage-200` | `#c9ccc8` | 비활성 텍스트 / 보조 배경 (= `border-strong`, `fg-disabled`) |
40-
| `sage-300` | `#b4bdaf` | 보조 텍스트 (= `fg-subtle`) |
41-
| `sage-400` | `#a0a89d` | 보조 텍스트 강조 (= `fg-muted`) |
42-
| `sage-500` | `#626e5c` | **Primary**, 활성 / 포커스 |
40+
| `sage-300` | `#b4bdaf` | 비활성 보조 / placeholder |
41+
| `sage-400` | `#a0a89d` | 보조 텍스트 (= `fg-subtle`) |
42+
| `sage-500` | `#626e5c` | **Primary**, 활성 / 포커스, 본문 보조 텍스트 (= `fg-muted`) |
4343
| `sage-600` | `#3e4739` | Primary hover |
4444
| `sage-700` | `#2b3625` | Primary pressed / 강조 컴포넌트 |
4545
| `sage-800` | `#1f271b` | 주요 헤딩 (= `fg-strong`) |
@@ -61,8 +61,8 @@ Tailwind 사용: `bg-sage-{n}`, `text-sage-{n}`, `border-sage-{n}`.
6161
| `--color-border-strong` | `sage-200` | `border-border-strong` |
6262
| `--color-fg` | `sage-950` | `text-fg` |
6363
| `--color-fg-strong` | `sage-800` | `text-fg-strong` |
64-
| `--color-fg-muted` | `sage-400` | `text-fg-muted` |
65-
| `--color-fg-subtle` | `sage-300` | `text-fg-subtle` |
64+
| `--color-fg-muted` | `sage-500` | `text-fg-muted` |
65+
| `--color-fg-subtle` | `sage-400` | `text-fg-subtle` |
6666
| `--color-fg-disabled` | `sage-200` | `text-fg-disabled` |
6767
| `--color-fg-on-primary` | `white` | `text-fg-on-primary` |
6868
| `--color-primary` | `sage-500` | `bg-primary`, `text-primary` |
@@ -255,7 +255,7 @@ Tailwind v4 기본 `--spacing: 0.25rem` (= 4px) 사용. `p-4` = `16px`.
255255

256256
### Feedback
257257
- `Toast` — 4종 (success / info / warning / error), 우상단 stack, 4초 자동 dismiss, `z-toast`.
258-
- `Modal``sm / md / lg / fullscreen`, focus trap 필수, `z-modal` + `z-modal-backdrop`.
258+
- `Modal` `shared/ui/Modal`. `title` + `children` + 옵션 `footer` 슬롯, Esc·백드롭 클릭으로 닫힘, `z-modal`, body 스크롤 잠금 + 포커스 복원. (전체 focus trap 은 추후)
259259
- `Drawer` — 우측 슬라이드, 세션 설정 등.
260260
- `Popover`, `Tooltip` — 키보드 접근 가능.
261261
- `ConfirmDialog` — 파괴적 액션(삭제, 회원 탈퇴) 전용.
@@ -281,7 +281,7 @@ Tailwind v4 기본 `--spacing: 0.25rem` (= 4px) 사용. `p-4` = `16px`.
281281

282282
| 도메인 상태 | 시각 컬러 | 토큰 | 컴포넌트 예 |
283283
|---|---|---|---|
284-
| `READY` / `PENDING` / `QUEUED` | neutral | `text-fg-muted` (sage-400) | 회색 Badge |
284+
| `READY` / `PENDING` / `QUEUED` | neutral | `text-fg-muted` (sage-500) | 회색 Badge |
285285
| `IN_PROGRESS` / `PROCESSING` / `ANALYZING` | warning | `bg-warning-50 text-warning-700` | 노란 Badge + spinner |
286286
| `COMPLETED` / `ANALYZED` / `ACTIVE` | success | `bg-success-50 text-success-700` | 초록 Badge |
287287
| `INTERRUPTED` | warning | `bg-warning-50 text-warning-700` | 노란 Badge (느낌표 아이콘) |

docs/environment.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@ EMBEDDING_MODEL=gemini-embedding-001
136136
EMBEDDING_DIM=1536 # DB 컬럼 차원과 일치 필수
137137
EMBEDDING_CHUNK_SIZE=1000
138138
EMBEDDING_CHUNK_OVERLAP=200
139-
EMBEDDING_BATCH_SIZE=32
139+
EMBEDDING_BATCH_SIZE=32 # 한 요청당 청크 수 (작을수록 429 회피, 호출 수↑)
140+
EMBEDDING_MAX_RETRIES=5 # 429(RESOURCE_EXHAUSTED) 지수 백오프 재시도 횟수
141+
EMBEDDING_RETRY_BASE_DELAY_SEC=2.0 # delay = base*2^attempt + jitter (상한 30s)
140142
141143
# ===== Markdown 산출물 키 템플릿 =====
142144
ANALYZED_RESUME_MD_KEY_TEMPLATE=analyzed/resume/{resume_id}/summary.md

frontend/src/app/styles/global.css

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,32 @@
103103
50%, 100% { opacity: 0; }
104104
}
105105

106+
@keyframes modal-fade {
107+
from {
108+
opacity: 0;
109+
}
110+
to {
111+
opacity: 1;
112+
}
113+
}
114+
@keyframes modal-pop {
115+
from {
116+
opacity: 0;
117+
transform: translateY(12px) scale(0.98);
118+
}
119+
to {
120+
opacity: 1;
121+
transform: translateY(0) scale(1);
122+
}
123+
}
124+
125+
.anim-modal-backdrop {
126+
animation: modal-fade var(--duration-normal) var(--ease-decelerate) both;
127+
}
128+
.anim-modal-panel {
129+
animation: modal-pop var(--duration-normal) var(--ease-decelerate) both;
130+
}
131+
106132
.anim-hero-rise {
107133
animation: hero-rise 0.8s var(--ease-decelerate) both;
108134
}

0 commit comments

Comments
 (0)