diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 4b54bb99..b4d57bfb 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -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" diff --git a/ai/src/ai_server/messaging/consumers/questions_consumer.py b/ai/src/ai_server/messaging/consumers/questions_consumer.py index 9b5fe451..5a415565 100644 --- a/ai/src/ai_server/messaging/consumers/questions_consumer.py +++ b/ai/src/ai_server/messaging/consumers/questions_consumer.py @@ -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): @@ -53,12 +57,14 @@ 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, ) @@ -66,7 +72,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None: 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, ) diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index a17712a3..ec372fbd 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -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) diff --git a/ai/tests/test_questions_consumer.py b/ai/tests/test_questions_consumer.py index 64b2e2a1..27e5cf59 100644 --- a/ai/tests/test_questions_consumer.py +++ b/ai/tests/test_questions_consumer.py @@ -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() diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListener.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListener.java new file mode 100644 index 00000000..ca576967 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListener.java @@ -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 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 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()); + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java index 6fefdd59..95ca76d3 100644 --- a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java +++ b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java @@ -106,4 +106,8 @@ public void markFailed(String errorCode, String errorMessage) { this.errorCode = errorCode; this.errorMessage = errorMessage; } + + public void markDeleted() { + this.deleted = true; + } } diff --git a/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java b/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java index 599eaf79..39ba37e3 100644 --- a/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java +++ b/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java @@ -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; @@ -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 등을 최신화. diff --git a/backend/src/main/java/com/stackup/stackup/github/application/event/RepositoryDeletedEvent.java b/backend/src/main/java/com/stackup/stackup/github/application/event/RepositoryDeletedEvent.java new file mode 100644 index 00000000..45614a30 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/application/event/RepositoryDeletedEvent.java @@ -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) { +} diff --git a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java index 4a6b7f45..09f967b6 100644 --- a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java +++ b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java @@ -113,4 +113,8 @@ public void markAnalyzed() { public void markFailed() { this.status = RepositoryStatus.FAILED; } + + public void markDeleted() { + this.deleted = true; + } } diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java b/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java index ab282eb9..3c112e78 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java +++ b/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java @@ -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; @@ -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) { diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/event/ResumeDeletedEvent.java b/backend/src/main/java/com/stackup/stackup/resume/application/event/ResumeDeletedEvent.java new file mode 100644 index 00000000..662d99e4 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/application/event/ResumeDeletedEvent.java @@ -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) { +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java index a7e1bec7..33df1d51 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java +++ b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java @@ -80,4 +80,8 @@ public void markAnalyzed() { public void markFailed() { this.status = ResumeStatus.FAILED; } + + public void markDeleted() { + this.deleted = true; + } } diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java index d50a705b..e1f3f844 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java @@ -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 contextDocumentIds ) { public SessionCreateCommand toCommand() { return new SessionCreateCommand( title, memo, - mode, + mode == null ? SessionMode.ONLINE : mode, interviewType, jobCategory, maxQuestions,