Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
b7deeca
chore: ignore local .worktrees/ for parallel feature development
csh1668 May 8, 2026
21b2d4e
feat(infra): stackup.realtime exchange 및 session.notify 큐 추가
csh1668 May 8, 2026
ddd7507
feat(ai): 메시징 관련 설정 추가
csh1668 May 8, 2026
92684bd
feat(ai): envelope 및 메시지 컨텍스트 Pydantic 모델 추가
csh1668 May 8, 2026
5bdbe0c
feat(ai): 이력서 분석 요청/콜백 페이로드 추가
csh1668 May 8, 2026
6d141c8
refactor(ai): 공용 camel_config 헬퍼로 분리
csh1668 May 8, 2026
3add400
feat(ai): messageId 중복 제거용 LRU 멱등 스토어 추가
csh1668 May 8, 2026
6f279e2
feat(ai): aio-pika 연결 래퍼 및 콜백 퍼블리셔 추가
csh1668 May 8, 2026
a1beff7
feat(ai): 멱등 처리 포함 resume echo 컨슈머 추가
csh1668 May 8, 2026
e43dd29
feat(ai): FastAPI lifespan에 메시징 런타임 연결
csh1668 May 8, 2026
9d34789
chore(realtime): Go 모듈 초기 설정 및 의존성 추가
csh1668 May 8, 2026
1e53f1c
feat(realtime): env 태그 기반 설정 로딩(config) 추가
csh1668 May 8, 2026
6b1702e
feat(realtime): 트레이스 컨텍스트 및 HTTP 미들웨어 추가
csh1668 May 8, 2026
50f6224
feat(realtime): 세션 구독자 레지스트리 추가(느린 컨슈머 드롭)
csh1668 May 8, 2026
db9aef6
feat(realtime): 세션 구독자 레지스트리 추가(느린 컨슈머 드롭)
csh1668 May 8, 2026
e312e14
feat(realtime): AMQP 연결 및 재연결 컨슈머 추가
csh1668 May 8, 2026
634d6da
feat(realtime): chi 라우터 + health/SSE 엔드포인트 추가
csh1668 May 8, 2026
ac0a9ba
feat(realtime): HTTP 서버 및 AMQP 컨슈머 조립(main) 추가
csh1668 May 8, 2026
d3bee9c
feat(realtime): 멀티스테이지 Alpine Dockerfile 추가(wget 헬스체크)
csh1668 May 8, 2026
bb6cdc8
reword df880cd # docs(realtime): Go 서버용 CLAUDE.md 추가
csh1668 May 8, 2026
de7282a
feat(infra): docker-compose에 ai/realtime 서비스 추가
csh1668 May 8, 2026
0898054
fix(infra): compose 인증용 RabbitMQ stackup 사용자 추가
csh1668 May 8, 2026
9a22bc1
fix(infra): realtime 헬스체크 wget 호출 방식 수정(GET 사용)
csh1668 May 8, 2026
c4dc0a8
docs(messaging): stackup.realtime exchange 및 session.notify 스키마 추가
csh1668 May 8, 2026
bcaedcf
docs: 아키텍처/환경변수 정리(SSE+WS, realtime/ai)
csh1668 May 8, 2026
12ce044
docs(claude): realtime+ai bootstrap 컨텍스트 정렬
csh1668 May 8, 2026
d64611f
chore: .gitignore에 .env 추가
csh1668 May 8, 2026
b78d0e4
fix: add qos and close handling in realtime consumer
Copilot May 11, 2026
f48a4e8
chore: clarify qos prefetch handling
Copilot May 11, 2026
2cc29fb
chore: restore realtime go module files
Copilot May 11, 2026
998afe8
chore: refine qos naming and comment
Copilot May 11, 2026
31362eb
chore: clarify qos intent and close handling
Copilot May 11, 2026
4929f66
docs: document qos prefetch constant
Copilot May 11, 2026
bf494f4
chore: improve qos error context
Copilot May 11, 2026
c343a57
chore: refine qos error messaging
Copilot May 11, 2026
8eda222
fix(ai): lifespan에서 runtime.start() 실패 시 좀비 connection 정리
csh1668 May 11, 2026
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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,10 @@ MINIO_ROOT_PASSWORD=minioadmin
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
MINIO_BUCKET=stackup

# AI server
AI_PORT=8000

# RealTime server
REALTIME_PORT=8081
REALTIME_LOG_LEVEL=info
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
.vscode
.claude
.claude

.worktrees/
.env
!.env.example
9 changes: 4 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,10 @@ PostgreSQL+pgvector · S3/MinIO
|--------|------|------|
| 프론트엔드 | [`frontend/CLAUDE.md`](./frontend/CLAUDE.md) | React 19 + TypeScript + Vite, FSD 구조 |
| 백엔드 (Core) | [`backend/CLAUDE.md`](./backend/CLAUDE.md) | Java 21 + Spring Boot 4 + JPA + QueryDSL |
| RealTime 서버 | [`realtime/CLAUDE.md`](./realtime/CLAUDE.md) | Go 1.26 + chi + amqp091-go, SSE/WebSocket |
| AI 서버 | [`ai/CLAUDE.md`](./ai/CLAUDE.md) | Python 3.13 + FastAPI + LangChain |
| 인프라 | [`infra/CLAUDE.md`](./infra/CLAUDE.md) | Docker Compose: PG(+pgvector), RabbitMQ, MinIO |

> **RealTime Server (Go)** 는 아직 구현 디렉토리 없음. 추가 시 `realtime/CLAUDE.md` 생성.

각 레이어 하위 슬라이스/도메인 패키지에도 `CLAUDE.md`가 배치되어 있을 수 있다. 작업할 디렉토리에서 가장 가까운 것을 우선 읽는다.

---
Expand Down Expand Up @@ -144,9 +143,9 @@ cd frontend && npm install && npm run dev
- Phase 1 (MVP) 진행 중. US-01 ~ US-20 우선.
- 디자인 시스템 토큰 파일 미생성 — 디자인 적용 첫 PR에서 `frontend/src/app/styles/tokens.css` 생성.
- Spring Security, RabbitMQ starter, Flyway는 백엔드 기능 작성 PR과 함께 도입.
- `infra/rabbitmq/definitions.json`은 현재 큐 6개 정의 (resume/repo 분석, questions/followup 생성, 콜백 2개). 피드백 큐는 US-24 작업 시 추가.
- `infra/rabbitmq/definitions.json`은 현재 exchange 3개 (`stackup.core-to-ai`, `stackup.ai-to-core`, `stackup.realtime`), 큐 7개 정의. 피드백 큐는 US-24 작업 시 추가.
- **Redis 미사용** — 휘발성 데이터(OAuth state, 멱등 키, 질문 풀 캐시)는 PostgreSQL의 short-lived 레코드 또는 Core 서버 인메모리로 처리.
- 실시간 푸시는 **SSE 단일화** (양방향 WebSocket 미사용). 미디어 스트림만 WebRTC.
- `RealTime Server (Go)`는 아직 디렉토리 없음. SSE/WebRTC가 필요한 시점에 작성.
- 실시간 푸시는 **SSE + WebSocket 병행**. SSE는 작업 상태(RT2), WebSocket은 라이브 면접 메시지(RT1) 및 음성 스트림(RT3). 미디어 시그널링은 추후 WebRTC 도입 시점에 검토.
- `RealTime Server (Go)`는 `realtime/` 디렉토리에 부트스트랩 완료. 현재 SSE만 활성, WS 엔드포인트는 US-Session-03 / US-Voice-01에서 도입.

문서 변경 원칙: 코드와 함께 같은 PR에 포함. 단일 출처 원칙(SSOT) 유지.
15 changes: 8 additions & 7 deletions ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,12 +290,13 @@ docker run --env-file .env -p 8000:8000 stackup-ai

---

## 16. 현재 상태 (2026-04 기준)

- FastAPI 부트스트랩 + 헬스체크만 구현
- RabbitMQ consumer 미구현 → US-09(이력서 분석) 작업 시 도입
- LangChain 기본 import만 있음, 체인·프롬프트 정의 0
- pgvector 연동 미구현
- 음성 모듈은 Phase 2에서 본격 작성
## 16. 현재 상태 (2026-05 기준)

- FastAPI 부트스트랩 + 헬스체크
- RabbitMQ consumer **echo만** 구현: `ai.analyze.resume` → `callback.analysis` 즉시 echo 응답 (멱등 LRU + structlog)
- 비즈니스 로직 (PDF 파싱·LLM·임베딩·S3·pgvector) 미구현 — US-09 본 구현 PR에서 추가
- `analyze.repository` / `generate.questions` / `generate.followup` consumer는 큐 정의만 존재, 코드 없음
- LangChain import만 있음, 체인·프롬프트 정의 0
- 음성 모듈은 Phase 2

각 도입 시 본 문서 갱신.
8 changes: 8 additions & 0 deletions ai/src/ai_server/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ class Settings(BaseSettings):
# RabbitMQ
rabbitmq_url: str = "amqp://stackup:stackup@localhost:5672/"

# AI consumer
ai_queue_resume: str = "ai.analyze.resume"
ai_queue_prefetch: int = 10
ai_callback_exchange: str = "stackup.ai-to-core"
ai_callback_routing_analysis: str = "callback.analysis"
ai_publisher_name: str = "ai-server"
ai_idempotency_lru_size: int = 1024

# S3 / MinIO
s3_endpoint_url: str = "http://localhost:9000"
s3_access_key: str = ""
Expand Down
23 changes: 17 additions & 6 deletions ai/src/ai_server/main.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager

import structlog
from fastapi import FastAPI

from ai_server.api.health import router as health_router
from ai_server.config.settings import Settings, get_settings
from ai_server.messaging.runner import MessagingRuntime

log = structlog.get_logger(__name__)

@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Startup
yield
# Shutdown

def _build_lifespan(settings: Settings):
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
runtime = MessagingRuntime(settings)
app.state.messaging = runtime
try:
await runtime.start()
yield
finally:
await runtime.stop()

Comment on lines +16 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runtime.start() 실패 시 finally 블록 진입 불가로 runtime.stop()이 호출되지 않고 RabbitMQ 연결이 좀비가 되는 문제가 있을 것 같아요.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좀비 connection 막도록 try/finally 진입 순서를 변경해 runtime.start() 실패 시에도 stop() 이 호출되도록 수정했습니다. MessagingRuntime.stop() 은 내부 상태 None 가드가 있어 부분 초기화에서도 안전합니다. 회귀 방지 테스트(ai/tests/test_lifespan.py) 추가. commit 8eda222.

return lifespan


def create_app(settings: Settings | None = None) -> FastAPI:
Expand All @@ -21,7 +32,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
lifespan=lifespan,
lifespan=_build_lifespan(settings),
)

app.include_router(health_router)
Expand Down
Empty file.
49 changes: 49 additions & 0 deletions ai/src/ai_server/messaging/connection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

import structlog
from aio_pika import connect_robust
from aio_pika.abc import AbstractRobustChannel, AbstractRobustConnection

log = structlog.get_logger(__name__)


class RabbitConnection:
"""Thin wrapper around aio-pika's connect_robust.

Holds one connection + one channel. Auto-reconnect is provided by
aio-pika; we just expose lifecycle hooks for FastAPI lifespan.
"""

def __init__(self, url: str, prefetch: int) -> None:
self._url = url
self._prefetch = prefetch
self._connection: AbstractRobustConnection | None = None
self._channel: AbstractRobustChannel | None = None

async def open(self) -> None:
log.info("rabbit.connect.start", url_masked=_mask(self._url))
self._connection = await connect_robust(self._url)
channel = await self._connection.channel()
await channel.set_qos(prefetch_count=self._prefetch)
self._channel = channel # type: ignore[assignment]
log.info("rabbit.connect.ok", prefetch=self._prefetch)

async def close(self) -> None:
if self._connection is not None and not self._connection.is_closed:
await self._connection.close()
log.info("rabbit.connect.closed")

@property
def channel(self) -> AbstractRobustChannel:
if self._channel is None:
raise RuntimeError("RabbitConnection not opened")
return self._channel


def _mask(url: str) -> str:
# amqp://user:pass@host:port/ → amqp://***@host:port/
if "@" not in url:
return url
scheme, rest = url.split("://", 1) if "://" in url else ("", url)
creds, hostpart = rest.split("@", 1)
return f"{scheme}://***@{hostpart}" if scheme else f"***@{hostpart}"
Empty file.
86 changes: 86 additions & 0 deletions ai/src/ai_server/messaging/consumers/resume_consumer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from __future__ import annotations

import structlog
from aio_pika.abc import AbstractIncomingMessage

from ai_server.messaging.idempotency import LruIdempotencyStore
from ai_server.messaging.publisher import CallbackPublisher
from ai_server.model.envelope import Envelope
from ai_server.model.messages.analyze import (
AnalysisCallbackPayload,
ResumeAnalyzeRequest,
)

log = structlog.get_logger(__name__)


def build_echo_callback(
envelope: Envelope[ResumeAnalyzeRequest],
) -> AnalysisCallbackPayload:
rid = envelope.payload.resume_id
return AnalysisCallbackPayload(
target_type="RESUME",
target_id=rid,
status="ANALYZED",
summary="[ECHO] not yet analyzed",
tech_stack=[],
document_s3_key=f"echo/resume/{rid}/echo.md",
embedding_chunk_count=0,
)


class ResumeConsumer:
def __init__(
self,
*,
publisher: CallbackPublisher,
idempotency: LruIdempotencyStore,
callback_routing_key: str,
) -> None:
self._publisher = publisher
self._idempotency = idempotency
self._callback_routing_key = callback_routing_key

async def handle(self, message: AbstractIncomingMessage) -> None:
async with message.process(requeue=False):
try:
envelope = Envelope[ResumeAnalyzeRequest].model_validate_json(
message.body
)
except Exception as exc: # parse error → DLQ-ready (auto NACK on raise)
log.error(
"resume.parse.failed",
error=str(exc),
delivery_tag=message.delivery_tag,
)
raise

if self._idempotency.is_seen_then_mark(envelope.message_id):
log.info(
"resume.idempotent.skip",
message_id=envelope.message_id,
trace_id=envelope.trace_id,
)
return

log.info(
"resume.echo.start",
message_id=envelope.message_id,
resume_id=envelope.payload.resume_id,
trace_id=envelope.trace_id,
)
callback = build_echo_callback(envelope)
await self._publisher.publish(
routing_key=self._callback_routing_key,
message_type="callback.analysis",
payload=callback,
trace_id=envelope.trace_id,
correlation_id=envelope.message_id,
context=envelope.context,
)
log.info(
"resume.echo.done",
message_id=envelope.message_id,
resume_id=envelope.payload.resume_id,
trace_id=envelope.trace_id,
)
27 changes: 27 additions & 0 deletions ai/src/ai_server/messaging/idempotency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from collections import OrderedDict
from threading import Lock


class LruIdempotencyStore:
"""Thread-safe LRU set for messageId-based idempotency.

`is_seen_then_mark(key)` returns True if the key was already present
(caller should ACK + skip), or False if it's new (record it and process).
"""

def __init__(self, max_size: int) -> None:
if max_size <= 0:
raise ValueError("max_size must be > 0")
self._max_size = max_size
self._store: OrderedDict[str, None] = OrderedDict()
self._lock = Lock()

def is_seen_then_mark(self, key: str) -> bool:
with self._lock:
if key in self._store:
self._store.move_to_end(key)
return True
self._store[key] = None
if len(self._store) > self._max_size:
self._store.popitem(last=False)
return False
81 changes: 81 additions & 0 deletions ai/src/ai_server/messaging/publisher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from __future__ import annotations

from datetime import datetime, timezone
from uuid import uuid4

import structlog
from aio_pika import DeliveryMode, ExchangeType, Message
from pydantic import BaseModel

from ai_server.messaging.connection import RabbitConnection
from ai_server.model.envelope import Envelope, MessageContext

log = structlog.get_logger(__name__)


class CallbackPublisher:
def __init__(
self,
connection: RabbitConnection,
exchange_name: str,
publisher_name: str,
) -> None:
self._connection = connection
self._exchange_name = exchange_name
self._publisher_name = publisher_name
self._exchange = None # set in open()

async def open(self) -> None:
self._exchange = await self._connection.channel.declare_exchange(
self._exchange_name,
ExchangeType.TOPIC,
durable=True,
passive=True, # 정의 파일이 이미 만들었음. 우리는 잡기만.
)

async def publish(
self,
*,
routing_key: str,
message_type: str,
payload: BaseModel,
trace_id: str,
correlation_id: str,
context: MessageContext,
version: str = "v1",
) -> None:
if self._exchange is None:
raise RuntimeError("CallbackPublisher not opened")

envelope = Envelope[type(payload)].model_validate( # type: ignore[misc]
{
"messageId": str(uuid4()),
"messageType": message_type,
"version": version,
"traceId": trace_id,
"publishedAt": datetime.now(timezone.utc),
"publisher": self._publisher_name,
"payload": payload.model_dump(by_alias=True),
"context": context.model_dump(by_alias=True),
}
)

body = envelope.model_dump_json(by_alias=True).encode("utf-8")
msg = Message(
body=body,
content_type="application/json",
content_encoding="utf-8",
delivery_mode=DeliveryMode.PERSISTENT,
message_id=envelope.message_id,
correlation_id=correlation_id,
headers={"x-trace-id": trace_id, "x-attempt": 0},
)
await self._exchange.publish(msg, routing_key=routing_key)
log.info(
"rabbit.publish",
exchange=self._exchange_name,
routing_key=routing_key,
message_id=envelope.message_id,
correlation_id=correlation_id,
trace_id=trace_id,
)
Loading