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 ai/src/ai_server/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ class Settings(BaseSettings):
ai_callback_routing_feedback: str = "callback.feedback"
ai_callback_routing_voice: str = "callback.voice"
feedback_rag_top_k: int = 5
# 질문 풀 초기 크기. Core 의 applyPool 이 questions[0] 만 INSERT 하므로 1 로 고정해 토큰 낭비 차단.
# 후속 작업에서 풀 저장 도입 시 늘리기 (예: 5).
questions_initial_pool_size: int = 1

# STT (음성 답변). "auto" 면 deepgram > openai_whisper > mock 순으로 키 보유 여부에 따라 자동 선택.
stt_provider: Literal["auto", "mock", "openai_whisper", "deepgram"] = "auto"
Expand Down
8 changes: 7 additions & 1 deletion ai/src/ai_server/messaging/consumers/questions_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,15 @@ def __init__(
publisher: CallbackPublisher,
idempotency: LruIdempotencyStore,
callback_routing_key: str,
initial_pool_size: int = 1,
) -> None:
self._generator = generator
self._publisher = publisher
self._idempotency = idempotency
self._callback_routing_key = callback_routing_key
# Core 의 QuestionsCallbackService.applyPool 은 questions[0] 만 INSERT 하고 나머지는 폐기.
# 토큰 낭비를 줄이기 위해 envelope.max_questions 대신 풀 크기를 강제. 후속 작업에서 풀 저장 도입 시 늘리기 쉬움.
self._initial_pool_size = max(1, initial_pool_size)

async def handle(self, message: AbstractIncomingMessage) -> None:
async with message.process(requeue=False):
Expand All @@ -53,20 +57,22 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
return

req = envelope.payload
effective_pool_size = self._initial_pool_size # envelope.max_questions 무시
log.info(
"questions.generate.start",
message_id=envelope.message_id,
session_id=req.session_id,
doc_count=len(req.documents),
max_questions=req.max_questions,
pool_size=effective_pool_size,
trace_id=envelope.trace_id,
)

context_text = _build_context(req.documents)
pool = await self._generator.generate(
job_category=req.job_category,
interview_type=req.interview_type,
max_questions=req.max_questions,
max_questions=effective_pool_size,
context=context_text,
)

Expand Down
1 change: 1 addition & 0 deletions ai/src/ai_server/messaging/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ def __init__(self, settings: Settings) -> None:
publisher=self._publisher,
idempotency=self._idempotency,
callback_routing_key=settings.ai_callback_routing_questions,
initial_pool_size=settings.questions_initial_pool_size,
)

# 꼬리질문 생성 (US-19)
Expand Down
3 changes: 2 additions & 1 deletion ai/tests/test_questions_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ async def test_consumer_generates_questions_and_publishes_callback():
call = generator.generate.await_args
assert call.kwargs["job_category"] == "BACKEND"
assert call.kwargs["interview_type"] == "TECHNICAL"
assert call.kwargs["max_questions"] == 5
# envelope.max_questions(=5) 무시하고 initial_pool_size(default 1) 로 강제. Core 가 첫 질문만 사용.
assert call.kwargs["max_questions"] == 1
assert "Java" in call.kwargs["context"]

publisher.publish.assert_awaited_once()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.stackup.stackup.document.application;

import com.stackup.stackup.document.domain.AnalyzedDocument;
import com.stackup.stackup.document.domain.AnalyzedDocumentRepository;
import com.stackup.stackup.github.application.event.RepositoryDeletedEvent;
import com.stackup.stackup.resume.application.event.ResumeDeletedEvent;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

// Resume / GithubRepository soft delete 발생 시 관련 AnalyzedDocument 도 cascade soft delete.
// 도메인 cycle 회피 — resume/github 가 document 도메인을 직접 import 하지 않고 이벤트로 위임.
@Component
@RequiredArgsConstructor
public class AnalyzedDocumentCascadeListener {

private static final Logger log = LoggerFactory.getLogger(AnalyzedDocumentCascadeListener.class);

private final AnalyzedDocumentRepository analyzedDocumentRepository;

@EventListener
@Transactional
public void on(ResumeDeletedEvent event) {
List<AnalyzedDocument> docs = analyzedDocumentRepository
.findActiveByResumeIdAndOwner(event.resumeId(), event.userId());
for (AnalyzedDocument doc : docs) {
doc.markDeleted();
}
if (!docs.isEmpty()) {
log.info("AnalyzedDocument cascade soft delete (resume). userId={}, resumeId={}, count={}",
event.userId(), event.resumeId(), docs.size());
}
}

@EventListener
@Transactional
public void on(RepositoryDeletedEvent event) {
List<AnalyzedDocument> docs = analyzedDocumentRepository
.findActiveByRepositoryIdAndOwner(event.repositoryId(), event.userId());
for (AnalyzedDocument doc : docs) {
doc.markDeleted();
}
if (!docs.isEmpty()) {
log.info("AnalyzedDocument cascade soft delete (repository). userId={}, repositoryId={}, count={}",
event.userId(), event.repositoryId(), docs.size());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,8 @@ public void markFailed(String errorCode, String errorMessage) {
this.errorCode = errorCode;
this.errorMessage = errorMessage;
}

public void markDeleted() {
this.deleted = true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.stackup.stackup.github.application.dto.CandidateRepositoryResult;
import com.stackup.stackup.github.application.dto.GithubRepositoryResult;
import com.stackup.stackup.github.application.dto.RegisterRepositoryCommand;
import com.stackup.stackup.github.application.event.RepositoryDeletedEvent;
import com.stackup.stackup.github.application.event.RepositoryRegisteredEvent;
import com.stackup.stackup.github.domain.GithubRepository;
import com.stackup.stackup.github.domain.GithubRepositoryRepository;
Expand Down Expand Up @@ -80,7 +81,9 @@ public GithubRepositoryResult get(Long userId, Long repositoryId) {
@Transactional
public void delete(Long userId, Long repositoryId) {
GithubRepository repo = loadOwned(userId, repositoryId);
repositoryRepository.delete(repo);
repo.markDeleted();
// 분석 결과 cascade — document 도메인 listener 가 RepositoryDeletedEvent 수신해 AnalyzedDocument soft delete.
events.publishEvent(new RepositoryDeletedEvent(userId, repositoryId));
}

// US-08: GitHub 메타 재동기화. 등록된 레포의 default_branch / name / url 등을 최신화.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.stackup.stackup.github.application.event;

// GithubRepository soft delete commit 후 발화. document 도메인이 수신해 관련 AnalyzedDocument cascade soft delete.
public record RepositoryDeletedEvent(Long userId, Long repositoryId) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,8 @@ public void markAnalyzed() {
public void markFailed() {
this.status = RepositoryStatus.FAILED;
}

public void markDeleted() {
this.deleted = true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.stackup.stackup.common.storage.ObjectStorageClient;
import com.stackup.stackup.resume.application.dto.ResumeResult;
import com.stackup.stackup.resume.application.dto.ResumeUploadCommand;
import com.stackup.stackup.resume.application.event.ResumeDeletedEvent;
import com.stackup.stackup.resume.application.event.ResumeUploadedEvent;
import com.stackup.stackup.resume.domain.Resume;
import com.stackup.stackup.resume.domain.ResumeFileType;
Expand Down Expand Up @@ -65,8 +66,10 @@ public ResumeResult get(Long userId, Long resumeId) {
@Transactional
public void delete(Long userId, Long resumeId) {
Resume resume = loadOwned(userId, resumeId);
// 객체(S3 본문) 삭제는 후속 정책(보존 vs 즉시 cleanup)에 따라. 우선 row soft delete.
resumeRepository.delete(resume);
resume.markDeleted();
// 분석 결과 cascade — document 도메인 listener 가 ResumeDeletedEvent 받아 AnalyzedDocument soft delete.
// 직접 의존 회피 (ArchUnit 도메인 cycle 방지).
events.publishEvent(new ResumeDeletedEvent(userId, resumeId));
}

private Resume loadOwned(Long userId, Long resumeId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.stackup.stackup.resume.application.event;

// Resume soft delete commit 후 발화. document 도메인이 수신해 관련 AnalyzedDocument cascade soft delete.
public record ResumeDeletedEvent(Long userId, Long resumeId) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,8 @@ public void markAnalyzed() {
public void markFailed() {
this.status = ResumeStatus.FAILED;
}

public void markDeleted() {
this.deleted = true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,21 @@
public record SessionCreateRequest(
@Size(max = 200) String title,
@Size(max = 4000) String memo,
@NotNull SessionMode mode,
// mode: 현재 시연 범위에선 ONLINE 만 유효. 미입력 시 default 적용 (Sprint 4 에서 정식 분기).
SessionMode mode,
@NotNull InterviewType interviewType,
@NotNull JobCategory jobCategory,
@Min(1) @Max(30) Integer maxQuestions,
@Min(5) @Max(180) Integer maxDurationMinutes,
// 첫 질문 + 꼬리질문 최소 1쌍 보장을 위해 하한 2. applyFollowup 자동 종료 흐름 결합.
@Min(2) @Max(30) Integer maxQuestions,
// maxDurationMinutes: 시간 기반 자동 종료 미구현. DB 저장만 유지, 입력 검증 풀어 미입력 허용.
Integer maxDurationMinutes,
List<Long> contextDocumentIds
) {
public SessionCreateCommand toCommand() {
return new SessionCreateCommand(
title,
memo,
mode,
mode == null ? SessionMode.ONLINE : mode,
interviewType,
jobCategory,
maxQuestions,
Expand Down