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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ S3_REGION=us-east-1
LLM_API_KEY=
LLM_BASE_URL=https://factchat-cloud.mindlogic.ai/v1/gateway
LLM_PRO_MODEL=gemini-3.1-pro-preview
LLM_FLASH_MODEL=gemini-3.1-flash-lite-preview
LLM_FLASH_TEMPERATURE=0.4
LLM_FLASH_MAX_TOKENS=512

# RealTime server
REALTIME_PORT=38020
Expand Down
200 changes: 200 additions & 0 deletions SPRINT2_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# Sprint 2 — 텍스트 면접 E2E 작업 계획

> 범위: **US-13 ~ US-20**. 세션 생성 → 첫 질문 → 텍스트 답변 → 꼬리질문 → 세션 종료까지의 골든 패스 완성. 피드백 리포트(US-24)는 Sprint 3로 분리.

작성일: 2026-05-29 / 기준 브랜치: `main` (Sprint 1 #30까지 머지 완료)

---

## 1. Sprint 1 정리

- 열린 PR 없음. Sprint 1 마지막 PR(#30, Sprint1 백엔드 및 AI서버 API 연동)이 5/28에 머지됨.
- 머지 완료: auth(GitHub OAuth + JWT), resume 업로드/분석, GitHub repo 등록/분석, document 메타·요약·임베딩 upsert, SSE stream-token + 분석 상태 푸시, RabbitMQ DLX·DLQ + 재시도 정책(US-28), `.env` 자동 sync 워크플로.
- **Sprint 2 진입 준비 완료 상태.**

---

## 2. 현재 면접 도메인 진척도

| 레이어 | 상태 |
|---|---|
| **DB** | 7개 테이블(`interview_sessions`, `interview_messages`, `session_contexts`, `message_voice_analyses`, `session_feedbacks` 등) + 모든 ENUM 이미 마이그레이션됨 |
| **Backend `session/`** | [`InterviewSession.java`](backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java), [`InterviewMessage.java`](backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java), Repository, Enum 까지만. `application/` · `presentation/` · `infrastructure/` 패키지 **부재** |
| **AI 서버** | `analyze.resume / .repository / .web` 컨슈머만 본 구현. `generate.questions` · `generate.followup` 큐는 정의만, **코드 없음** ([`ai/CLAUDE.md §16`](ai/CLAUDE.md)) |
| **Frontend** | [`features/interview/index.ts`](frontend/src/features/interview/index.ts), [`pages/Interview/index.ts`](frontend/src/pages/Interview/index.ts), [`features/feedback/index.ts`](frontend/src/features/feedback/index.ts), [`pages/History/index.ts`](frontend/src/pages/History/index.ts) 모두 1줄 빈 export 스캐폴딩 |
| **RealTime** | SSE + RabbitMQ `realtime.session.*` bridge 동작 중. Core 가 publish 만 하면 그대로 fan-out |
| **메시징 스펙** | [`docs/messaging.md §5.6~5.9`](docs/messaging.md), envelope · 큐 · DLQ 모두 확정 |

데이터·흐름·SSE 이벤트 카탈로그 모두 사양화 완료 ([`docs/data-flow.md §3`](docs/data-flow.md), [`docs/event-stream.md §3.3~3.4`](docs/event-stream.md)) — **사양은 다 있고 코드만 채우면 됨.**

---

## 3. 작업 분해 — 트랙별

### 트랙 A — Backend session 도메인

세션 생성부터 종료까지의 트랜잭션 · 메시지 발행 · SSE push 전부.

#### A-1. Application / Presentation 계층 신설
- `session/application/SessionService`
- `create(cmd)` — `interview_sessions` INSERT(`status=READY`) + `session_contexts` INSERT
- `start(id)` — `READY → IN_PROGRESS`, `started_at` 세팅
- `submitAnswer(id, content)` — INTERVIEWEE 메시지 INSERT + `generate.followup` 발행
- `end(id, reason)` — `IN_PROGRESS → COMPLETED`, `ended_at`
- `get(id)`, `list(userId, pageable)`, `getMessages(id)`
- `session/application/dto/` — `SessionCreateCommand`, `SessionResult`, `MessageSubmitCommand`, `MessageResult`
- `session/presentation/SessionController`
- `POST /api/sessions`
- `POST /api/sessions/{id}/start`
- `POST /api/sessions/{id}/messages`
- `POST /api/sessions/{id}/end`
- `GET /api/sessions/{id}`
- `GET /api/sessions`
- `GET /api/sessions/{id}/messages`

#### A-2. RabbitMQ 발행 (`session/infrastructure/SessionEventPublisher`)
- 세션 생성 commit 후 `generate.questions` 발행 (`@TransactionalEventListener(AFTER_COMMIT)`, [`docs/messaging.md §3`](docs/messaging.md))
- 답변 INSERT commit 후 `generate.followup` 발행

#### A-3. RabbitMQ 컨슈머 (`session/infrastructure/SessionCallbackHandler`)
`core.callback.questions` 구독, `payload.kind`로 분기.
- `POOL` — 질문 풀 저장 + 첫 질문 `interview_messages` INSERT + SSE `session.message` push + (옵션) `realtime.session.notify` 발행
- `FOLLOWUP` — INTERVIEWER 메시지 INSERT(`parent_message_id` 매핑) + SSE push + `total_question_count >= max_questions` 시 자동 종료 트리거

#### A-4. 세션 종료 정책
- 백엔드 컷: `total_question_count >= max_questions` 또는 timeout
- `interview_sessions.status=COMPLETED`, `ended_at`, SSE `session.state` push

#### A-5. ArchUnit 룰 위반 점검 ([`backend/CLAUDE.md §16`](backend/CLAUDE.md))

#### A-6. 테스트
- `SessionServiceTest` (Mockito)
- `SessionControllerTest` (`@WebMvcTest`)
- 컨슈머 통합 테스트 (Testcontainer RabbitMQ + PG)

---

### 트랙 B — AI 서버 질문 / 꼬리질문

[`ai/CLAUDE.md §14`](ai/CLAUDE.md) 절차 따라.

#### B-1. 메시지 모델
- `model/messages/generate.py` — `GenerateQuestionsPayload`, `GenerateFollowupPayload`, `CallbackQuestionsPayload`

#### B-2. 컨슈머 2종
- `messaging/consumers/questions_consumer.py`
- `messaging/consumers/followup_consumer.py`
- 멱등(messageId) 패턴은 기존 컨슈머 동일

#### B-3. 질문 풀 생성 체인 (`chain/question_pool_chain.py`)
- 입력: `interviewType`, `jobCategory`, `documentIds`, `maxQuestions`
- RAG: Core 내부 API `POST /api/internal/embeddings/search` 로 컨텍스트 chunk fetch → 프롬프트 주입
- LLM: **Pro 모델** (Gemini 3.1 Pro), Pydantic 출력 파서로 `[{ category, question }]` 강제
- 프롬프트: `chain/prompts/question_pool.py` 신규

#### B-4. 꼬리질문 체인 (`chain/followup_chain.py`)
- 입력: 직전 질문 / 답변, sessionId
- **Flash 모델** + 답변 평가 스키마(`specificity`, `logic`, `structure`)
- SLA < 3 초 — 응답 토큰 cap, streaming 미사용

#### B-5. 임베딩 provider
- **운영/개발: [`GeminiEmbeddingProvider`](ai/src/ai_server/rag/embedder.py) 사용** (이미 구현되어 있음). 설정에서 default 로 스위치
- **테스트: `MockEmbeddingProvider`** 유지 (단위/통합 테스트 결정론 확보)
- [`ai/CLAUDE.md §16`](ai/CLAUDE.md) 의 "MockEmbeddingProvider default" 기술은 본 스프린트에서 갱신

#### B-6. 콜백 발행
- `callback.questions` (`kind=POOL` / `FOLLOWUP`)

#### B-7. `ai_request_logs`
- Core API 또는 자체 publish 로 input/output 토큰 · latency 기록

#### B-8. 테스트
- `FakeListLLM` mock 으로 chain 단위
- Testcontainer 로 컨슈머 통합

---

### 트랙 C — Frontend Interview UX

[`frontend/CLAUDE.md §5`](frontend/CLAUDE.md) 라우트 가이드 기준.

#### C-1. 라우터 등록
- `/sessions/new`, `/sessions/:id`, `/history`, `/history/:id`
- `RequireAuth` wrap

#### C-2. `features/interview/`
- `api/interview.ts` — create / start / submitAnswer / end / get / list / getMessages
- `model/types.ts` — `Session`, `InterviewMessage`, ENUM 미러 ([`docs/glossary.md`](docs/glossary.md))
- `model/useSession.ts`, `useSessionMessages.ts`, `useSubmitAnswer.ts`
- `ui/SessionConfigForm.tsx` — mode / type / jobCategory / maxQuestions / maxDuration + `analyzed_documents` 멀티셀렉트(컨텍스트 문서)
- `ui/MessageList.tsx`, `ui/AnswerInput.tsx`, `ui/SessionControls.tsx` (start, end, 진행률)
- `ui/QuestionPendingState.tsx` — AI 풀 생성 대기용 4-state pending

#### C-3. SSE 연동
- 기존 [`useEventStream.ts`](frontend/src/shared/hooks/useEventStream.ts) 에 `session.message` · `session.state` 핸들러 추가
- `GET /api/stream/sessions/{sessionId}` — Sprint 1 의 stream-token API 재사용

#### C-4. `pages/Interview/`
- `NewSessionPage` (config form 만)
- `InterviewPage` (`:id`, AsyncBoundary 로 감싸고 진행 중 / 종료 분기)

#### C-5. `pages/History/`
- 세션 리스트(상태 뱃지) · 세션 상세(메시지 시퀀스)

#### C-6. 테스트
- Vitest 로 hook
- Playwright 로 "세션 생성 → 답변 3회 → 종료" 골든 패스

---

### 트랙 D — RealTime 서버

현재 `q.realtime.session.notify` 컨슈머가 SSE 로 fan-out 됨. **Core 가 publish 만 시작하면 별도 작업 없음.** 다만 확인할 것:

- Core 가 `q.realtime.session.notify` 로 publish vs 자기 인메모리 SSE 둘 중 어디로 보낼지 정책 통일 ([`docs/event-stream.md §6`](docs/event-stream.md)) — Phase 1 은 Core 인메모리로도 충분. RealTime 경유로 가면 멀티 인스턴스 대비 가능. **Sprint 2 에선 인메모리 + 별도 publish 둘 다 가능** — 결정 필요
- session 권한 검증(`/api/stream/sessions/:id` 본인 세션 검사) 이 이미 들어가 있는지 확인

---

## 4. 결정 사항 / 미결정 사항

### 4.1 확정된 결정 (2026-05-29)

| # | 항목 | 결정 | 근거 |
|---|---|---|---|
| 1 | **임베딩 provider** | 운영 / 개발은 [`GeminiEmbeddingProvider`](ai/src/ai_server/rag/embedder.py) 사용 (이미 구현됨), 테스트는 `MockEmbeddingProvider` 유지 | Mock 만 쓰면 RAG 검색이 noise, 신규 구현체 도입 불필요 |
| 2 | **첫 질문 push 방식** | Core 가 `callback.questions (POOL)` 수신 즉시 첫 질문을 `interview_messages` INSERT + SSE `session.message` push | round-trip · race 회피, SSE 인프라 재사용 |
| 3 | **SSE 전송 경로** | Phase 1 은 Core 인메모리 단일화 | RT2 (분석 상태) 와 경로 통일, 디버깅 비용 ↓. RealTime 서버는 멀티 인스턴스 / 음성 단계에서 본격 활용 |
| 4 | **답변 멱등키** | `POST /api/sessions/{id}/messages` 한정으로 `Idempotency-Key` 헤더 도입 (Frontend UUID 발급 → Core 가 `processed_messages` 테이블에 24h 캐시) | `(session_id, sequence_number) UNIQUE` 깨짐 방지, SSE 재연결 + 자동 재시도 시 중복 INSERT 차단 |

### 4.2 미결정 사항 (Sprint 2 진입 전 픽스 필요)

| # | 항목 | 책임 | 메모 |
|---|---|---|---|
| 5 | 세션 종료 후 피드백 (US-24) Sprint 3 로 미루는지 확정 | PO | 본 계획은 분리를 가정. Sprint 2 종료 시점에 `/sessions/:id/feedback` placeholder 만 두는 안 검토 |
| 6 | Flyway 추가 마이그레이션 필요 여부 | Core | 핵심 결정: **질문 풀 저장 위치** — `interview_sessions.question_pool JSONB` 컬럼 추가 vs 별도 테이블 vs Core 인메모리. 컬럼 추가 시 마이그레이션 1 개 필요 |

---

## 5. 권장 진행 순서

1. **미결정 사항 5·6 회의로 픽스** (10 분)
2. **메시지 스키마 정합성 PR** — envelope 모델만 Core / AI 양쪽에 추가 → 트랙 A · B 의 unblock
3. **트랙 A · B · C 병렬 진행** (각각 PR 1~2 개)
4. **통합 시나리오 테스트** — 세션 생성 → 첫 질문 → 답변 → 꼬리질문 → 종료 골든 패스 E2E
5. **트랙 D 정책 통일** + 멀티 인스턴스 대비 (선택)

Sprint 1 PR 들이 평균 1~2 개 도메인씩 끊어 머지된 패턴을 보면, Sprint 2 도 트랙별 PR 2~3 개씩(총 8~10 PR)으로 끊는 게 자연스러움.

---

## 6. Definition of Done

- [ ] `/sessions/new` 에서 컨텍스트 문서를 골라 세션 생성 가능
- [ ] 세션 생성 후 첫 질문이 SSE 로 도착
- [ ] 답변 제출 → < 3 초 내 꼬리질문 push
- [ ] `maxQuestions` 도달 시 자동 종료, `/history` 에 COMPLETED 표시
- [ ] `/history/:id` 에서 전체 메시지 트리 조회 가능
- [ ] DLQ 격리 정책 ([`docs/messaging.md §6`](docs/messaging.md)) 가 generate.* 큐에도 적용 확인
- [ ] `ai_request_logs` 에 질문 풀 / 꼬리질문 호출 기록
- [ ] 골든 패스 E2E 테스트 통과
3 changes: 3 additions & 0 deletions ai/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ LLM_API_KEY=
LLM_BASE_URL=https://factchat-cloud.mindlogic.ai/v1/gateway
LLM_PRO_MODEL=gemini-3.1-pro-preview
LLM_PRO_TEMPERATURE=0.2
LLM_FLASH_MODEL=gemini-3.1-flash-lite-preview
LLM_FLASH_TEMPERATURE=0.4
LLM_FLASH_MAX_TOKENS=512

# Github 관련
CORE_INTERNAL_BASE_URL=http://localhost:38010
Expand Down
25 changes: 13 additions & 12 deletions ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ ai/
| `ai.analyze.resume` | `analyze.resume` | 본 구현 (PDF → MD) |
| `ai.analyze.repository` | `analyze.repository` | 본 구현 (GitHub README + tree + 소스 sampling) |
| `ai.analyze.web` | `analyze.web` | 본 구현 (URL → trafilatura) |
| `ai.generate.questions` | `generate.questions` | 큐만, 코드 미구현 |
| `ai.generate.followup` | `generate.followup` | 큐만, 코드 미구현 |
| `ai.generate.questions` | `generate.questions` | 본 구현 (Pro 모델, 질문 풀 생성, US-18) |
| `ai.generate.followup` | `generate.followup` | 본 구현 (Flash 모델, 답변 평가+꼬리질문, US-19) |

콜백 발행: `ai.callback.{type}` 익스체인지.
상세 envelope/스키마/재시도: [`/docs/messaging.md`](../docs/messaging.md).
Expand Down Expand Up @@ -310,19 +310,20 @@ docker run --env-file .env -p 8000:8000 stackup-ai
## 16. 현재 상태 (2026-05 기준)

- FastAPI 부트스트랩 + 헬스체크
- RabbitMQ consumer `ai.analyze.resume` 본 구현:
- PDF 텍스트 추출 (`analyzer/sources/pdf.py`, pypdf)
- 분석 consumer 본 구현 — `analyze.resume` / `analyze.repository` / `analyze.web`:
- PDF·GitHub Repo·웹 URL 소스 추출 추상화 (`analyzer/sources/`)
- LLM 분석 (`chain/document_analysis_chain.py`, Gemini Pro + Pydantic 출력 파서)
- 분석 MD를 스토리지에 저장
- `callback.analysis` 발행 (status `ANALYZED` / `FAILED`, retriable 플래그 포함)
- **스토리지 추상화 도입** (`storage/`): `ObjectStorage` 인터페이스 + 구현체 두 개.
- `S3Storage` (기본) — boto3 + `asyncio.to_thread`. MinIO·AWS S3 모두 호환. `S3_ENDPOINT_URL`만 바꿔 swap.
- `LocalFilesystemStorage` (dev/test 전용) — aiofiles + traversal 방어.
- `STORAGE_BACKEND=s3|local` 환경변수 한 줄로 전환.
- **소스 추출 추상화 도입** (`analyzer/sources/`): `SourceExtractor` 인터페이스 + `PdfSourceExtractor`.
GitHub repo / 웹 이력서 추출기는 동일 인터페이스로 후속 PR에서 추가 예정.
- 임베딩·청킹·pgvector upsert는 미구현 → 후속 PR. `embedding_chunk_count`는 0으로 발행.
- `analyze.repository` / `generate.questions` / `generate.followup` consumer는 큐 정의만, 코드 없음
- 면접 consumer 본 구현 — `generate.questions` (US-18) / `generate.followup` (US-19):
- 질문 풀 생성 (Pro 모델, `chain/question_generation_chain.py`)
- 꼬리질문 + 답변 평가 (Flash 모델, `chain/followup_generation_chain.py`)
- 콜백: `callback.questions` (`kind=POOL|FOLLOWUP`)
- **임베딩 본 구현** (`rag/`): `MarkdownChunker` + `GeminiEmbeddingProvider` (1536d, `gemini-embedding-001`).
운영/개발 default 는 gemini, 테스트는 `MockEmbeddingProvider`.
- **스토리지 추상화** (`storage/`): `S3Storage`(기본) / `LocalFilesystemStorage`. `STORAGE_BACKEND` 토글.
- **LLM 호출 로깅 본 구현** (`observability/llm_logging_callback.py`, US-30):
LangChain `AsyncCallbackHandler` 가 토큰/latency 측정 → Core `/api/internal/ai-logs` POST.
- 음성 모듈은 Phase 2

각 도입 시 본 문서 갱신.
13 changes: 12 additions & 1 deletion ai/src/ai_server/chain/document_analysis_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from ai_server.analyzer.sources.base import SourceType
from ai_server.chain.prompts.document_analysis 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


class DocumentAnalysisResult(BaseModel):
Expand Down Expand Up @@ -42,7 +44,7 @@ async def analyze(


# 프롬프트 -> LLM -> 파서 하나로 묶어서 처리함
def build_document_analysis_chain(settings: Settings) -> Runnable:
def build_document_analysis_chain(settings: Settings, core_client: CoreClient | None = None) -> Runnable:
from langchain_openai import ChatOpenAI

parser = PydanticOutputParser(pydantic_object=DocumentAnalysisResult)
Expand All @@ -53,10 +55,19 @@ def build_document_analysis_chain(settings: Settings) -> Runnable:
]
).partial(format_instructions=parser.get_format_instructions())

callbacks = []
if core_client is not None:
callbacks.append(CoreAiLogCallback(
core_client=core_client,
request_type="analyze.document",
default_model=settings.llm_pro_model,
))

llm = ChatOpenAI(
model=settings.llm_pro_model,
temperature=settings.llm_pro_temperature,
api_key=settings.llm_api_key or None,
base_url=settings.llm_base_url,
callbacks=callbacks,
)
return prompt | llm | parser
87 changes: 87 additions & 0 deletions ai/src/ai_server/chain/followup_generation_chain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from __future__ import annotations

from typing import Protocol

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.followup_generation import HUMAN_PROMPT, SYSTEM_PROMPT
from ai_server.config.settings import Settings
from ai_server.core.client import CoreClient
from ai_server.model.messages.followup import AnswerEvaluation
from ai_server.observability.llm_logging_callback import CoreAiLogCallback


class FollowupResult(BaseModel):
followup_question: str = Field(..., description="한국어 꼬리질문 1개")
answer_evaluation: AnswerEvaluation


class FollowupGenerator(Protocol):
async def generate(
self,
*,
job_category: str,
interview_type: str,
previous_question: str,
answer_text: str,
) -> FollowupResult: ...


class LlmFollowupGenerator:
def __init__(self, chain: Runnable) -> None:
self._chain = chain

async def generate(
self,
*,
job_category: str,
interview_type: str,
previous_question: str,
answer_text: str,
) -> FollowupResult:
result = await self._chain.ainvoke(
{
"job_category": job_category,
"interview_type": interview_type,
"previous_question": previous_question,
"answer_text": answer_text,
}
)
if not isinstance(result, FollowupResult):
raise TypeError(
f"chain returned {type(result).__name__}, expected FollowupResult"
)
return result


def build_followup_generation_chain(settings: Settings, core_client: CoreClient | None = None) -> Runnable:
from langchain_openai import ChatOpenAI

parser = PydanticOutputParser(pydantic_object=FollowupResult)
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="generate.followup",
default_model=settings.llm_flash_model,
))

llm = ChatOpenAI(
model=settings.llm_flash_model,
temperature=settings.llm_flash_temperature,
api_key=settings.llm_api_key or None,
base_url=settings.llm_base_url,
max_tokens=settings.llm_flash_max_tokens,
callbacks=callbacks,
)
return prompt | llm | parser
Loading