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
2 changes: 2 additions & 0 deletions ai/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
2 changes: 2 additions & 0 deletions ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions ai/src/ai_server/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions ai/src/ai_server/messaging/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
109 changes: 88 additions & 21 deletions ai/src/ai_server/rag/embedder.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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(
Expand All @@ -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":
Expand Down
102 changes: 101 additions & 1 deletion ai/tests/test_embedder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
14 changes: 7 additions & 7 deletions docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand All @@ -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` |
Expand Down Expand Up @@ -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` — 파괴적 액션(삭제, 회원 탈퇴) 전용.
Expand All @@ -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 (느낌표 아이콘) |
Expand Down
4 changes: 3 additions & 1 deletion docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/app/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading