From dd2c90059b786c531616d262c330541f6241e3bb Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 11:05:43 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(backend):=20=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=EC=A7=91=EC=A4=91=20=EC=9E=AC=EB=8F=84=EC=A0=84=20=E2=80=94=20?= =?UTF-8?q?=EB=82=AE=EC=95=98=EB=8D=98=20=ED=8F=89=EA=B0=80=20=EC=B6=95?= =?UTF-8?q?=EC=9D=84=20=EC=84=B8=EC=85=98=EC=97=90=20=EC=83=88=EA=B8=B4?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /api/sessions/{id}/retry` 에 `{"focusOnWeakness": true}` 를 주면, 원본 세션 피드백에서 낮았던 평가 축을 새 세션의 집중 영역으로 새긴다. 질문 생성이 그 영역을 검증하는 질문을 우선 배치한다. ## 약점 선정 - 임계값(`interview.weakness-focus.score-threshold`, 기본 70) 미만인 축을 낮은 순 최대 2개 - **전부 기준 이상이어도 가장 낮은 하나는 고른다** — 눌렀는데 아무것도 안 바뀌면 버튼이 고장난 것처럼 보인다 - 피드백이 없으면(중단 세션 등) 빈 목록 → 일반 재도전과 동일하게 동작 `@Value` 필드에 자바 초기값(`= 70`)도 함께 뒀다. Spring 밖(단위 테스트)에서는 주입이 안 돼 0.0 이 되고, 그러면 모든 축이 '기준 이상'으로 판정돼 **조용히 다른 동작**을 한다. 실제로 이 테스트를 쓰다가 걸렸다. ## 전달 경로 V25 로 `interview_sessions.focus_areas`(JSONB, `SessionFocusArea` name 배열) 추가. `SessionQuestionsRequester` 가 세션에서 읽어 `generate.questions.focusAreas` 로 싣는다 — 이벤트 레코드를 늘리지 않고 이미 갖고 있던 `sessionRepository` 로 조회한다. `SessionResponse.focusAreas` 로도 노출해 화면이 "무엇을 겨냥하는 면접인지" 보여줄 수 있게 했다. ## 테스트 `SessionServiceTest` +3 — 기준 미만 축 2개 선정, 전부 강할 때 최저 1개 폴백, 피드백 없을 때 일반 재도전 폴백. 기존 retry 테스트는 새 시그니처에 맞춰 갱신. --- backend/CLAUDE.md | 11 +++ backend/openapi.json | 25 +++++- .../SessionQuestionsRequester.java | 27 +++++- .../session/application/SessionService.java | 82 +++++++++++++++++- .../dto/GenerateQuestionsPayload.java | 4 +- .../application/dto/SessionResult.java | 21 +++++ .../session/domain/InterviewSession.java | 14 +++ .../session/domain/SessionFocusArea.java | 11 +++ .../presentation/SessionController.java | 10 ++- .../presentation/dto/SessionResponse.java | 3 + .../presentation/dto/SessionRetryRequest.java | 11 +++ .../V25__add_session_focus_areas.sql | 4 + .../application/SessionServiceTest.java | 85 ++++++++++++++++++- 13 files changed, 296 insertions(+), 12 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/session/domain/SessionFocusArea.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionRetryRequest.java create mode 100644 backend/src/main/resources/db/migration/V25__add_session_focus_areas.sql diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 91eacaaa..734aa2cf 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -446,6 +446,17 @@ docker compose up -d 분석 미완료에 `DOC_NOT_ANALYZED` 를 던지므로, 원본 설정을 그대로 재전송하면 그 사이 자료 하나 지운 사용자는 재도전 자체가 막힌다. 빠진 자료는 응답 `contextDocumentIds` 를 원본과 비교해 프론트가 안내한다. +- **약점 집중 재도전 본 구현 (B-3)**: `POST /api/sessions/{id}/retry` 에 `{"focusOnWeakness":true}` + 를 주면 `SessionService.retry` 가 원본 세션 피드백에서 낮은 평가 축을 골라 새 세션의 + `focus_areas`(V25, JSONB, `SessionFocusArea` name 배열)에 새긴다. 선정 기준: 임계값 + (`interview.weakness-focus.score-threshold`, 기본 70) 미만인 축을 낮은 순 최대 2개. + **전부 기준 이상이어도 가장 낮은 하나는 고른다** — 눌렀는데 아무것도 안 바뀌면 고장으로 보인다. + 피드백이 없으면(중단 세션) 빈 목록이라 일반 재도전과 같아진다. + `SessionQuestionsRequester` 가 세션에서 읽어 `generate.questions.focusAreas` 로 싣고, + AI 프롬프트가 그 영역을 검증하는 질문을 과반으로 배치한다(단, 자료 근거 없는 질문 생성 금지 — + target_evidence 필수 조건은 면제되지 않는다). + - `@Value` 필드에 **자바 초기값도 함께** 둔다(`= 70`). Spring 밖(단위 테스트)에서는 주입이 + 안 돼 0.0 이 되고, 그러면 모든 축이 '기준 이상'으로 판정돼 조용히 다른 동작을 한다. - **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당. - **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로. diff --git a/backend/openapi.json b/backend/openapi.json index fcb66c30..6499fffe 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -375,7 +375,7 @@ "post" : { "tags" : [ "Sessions" ], "summary" : "같은 설정으로 다시 면접 (US-13 재도전)", - "description" : "기존 세션의 설정(모드·직군·질문 수·JD 등)을 그대로 복사해 새 세션을 만든다. 연결 자료는 지금도 살아있고 분석이 끝난 것만 다시 잇는다 — 그 사이 삭제된 자료 때문에 재도전 전체가 404 로 막히지 않게 하기 위함. 응답의 contextDocumentIds 를 원본과 비교하면 무엇이 빠졌는지 알 수 있다.", + "description" : "기존 세션의 설정(모드·직군·질문 수·JD 등)을 그대로 복사해 새 세션을 만든다. focusOnWeakness=true 면 원본 피드백에서 낮았던 평가 축을 집중 영역으로 새겨, 질문 생성이 그 영역을 검증하는 질문을 우선 배치한다. 연결 자료는 지금도 살아있고 분석이 끝난 것만 다시 잇는다 — 그 사이 삭제된 자료 때문에 재도전 전체가 404 로 막히지 않게 하기 위함. 응답의 contextDocumentIds 를 원본과 비교하면 무엇이 빠졌는지 알 수 있다.", "operationId" : "retrySession", "parameters" : [ { "name" : "sessionId", @@ -386,6 +386,15 @@ "format" : "int64" } } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/SessionRetryRequest" + } + } + } + }, "responses" : { "201" : { "description" : "새 세션 생성 완료", @@ -3056,6 +3065,12 @@ "targetJobDescription" : { "type" : "string" }, + "focusAreas" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, "createdAt" : { "type" : "string", "format" : "date-time" @@ -3074,6 +3089,14 @@ } } }, + "SessionRetryRequest" : { + "type" : "object", + "properties" : { + "focusOnWeakness" : { + "type" : "boolean" + } + } + }, "MessageSubmitRequest" : { "type" : "object", "properties" : { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java index 23e138cb..2dbe8c9b 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java @@ -15,6 +15,7 @@ import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload.DocumentContext; import com.stackup.stackup.session.application.event.SelfIntroAnsweredEvent; import com.stackup.stackup.session.application.event.SessionCreatedEvent; +import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.SessionQuestionPoolRepository; import java.io.IOException; @@ -44,6 +45,7 @@ public class SessionQuestionsRequester { private static final Logger log = LoggerFactory.getLogger(SessionQuestionsRequester.class); private static final JsonMapper JSON = JsonMapper.builder().build(); private static final TypeReference> TECH_STACK_TYPE = new TypeReference<>() {}; + private static final TypeReference> FOCUS_AREA_TYPE = new TypeReference<>() {}; private static final long MAX_MARKDOWN_BYTES = 200_000L; // envelope 비대화 방지 private static final int DEFAULT_GENERAL_QUESTION_COUNT = 3; @@ -91,6 +93,7 @@ public void onSelfIntroAnswered(SelfIntroAnsweredEvent event) { } List recentQuestions = recentQuestions(event.userId(), event.sessionId()); + List focusAreas = focusAreas(event.sessionId()); GenerateQuestionsPayload payload = new GenerateQuestionsPayload( event.sessionId(), event.mode(), @@ -101,7 +104,8 @@ public void onSelfIntroAnswered(SelfIntroAnsweredEvent event) { recentQuestions, event.selfIntroAnswer(), event.targetCompanyName(), - event.targetJobDescription() + event.targetJobDescription(), + focusAreas ); publisher.publishToAi( properties.routingKeys().generateQuestions(), @@ -109,9 +113,26 @@ public void onSelfIntroAnswered(SelfIntroAnsweredEvent event) { new MessageContext(event.userId(), event.sessionId(), null, null) ); log.info("generate.questions published (post self-intro). sessionId={}, doc_count={}, " - + "pool_count={}, max={}, recent_q={}, intro_len={}", + + "pool_count={}, max={}, recent_q={}, intro_len={}, focus={}", event.sessionId(), documents.size(), poolCount, event.maxQuestions(), recentQuestions.size(), - event.selfIntroAnswer() == null ? 0 : event.selfIntroAnswer().length()); + event.selfIntroAnswer() == null ? 0 : event.selfIntroAnswer().length(), focusAreas); + } + + // 약점 집중 재도전으로 만들어진 세션이면 겨냥할 평가 축을 꺼낸다. 아니면 빈 목록. + // 이벤트를 늘리지 않고 세션에서 직접 읽는다(이미 sessionRepository 를 쓰고 있다). + private List focusAreas(Long sessionId) { + return sessionRepository.findById(sessionId) + .map(InterviewSession::getFocusAreas) + .filter(json -> json != null && !json.isBlank()) + .map(json -> { + try { + return JSON.readValue(json, FOCUS_AREA_TYPE); + } catch (JsonProcessingException e) { + log.warn("focus_areas parse failed. sessionId={}, raw={}", sessionId, json, e); + return List.of(); + } + }) + .orElseGet(List::of); } // 같은 유저의 최근 N개 세션에서 출제된 질문을 모아 AI 의 중복 회피용으로 전달. diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java index 0c44572e..0458a4a8 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java @@ -1,5 +1,7 @@ package com.stackup.stackup.session.application; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.json.JsonMapper; import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.exception.DomainException; import com.stackup.stackup.common.security.StreamTokenProvider; @@ -16,6 +18,8 @@ import com.stackup.stackup.session.domain.SessionMode; import com.stackup.stackup.session.domain.SessionContext; import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.session.domain.SessionFeedbackRepository; +import com.stackup.stackup.session.domain.SessionFocusArea; import com.stackup.stackup.session.domain.SessionStatus; import com.stackup.stackup.user.domain.User; import com.stackup.stackup.user.domain.UserRepository; @@ -23,8 +27,12 @@ import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -38,13 +46,23 @@ @Transactional(readOnly = true) public class SessionService { + private static final Logger log = LoggerFactory.getLogger(SessionService.class); + private static final JsonMapper JSON = JsonMapper.builder().build(); + private final InterviewSessionRepository sessionRepository; private final SessionContextRepository contextRepository; private final AnalyzedDocumentRepository documentRepository; + private final SessionFeedbackRepository feedbackRepository; private final UserRepository userRepository; private final ApplicationEventPublisher events; private final StreamTokenProvider streamTokenProvider; + // 이 점수 미만인 평가 축을 '약점'으로 본다. + // 필드 초기값을 함께 두는 이유: Spring 밖(단위 테스트)에서는 @Value 가 주입되지 않아 + // 0.0 이 되고, 그러면 모든 축이 '기준 이상'으로 판정돼 조용히 다른 동작을 한다. + @Value("${interview.weakness-focus.score-threshold:70}") + private double weaknessScoreThreshold = 70; + @Transactional public SessionResult create(Long userId, SessionCreateCommand command) { User user = loadUser(userId); @@ -101,13 +119,13 @@ public SessionResult create(Long userId, SessionCreateCommand command) { * 응답의 {@code contextDocumentIds} 로 드러나므로 호출자가 원본과 비교해 안내할 수 있다. */ @Transactional - public SessionResult retry(Long userId, Long sourceSessionId) { + public SessionResult retry(Long userId, Long sourceSessionId, boolean focusOnWeakness) { InterviewSession source = loadOwned(userId, sourceSessionId); List reusableDocumentIds = contextDocumentIds(sourceSessionId).stream() .filter(id -> isReusableContext(id, userId)) .toList(); - return create(userId, new SessionCreateCommand( + SessionResult created = create(userId, new SessionCreateCommand( source.getTitle(), source.getMemo(), source.getMode(), @@ -120,6 +138,66 @@ public SessionResult retry(Long userId, Long sourceSessionId) { source.getTargetCompanyName(), source.getTargetJobDescription() )); + + if (!focusOnWeakness) { + return created; + } + List weakAreas = weakAreasOf(sourceSessionId); + if (weakAreas.isEmpty()) { + // 피드백이 없는 세션(중단 등) — 집중 영역 없이 일반 재도전과 같아진다. + return created; + } + // 같은 트랜잭션이라 dirty checking 으로 반영된다. + InterviewSession newSession = sessionRepository.findById(created.id()) + .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND)); + newSession.assignFocusAreas(toJson(weakAreas)); + return SessionResult.of(newSession, created.contextDocumentIds()); + } + + /** + * 원본 세션의 피드백에서 약한 평가 축을 고른다. + * + *

기준 미만인 축만, 낮은 순으로 최대 2개. 전부 기준 이상이면 **가장 낮은 하나**를 고른다 — + * 사용자가 "약점 집중"을 눌렀는데 아무것도 안 바뀌면 버튼이 고장난 것처럼 보인다. + * 피드백이 없으면(중단 세션 등) 빈 목록이라 일반 재도전과 같아진다. + */ + private List weakAreasOf(Long sourceSessionId) { + return feedbackRepository.findBySession_Id(sourceSessionId) + .map(feedback -> { + List> scored = new ArrayList<>(); + addScore(scored, SessionFocusArea.TECHNICAL, feedback.getTechnicalAccuracy()); + addScore(scored, SessionFocusArea.LOGIC, feedback.getLogicScore()); + addScore(scored, SessionFocusArea.COMMUNICATION, feedback.getCommunicationScore()); + scored.sort(Map.Entry.comparingByValue()); + + List weak = scored.stream() + .filter(e -> e.getValue() < weaknessScoreThreshold) + .limit(2) + .map(Map.Entry::getKey) + .collect(Collectors.toCollection(ArrayList::new)); + if (weak.isEmpty() && !scored.isEmpty()) { + weak.add(scored.get(0).getKey()); + } + return weak; + }) + .orElseGet(List::of); + } + + private void addScore(List> target, + SessionFocusArea area, Double score) { + if (score != null) { + target.add(Map.entry(area, score)); + } + } + + private String toJson(List areas) { + try { + return JSON.writeValueAsString(areas.stream().map(Enum::name).toList()); + } catch (JsonProcessingException e) { + // 직렬화가 실패해도 재도전 자체는 성공해야 한다 — 집중 영역만 없는 일반 면접이 된다. + log.warn("focus_areas serialize failed. areas={}", areas, e); + return null; + } } // 삭제됐거나 아직 분석이 끝나지 않은 자료는 조용히 제외한다(linkContexts 는 둘 다 예외를 던진다). diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java index a3d92d55..c49676d1 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java @@ -19,7 +19,9 @@ public record GenerateQuestionsPayload( String selfIntroAnswer, // 직무 맞춤 모드 전용. 지원 회사명 + 채용공고(JD). 적합도·지원동기 질문의 근거. 다른 모드는 null. String targetCompanyName, - String targetJobDescription + String targetJobDescription, + // 약점 집중 재도전에서만 채워진다(SessionFocusArea name 목록). 비어 있으면 일반 면접. + List focusAreas ) { public record DocumentContext( Long documentId, diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java index 473ef6f8..74490d0d 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java @@ -1,5 +1,8 @@ package com.stackup.stackup.session.application.dto; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.json.JsonMapper; import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.JobCategory; import com.stackup.stackup.session.domain.SessionMode; @@ -26,9 +29,26 @@ public record SessionResult( // 직무 맞춤 모드 전용. 다른 모드는 null. String targetCompanyName, String targetJobDescription, + // 약점 집중 재도전의 겨냥 축(SessionFocusArea name). 일반 면접은 빈 목록. + List focusAreas, Instant createdAt, Instant updatedAt ) { + private static final JsonMapper JSON = JsonMapper.builder().build(); + private static final TypeReference> FOCUS_AREA_TYPE = new TypeReference<>() {}; + + // 저장은 JSON 문자열, 응답은 목록. 파싱 실패는 '집중 영역 없음'으로 흘린다(조회가 깨지면 안 된다). + private static List parseFocusAreas(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + return JSON.readValue(json, FOCUS_AREA_TYPE); + } catch (JsonProcessingException e) { + return List.of(); + } + } + public static SessionResult of(InterviewSession session, List documentIds) { return new SessionResult( session.getId(), @@ -48,6 +68,7 @@ public static SessionResult of(InterviewSession session, List documentIds) documentIds, session.getTargetCompanyName(), session.getTargetJobDescription(), + parseFocusAreas(session.getFocusAreas()), session.getCreatedAt(), session.getUpdatedAt() ); diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java index 3fb6dbb2..c79bd374 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java @@ -16,6 +16,8 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -88,6 +90,13 @@ public class InterviewSession extends BaseSoftDeleteEntity { @Column(name = "target_job_description", columnDefinition = "text") private String targetJobDescription; + // 약점 집중 재도전에서 겨냥할 평가 축. SessionFocusArea enum name 의 JSON 배열 + // (예: ["LOGIC","COMMUNICATION"]). 지정 없으면 null — 일반 면접과 동일하게 동작한다. + // 직렬화/역직렬화는 application 레이어가 한다(SessionFeedback.improvementKeywords 와 같은 방식). + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "focus_areas", columnDefinition = "jsonb") + private String focusAreas; + @Column(nullable = false, length = 20) @Enumerated(EnumType.STRING) private SessionStatus status = SessionStatus.READY; @@ -155,6 +164,11 @@ public static InterviewSession create(User user, String title, String memo, Sess maxQuestions, maxDurationMinutes, generalQuestionCount, maxFollowupsPerQuestion); } + // 약점 집중 재도전에서만 채워진다. assignTargetRole 과 같은 이유로 별도 메서드. + public void assignFocusAreas(String focusAreasJson) { + this.focusAreas = focusAreasJson; + } + // 직무 맞춤 모드의 타깃 회사/JD 부여. create 시그니처를 늘리지 않으려 별도 메서드로 둔다. public void assignTargetRole(String companyName, String jobDescription) { this.targetCompanyName = companyName; diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionFocusArea.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionFocusArea.java new file mode 100644 index 00000000..c8057692 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionFocusArea.java @@ -0,0 +1,11 @@ +package com.stackup.stackup.session.domain; + +// 약점 집중 재도전에서 겨냥할 평가 축. session_feedbacks 의 점수 축과 1:1. +public enum SessionFocusArea { + // technical_accuracy — 기술 정확도 + TECHNICAL, + // logic_score — 논리력/구조 + LOGIC, + // communication_score — 전달력 + COMMUNICATION +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java index 3e72762f..420a2696 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java @@ -5,6 +5,7 @@ import com.stackup.stackup.session.application.SessionService; import com.stackup.stackup.session.presentation.dto.SessionCreateRequest; import com.stackup.stackup.session.presentation.dto.SessionResponse; +import com.stackup.stackup.session.presentation.dto.SessionRetryRequest; import com.stackup.stackup.session.presentation.dto.SessionUpdateRequest; import com.stackup.stackup.session.presentation.dto.StreamTokenResponse; import io.swagger.v3.oas.annotations.Operation; @@ -91,6 +92,8 @@ public SessionResponse get( operationId = "retrySession", summary = "같은 설정으로 다시 면접 (US-13 재도전)", description = "기존 세션의 설정(모드·직군·질문 수·JD 등)을 그대로 복사해 새 세션을 만든다. " + + "focusOnWeakness=true 면 원본 피드백에서 낮았던 평가 축을 집중 영역으로 새겨, 질문 생성이 " + + "그 영역을 검증하는 질문을 우선 배치한다. " + "연결 자료는 지금도 살아있고 분석이 끝난 것만 다시 잇는다 — 그 사이 삭제된 자료 때문에 " + "재도전 전체가 404 로 막히지 않게 하기 위함. 응답의 contextDocumentIds 를 원본과 비교하면 " + "무엇이 빠졌는지 알 수 있다." @@ -104,9 +107,12 @@ public SessionResponse get( @ResponseStatus(HttpStatus.CREATED) public SessionResponse retry( @AuthenticationPrincipal UserPrincipal principal, - @PathVariable Long sessionId + @PathVariable Long sessionId, + @RequestBody(required = false) SessionRetryRequest request ) { - return SessionResponse.from(sessionService.retry(principal.userId(), sessionId)); + boolean focusOnWeakness = request != null && request.focusOnWeaknessOrDefault(); + return SessionResponse.from( + sessionService.retry(principal.userId(), sessionId, focusOnWeakness)); } @Operation(operationId = "updateSessionMeta", summary = "세션 제목/메모 수정") diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java index 673b1157..6fe6b17a 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java @@ -25,6 +25,8 @@ public record SessionResponse( List contextDocumentIds, String targetCompanyName, String targetJobDescription, + // 약점 집중 재도전의 겨냥 축(TECHNICAL|LOGIC|COMMUNICATION). 일반 면접은 빈 목록. + List focusAreas, Instant createdAt, Instant updatedAt ) { @@ -47,6 +49,7 @@ public static SessionResponse from(SessionResult r) { r.contextDocumentIds(), r.targetCompanyName(), r.targetJobDescription(), + r.focusAreas(), r.createdAt(), r.updatedAt() ); diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionRetryRequest.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionRetryRequest.java new file mode 100644 index 00000000..297148db --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionRetryRequest.java @@ -0,0 +1,11 @@ +package com.stackup.stackup.session.presentation.dto; + +// 재도전 옵션. 본문 없이 호출하면 focusOnWeakness=false 와 같다(설정만 복사하는 일반 재도전). +public record SessionRetryRequest( + // true 면 원본 세션 피드백에서 낮았던 평가 축을 새 세션의 집중 영역으로 새긴다. + Boolean focusOnWeakness +) { + public boolean focusOnWeaknessOrDefault() { + return Boolean.TRUE.equals(focusOnWeakness); + } +} diff --git a/backend/src/main/resources/db/migration/V25__add_session_focus_areas.sql b/backend/src/main/resources/db/migration/V25__add_session_focus_areas.sql new file mode 100644 index 00000000..88710ab6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V25__add_session_focus_areas.sql @@ -0,0 +1,4 @@ +-- B-3 약점 집중 재도전. 이전 면접 피드백에서 낮았던 평가 축을 새 세션에 새겨, +-- generate.questions 가 그 영역을 검증하는 질문을 우선 배치하게 한다. +-- 값 예: ["LOGIC","COMMUNICATION"] (SessionFocusArea enum name 배열). 지정 없으면 NULL. +ALTER TABLE interview_sessions ADD COLUMN focus_areas JSONB; diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java index 7ec5ad74..53bd96c3 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java @@ -21,6 +21,8 @@ import com.stackup.stackup.session.domain.JobCategory; import com.stackup.stackup.session.domain.SessionContext; import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.session.domain.SessionFeedback; +import com.stackup.stackup.session.domain.SessionFeedbackRepository; import com.stackup.stackup.session.domain.SessionMode; import com.stackup.stackup.session.domain.SessionStatus; import com.stackup.stackup.user.domain.User; @@ -42,6 +44,7 @@ class SessionServiceTest { @Mock SessionContextRepository contextRepository; @Mock AnalyzedDocumentRepository documentRepository; @Mock UserRepository userRepository; + @Mock SessionFeedbackRepository feedbackRepository; @Mock ApplicationEventPublisher events; @InjectMocks SessionService service; @@ -197,7 +200,7 @@ void retry_copiesSettingsIntoNewSession() { return s; }); - SessionResult result = service.retry(1L, 50L); + SessionResult result = service.retry(1L, 50L, false); assertThat(result.id()).isEqualTo(101L); assertThat(result.title()).isEqualTo("백엔드 모의면접"); @@ -243,17 +246,71 @@ void retry_skipsDeletedOrUnanalyzedContextDocuments() { return s; }); - SessionResult result = service.retry(1L, 50L); + SessionResult result = service.retry(1L, 50L, false); // 살아있는 7L 만 다시 연결 — 나머지는 조용히 제외한다(호출자가 원본과 비교해 안내). assertThat(result.contextDocumentIds()).containsExactly(7L); } + // 약점 집중: 기준(70) 미만인 축만, 낮은 순으로 최대 2개. + @Test + void retry_marksWeakestAxesAsFocusAreas() { + User user = userFixture(1L); + InterviewSession source = sessionFixture(50L); + SessionFeedback feedback = feedbackFixture(source, 82.0, 55.0, 61.0); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)).thenReturn(Optional.of(source)); + when(contextRepository.findBySession_Id(50L)).thenReturn(List.of()); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); + when(feedbackRepository.findBySession_Id(50L)).thenReturn(Optional.of(feedback)); + captureCreatedSession(101L); + + SessionResult result = service.retry(1L, 50L, true); + + // 55(논리) < 61(전달력) < 70 → 둘 다. 82(기술)는 기준 이상이라 제외. + assertThat(result.focusAreas()).containsExactly("LOGIC", "COMMUNICATION"); + } + + // 전부 기준 이상이어도 가장 낮은 하나는 고른다 — 눌렀는데 아무것도 안 바뀌면 고장으로 보인다. + @Test + void retry_picksLowestAxisEvenWhenAllScoresAreStrong() { + User user = userFixture(1L); + InterviewSession source = sessionFixture(50L); + SessionFeedback feedback = feedbackFixture(source, 91.0, 88.0, 95.0); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)).thenReturn(Optional.of(source)); + when(contextRepository.findBySession_Id(50L)).thenReturn(List.of()); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); + when(feedbackRepository.findBySession_Id(50L)).thenReturn(Optional.of(feedback)); + captureCreatedSession(101L); + + SessionResult result = service.retry(1L, 50L, true); + + assertThat(result.focusAreas()).containsExactly("LOGIC"); + } + + // 중단 세션은 피드백이 없다 — 집중 영역 없이 일반 재도전과 같아진다. + @Test + void retry_withoutFeedbackFallsBackToPlainRetry() { + User user = userFixture(1L); + InterviewSession source = sessionFixture(50L); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)).thenReturn(Optional.of(source)); + when(contextRepository.findBySession_Id(50L)).thenReturn(List.of()); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); + when(feedbackRepository.findBySession_Id(50L)).thenReturn(Optional.empty()); + captureCreatedSession(101L); + + SessionResult result = service.retry(1L, 50L, true); + + assertThat(result.focusAreas()).isEmpty(); + } + @Test void retry_rejectsSessionOfAnotherUser() { when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> service.retry(1L, 50L)) + assertThatThrownBy(() -> service.retry(1L, 50L, false)) .isInstanceOf(DomainException.class); } @@ -357,6 +414,28 @@ void delete_softDeletesInsteadOfHardDelete() { verify(sessionRepository, org.mockito.Mockito.never()).delete(any(InterviewSession.class)); } + // create() 가 save 하는 세션에 id 를 매기고, retry 가 다시 찾을 수 있게 findById 도 이어준다. + private void captureCreatedSession(Long id) { + InterviewSession[] holder = new InterviewSession[1]; + when(sessionRepository.save(any(InterviewSession.class))).thenAnswer(inv -> { + InterviewSession s = inv.getArgument(0); + ReflectionTestUtils.setField(s, "id", id); + holder[0] = s; + return s; + }); + org.mockito.Mockito.lenient().when(sessionRepository.findById(id)) + .thenAnswer(inv -> Optional.ofNullable(holder[0])); + } + + private SessionFeedback feedbackFixture( + InterviewSession session, Double technical, Double logic, Double communication) { + SessionFeedback feedback = mock(SessionFeedback.class); + org.mockito.Mockito.lenient().when(feedback.getTechnicalAccuracy()).thenReturn(technical); + org.mockito.Mockito.lenient().when(feedback.getLogicScore()).thenReturn(logic); + org.mockito.Mockito.lenient().when(feedback.getCommunicationScore()).thenReturn(communication); + return feedback; + } + private User userFixture(Long id) { User user = User.createGithubUser(123L, "octocat", null, null, "tok"); ReflectionTestUtils.setField(user, "id", id); From 18038077c62b02bb1001c828872ca65158b88ce9 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 11:05:43 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix(ai):=20=EC=95=BD=EC=A0=90=20=EC=A7=91?= =?UTF-8?q?=EC=A4=91=20=EC=98=81=EC=97=AD=EC=9D=84=20=EC=A7=88=EB=AC=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generate.questions` 의 `focusAreas`(TECHNICAL|LOGIC|COMMUNICATION)를 받아, 그 영역을 검증하는 질문이 과반이 되도록 프롬프트에 지시한다. - `_FOCUS_GUIDE` — 축별 **질문 설계 관점**을 명시. 라벨만 넘기면 LLM 이 제각각 해석한다 (LOGIC 을 "논리 퀴즈"로 받는 식). 기술 정확도는 동작 원리·트레이드오프, 논리력은 판단 근거와 대안 폐기 이유, 전달력은 눈높이 설명으로 각각 다른 질문이 나오게 했다 - **근거 없는 질문 생성 금지는 그대로**다. 집중 영역은 *무엇을 묻는가*를 바꾸는 것이지 target_evidence 필수 조건을 면제하지 않는다 — 자료가 부족하면 집중 비중을 줄이도록 명시 - 지정이 없으면 "(지정 없음 — 치우치지 말 것)" 이 들어가 일반 면접과 동일하게 동작 테스트 `test_question_focus_areas.py` 신설 (11) — 미지정 시 쏠림 지시가 새지 않는지, 축별 관점 문구가 실리는지, 모르는 축이 와도 깨지지 않는지(Core 가 축을 늘릴 수 있다), 생성기가 프롬프트 변수를 항상 채우는지(비면 템플릿 렌더가 KeyError). pytest 362건 통과. --- ai/CLAUDE.md | 5 + .../chain/prompts/question_generation.py | 10 +- .../chain/question_generation_chain.py | 33 ++++++ .../messaging/consumers/questions_consumer.py | 1 + ai/src/ai_server/model/messages/questions.py | 3 + ai/tests/test_question_focus_areas.py | 110 ++++++++++++++++++ 6 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 ai/tests/test_question_focus_areas.py diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index 630fc028..95702976 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -333,6 +333,11 @@ docker run --env-file .env -p 8000:8000 stackup-ai - 콜백: `callback.questions` (`kind=POOL|FOLLOWUP`) - **자기소개 기반 질문 생성**: `generate.questions` 는 자기소개 답변을 받은 뒤 발행되며, payload 의 `selfIntroAnswer` 를 프롬프트(`chain/prompts/question_generation.py`)의 1차 근거로 사용한다(없으면 자료만으로). + - **약점 집중 질문 생성**: payload 의 `focusAreas`(TECHNICAL|LOGIC|COMMUNICATION, 지난 면접에서 + 낮았던 축)가 채워지면 프롬프트의 집중 영역 블록(`_format_focus_areas`, 축별 질문 설계 관점을 + `_FOCUS_GUIDE` 로 명시)이 그 영역을 검증하는 질문을 과반으로 배치하게 한다. 라벨만 넘기면 LLM 이 + 제각각 해석하므로 관점 문구를 붙인다. 근거 없는 질문 생성 금지 조건은 그대로 — 자료가 부족하면 + 집중 비중을 줄인다. 지정이 없으면 "(지정 없음)" 이 들어가 일반 면접과 동일. - **직무 맞춤(JOB_TAILORED) 질문 생성**: `mode=JOB_TAILORED` 면 payload 의 `targetCompanyName`· `targetJobDescription`(JD)이 채워진다. 프롬프트가 `target_role` 블록으로 받아 이력서↔JD 교집합·갭, JD 요구 충족 검증, 지원동기·컬처핏 질문을 우선 생성한다(다른 모드는 '(일반 면접)' 안내라 무시). diff --git a/ai/src/ai_server/chain/prompts/question_generation.py b/ai/src/ai_server/chain/prompts/question_generation.py index a36b7dd8..2370b159 100644 --- a/ai/src/ai_server/chain/prompts/question_generation.py +++ b/ai/src/ai_server/chain/prompts/question_generation.py @@ -51,6 +51,10 @@ "---\n" "{self_introduction}\n" "---\n\n" + "이번 면접의 집중 영역 (약점 집중 재도전일 때만 채워짐):\n" + "---\n" + "{focus_areas}\n" + "---\n\n" "타깃 회사/직무 (직무 맞춤 모드일 때만 채워짐):\n" "---\n" "{target_role}\n" @@ -84,7 +88,11 @@ " - expected_signal: 좋은 답변이 드러내야 할 핵심(평가 관점). 한 문장.\n" "5. 지원자 컨텍스트의 기술/프로젝트를 구체적으로 언급하는 PROJECT_DEEP_DIVE / " "TECH_CHOICE 질문을 우선합니다.\n" - "6. 아래는 같은 지원자가 최근 면접에서 이미 받은 질문들입니다. 이와 " + "6. **집중 영역이 지정돼 있으면** 그 영역을 검증하는 질문이 과반이 되도록 배치합니다. " + "다만 자료에 근거가 없는 질문을 지어내지는 마세요 — 근거가 부족하면 집중 영역 비중을 줄이고 " + "자료가 받쳐주는 질문으로 채웁니다. 집중 영역은 **무엇을 묻는가**를 바꾸는 것이지 " + "카테고리 규칙(4번의 target_evidence 필수 조건)을 면제하지 않습니다.\n" + "7. 아래는 같은 지원자가 최근 면접에서 이미 받은 질문들입니다. 이와 " "**의미상 중복되는 질문은 만들지 말고**, 새로운 주제나 다른 각도로 출제하세요:\n" "---\n" "{recent_questions}\n" diff --git a/ai/src/ai_server/chain/question_generation_chain.py b/ai/src/ai_server/chain/question_generation_chain.py index a4ab7934..bb088109 100644 --- a/ai/src/ai_server/chain/question_generation_chain.py +++ b/ai/src/ai_server/chain/question_generation_chain.py @@ -29,6 +29,36 @@ def _format_self_introduction(self_introduction: str | None) -> str: return text if text else "(자기소개 없음)" +# 평가 축 → 질문 설계 지침. 라벨만 넘기면 LLM 이 제각각 해석하므로 관점을 명시한다. +_FOCUS_GUIDE = { + "TECHNICAL": ( + "기술 정확도 — 개념을 정확히 아는지 파고드는 질문. 사용한 기술의 동작 원리·트레이드오프·" + "대안과의 비교를 묻고, 뭉뚱그린 답으로는 통과할 수 없게 구체적인 지점을 짚으세요." + ), + "LOGIC": ( + "논리력 — 결론에 이른 근거와 판단 과정을 드러내게 하는 질문. 왜 그 선택이었는지, 다른 안을 " + "왜 버렸는지, 가정이 틀렸다면 어떻게 되는지를 묻습니다." + ), + "COMMUNICATION": ( + "전달력 — 복잡한 내용을 상대 눈높이로 설명하게 하는 질문. 비전공자/신입에게 설명하기, " + "설계 의도를 짧게 요약하기처럼 구조적으로 말해야 풀리는 질문을 섞으세요." + ), +} + + +def _format_focus_areas(focus_areas: list[str] | None) -> str: + """약점 집중 블록. 지정이 없으면 일반 면접 안내.""" + if not focus_areas: + return "(지정 없음 — 특정 영역에 치우치지 말고 자료가 받쳐주는 대로 출제.)" + lines = [ + "지난 면접에서 아래 영역의 점수가 낮았습니다. 이번 면접은 그 영역을 다시 검증합니다:" + ] + for area in focus_areas: + guide = _FOCUS_GUIDE.get(area) + lines.append(f"- {area}: {guide}" if guide else f"- {area}") + return "\n".join(lines) + + def _format_target_role(company_name: str | None, job_description: str | None) -> str: """직무 맞춤 모드의 타깃 회사/JD 블록. 둘 다 없으면 일반 면접 안내.""" company = (company_name or "").strip() @@ -57,6 +87,7 @@ async def generate( self_introduction: str | None = None, target_company_name: str | None = None, target_job_description: str | None = None, + focus_areas: list[str] | None = None, ) -> GeneratedQuestionPool: ... @@ -75,6 +106,7 @@ async def generate( self_introduction: str | None = None, target_company_name: str | None = None, target_job_description: str | None = None, + focus_areas: list[str] | None = None, ) -> GeneratedQuestionPool: result = await self._chain.ainvoke( { @@ -87,6 +119,7 @@ async def generate( "target_role": _format_target_role( target_company_name, target_job_description ), + "focus_areas": _format_focus_areas(focus_areas), } ) if not isinstance(result, GeneratedQuestionPool): diff --git a/ai/src/ai_server/messaging/consumers/questions_consumer.py b/ai/src/ai_server/messaging/consumers/questions_consumer.py index 5ef16303..e9f973e9 100644 --- a/ai/src/ai_server/messaging/consumers/questions_consumer.py +++ b/ai/src/ai_server/messaging/consumers/questions_consumer.py @@ -124,6 +124,7 @@ async def _generate_pool_payload( self_introduction=req.self_intro_answer, target_company_name=req.target_company_name, target_job_description=req.target_job_description, + focus_areas=req.focus_areas, ) except Exception as exc: # noqa: BLE001 log.exception( diff --git a/ai/src/ai_server/model/messages/questions.py b/ai/src/ai_server/model/messages/questions.py index 442b0a06..8de1be05 100644 --- a/ai/src/ai_server/model/messages/questions.py +++ b/ai/src/ai_server/model/messages/questions.py @@ -45,6 +45,9 @@ class GenerateQuestionsRequest(BaseModel): # 직무 맞춤(JOB_TAILORED) 모드 전용. 지원 회사명 + 채용공고(JD) 원문. 다른 모드는 None/빈값. target_company_name: str | None = None target_job_description: str | None = None + # 약점 집중 재도전에서만 채워진다. TECHNICAL|LOGIC|COMMUNICATION 중 1~2개. + # 지난 면접에서 낮았던 평가 축이며, 그 영역을 검증하는 질문을 우선 배치한다. + focus_areas: list[str] = [] class GeneratedQuestion(BaseModel): diff --git a/ai/tests/test_question_focus_areas.py b/ai/tests/test_question_focus_areas.py new file mode 100644 index 00000000..971a7250 --- /dev/null +++ b/ai/tests/test_question_focus_areas.py @@ -0,0 +1,110 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ai_server.chain.question_generation_chain import ( + GeneratedQuestionPool, + LlmQuestionGenerator, + _format_focus_areas, +) +from ai_server.model.messages.questions import ( + GeneratedQuestion, + GenerateQuestionsRequest, +) + + +class TestFormatFocusAreas: + def test_no_focus_says_do_not_skew(self) -> None: + text = _format_focus_areas(None) + + assert "지정 없음" in text + # 지정이 없을 때 특정 영역으로 쏠리라는 지시가 새어 들어가면 안 된다. + assert "낮았습니다" not in text + + def test_empty_list_is_same_as_none(self) -> None: + assert _format_focus_areas([]) == _format_focus_areas(None) + + # 라벨만 넘기면 LLM 이 제각각 해석한다 — 축마다 질문 설계 관점을 붙여야 한다. + @pytest.mark.parametrize( + ("area", "expected"), + [ + ("TECHNICAL", "동작 원리"), + ("LOGIC", "근거"), + ("COMMUNICATION", "전달력"), + ], + ) + def test_known_area_carries_guidance(self, area: str, expected: str) -> None: + text = _format_focus_areas([area]) + + assert area in text + assert expected in text + + def test_multiple_areas_are_all_listed(self) -> None: + text = _format_focus_areas(["LOGIC", "COMMUNICATION"]) + + assert "LOGIC" in text + assert "COMMUNICATION" in text + + # Core 가 새 축을 추가해도 프롬프트가 깨지지 않아야 한다. + def test_unknown_area_degrades_gracefully(self) -> None: + text = _format_focus_areas(["SOMETHING_NEW"]) + + assert "SOMETHING_NEW" in text + + +class TestGeneratorPassesFocusAreas: + @pytest.mark.asyncio + async def test_focus_areas_reach_the_prompt(self) -> None: + chain = MagicMock() + chain.ainvoke = AsyncMock( + return_value=GeneratedQuestionPool( + questions=[ + GeneratedQuestion(category="CS_FUNDAMENTAL", question="q?"), + ] + ) + ) + generator = LlmQuestionGenerator(chain) + + await generator.generate( + job_categories=["BACKEND"], + mode="TECHNICAL", + max_questions=3, + context="ctx", + focus_areas=["LOGIC"], + ) + + variables = chain.ainvoke.await_args.args[0] + assert "LOGIC" in variables["focus_areas"] + + @pytest.mark.asyncio + async def test_omitting_focus_areas_still_fills_the_slot(self) -> None: + chain = MagicMock() + chain.ainvoke = AsyncMock( + return_value=GeneratedQuestionPool( + questions=[ + GeneratedQuestion(category="CS_FUNDAMENTAL", question="q?"), + ] + ) + ) + generator = LlmQuestionGenerator(chain) + + await generator.generate( + job_categories=["BACKEND"], mode="TECHNICAL", max_questions=3, context="ctx" + ) + + # 프롬프트 변수는 항상 채워져야 한다 — 비면 템플릿 렌더가 KeyError 로 깨진다. + assert "지정 없음" in chain.ainvoke.await_args.args[0]["focus_areas"] + + +class TestRequestModel: + def test_focus_areas_default_to_empty(self) -> None: + req = GenerateQuestionsRequest(sessionId=1, mode="TECHNICAL") + + assert req.focus_areas == [] + + def test_focus_areas_parse_from_camel_case(self) -> None: + req = GenerateQuestionsRequest( + sessionId=1, mode="TECHNICAL", focusAreas=["LOGIC", "COMMUNICATION"] + ) + + assert req.focus_areas == ["LOGIC", "COMMUNICATION"] From f2eae3e3eb4f9800942e4bb5aea40a00f17f8189 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 11:05:56 +0900 Subject: [PATCH 3/5] =?UTF-8?q?chore:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20API?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=20=EC=9E=AC=EC=83=9D=EC=84=B1=20(focusAre?= =?UTF-8?q?as=20=C2=B7=20retry=20=EC=98=B5=EC=85=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/shared/api/generated.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index 6ed68880..22300ce5 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -91,7 +91,7 @@ export interface paths { put?: never; /** * 같은 설정으로 다시 면접 (US-13 재도전) - * @description 기존 세션의 설정(모드·직군·질문 수·JD 등)을 그대로 복사해 새 세션을 만든다. 연결 자료는 지금도 살아있고 분석이 끝난 것만 다시 잇는다 — 그 사이 삭제된 자료 때문에 재도전 전체가 404 로 막히지 않게 하기 위함. 응답의 contextDocumentIds 를 원본과 비교하면 무엇이 빠졌는지 알 수 있다. + * @description 기존 세션의 설정(모드·직군·질문 수·JD 등)을 그대로 복사해 새 세션을 만든다. focusOnWeakness=true 면 원본 피드백에서 낮았던 평가 축을 집중 영역으로 새겨, 질문 생성이 그 영역을 검증하는 질문을 우선 배치한다. 연결 자료는 지금도 살아있고 분석이 끝난 것만 다시 잇는다 — 그 사이 삭제된 자료 때문에 재도전 전체가 404 로 막히지 않게 하기 위함. 응답의 contextDocumentIds 를 원본과 비교하면 무엇이 빠졌는지 알 수 있다. */ post: operations["retrySession"]; delete?: never; @@ -992,6 +992,7 @@ export interface components { contextDocumentIds?: number[]; targetCompanyName?: string; targetJobDescription?: string; + focusAreas?: string[]; /** Format: date-time */ createdAt?: string; /** Format: date-time */ @@ -1000,6 +1001,9 @@ export interface components { StreamTokenResponse: { streamToken?: string; }; + SessionRetryRequest: { + focusOnWeakness?: boolean; + }; MessageSubmitRequest: { content: string; }; @@ -1740,7 +1744,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["SessionRetryRequest"]; + }; + }; responses: { /** @description 새 세션 생성 완료 */ 201: { From 64e2dd3c325267a48513fd8d8db593288a7c5603 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 11:05:56 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(frontend):=20=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=EC=A7=91=EC=A4=91=20=EC=9E=AC=EB=8F=84=EC=A0=84=20CTA=20+=20?= =?UTF-8?q?=EC=A7=91=EC=A4=91=20=EC=98=81=EC=97=AD=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 피드백 화면: "약점 집중해서 다시" 를 primary 로. 점수가 나온 직후라 여기서는 그게 기본 제안이고, "같은 설정으로 다시" 는 secondary 로 내렸다 - 종료 패널: 완료 세션에만 약점 집중을 함께 제안한다. 약점은 피드백 점수가 근거라 피드백이 없는 중단 세션에는 뜻이 없다 - 면접 화면 헤더에 "논리력·전달력 집중" 표시 + 시작 시 토스트. 질문 성격이 왜 달라졌는지 보이지 않으면 사용자는 이유를 알 수 없다 - `focusAreaLabel` — 모르는 축은 원본을 그대로 쓴다(서버가 축을 늘려도 빈칸이 되지 않게) 테스트: `SessionEndedPanel.test.tsx` +2(완료 세션의 약점 집중 호출 인자, 중단 세션엔 미노출), `focusArea.test.ts` 신설(2). vitest 90/90 · eslint 0 · tsc · build 통과. --- .../src/features/interview/api/sessionApi.ts | 11 +++++++++-- .../features/interview/lib/focusArea.test.ts | 15 +++++++++++++++ .../src/features/interview/lib/focusArea.ts | 10 ++++++++++ .../features/interview/model/useRetrySession.ts | 11 ++++++++++- .../interview/ui/live/InterviewStage.tsx | 8 ++++++++ .../ui/live/SessionEndedPanel.test.tsx | 17 +++++++++++++++++ .../interview/ui/live/SessionEndedPanel.tsx | 9 +++++++++ .../SessionFeedback/ui/SessionFeedbackPage.tsx | 13 ++++++++++++- 8 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 frontend/src/features/interview/lib/focusArea.test.ts create mode 100644 frontend/src/features/interview/lib/focusArea.ts diff --git a/frontend/src/features/interview/api/sessionApi.ts b/frontend/src/features/interview/api/sessionApi.ts index f818c190..0d06686f 100644 --- a/frontend/src/features/interview/api/sessionApi.ts +++ b/frontend/src/features/interview/api/sessionApi.ts @@ -12,8 +12,15 @@ export async function getSession(id: number): Promise { } // 같은 설정으로 새 세션 생성. 삭제된 자료는 서버가 알아서 빼고 잇는다(응답의 contextDocumentIds). -export async function retrySession(id: number): Promise { - return (await apiClient.post(`/api/sessions/${id}/retry`)).data +export async function retrySession( + id: number, + options: { focusOnWeakness?: boolean } = {}, +): Promise { + return ( + await apiClient.post(`/api/sessions/${id}/retry`, { + focusOnWeakness: options.focusOnWeakness ?? false, + }) + ).data } export async function startSession(id: number): Promise { diff --git a/frontend/src/features/interview/lib/focusArea.test.ts b/frontend/src/features/interview/lib/focusArea.test.ts new file mode 100644 index 00000000..15dd3e10 --- /dev/null +++ b/frontend/src/features/interview/lib/focusArea.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest' +import { focusAreaLabel } from './focusArea' + +describe('focusAreaLabel', () => { + it('알려진 축은 한국어 표시명으로 바꾼다', () => { + expect(focusAreaLabel('TECHNICAL')).toBe('기술 정확도') + expect(focusAreaLabel('LOGIC')).toBe('논리력') + expect(focusAreaLabel('COMMUNICATION')).toBe('전달력') + }) + + // 서버가 축을 늘려도 화면이 빈칸이 되지 않아야 한다. + it('모르는 축은 원본을 그대로 쓴다', () => { + expect(focusAreaLabel('SOMETHING_NEW')).toBe('SOMETHING_NEW') + }) +}) diff --git a/frontend/src/features/interview/lib/focusArea.ts b/frontend/src/features/interview/lib/focusArea.ts new file mode 100644 index 00000000..5d2515de --- /dev/null +++ b/frontend/src/features/interview/lib/focusArea.ts @@ -0,0 +1,10 @@ +// 세션의 집중 영역(SessionFocusArea) 표시명. 서버가 축을 늘려도 화면이 깨지지 않게 원본을 폴백한다. +const LABEL: Record = { + TECHNICAL: '기술 정확도', + LOGIC: '논리력', + COMMUNICATION: '전달력', +} + +export function focusAreaLabel(area: string): string { + return LABEL[area] ?? area +} diff --git a/frontend/src/features/interview/model/useRetrySession.ts b/frontend/src/features/interview/model/useRetrySession.ts index dde8c9b7..153cb73b 100644 --- a/frontend/src/features/interview/model/useRetrySession.ts +++ b/frontend/src/features/interview/model/useRetrySession.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { toast } from '@/shared/ui' import { retrySession } from '../api/sessionApi' +import { focusAreaLabel } from '../lib/focusArea' import { sessionKeys } from './useSession' /** @@ -15,7 +16,10 @@ export function useRetrySession(sourceContextCount?: number) { const queryClient = useQueryClient() return useMutation({ - mutationFn: retrySession, + mutationFn: (input: number | { sessionId: number; focusOnWeakness?: boolean }) => + typeof input === 'number' + ? retrySession(input) + : retrySession(input.sessionId, { focusOnWeakness: input.focusOnWeakness }), onSuccess: (session) => { void queryClient.invalidateQueries({ queryKey: sessionKeys.all }) const linked = session.contextDocumentIds?.length ?? 0 @@ -24,6 +28,11 @@ export function useRetrySession(sourceContextCount?: number) { `삭제된 자료 ${sourceContextCount - linked}개는 제외하고 시작합니다.`, ) } + // 집중 영역이 잡혔으면 무엇을 겨냥하는지 알려준다 — 모르면 질문이 달라진 이유를 알 수 없다. + const focus = session.focusAreas ?? [] + if (focus.length > 0) { + toast.info(`${focus.map(focusAreaLabel).join('·')} 중심으로 질문합니다.`) + } navigate(`/sessions/${session.id}`) }, onError: () => { diff --git a/frontend/src/features/interview/ui/live/InterviewStage.tsx b/frontend/src/features/interview/ui/live/InterviewStage.tsx index cdd1fefe..85d3f994 100644 --- a/frontend/src/features/interview/ui/live/InterviewStage.tsx +++ b/frontend/src/features/interview/ui/live/InterviewStage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { StatusBadge } from '@/shared/ui/StatusBadge' +import { focusAreaLabel } from '../../lib/focusArea' import { Button } from '@/shared/ui/Button' import { ConfirmDialog } from '@/shared/ui/ConfirmDialog' import { isQuestion, isTranscribing, sessionProgress } from '@/domain/session' @@ -80,6 +81,7 @@ export function InterviewStage({ const [transcriptOpen, setTranscriptOpen] = useState(false) const [endConfirmOpen, setEndConfirmOpen] = useState(false) const progress = sessionProgress(session) + const focusAreas = session.focusAreas ?? [] const currentQuestion = [...items].reverse().find(isQuestion) const lastItem = items[items.length - 1] const transcribing = Boolean(lastItem && isTranscribing(lastItem)) @@ -114,6 +116,12 @@ export function InterviewStage({

질문 {progress.current} / {progress.max} + {/* 약점 집중 면접이면 무엇을 겨냥하는지 보여준다 — 질문 성격이 달라진 이유다. */} + {focusAreas.length > 0 && ( + + {focusAreas.map(focusAreaLabel).join('·')} 집중 + + )}

diff --git a/frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx b/frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx index ee7d01df..d3d621d5 100644 --- a/frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx +++ b/frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx @@ -63,6 +63,23 @@ describe('SessionEndedPanel', () => { expect(retryMutate).toHaveBeenCalledWith(7) }) + it('완료 세션은 약점 집중 재도전도 제안한다', async () => { + renderPanel('COMPLETED') + + await userEvent.click(screen.getByRole('button', { name: '약점 집중해서 다시' })) + + expect(retryMutate).toHaveBeenCalledWith({ sessionId: 7, focusOnWeakness: true }) + }) + + // 약점은 피드백 점수가 근거다 — 피드백이 없는 중단 세션에는 제안하지 않는다. + it('중단 세션에는 약점 집중을 제안하지 않는다', () => { + renderPanel('INTERRUPTED') + + expect( + screen.queryByRole('button', { name: '약점 집중해서 다시' }), + ).not.toBeInTheDocument() + }) + // 자료 수를 넘겨야 "삭제된 자료 N개 제외" 안내가 가능하다. it('원본 세션의 자료 수를 재도전 훅에 넘긴다', () => { render( diff --git a/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx b/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx index 8025df9c..fa10b51c 100644 --- a/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx +++ b/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx @@ -52,6 +52,15 @@ export function SessionEndedPanel({ > 같은 설정으로 다시 + {/* 약점 집중은 피드백 점수가 근거라 완료 세션에서만 뜻이 있다. */} + {status === 'COMPLETED' && ( + + )} diff --git a/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx b/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx index ca6bbd60..9dc77383 100644 --- a/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx +++ b/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx @@ -30,7 +30,18 @@ export default function SessionFeedbackPage() { description="점수 옆에 그렇게 매긴 근거가 함께 붙습니다." actions={
- + From d396532423ecad52149c96a64c28c45ef83893a9 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 11:05:56 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=EC=95=BD=EC=A0=90=20=EC=A7=91?= =?UTF-8?q?=EC=A4=91=20=EC=9E=AC=EB=8F=84=EC=A0=84=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `docs/database.md` — interview_sessions.focus_areas 반영 - `docs/messaging.md` — generate.questions 페이로드에 focusAreas 추가 - `backend/CLAUDE.md` — 약점 선정 규칙(임계값·최저 1개 폴백)과 @Value 초기값 함정 기록 - `ai/CLAUDE.md` — 축별 질문 설계 관점(_FOCUS_GUIDE)과 근거 필수 조건 유지 명시 --- docs/database.md | 1 + docs/messaging.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/database.md b/docs/database.md index 5ed0e431..ed161663 100644 --- a/docs/database.md +++ b/docs/database.md @@ -169,6 +169,7 @@ CREATE TABLE interview_sessions ( -- 직무 맞춤(JOB_TAILORED) 모드 전용. 지원 회사명 + 채용공고(JD) 원문. 다른 모드는 NULL. (V18) target_company_name VARCHAR(200), target_job_description TEXT, + focus_areas JSONB, -- 약점 집중 재도전의 겨냥 축 배열(TECHNICAL|LOGIC|COMMUNICATION). 일반 면접은 NULL status VARCHAR(20) NOT NULL DEFAULT 'READY' CHECK (status IN ('READY','IN_PROGRESS','INTERRUPTED','COMPLETED','CANCELLED')), total_question_count INT DEFAULT 0, diff --git a/docs/messaging.md b/docs/messaging.md index a0960536..4c39448a 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -239,7 +239,8 @@ "recentQuestions": ["이전 면접 질문 텍스트", "..."], "selfIntroAnswer": "안녕하세요, 결제 시스템을 만든 백엔드 3년차입니다…", "targetCompanyName": "토스", - "targetJobDescription": "Kotlin/Spring 백엔드, 대용량 결제 시스템 경험 우대 …" + "targetJobDescription": "Kotlin/Spring 백엔드, 대용량 결제 시스템 경험 우대 …", + "focusAreas": ["LOGIC", "COMMUNICATION"] }, "context": { "userId": 123, "sessionId": 99 } }