diff --git a/back/src/main/java/com/back/BackApplication.java b/back/src/main/java/com/back/BackApplication.java index 1ac181e..aa30a36 100644 --- a/back/src/main/java/com/back/BackApplication.java +++ b/back/src/main/java/com/back/BackApplication.java @@ -2,12 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @EnableJpaAuditing @SpringBootApplication @EnableCaching +@ConfigurationPropertiesScan public class BackApplication { public static void main(String[] args) { diff --git a/back/src/main/java/com/back/domain/node/dto/decision/DecNodeDto.java b/back/src/main/java/com/back/domain/node/dto/decision/DecNodeDto.java index 7a58c43..cebb81c 100644 --- a/back/src/main/java/com/back/domain/node/dto/decision/DecNodeDto.java +++ b/back/src/main/java/com/back/domain/node/dto/decision/DecNodeDto.java @@ -22,5 +22,7 @@ public record DecNodeDto( List options, Integer selectedIndex, Integer parentOptionIndex, - String description + String description, + String aiNextSituation, + String aiNextRecommendedOption ) {} diff --git a/back/src/main/java/com/back/domain/node/entity/BaseLine.java b/back/src/main/java/com/back/domain/node/entity/BaseLine.java index 3ef3d8b..a3e933d 100644 --- a/back/src/main/java/com/back/domain/node/entity/BaseLine.java +++ b/back/src/main/java/com/back/domain/node/entity/BaseLine.java @@ -30,14 +30,12 @@ public class BaseLine extends BaseEntity { @Column(length = 100, nullable = false) private String title; - // BaseLine ←→ BaseNode 양방향 매핑 (BaseNode 쪽에 @ManyToOne BaseLine baseLine 있어야 함) + // BaseLine <-> BaseNode 양방향 매핑 (BaseNode 쪽에 @ManyToOne BaseLine baseLine 있어야 함) @OneToMany(mappedBy = "baseLine", cascade = CascadeType.ALL, orphanRemoval = false) @Builder.Default private List baseNodes = new ArrayList<>(); - /** - * 중간 피벗 나이 목록 반환(헤더/꼬리 제외, 중복 제거, 오름차순) - */ + //중간 피벗 나이 목록 반환(헤더/꼬리 제외, 중복 제거, 오름차순) public List pivotAges() { List nodes = this.baseNodes; if (nodes == null || nodes.size() <= 2) { diff --git a/back/src/main/java/com/back/domain/node/mapper/NodeMappers.java b/back/src/main/java/com/back/domain/node/mapper/NodeMappers.java index 92c7ca5..f7cfd88 100644 --- a/back/src/main/java/com/back/domain/node/mapper/NodeMappers.java +++ b/back/src/main/java/com/back/domain/node/mapper/NodeMappers.java @@ -80,7 +80,9 @@ private NodeMappers() {} opts.isEmpty() ? null : List.copyOf(opts), e.getSelectedIndex(), e.getParentOptionIndex(), - e.getDescription() + e.getDescription(), + null, + null ); }; diff --git a/back/src/main/java/com/back/domain/node/service/DecisionFlowService.java b/back/src/main/java/com/back/domain/node/service/DecisionFlowService.java index 2109de8..cfe006a 100644 --- a/back/src/main/java/com/back/domain/node/service/DecisionFlowService.java +++ b/back/src/main/java/com/back/domain/node/service/DecisionFlowService.java @@ -15,6 +15,7 @@ import com.back.domain.node.repository.BaseNodeRepository; import com.back.domain.node.repository.DecisionLineRepository; import com.back.domain.node.repository.DecisionNodeRepository; +import com.back.global.ai.vector.AIVectorService; import com.back.global.exception.ApiException; import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -30,6 +31,7 @@ class DecisionFlowService { private final DecisionNodeRepository decisionNodeRepository; private final BaseNodeRepository baseNodeRepository; private final NodeDomainSupport support; + private final AIVectorService aiVectorService; // 가장 중요한: from-base 서버 해석(옵션 1~2개 허용; 단일 옵션은 선택 슬롯에만 반영) public DecNodeDto createDecisionNodeFromBase(DecisionNodeFromBaseRequest request) { @@ -104,7 +106,32 @@ public DecNodeDto createDecisionNodeFromBase(DecisionNodeFromBaseRequest request if (updated == 0) { throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "branch slot was taken by another request"); } - return mapper.toResponse(saved); + DecNodeDto baseDto = mapper.toResponse(saved); + + // 여기서 AI 힌트 주입 (응답 직전 동기) + List orderedList = decisionNodeRepository + .findByDecisionLine_IdOrderByAgeYearAscIdAsc(baseDto.decisionLineId()); + var hint = aiVectorService.generateNextHint(baseDto.userId(), baseDto.decisionLineId(), orderedList); + + return new DecNodeDto( + baseDto.id(), + baseDto.userId(), + baseDto.type(), + baseDto.category(), + baseDto.situation(), + baseDto.decision(), + baseDto.ageYear(), + baseDto.decisionLineId(), + baseDto.parentId(), + baseDto.baseNodeId(), + baseDto.background(), + baseDto.options(), + baseDto.selectedIndex(), + baseDto.parentOptionIndex(), + baseDto.description(), + hint.aiNextSituation(), + hint.aiNextRecommendedOption() + ); } // 가장 중요한: next 서버 해석(부모 기준 라인/다음 피벗/베이스 매칭 결정) @@ -156,7 +183,32 @@ public DecNodeDto createDecisionNodeNext(DecisionNodeNextRequest request) { ); DecisionNode saved = decisionNodeRepository.save(mapper.toEntity(createReq)); - return mapper.toResponse(saved); + DecNodeDto baseDto = mapper.toResponse(saved); + + // AI 힌트 주입 (응답 직전 동기) + List ordered_decision = decisionNodeRepository + .findByDecisionLine_IdOrderByAgeYearAscIdAsc(baseDto.decisionLineId()); + var hint = aiVectorService.generateNextHint(baseDto.userId(), baseDto.decisionLineId(), ordered_decision); + + return new DecNodeDto( + baseDto.id(), + baseDto.userId(), + baseDto.type(), + baseDto.category(), + baseDto.situation(), + baseDto.decision(), + baseDto.ageYear(), + baseDto.decisionLineId(), + baseDto.parentId(), + baseDto.baseNodeId(), + baseDto.background(), + baseDto.options(), + baseDto.selectedIndex(), + baseDto.parentOptionIndex(), + baseDto.description(), + hint.aiNextSituation(), + hint.aiNextRecommendedOption() + ); } // 라인 취소 diff --git a/back/src/main/java/com/back/global/ai/config/SituationAiProperties.java b/back/src/main/java/com/back/global/ai/config/SituationAiProperties.java new file mode 100644 index 0000000..807b60c --- /dev/null +++ b/back/src/main/java/com/back/global/ai/config/SituationAiProperties.java @@ -0,0 +1,22 @@ +/** + * [요약] 상황 힌트용 AI 파라미터 바인딩 전용 클래스 (컴포넌트 금지) + * - application.yml 의 ai.situation.* 값을 바인딩한다. + */ +package com.back.global.ai.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "ai.situation") +public class SituationAiProperties { + private int topK = 5; + private int contextCharLimit = 1000; + private int maxOutputTokens = 384; + + // getters/setters + public int getTopK() { return topK; } + public void setTopK(int topK) { this.topK = topK; } + public int getContextCharLimit() { return contextCharLimit; } + public void setContextCharLimit(int contextCharLimit) { this.contextCharLimit = contextCharLimit; } + public int getMaxOutputTokens() { return maxOutputTokens; } + public void setMaxOutputTokens(int maxOutputTokens) { this.maxOutputTokens = maxOutputTokens; } +} diff --git a/back/src/main/java/com/back/global/ai/vector/AIVectorService.java b/back/src/main/java/com/back/global/ai/vector/AIVectorService.java new file mode 100644 index 0000000..aca6f84 --- /dev/null +++ b/back/src/main/java/com/back/global/ai/vector/AIVectorService.java @@ -0,0 +1,20 @@ +/* + * [파일 요약] + * - 인메모리 유사-벡터 검색과 초경량 프롬프트 생성을 통해 + * "다음 입력용" AI 힌트(상황 한 문장 + 추천 1개)를 동기 반환한다. + * - 컨트롤러/엔티티는 건드리지 않고, 서비스 레이어에서만 호출해 DTO 에 주입한다. + */ +package com.back.global.ai.vector; + +import com.back.domain.node.entity.DecisionNode; +import java.util.List; + +public interface AIVectorService { + + + // 다음 입력을 돕는 AI 힌트(상황/추천)를 생성하여 반환 + AiNextHint generateNextHint(Long userId, Long decisionLineId, List orderedNodes); + + // 응답 모델: 상황 1문장 + 추천 선택지 1개(15자 이내), 둘 다 null 가능 + record AiNextHint(String aiNextSituation, String aiNextRecommendedOption) {} +} diff --git a/back/src/main/java/com/back/global/ai/vector/AIVectorServiceImpl.java b/back/src/main/java/com/back/global/ai/vector/AIVectorServiceImpl.java new file mode 100644 index 0000000..da0db92 --- /dev/null +++ b/back/src/main/java/com/back/global/ai/vector/AIVectorServiceImpl.java @@ -0,0 +1,94 @@ +/* + * [파일 요약] + * - 인메모리 검색 결과(얇은 콘텍스트) + 이전 경로 요약으로 초경량 프롬프트를 만들고 + * TextAiClient 를 동기 호출하여 JSON 2필드(situation, recommendedOption)만 추출한다. + * - 토큰/콘텍스트 길이는 프로퍼티로 제어 가능. + */ +package com.back.global.ai.vector; + +import com.back.domain.node.entity.DecisionNode; +import com.back.global.ai.client.text.TextAiClient; +import com.back.global.ai.config.SituationAiProperties; +import com.back.global.ai.dto.AiRequest; +import com.back.global.ai.prompt.SituationPrompt; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +@Service +@Profile("!test") +@RequiredArgsConstructor +public class AIVectorServiceImpl implements AIVectorService { + + private final TextAiClient textAiClient; + private final AIVectorServiceSupportDomain support; + private final SituationAiProperties props; + + // 프로퍼티 바인딩 필드 + private int topK = 5; + private int contextCharLimit = 1000; + private int maxOutputTokens = 384; + + public void setTopK(int topK) { this.topK = topK; } + public void setContextCharLimit(int contextCharLimit) { this.contextCharLimit = contextCharLimit; } + public void setMaxOutputTokens(int maxOutputTokens) { this.maxOutputTokens = maxOutputTokens; } + + /** 한 줄 요약: 경로 요약 + 얇은 콘텍스트로 프롬프트 최소화 후 AI 힌트 생성 */ + @Override + public AiNextHint generateNextHint(Long userId, Long decisionLineId, List orderedNodes) { + if (orderedNodes == null || orderedNodes.isEmpty()) { + return new AiNextHint(null, null); + } + + // 1) 질의/콘텍스트 준비 + String query = support.buildQueryFromNodes(orderedNodes); + List ctxSnippets = support.searchRelatedContexts(query, topK, Math.max(120, contextCharLimit / Math.max(1, topK))); + String relatedContext = support.joinWithLimit(ctxSnippets, contextCharLimit); + + // 2) 초경량 RAG 프롬프트 생성 + String prompt = buildRagPrompt(query, relatedContext); + + // 3) 동기 호출 (CompletableFuture.join 사용) — 응답 즉시 필요 + AiRequest req = new AiRequest(prompt, Map.of(), Math.max(128, maxOutputTokens)); + String response = textAiClient.generateText(req).join(); + + // 4) JSON 2필드만 추출 + String situation = SituationPrompt.extractSituation(response); + String option = SituationPrompt.extractRecommendedOption(response); + + return new AiNextHint(emptyToNull(situation), emptyToNull(option)); + } + + private String buildRagPrompt(String previousSummary, String relatedContext) { + String ctx = (relatedContext == null || relatedContext.isBlank()) ? "(관련 콘텍스트 없음)" : relatedContext; + return """ + 당신은 인생 시뮬레이션 도우미입니다. + 아래의 '이전 선택 요약'과 '관련 콘텍스트'를 참고하여, + **동일 연도 시점**에서 자연스러운 새로운 상황을 **한 문장**으로 생성하세요. + + ## 이전 선택 요약 + %s + + ## 관련 콘텍스트(발췌) + %s + + ### 제약 + - 반드시 현재 베이스 상황과 동일한 연/시점 + - 과장/모호 금지, 구체적이고 현실적인 한 문장 + - 선택 분기가 필요 + + ### 응답(JSON) + { + "situation": "한 문장", + "recommendedOption": "15자 이내 선택지" + } + """.formatted(previousSummary, ctx); + } + + private String emptyToNull(String s) { + return (s == null || s.isBlank()) ? null : s.trim(); + } +} diff --git a/back/src/main/java/com/back/global/ai/vector/AIVectorServiceStub.java b/back/src/main/java/com/back/global/ai/vector/AIVectorServiceStub.java new file mode 100644 index 0000000..41c2a0b --- /dev/null +++ b/back/src/main/java/com/back/global/ai/vector/AIVectorServiceStub.java @@ -0,0 +1,23 @@ +/** + * [요약] 테스트 프로파일에서만 활성화되는 AI 스텁 서비스(외부 호출 없음) + * - generateNextHint: 고정 문자열/또는 null 반환 + */ +package com.back.global.ai.vector; + +import com.back.domain.node.entity.DecisionNode; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@Profile("test") +public class AIVectorServiceStub implements AIVectorService { + + // 다음 입력 힌트(상수 또는 null) 반환 + @Override + public AiNextHint generateNextHint(Long userId, Long decisionLineId, List orderedNodes) { + + return new AiNextHint("테스트-상황(한 문장)", "테스트-추천"); + } +} diff --git a/back/src/main/java/com/back/global/ai/vector/AIVectorServiceSupportDomain.java b/back/src/main/java/com/back/global/ai/vector/AIVectorServiceSupportDomain.java new file mode 100644 index 0000000..50f09f7 --- /dev/null +++ b/back/src/main/java/com/back/global/ai/vector/AIVectorServiceSupportDomain.java @@ -0,0 +1,48 @@ +/** + * [요약] 경로 요약/인메모리 검색 보조 유틸 + */ +package com.back.global.ai.vector; + +import com.back.domain.node.entity.DecisionNode; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.stream.Collectors; + +@Component +public class AIVectorServiceSupportDomain { + + // 이전 결정 경로를 요약 쿼리 문자열로 만든다 + public String buildQueryFromNodes(List nodes) { + return nodes.stream() + .map(n -> String.format("- (%d세) %s → %s", + n.getAgeYear(), + safe(n.getSituation()), + safe(n.getDecision()))) + .collect(Collectors.joining("\n")); + } + + // 간단한 인메모리 유사 검색으로 상위 K 콘텍스트를 수집한다 (스텁) + public List searchRelatedContexts(String query, int topK, int eachSnippetLimit) { + return Collections.emptyList(); // 추후 RAM 인덱스 교체 지점 + } + + // 여러 스니펫을 합치되 총 글자수 제한을 적용한다 + public String joinWithLimit(List snippets, int totalCharLimit) { + StringBuilder sb = new StringBuilder(); + for (String s : snippets) { + if (s == null || s.isBlank()) continue; + if (sb.length() + s.length() + 1 > totalCharLimit) break; + if (!sb.isEmpty()) sb.append("\n"); + sb.append(trim(s, Math.min(s.length(), Math.max(50, totalCharLimit / 5)))); + } + return sb.toString(); + } + + private String safe(String s) { return s == null ? "" : s.trim(); } + private String trim(String s, int limit) { + if (s == null) return ""; + if (s.length() <= limit) return s; + return s.substring(0, Math.max(0, limit - 3)) + "..."; + } +} diff --git a/back/src/main/resources/application.yml b/back/src/main/resources/application.yml index afe0600..7b77819 100644 --- a/back/src/main/resources/application.yml +++ b/back/src/main/resources/application.yml @@ -85,6 +85,11 @@ ai: timeout-seconds: 60 max-retries: 3 # TODO: 추후 이미지 AI 설정 추가 예정 + situation: + topK: 5 + contextCharLimit: 1000 + maxOutputTokens: 384 + server: servlet: diff --git a/back/src/test/java/com/back/domain/node/controller/AiCallBudget.java b/back/src/test/java/com/back/domain/node/controller/AiCallBudget.java new file mode 100644 index 0000000..deeb4e9 --- /dev/null +++ b/back/src/test/java/com/back/domain/node/controller/AiCallBudget.java @@ -0,0 +1,12 @@ +// [TEST-ONLY] AI 실호출 예산 관리 +package com.back.domain.node.controller; + +import java.util.concurrent.atomic.AtomicInteger; + +public class AiCallBudget { + private final AtomicInteger budget = new AtomicInteger(0); + // 한줄 요약: 남은 실호출 횟수를 설정한다 + public void reset(int n) { budget.set(Math.max(0, n)); } + // 한줄 요약: 남은 실호출이 있으면 1 소진하고 true + public boolean consume() { return budget.getAndUpdate(x -> x > 0 ? x - 1 : 0) > 0; } +} diff --git a/back/src/test/java/com/back/domain/node/controller/AiOnceDelegateTestConfig.java b/back/src/test/java/com/back/domain/node/controller/AiOnceDelegateTestConfig.java new file mode 100644 index 0000000..c97f4d7 --- /dev/null +++ b/back/src/test/java/com/back/domain/node/controller/AiOnceDelegateTestConfig.java @@ -0,0 +1,44 @@ +/** + * [TEST-ONLY] AI 1회 실호출 래퍼 + * - 첫 호출만 실제 구현으로 위임하고, 이후 호출은 스텁 결과를 반환한다. + */ +package com.back.domain.node.controller; + +import com.back.global.ai.client.text.TextAiClient; +import com.back.global.ai.config.SituationAiProperties; +import com.back.global.ai.vector.AIVectorService; +import com.back.global.ai.vector.AIVectorServiceImpl; +import com.back.global.ai.vector.AIVectorServiceSupportDomain; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; + +@TestConfiguration +@Profile("test") +public class AiOnceDelegateTestConfig { + + // 한줄 요약: 테스트가 실호출 횟수를 동적으로 제어할 수 있게 예산 빈을 제공한다 + @Bean + public AiCallBudget aiCallBudget() { + return new AiCallBudget(); + } + + + + // 한줄 요약: 예산>0 이면 실제 구현, 아니면 스텁 값을 반환한다 + @Bean + @Primary + public AIVectorService aiOnceDelegate( + TextAiClient textAiClient, + AIVectorServiceSupportDomain support, + SituationAiProperties props, + AiCallBudget budget + ) { + AIVectorService real = new AIVectorServiceImpl(textAiClient, support, props); + AIVectorService stub = (u, d, nodes) -> new AIVectorService.AiNextHint("테스트-상황(한 문장)", "테스트-추천"); + return (userId, lineId, orderedNodes) -> + budget.consume() ? real.generateNextHint(userId, lineId, orderedNodes) + : stub.generateNextHint(userId, lineId, orderedNodes); + } +} diff --git a/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java b/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java index f2bd984..796188b 100644 --- a/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java +++ b/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java @@ -17,7 +17,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.jdbc.Sql; import org.springframework.test.context.jdbc.SqlConfig; import org.springframework.test.web.servlet.MockMvc; @@ -34,6 +36,8 @@ @SpringBootTest @AutoConfigureMockMvc(addFilters = false) @TestInstance(TestInstance.Lifecycle.PER_CLASS) +@ActiveProfiles("test") +@Import(AiOnceDelegateTestConfig.class) @DisplayName("Re:Life — DecisionFlowController from-base · next · cancel · complete 통합 테스트") @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED) @Sql( @@ -59,6 +63,7 @@ public class DecisionFlowControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper om; @Autowired private UserRepository userRepository; + @Autowired AiCallBudget aiCallBudget; private Long userId; @@ -89,6 +94,7 @@ class FromBase { @Test @DisplayName("성공 : 유효한 피벗에서 options[2]와 selectedAltIndex로 첫 결정을 생성하면 201과 DecLineDto를 반환한다") void success_createFromBase() throws Exception { + aiCallBudget.reset(0); var baseInfo = createBaseLineAndPickFirstPivot(userId); String req = """ @@ -112,6 +118,8 @@ void success_createFromBase() throws Exception { .andExpect(jsonPath("$.decisionLineId").exists()) .andExpect(jsonPath("$.baseNodeId").isNumber()) .andExpect(jsonPath("$.decision").value("휴학")) + .andExpect(jsonPath("$.aiNextSituation").isNotEmpty()) + .andExpect(jsonPath("$.aiNextRecommendedOption").isNotEmpty()) .andReturn(); JsonNode body = om.readTree(res.getResponse().getContentAsString()); @@ -121,6 +129,7 @@ void success_createFromBase() throws Exception { @Test @DisplayName("실패 : 존재하지 않는 베이스라인으로 from-base 요청 시 404/N002를 반환한다") void fail_baseLineNotFound_onFromBase() throws Exception { + aiCallBudget.reset(0); var baseInfo = createBaseLineAndPickFirstPivot(userId); String req = """ { @@ -146,6 +155,7 @@ void fail_baseLineNotFound_onFromBase() throws Exception { @Test @DisplayName("실패 : 잘못된 pivotAge 또는 pivotOrd(범위 밖)면 400/C001을 반환한다") void fail_invalidPivot_onFromBase() throws Exception { + aiCallBudget.reset(0); var baseInfo = createBaseLineAndPickFirstPivot(userId); String bad = """ { @@ -170,6 +180,7 @@ void fail_invalidPivot_onFromBase() throws Exception { @Test @DisplayName("실패 : 동일 분기 슬롯을 두 번 링크하려 하면 400/C001(이미 링크됨)을 반환한다") void fail_branchSlotAlreadyLinked() throws Exception { + aiCallBudget.reset(0); var baseInfo = createBaseLineAndPickFirstPivot(userId); String first = """ @@ -209,6 +220,7 @@ class NextDecision { @Test @DisplayName("성공 : 부모에서 다음 피벗 나이로 생성하면 201과 DecLineDto(부모 id/다음 나이)를 반환한다") void success_createNextDecision() throws Exception { + aiCallBudget.reset(0); var head = startDecisionFromBase(userId); long parentId = head.decisionNodeId; @@ -228,6 +240,8 @@ void success_createNextDecision() throws Exception { .content(nextReq)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.parentId").value(parentId)) + .andExpect(jsonPath("$.aiNextSituation").value("테스트-상황(한 문장)")) + .andExpect(jsonPath("$.aiNextRecommendedOption").value("테스트-추천")) .andReturn(); JsonNode body = om.readTree(res.getResponse().getContentAsString()); @@ -238,6 +252,7 @@ void success_createNextDecision() throws Exception { @Test @DisplayName("실패 : 존재하지 않는 부모 결정 노드로 next 요청 시 404/N001을 반환한다") void fail_parentDecisionNotFound() throws Exception { + aiCallBudget.reset(0); String nextReq = """ { "userId": %d, @@ -260,6 +275,7 @@ void fail_parentDecisionNotFound() throws Exception { @Test @DisplayName("실패 : 동일 나이로 재생성 시도(부모 ageYear와 같음)면 400/C001을 반환한다") void fail_duplicateAgeOnLine() throws Exception { + aiCallBudget.reset(0); var head = startDecisionFromBase(userId); String nextReq = """ @@ -282,6 +298,7 @@ void fail_duplicateAgeOnLine() throws Exception { @Test @DisplayName("실패 : 부모 결정 나이보다 작은 나이로 next 요청 시 400/C001을 반환한다") void fail_nextAgeLessThanParent() throws Exception { + aiCallBudget.reset(0); var head = startDecisionFromBase(userId); int invalidAge = head.ageYear - 1; @@ -313,6 +330,7 @@ class Lifecycle { @Test @DisplayName("성공 : 취소 요청 시 라인 상태가 CANCELLED로 바뀐다") void success_cancel() throws Exception { + aiCallBudget.reset(0); var head = startDecisionFromBase(userId); mockMvc.perform(post("/api/v1/decision-flow/{decisionLineId}/cancel", head.decisionLineId)) .andExpect(status().isOk()) @@ -323,6 +341,7 @@ void success_cancel() throws Exception { @Test @DisplayName("성공 : 완료 요청 시 라인 상태가 COMPLETED로 바뀐다") void success_complete() throws Exception { + aiCallBudget.reset(0); var head = startDecisionFromBase(userId); mockMvc.perform(post("/api/v1/decision-flow/{decisionLineId}/complete", head.decisionLineId)) .andExpect(status().isOk()) @@ -333,6 +352,7 @@ void success_complete() throws Exception { @Test @DisplayName("실패 : 존재하지 않는 decisionLineId 취소/완료 시 404/N003를 반환한다") void fail_lineNotFound_onLifecycle() throws Exception { + aiCallBudget.reset(0); mockMvc.perform(post("/api/v1/decision-flow/{decisionLineId}/cancel", 9999999L)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("N003")); @@ -344,6 +364,7 @@ void fail_lineNotFound_onLifecycle() throws Exception { @Test @DisplayName("실패 : 완료/취소된 라인에서 next 시도 시 400/C001(line is locked)을 반환한다") void fail_nextAfterLocked() throws Exception { + aiCallBudget.reset(0); var head = startDecisionFromBase(userId); mockMvc.perform(post("/api/v1/decision-flow/{decisionLineId}/complete", head.decisionLineId)) .andExpect(status().isOk()); @@ -396,6 +417,7 @@ class ForkBranch { @Test @DisplayName("성공 : 헤드 결정에서 다른 선택지로 fork 하면 새 decisionLineId와 교체된 decision을 반환한다") void success_forkFromHead_changesSelection_createsNewLine() throws Exception { + aiCallBudget.reset(0); var head = startDecisionFromBase(userId); // 기존 헬퍼: options ["선택 A","선택 B"], selectedIndex=0 String req = """ @@ -432,6 +454,7 @@ void success_forkFromHead_changesSelection_createsNewLine() throws Exception { @Test @DisplayName("성공 : 같은 노드에서 여러 번 fork 하면 각기 다른 decisionLineId가 발급된다(무한 세계선)") void success_multipleForksFromSameNode() throws Exception { + aiCallBudget.reset(0); var head = startDecisionFromBase(userId); String fork0 = """ @@ -463,6 +486,7 @@ void success_multipleForksFromSameNode() throws Exception { @Test @DisplayName("성공 : fork로 생성된 새 라인을 /next로 계속 이어갈 수 있다") void success_forkThenContinueWithNext() throws Exception { + aiCallBudget.reset(0); var head = startDecisionFromBase(userId); String fork = """ @@ -503,6 +527,7 @@ void success_forkThenContinueWithNext() throws Exception { @Test @DisplayName("실패 : targetOptionIndex가 옵션 수를 초과하면 400/C001을 반환한다") void fail_targetOptionIndexOutOfRange() throws Exception { + aiCallBudget.reset(0); var head = startDecisionFromBase(userId); // 헤드는 옵션 2개 String bad = """ @@ -519,6 +544,7 @@ void fail_targetOptionIndexOutOfRange() throws Exception { @Test @DisplayName("실패 : 옵션이 없는 결정 노드에서 fork 시도 시 400/C001을 반환한다") void fail_forkOnNodeWithoutOptions() throws Exception { + aiCallBudget.reset(0); // 헤드 생성(옵션 2개) 후, 옵션 없이 다음 노드 생성 var head = startDecisionFromBase(userId); @@ -554,6 +580,7 @@ void fail_forkOnNodeWithoutOptions() throws Exception { @Test @DisplayName("실패 : 존재하지 않는 결정 노드로 fork 시 404/N001을 반환한다") void fail_parentDecisionNotFound_onFork() throws Exception { + aiCallBudget.reset(0); String req = """ {"userId": %d, "parentDecisionNodeId": 9999999, "targetOptionIndex": 0, "keepUntilParent": true} """.formatted(userId);