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
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
/**
* [DTO-RES] DecisionNode 응답(보강)
* - followPolicy/pinnedCommitId/virtual 플래그를 추가해 해석 상태를 노출
* - effective* 필드는 Resolver가 계산한 최종 표현값(렌더 우선 사용)
* [DTO-RES] DecisionNode 응답(보강 최종본)
* - 정책/핀/가상(followPolicy/pinnedCommitId/virtual)과 effective* 해석값 노출
* - 렌더 편의: childrenIds/root/pivot 링크 + 단일 패스 렌더용 힌트(renderPhase, incoming*)
*/
package com.back.domain.node.dto.decision;

import com.back.domain.node.entity.FollowPolicy;
import com.back.domain.node.entity.NodeCategory;

import java.util.List;

public record DecNodeDto(
Expand Down Expand Up @@ -40,10 +41,16 @@ public record DecNodeDto(
List<String> effectiveOptions,
String effectiveDescription,

// 기본 렌더 편의
List<Long> childrenIds, // 이 DECISION의 자식들 id(시간순)
Boolean root, // 라인 헤더면 true
Long pivotLinkBaseNodeId, // 베이스 분기 슬롯에서 올라온 첫 노드면 해당 BaseNode id
Integer pivotSlotIndex // 0/1 (분기 슬롯 인덱스), 아니면 null
Integer pivotSlotIndex, // 0/1 (분기 슬롯 인덱스), 아니면 null

// 단일 패스 렌더용 최소 힌트
Integer renderPhase, // 1=from-base 라인, 2..N=fork 깊이(부모 라인 +1)
Long incomingFromId, // 이 노드로 "들어오는" 에지의 from 노드 id(루트면 포크 원본, 아니면 parentId)
String incomingEdgeType // "normal" | "fork"
) {
// === 호환 오버로드(기존 서비스 호출 유지) ===
public DecNodeDto(
Expand All @@ -62,7 +69,7 @@ public DecNodeDto(
parentOptionIndex, description, aiNextSituation, aiNextRecommendedOption,
followPolicy, pinnedCommitId, virtual, effectiveCategory, effectiveSituation,
effectiveDecision, effectiveOptions, effectiveDescription,
null, null, null, null); // ← 새 필드는 null 기본값
null, null, null, null,
null, null, null); // 새 필드는 null 기본값
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,6 @@ private Long currentUserId() {
public DecisionLineLifecycleDto completeDecisionLine(Long decisionLineId) {
DecisionLine line = support.requireDecisionLine(decisionLineId);
try { line.complete(); } catch (RuntimeException e) { throw support.mapDomainToApi(e); }
decisionLineRepository.save(line);
return new DecisionLineLifecycleDto(line.getId(), line.getStatus());
}

Expand Down
169 changes: 142 additions & 27 deletions back/src/main/java/com/back/domain/node/service/NodeQueryService.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import org.springframework.transaction.annotation.Transactional;

import java.util.*;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
Expand Down Expand Up @@ -52,13 +53,22 @@ public TreeDto getTreeForBaseLine(Long baseLineId) {
List<BaseNode> orderedBase = support.getOrderedBaseNodes(baseLineId);
List<BaseNodeDto> baseDtos = orderedBase.stream().map(mappers.BASE_READ::map).toList();

// 전체 라인 공용 pivot 역인덱스
// 가장 많이 사용하는: 전체 라인 공용 pivot 역인덱스
Map<Long, PivotMark> pivotIndex = buildPivotIndex(baseLineId);

// ===== (1) 노드 수집 =====
record Key(Long baseId, Integer age) {}
record View(DecisionNode dn, DecNodeDto dto, boolean isRoot, Long baseId, Integer age,
List<Long> childrenIds, Long pivotBaseId, Integer pivotSlot) {}

List<DecNodeDto> decDtos = new ArrayList<>();
List<DecisionLine> lines = decisionLineRepository.findByBaseLine_Id(baseLineId);

List<View> pool = new ArrayList<>();
Map<Long, List<View>> byLine = new HashMap<>();

for (DecisionLine line : lines) {
// 가장 많이 사용하는 함수 호출 위에 한줄로만 요약 주석: 라인의 노드를 타임라인 정렬로 로드
List<DecisionNode> ordered = decisionNodeRepository
.findByDecisionLine_IdOrderByAgeYearAscIdAsc(line.getId());

Expand All @@ -67,29 +77,126 @@ public TreeDto getTreeForBaseLine(Long baseLineId) {
for (DecisionNode dn : ordered) {
DecNodeDto base = mappers.DECISION_READ.map(dn);

List<Long> childrenIds = childrenIndex.getOrDefault(dn.getId(), List.of());
boolean isRoot = (dn.getParent() == null);
List<Long> childrenIds = childrenIndex.getOrDefault(dn.getId(), List.of());

PivotMark mark = pivotIndex.get(base.id());
Long pivotBaseId = (mark != null) ? mark.baseNodeId() : null;
Long pivotBaseId = (mark != null) ? mark.baseNodeId() :
(dn.getBaseNode() != null ? dn.getBaseNode().getId() : null);
Integer pivotSlot = (mark != null) ? mark.slotIndex() : null;

// effective*는 DECISION_READ에서 이미 계산/주입되어 있다면 그대로 사용
decDtos.add(new DecNodeDto(
base.id(), base.userId(), base.type(), base.category(),
base.situation(), base.decision(), base.ageYear(),
base.decisionLineId(), base.parentId(), base.baseNodeId(),
base.background(), base.options(), base.selectedIndex(),
base.parentOptionIndex(), base.description(),
base.aiNextSituation(), base.aiNextRecommendedOption(),
base.followPolicy(), base.pinnedCommitId(), base.virtual(),
base.effectiveCategory(), base.effectiveSituation(), base.effectiveDecision(),
base.effectiveOptions(), base.effectiveDescription(),
// ▼ 렌더 편의 필드
List.copyOf(childrenIds), isRoot, pivotBaseId, pivotSlot
));
Long baseId = pivotBaseId;
Integer age = dn.getAgeYear();

View v = new View(dn, base, isRoot, baseId, age, List.copyOf(childrenIds), pivotBaseId, pivotSlot);
pool.add(v);
byLine.computeIfAbsent(line.getId(), k -> new ArrayList<>()).add(v);
}
}

Map<Key, List<View>> byKey = pool.stream()
.filter(v -> v.baseId != null && v.age != null)
.collect(Collectors.groupingBy(v -> new Key(v.baseId, v.age)));

// ===== (2) 라인 간 포크 그래프 추론 → renderPhase 계산 =====
Map<Long, View> rootCand = new HashMap<>(); // lineId -> 루트 후보
for (Map.Entry<Long, List<View>> e : byLine.entrySet()) {
List<View> vs = e.getValue().stream()
.sorted(Comparator.comparing((View x) -> x.age)
.thenComparing(x -> x.dn.getId()))
.toList();

View first = vs.get(0);
// 가장 중요한 함수: 라인 루트가 헤더면 다음 피벗을 루트 후보로 대체
View cand = (first.isRoot && first.baseId == null && vs.size() > 1) ? vs.get(1) : first;
rootCand.put(e.getKey(), cand);
}

Map<Long, Set<Long>> g = new HashMap<>(); // originLineId -> {forkLineId}
Map<Long, Integer> indeg = new HashMap<>(); // lineId -> indegree

for (Map.Entry<Long, View> e : rootCand.entrySet()) {
Long lineId = e.getKey();
View me = e.getValue();
indeg.putIfAbsent(lineId, 0);

if (me.baseId != null && me.age != null) {
List<View> same = byKey.getOrDefault(new Key(me.baseId, me.age), List.of());
Optional<View> origin = same.stream()
.filter(o -> !o.dn.getDecisionLine().getId().equals(lineId))
.sorted(Comparator
.comparing((View o) -> o.dn.getDecisionLine().getId())
.thenComparing(o -> o.dn.getId()))
.findFirst();

if (origin.isPresent()) {
Long originLineId = origin.get().dn.getDecisionLine().getId();
g.computeIfAbsent(originLineId, k -> new HashSet<>()).add(lineId);
indeg.put(lineId, indeg.getOrDefault(lineId, 0) + 1);
indeg.putIfAbsent(originLineId, 0);
}
}
}

Map<Long, Integer> linePhase = new HashMap<>(); // lineId -> phase
ArrayDeque<Long> q = new ArrayDeque<>();
for (Map.Entry<Long, Integer> e : indeg.entrySet()) {
if (e.getValue() == 0) { // indegree==0 → from-base
linePhase.put(e.getKey(), 1);
q.add(e.getKey());
}
}
while (!q.isEmpty()) {
Long u = q.poll();
int next = linePhase.get(u) + 1;
for (Long v : g.getOrDefault(u, Set.of())) {
indeg.put(v, indeg.get(v) - 1);
linePhase.put(v, Math.max(linePhase.getOrDefault(v, 1), next));
if (indeg.get(v) == 0) q.add(v);
}
}

// ===== (3) DTO 주입: renderPhase + incomingFromId/incomingEdgeType =====
for (View v : pool) {
DecNodeDto b = v.dto;

Long lineId = v.dn.getDecisionLine().getId();
Integer renderPhase = linePhase.getOrDefault(lineId, 1);

Long incomingFromId = (v.isRoot)
? byKey.getOrDefault(new Key(v.baseId, v.age), List.of()).stream()
.filter(o -> !o.dn.getDecisionLine().getId().equals(lineId))
.sorted(Comparator
.comparing((View o) -> o.dn.getDecisionLine().getId())
.thenComparing(o -> o.dn.getId()))
.map(o -> o.dn.getId())
.findFirst()
.orElse(null)
: (v.dn.getParent() != null ? v.dn.getParent().getId() : null);

String incomingEdgeType = (v.isRoot && incomingFromId != null) ? "fork" : "normal";

decDtos.add(new DecNodeDto(
b.id(), b.userId(), b.type(), b.category(),
b.situation(), b.decision(), b.ageYear(),
b.decisionLineId(), b.parentId(), b.baseNodeId(),
b.background(), b.options(), b.selectedIndex(),
b.parentOptionIndex(), b.description(),
b.aiNextSituation(), b.aiNextRecommendedOption(),
b.followPolicy(), b.pinnedCommitId(), b.virtual(),
b.effectiveCategory(), b.effectiveSituation(), b.effectiveDecision(),
b.effectiveOptions(), b.effectiveDescription(),
v.childrenIds, v.isRoot, v.pivotBaseId, v.pivotSlot,
renderPhase, incomingFromId, incomingEdgeType
));
}

// 출력 순서 보장: phase → ageYear → id
decDtos.sort(Comparator
.comparing(DecNodeDto::renderPhase, Comparator.nullsLast(Integer::compareTo))
.thenComparing(DecNodeDto::ageYear, Comparator.nullsFirst(Integer::compareTo))
.thenComparing(DecNodeDto::id));

return new TreeDto(baseDtos, decDtos);
}

Expand Down Expand Up @@ -142,7 +249,8 @@ public DecisionLineListDto getDecisionLines(Long userId) {
// 가장 중요한: 특정 라인의 상세를 childrenIds/root/pivotLink*와 함께 반환
public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) {
DecisionLine line = decisionLineRepository.findById(decisionLineId)
.orElseThrow(() -> new ApiException(ErrorCode.DECISION_LINE_NOT_FOUND, "DecisionLine not found: " + decisionLineId));
.orElseThrow(() -> new ApiException(ErrorCode.DECISION_LINE_NOT_FOUND,
"DecisionLine not found: " + decisionLineId));

Long baseLineId = line.getBaseLine().getId();
Long baseBranchId = (line.getBaseBranch() != null) ? line.getBaseBranch().getId() : null;
Expand All @@ -153,7 +261,7 @@ public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) {

// parent→children 인덱스 구성
Map<Long, List<Long>> childrenIndex = buildChildrenIndex(ordered);
// 베이스 분기 슬롯 역인덱스 구성(altOpt1/2TargetDecisionId → (baseNodeId, slot))
// 베이스 분기 슬롯 역인덱스 구성
Map<Long, PivotMark> pivotIndex = buildPivotIndex(baseLineId);

List<DecNodeDto> nodes = ordered.stream().map(n -> {
Expand All @@ -178,12 +286,12 @@ public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) {
if (verId != null) {
NodeAtomVersion v = versionRepo.findById(verId).orElse(null);
if (v != null) {
if (v.getCategory() != null) effCategory = v.getCategory();
if (v.getSituation() != null) effSituation = v.getSituation();
if (v.getDecision() != null) effDecision = v.getDecision();
if (v.getCategory() != null) effCategory = v.getCategory();
if (v.getSituation() != null) effSituation = v.getSituation();
if (v.getDecision() != null) effDecision = v.getDecision();
List<String> parsed = parseOptionsJson(v.getOptionsJson());
if (parsed != null) effOpts = parsed;
if (v.getDescription() != null) effDesc = v.getDescription();
if (parsed != null) effOpts = parsed;
if (v.getDescription() != null) effDesc = v.getDescription();
}
}

Expand All @@ -194,6 +302,12 @@ public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) {
Long pivotBaseId = (mark != null) ? mark.baseNodeId() : null;
Integer pivotSlot = (mark != null) ? mark.slotIndex() : null;

// ===== 라인 상세 전용 렌더 힌트 =====
// 한줄 요약: 상세 화면은 한 라인만 보므로 phase=1 고정, incoming은 parent 기준(normal)
Integer renderPhase = 1;
Long incomingFromId = isRoot ? null : n.getParent().getId();
String incomingEdgeType = "normal";

return new DecNodeDto(
base.id(), base.userId(), base.type(), base.category(),
base.situation(), base.decision(), base.ageYear(),
Expand All @@ -202,10 +316,11 @@ public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) {
base.parentOptionIndex(), base.description(),
base.aiNextSituation(), base.aiNextRecommendedOption(),
base.followPolicy(), base.pinnedCommitId(), base.virtual(),
// effective*
// effective* (최종 해석 반영)
effCategory, effSituation, effDecision, effOpts, effDesc,
// ▼ 렌더 편의 필드
List.copyOf(childrenIds), isRoot, pivotBaseId, pivotSlot
// ▼ 렌더 편의 + 단일 패스 힌트(라인 내부 한정)
List.copyOf(childrenIds), isRoot, pivotBaseId, pivotSlot,
renderPhase, incomingFromId, incomingEdgeType
);
}).toList();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.back.domain.scenario.controller;

import com.back.domain.node.dto.decision.DecisionNodeNextRequest;
import com.back.domain.scenario.dto.*;
import com.back.domain.scenario.service.ScenarioService;
import com.back.global.common.PageResponse;
Expand Down Expand Up @@ -42,12 +43,13 @@ private Long getUserId(CustomUserDetails userDetails) {
@PostMapping
@Operation(summary = "시나리오 생성", description = "DecisionLine을 기반으로 AI 시나리오를 생성합니다.")
public ResponseEntity<ScenarioStatusResponse> createScenario(
@Valid @RequestBody ScenarioCreateRequest request,
@Valid @RequestPart("scenario") ScenarioCreateRequest request,
@RequestPart(value = "lastDecision", required = false) DecisionNodeNextRequest lastDecision,
@AuthenticationPrincipal CustomUserDetails userDetails
) {
Long userId = getUserId(userDetails);

ScenarioStatusResponse scenarioCreateResponse = scenarioService.createScenario(userId, request);
ScenarioStatusResponse scenarioCreateResponse = scenarioService.createScenario(userId,request, lastDecision);

return ResponseEntity.status(HttpStatus.CREATED).body(scenarioCreateResponse);
}
Expand Down
Loading