Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions back/src/main/java/com/back/BackApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,7 @@ public record DecNodeDto(
List<String> options,
Integer selectedIndex,
Integer parentOptionIndex,
String description
String description,
String aiNextSituation,
String aiNextRecommendedOption
) {}
6 changes: 2 additions & 4 deletions back/src/main/java/com/back/domain/node/entity/BaseLine.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<BaseNode> baseNodes = new ArrayList<>();

/**
* 중간 피벗 나이 목록 반환(헤더/꼬리 제외, 중복 제거, 오름차순)
*/
//중간 피벗 나이 목록 반환(헤더/꼬리 제외, 중복 제거, 오름차순)
public List<Integer> pivotAges() {
List<BaseNode> nodes = this.baseNodes;
if (nodes == null || nodes.size() <= 2) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ private NodeMappers() {}
opts.isEmpty() ? null : List.copyOf(opts),
e.getSelectedIndex(),
e.getParentOptionIndex(),
e.getDescription()
e.getDescription(),
null,
null
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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<DecisionNode> 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 서버 해석(부모 기준 라인/다음 피벗/베이스 매칭 결정)
Expand Down Expand Up @@ -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<DecisionNode> 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()
);
}

// 라인 취소
Expand Down
Original file line number Diff line number Diff line change
@@ -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; }
}
20 changes: 20 additions & 0 deletions back/src/main/java/com/back/global/ai/vector/AIVectorService.java
Original file line number Diff line number Diff line change
@@ -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<DecisionNode> orderedNodes);

// 응답 모델: 상황 1문장 + 추천 선택지 1개(15자 이내), 둘 다 null 가능
record AiNextHint(String aiNextSituation, String aiNextRecommendedOption) {}
}
Original file line number Diff line number Diff line change
@@ -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<DecisionNode> orderedNodes) {
if (orderedNodes == null || orderedNodes.isEmpty()) {
return new AiNextHint(null, null);
}

// 1) 질의/콘텍스트 준비
String query = support.buildQueryFromNodes(orderedNodes);
List<String> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<DecisionNode> orderedNodes) {

return new AiNextHint("테스트-상황(한 문장)", "테스트-추천");
}
}
Original file line number Diff line number Diff line change
@@ -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<DecisionNode> 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<String> searchRelatedContexts(String query, int topK, int eachSnippetLimit) {
return Collections.emptyList(); // 추후 RAM 인덱스 교체 지점
}

// 여러 스니펫을 합치되 총 글자수 제한을 적용한다
public String joinWithLimit(List<String> 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)) + "...";
}
}
5 changes: 5 additions & 0 deletions back/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ ai:
timeout-seconds: 60
max-retries: 3
# TODO: 추후 이미지 AI 설정 추가 예정
situation:
topK: 5
contextCharLimit: 1000
maxOutputTokens: 384


server:
servlet:
Expand Down
Original file line number Diff line number Diff line change
@@ -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; }
}
Loading