Skip to content

Commit 48a01ec

Browse files
committed
AI response add
1 parent 7016d79 commit 48a01ec

File tree

14 files changed

+359
-8
lines changed

14 files changed

+359
-8
lines changed

back/src/main/java/com/back/BackApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
56
import org.springframework.cache.annotation.EnableCaching;
67
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
78

89
@EnableJpaAuditing
910
@SpringBootApplication
1011
@EnableCaching
12+
@ConfigurationPropertiesScan
1113
public class BackApplication {
1214

1315
public static void main(String[] args) {

back/src/main/java/com/back/domain/node/dto/decision/DecNodeDto.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,7 @@ public record DecNodeDto(
2222
List<String> options,
2323
Integer selectedIndex,
2424
Integer parentOptionIndex,
25-
String description
25+
String description,
26+
String aiNextSituation,
27+
String aiNextRecommendedOption
2628
) {}

back/src/main/java/com/back/domain/node/entity/BaseLine.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,12 @@ public class BaseLine extends BaseEntity {
3030
@Column(length = 100, nullable = false)
3131
private String title;
3232

33-
// BaseLine ←→ BaseNode 양방향 매핑 (BaseNode 쪽에 @ManyToOne BaseLine baseLine 있어야 함)
33+
// BaseLine <-> BaseNode 양방향 매핑 (BaseNode 쪽에 @ManyToOne BaseLine baseLine 있어야 함)
3434
@OneToMany(mappedBy = "baseLine", cascade = CascadeType.ALL, orphanRemoval = false)
3535
@Builder.Default
3636
private List<BaseNode> baseNodes = new ArrayList<>();
3737

38-
/**
39-
* 중간 피벗 나이 목록 반환(헤더/꼬리 제외, 중복 제거, 오름차순)
40-
*/
38+
//중간 피벗 나이 목록 반환(헤더/꼬리 제외, 중복 제거, 오름차순)
4139
public List<Integer> pivotAges() {
4240
List<BaseNode> nodes = this.baseNodes;
4341
if (nodes == null || nodes.size() <= 2) {

back/src/main/java/com/back/domain/node/mapper/NodeMappers.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ private NodeMappers() {}
8080
opts.isEmpty() ? null : List.copyOf(opts),
8181
e.getSelectedIndex(),
8282
e.getParentOptionIndex(),
83-
e.getDescription()
83+
e.getDescription(),
84+
null,
85+
null
8486
);
8587
};
8688

back/src/main/java/com/back/domain/node/service/DecisionFlowService.java

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import com.back.domain.node.repository.BaseNodeRepository;
1616
import com.back.domain.node.repository.DecisionLineRepository;
1717
import com.back.domain.node.repository.DecisionNodeRepository;
18+
import com.back.global.ai.vector.AIVectorService;
1819
import com.back.global.exception.ApiException;
1920
import com.back.global.exception.ErrorCode;
2021
import lombok.RequiredArgsConstructor;
@@ -30,6 +31,7 @@ class DecisionFlowService {
3031
private final DecisionNodeRepository decisionNodeRepository;
3132
private final BaseNodeRepository baseNodeRepository;
3233
private final NodeDomainSupport support;
34+
private final AIVectorService aiVectorService;
3335

3436
// 가장 중요한: from-base 서버 해석(옵션 1~2개 허용; 단일 옵션은 선택 슬롯에만 반영)
3537
public DecNodeDto createDecisionNodeFromBase(DecisionNodeFromBaseRequest request) {
@@ -104,7 +106,32 @@ public DecNodeDto createDecisionNodeFromBase(DecisionNodeFromBaseRequest request
104106
if (updated == 0) {
105107
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "branch slot was taken by another request");
106108
}
107-
return mapper.toResponse(saved);
109+
DecNodeDto baseDto = mapper.toResponse(saved);
110+
111+
// 여기서 AI 힌트 주입 (응답 직전 동기)
112+
List<DecisionNode> orderedList = decisionNodeRepository
113+
.findByDecisionLine_IdOrderByAgeYearAscIdAsc(baseDto.decisionLineId());
114+
var hint = aiVectorService.generateNextHint(baseDto.userId(), baseDto.decisionLineId(), orderedList);
115+
116+
return new DecNodeDto(
117+
baseDto.id(),
118+
baseDto.userId(),
119+
baseDto.type(),
120+
baseDto.category(),
121+
baseDto.situation(),
122+
baseDto.decision(),
123+
baseDto.ageYear(),
124+
baseDto.decisionLineId(),
125+
baseDto.parentId(),
126+
baseDto.baseNodeId(),
127+
baseDto.background(),
128+
baseDto.options(),
129+
baseDto.selectedIndex(),
130+
baseDto.parentOptionIndex(),
131+
baseDto.description(),
132+
hint.aiNextSituation(),
133+
hint.aiNextRecommendedOption()
134+
);
108135
}
109136

110137
// 가장 중요한: next 서버 해석(부모 기준 라인/다음 피벗/베이스 매칭 결정)
@@ -156,7 +183,32 @@ public DecNodeDto createDecisionNodeNext(DecisionNodeNextRequest request) {
156183
);
157184

158185
DecisionNode saved = decisionNodeRepository.save(mapper.toEntity(createReq));
159-
return mapper.toResponse(saved);
186+
DecNodeDto baseDto = mapper.toResponse(saved);
187+
188+
// AI 힌트 주입 (응답 직전 동기)
189+
List<DecisionNode> ordered_decision = decisionNodeRepository
190+
.findByDecisionLine_IdOrderByAgeYearAscIdAsc(baseDto.decisionLineId());
191+
var hint = aiVectorService.generateNextHint(baseDto.userId(), baseDto.decisionLineId(), ordered_decision);
192+
193+
return new DecNodeDto(
194+
baseDto.id(),
195+
baseDto.userId(),
196+
baseDto.type(),
197+
baseDto.category(),
198+
baseDto.situation(),
199+
baseDto.decision(),
200+
baseDto.ageYear(),
201+
baseDto.decisionLineId(),
202+
baseDto.parentId(),
203+
baseDto.baseNodeId(),
204+
baseDto.background(),
205+
baseDto.options(),
206+
baseDto.selectedIndex(),
207+
baseDto.parentOptionIndex(),
208+
baseDto.description(),
209+
hint.aiNextSituation(),
210+
hint.aiNextRecommendedOption()
211+
);
160212
}
161213

162214
// 라인 취소
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* [요약] 상황 힌트용 AI 파라미터 바인딩 전용 클래스 (컴포넌트 금지)
3+
* - application.yml 의 ai.situation.* 값을 바인딩한다.
4+
*/
5+
package com.back.global.ai.config;
6+
7+
import org.springframework.boot.context.properties.ConfigurationProperties;
8+
9+
@ConfigurationProperties(prefix = "ai.situation")
10+
public class SituationAiProperties {
11+
private int topK = 5;
12+
private int contextCharLimit = 1000;
13+
private int maxOutputTokens = 384;
14+
15+
// getters/setters
16+
public int getTopK() { return topK; }
17+
public void setTopK(int topK) { this.topK = topK; }
18+
public int getContextCharLimit() { return contextCharLimit; }
19+
public void setContextCharLimit(int contextCharLimit) { this.contextCharLimit = contextCharLimit; }
20+
public int getMaxOutputTokens() { return maxOutputTokens; }
21+
public void setMaxOutputTokens(int maxOutputTokens) { this.maxOutputTokens = maxOutputTokens; }
22+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* [파일 요약]
3+
* - 인메모리 유사-벡터 검색과 초경량 프롬프트 생성을 통해
4+
* "다음 입력용" AI 힌트(상황 한 문장 + 추천 1개)를 동기 반환한다.
5+
* - 컨트롤러/엔티티는 건드리지 않고, 서비스 레이어에서만 호출해 DTO 에 주입한다.
6+
*/
7+
package com.back.global.ai.vector;
8+
9+
import com.back.domain.node.entity.DecisionNode;
10+
import java.util.List;
11+
12+
public interface AIVectorService {
13+
14+
15+
// 다음 입력을 돕는 AI 힌트(상황/추천)를 생성하여 반환
16+
AiNextHint generateNextHint(Long userId, Long decisionLineId, List<DecisionNode> orderedNodes);
17+
18+
// 응답 모델: 상황 1문장 + 추천 선택지 1개(15자 이내), 둘 다 null 가능
19+
record AiNextHint(String aiNextSituation, String aiNextRecommendedOption) {}
20+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* [파일 요약]
3+
* - 인메모리 검색 결과(얇은 콘텍스트) + 이전 경로 요약으로 초경량 프롬프트를 만들고
4+
* TextAiClient 를 동기 호출하여 JSON 2필드(situation, recommendedOption)만 추출한다.
5+
* - 토큰/콘텍스트 길이는 프로퍼티로 제어 가능.
6+
*/
7+
package com.back.global.ai.vector;
8+
9+
import com.back.domain.node.entity.DecisionNode;
10+
import com.back.global.ai.client.text.TextAiClient;
11+
import com.back.global.ai.config.SituationAiProperties;
12+
import com.back.global.ai.dto.AiRequest;
13+
import com.back.global.ai.prompt.SituationPrompt;
14+
import lombok.RequiredArgsConstructor;
15+
import org.springframework.context.annotation.Profile;
16+
import org.springframework.stereotype.Service;
17+
18+
import java.util.List;
19+
import java.util.Map;
20+
21+
@Service
22+
@Profile("!test")
23+
@RequiredArgsConstructor
24+
public class AIVectorServiceImpl implements AIVectorService {
25+
26+
private final TextAiClient textAiClient;
27+
private final AIVectorServiceSupportDomain support;
28+
private final SituationAiProperties props;
29+
30+
// 프로퍼티 바인딩 필드
31+
private int topK = 5;
32+
private int contextCharLimit = 1000;
33+
private int maxOutputTokens = 384;
34+
35+
public void setTopK(int topK) { this.topK = topK; }
36+
public void setContextCharLimit(int contextCharLimit) { this.contextCharLimit = contextCharLimit; }
37+
public void setMaxOutputTokens(int maxOutputTokens) { this.maxOutputTokens = maxOutputTokens; }
38+
39+
/** 한 줄 요약: 경로 요약 + 얇은 콘텍스트로 프롬프트 최소화 후 AI 힌트 생성 */
40+
@Override
41+
public AiNextHint generateNextHint(Long userId, Long decisionLineId, List<DecisionNode> orderedNodes) {
42+
if (orderedNodes == null || orderedNodes.isEmpty()) {
43+
return new AiNextHint(null, null);
44+
}
45+
46+
// 1) 질의/콘텍스트 준비
47+
String query = support.buildQueryFromNodes(orderedNodes);
48+
List<String> ctxSnippets = support.searchRelatedContexts(query, topK, Math.max(120, contextCharLimit / Math.max(1, topK)));
49+
String relatedContext = support.joinWithLimit(ctxSnippets, contextCharLimit);
50+
51+
// 2) 초경량 RAG 프롬프트 생성
52+
String prompt = buildRagPrompt(query, relatedContext);
53+
54+
// 3) 동기 호출 (CompletableFuture.join 사용) — 응답 즉시 필요
55+
AiRequest req = new AiRequest(prompt, Map.of(), Math.max(128, maxOutputTokens));
56+
String response = textAiClient.generateText(req).join();
57+
58+
// 4) JSON 2필드만 추출
59+
String situation = SituationPrompt.extractSituation(response);
60+
String option = SituationPrompt.extractRecommendedOption(response);
61+
62+
return new AiNextHint(emptyToNull(situation), emptyToNull(option));
63+
}
64+
65+
private String buildRagPrompt(String previousSummary, String relatedContext) {
66+
String ctx = (relatedContext == null || relatedContext.isBlank()) ? "(관련 콘텍스트 없음)" : relatedContext;
67+
return """
68+
당신은 인생 시뮬레이션 도우미입니다.
69+
아래의 '이전 선택 요약'과 '관련 콘텍스트'를 참고하여,
70+
**동일 연도 시점**에서 자연스러운 새로운 상황을 **한 문장**으로 생성하세요.
71+
72+
## 이전 선택 요약
73+
%s
74+
75+
## 관련 콘텍스트(발췌)
76+
%s
77+
78+
### 제약
79+
- 반드시 현재 베이스 상황과 동일한 연/시점
80+
- 과장/모호 금지, 구체적이고 현실적인 한 문장
81+
- 선택 분기가 필요
82+
83+
### 응답(JSON)
84+
{
85+
"situation": "한 문장",
86+
"recommendedOption": "15자 이내 선택지"
87+
}
88+
""".formatted(previousSummary, ctx);
89+
}
90+
91+
private String emptyToNull(String s) {
92+
return (s == null || s.isBlank()) ? null : s.trim();
93+
}
94+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* [요약] 테스트 프로파일에서만 활성화되는 AI 스텁 서비스(외부 호출 없음)
3+
* - generateNextHint: 고정 문자열/또는 null 반환
4+
*/
5+
package com.back.global.ai.vector;
6+
7+
import com.back.domain.node.entity.DecisionNode;
8+
import org.springframework.context.annotation.Profile;
9+
import org.springframework.stereotype.Service;
10+
11+
import java.util.List;
12+
13+
@Service
14+
@Profile("test")
15+
public class AIVectorServiceStub implements AIVectorService {
16+
17+
// 다음 입력 힌트(상수 또는 null) 반환
18+
@Override
19+
public AiNextHint generateNextHint(Long userId, Long decisionLineId, List<DecisionNode> orderedNodes) {
20+
21+
return new AiNextHint("테스트-상황(한 문장)", "테스트-추천");
22+
}
23+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* [요약] 경로 요약/인메모리 검색 보조 유틸
3+
*/
4+
package com.back.global.ai.vector;
5+
6+
import com.back.domain.node.entity.DecisionNode;
7+
import org.springframework.stereotype.Component;
8+
9+
import java.util.*;
10+
import java.util.stream.Collectors;
11+
12+
@Component
13+
public class AIVectorServiceSupportDomain {
14+
15+
// 이전 결정 경로를 요약 쿼리 문자열로 만든다
16+
public String buildQueryFromNodes(List<DecisionNode> nodes) {
17+
return nodes.stream()
18+
.map(n -> String.format("- (%d세) %s → %s",
19+
n.getAgeYear(),
20+
safe(n.getSituation()),
21+
safe(n.getDecision())))
22+
.collect(Collectors.joining("\n"));
23+
}
24+
25+
// 간단한 인메모리 유사 검색으로 상위 K 콘텍스트를 수집한다 (스텁)
26+
public List<String> searchRelatedContexts(String query, int topK, int eachSnippetLimit) {
27+
return Collections.emptyList(); // 추후 RAM 인덱스 교체 지점
28+
}
29+
30+
// 여러 스니펫을 합치되 총 글자수 제한을 적용한다
31+
public String joinWithLimit(List<String> snippets, int totalCharLimit) {
32+
StringBuilder sb = new StringBuilder();
33+
for (String s : snippets) {
34+
if (s == null || s.isBlank()) continue;
35+
if (sb.length() + s.length() + 1 > totalCharLimit) break;
36+
if (!sb.isEmpty()) sb.append("\n");
37+
sb.append(trim(s, Math.min(s.length(), Math.max(50, totalCharLimit / 5))));
38+
}
39+
return sb.toString();
40+
}
41+
42+
private String safe(String s) { return s == null ? "" : s.trim(); }
43+
private String trim(String s, int limit) {
44+
if (s == null) return "";
45+
if (s.length() <= limit) return s;
46+
return s.substring(0, Math.max(0, limit - 3)) + "...";
47+
}
48+
}

0 commit comments

Comments
 (0)