Skip to content

Commit a0699b2

Browse files
committed
synchronizing
1 parent a7778a5 commit a0699b2

3 files changed

Lines changed: 308 additions & 3 deletions

File tree

back/src/main/java/com/back/domain/node/controller/DvcsController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import org.springframework.web.bind.annotation.*;
1717

1818
import java.util.List;
19-
19+
// 이 컨트롤러는 현재 사용하지 않으며 향후 DVCS 를 이용한 라인, 노드 편집 기능이 추가될 때 활성화될 예정임
2020
@RestController
2121
@RequestMapping("/api/v1/dvcs")
2222
@RequiredArgsConstructor

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

Lines changed: 158 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@
22
* DecisionFlowService (개선판)
33
* - 라인 생성 시 해당 BaseLine의 기본 브랜치(main)를 라인에 부착
44
* - 기존 생성/next/취소/완료/포크 흐름은 유지하고, 버전 해석은 매퍼/리졸버로 위임
5+
*
6+
* [추가 요약 - 옵션 동기화(코리더 한정 횡·종 전개, 증분 Append, 숨은 노드 포함)]
7+
* 1) 노드가 생성되는 시점(from-base/next/fork)마다, 트리거 노드의 (baseLineId, ageYear)를 기준으로 코리더 라인 집합을 산출한다.
8+
* - 코리더 라인: 해당 라인의 첫 분기 나이(첫 from-base 또는 첫 fork)가 segAge(이번 노드 나이) 이상인 라인만 포함
9+
* - 이렇게 하면 같은 나이·같은 베이스라인이라도 다른 선택지로 일찍 갈라진 라인은 제외되어 덮어쓰기가 방지됨
10+
* 2) 코리더에 속한 모든 라인에서 동일 ageYear의 모든 결정노드(노말/프렐류드/from-base/포크 포함)를 수집한다.
11+
* 3) 후보 옵션들(트리거+각 노드)을 정규화(<=3, trim)한 뒤, prefix 충돌 없는 가장 긴 리스트를 리더로 선정한다.
12+
* 4) 리더를 증분 Append로만 반영(순서 보존, 덮어쓰기 금지), selectedIndex는 범위를 벗어나면 null 보정한다.
513
*/
14+
615
package com.back.domain.node.service;
716

817
import com.back.domain.node.dto.decision.*;
@@ -24,7 +33,9 @@
2433
import org.springframework.transaction.annotation.Transactional;
2534
import org.springframework.web.server.ResponseStatusException;
2635

27-
import java.util.List;
36+
import java.util.*;
37+
import java.util.function.BiFunction;
38+
import java.util.function.Function;
2839

2940
@Service
3041
@RequiredArgsConstructor
@@ -129,6 +140,11 @@ public DecNodeDto createDecisionNodeFromBase(DecisionNodeFromBaseRequest request
129140
: baseNodeRepository.linkAlt2IfEmpty(fresh.getId(), saved.getId());
130141
if (updated == 0) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "branch slot was taken by another request");
131142

143+
// ★ 추가: (코리더 한정) 숨은 노드 포함 옵션 동기화 — 증분 Append, 순서 보존
144+
// 가장 많이 사용하는 함수 호출 위에 한줄로만 요약 주석: 트리거 옵션 결정(입력값 우선, 없으면 엔티티에서 추출)
145+
List<String> leaderOpts = (opts != null && !opts.isEmpty()) ? opts : support.extractOptions(saved);
146+
syncOptionsAcrossAgeWithinCorridorLite(saved, leaderOpts);
147+
132148
DecNodeDto baseDto = mapper.toResponse(saved);
133149
List<DecisionNode> orderedList = decisionNodeRepository.findByDecisionLine_IdOrderByAgeYearAscIdAsc(baseDto.decisionLineId());
134150
var hint = aiVectorService.generateNextHint(baseDto.userId(), baseDto.decisionLineId(), orderedList);
@@ -218,11 +234,16 @@ public DecNodeDto createDecisionNodeNext(DecisionNodeNextRequest request) {
218234
DecisionNode saved = decisionNodeRepository.save(mapper.toEntity(createReq));
219235
DecNodeDto baseDto = mapper.toResponse(saved);
220236

237+
// ★ 추가: 코리더 한정 옵션 동기화(숨은 노드 포함, 증분 Append)
238+
List<String> leaderOpts = (request.options() != null && !request.options().isEmpty())
239+
? request.options()
240+
: support.extractOptions(saved);
241+
syncOptionsAcrossAgeWithinCorridorLite(saved, leaderOpts);
242+
221243
List<DecisionNode> ordered_decision = decisionNodeRepository
222244
.findByDecisionLine_IdOrderByAgeYearAscIdAsc(baseDto.decisionLineId());
223245
var hint = aiVectorService.generateNextHint(baseDto.userId(), baseDto.decisionLineId(), ordered_decision);
224246

225-
226247
saved.setAiHint(hint.aiNextSituation(), hint.aiNextRecommendedOption());
227248
decisionNodeRepository.save(saved);
228249

@@ -434,6 +455,12 @@ public DecNodeDto forkFromDecision(ForkFromDecisionRequest req) {
434455
DecisionNode saved = decisionNodeRepository.save(mapper.toEntity(createReq));
435456
prevNew = saved;
436457

458+
// ★ 추가: 포크 진행 중에도 해당 age에서 코리더 한정 동기화(프렐류드 포함)
459+
List<String> leaderOptsHere = (options != null && !options.isEmpty())
460+
? options
461+
: support.extractOptions(saved);
462+
syncOptionsAcrossAgeWithinCorridorLite(saved, leaderOptsHere);
463+
437464
if (isParent) {
438465
forkPointDto = mapper.toResponse(saved);
439466
forkAnchorSaved = saved; // AI 힌트 저장 대상으로 보관
@@ -533,4 +560,133 @@ private DecisionNode createPreludeUntilPivot(DecisionLine line,
533560
}
534561
return prev;
535562
}
563+
564+
/**
565+
* [추가/가장 중요한 함수] 코리더(공통 경로) 한정 옵션 동기화(Lite)
566+
* - 같은 BaseLine + 같은 ageYear 중, 첫 분기 나이(첫 from-base 또는 첫 fork)가 segAge 이상인 라인만 포함
567+
* - 포함된 라인의 동일 age 노드(노말/프렐류드/from-base/포크)에 리더 옵션을 증분 Append로 반영(순서 보존)
568+
*/
569+
// 가장 중요한 함수 한줄로만 요약: 코리더 집합(첫 분기 나이 ≥ segAge) 안의 동일 age 노드를 증분 동기화
570+
@Transactional
571+
protected void syncOptionsAcrossAgeWithinCorridorLite(DecisionNode trigger, List<String> triggerOptions) {
572+
if (trigger == null || trigger.getDecisionLine() == null) return;
573+
574+
final Integer segAge = trigger.getAgeYear();
575+
final DecisionLine triggerLine = trigger.getDecisionLine();
576+
if (segAge == null || triggerLine.getBaseLine() == null) return;
577+
final Long baseLineId = triggerLine.getBaseLine().getId();
578+
579+
// 가장 많이 사용하는 함수 호출 한줄로만 요약: 베이스라인의 모든 라인 조회
580+
List<DecisionLine> allLines = decisionLineRepository.findByBaseLine_Id(baseLineId);
581+
if (allLines == null || allLines.isEmpty()) return;
582+
583+
// 한줄 요약: 라인별 첫 from-base 나이/첫 fork 나이 계산
584+
Map<Long, LineBoundary> boundaryByLine = new HashMap<>();
585+
for (DecisionLine ln : allLines) {
586+
Integer fromBaseAge = null, forkAge = null;
587+
List<DecisionNode> seq = decisionNodeRepository.findByDecisionLine_IdOrderByAgeYearAscIdAsc(ln.getId());
588+
for (DecisionNode n : seq) {
589+
if (fromBaseAge == null && isFromBaseMark(n)) fromBaseAge = n.getAgeYear();
590+
if (forkAge == null && n.getParentOptionIndex() != null) forkAge = n.getAgeYear();
591+
if (fromBaseAge != null && forkAge != null) break;
592+
}
593+
boundaryByLine.put(ln.getId(), new LineBoundary(fromBaseAge, forkAge));
594+
}
595+
596+
// 한줄 요약: 첫 분기 경계 나이가 segAge 이상인 라인만 코리더로 선별
597+
Set<Long> corridorLineIds = new HashSet<>();
598+
for (DecisionLine ln : allLines) {
599+
if (ln.getStatus() == DecisionLineStatus.CANCELLED) continue;
600+
LineBoundary lb = boundaryByLine.get(ln.getId());
601+
int boundary = (lb != null) ? lb.firstDivergenceAge() : Integer.MAX_VALUE;
602+
if (segAge <= boundary) corridorLineIds.add(ln.getId());
603+
}
604+
if (corridorLineIds.isEmpty()) return;
605+
606+
// 동일 ageYear 대상 노드 수집(노말/프렐류드/from-base/포크 포함)
607+
List<DecisionNode> group = new ArrayList<>();
608+
for (DecisionLine ln : allLines) {
609+
if (!corridorLineIds.contains(ln.getId())) continue;
610+
List<DecisionNode> seq = decisionNodeRepository.findByDecisionLine_IdOrderByAgeYearAscIdAsc(ln.getId());
611+
for (DecisionNode n : seq) if (segAge.equals(n.getAgeYear())) group.add(n);
612+
}
613+
if (group.isEmpty()) return;
614+
615+
// 리더 옵션 선정(정규화 + prefix 충돌 없는 최장 리스트)
616+
Function<List<String>, List<String>> normalize = opts -> {
617+
if (opts == null) return List.of();
618+
return opts.stream().filter(s -> s != null && !s.isBlank())
619+
.map(String::trim).limit(3).toList();
620+
};
621+
List<List<String>> candidates = new ArrayList<>();
622+
candidates.add(normalize.apply(triggerOptions != null ? triggerOptions : support.extractOptions(trigger)));
623+
for (DecisionNode n : group) candidates.add(normalize.apply(support.extractOptions(n)));
624+
625+
BiFunction<List<String>, List<String>, Boolean> prefixOk = (a, b) -> {
626+
int m = Math.min(a.size(), b.size());
627+
for (int i = 0; i < m; i++) if (!Objects.equals(a.get(i), b.get(i))) return false;
628+
return true;
629+
};
630+
List<String> leader = List.of();
631+
outer:
632+
for (List<String> cand : candidates) {
633+
if (cand == null) continue;
634+
for (List<String> other : candidates) {
635+
if (other == null) continue;
636+
if (!(prefixOk.apply(cand, other) || prefixOk.apply(other, cand))) continue outer;
637+
}
638+
if (cand.size() > leader.size()) leader = cand;
639+
}
640+
if (leader.isEmpty()) return;
641+
642+
// 증분 Append 반영(순서 보존, 숨은 노드 포함)
643+
for (DecisionNode node : group) {
644+
List<String> cur = normalize.apply(support.extractOptions(node));
645+
boolean ok = true;
646+
for (int i = 0; i < Math.min(cur.size(), leader.size()); i++) {
647+
if (!Objects.equals(cur.get(i), leader.get(i))) { ok = false; break; }
648+
}
649+
if (!ok) continue;
650+
651+
String o1 = null, o2 = null, o3 = null;
652+
int sz = Math.min(3, leader.size());
653+
if (sz >= 1) o1 = leader.get(0);
654+
if (sz >= 2) o2 = leader.get(1);
655+
if (sz >= 3) o3 = leader.get(2);
656+
657+
node.setOption1(o1);
658+
node.setOption2(o2);
659+
node.setOption3(o3);
660+
661+
Integer sel = node.getSelectedIndex();
662+
int eff = (o3 != null) ? 3 : (o2 != null ? 2 : (o1 != null ? 1 : 0));
663+
if (sel != null && (sel < 0 || sel >= eff)) node.setSelectedIndex(null);
664+
665+
decisionNodeRepository.save(node);
666+
}
667+
}
668+
669+
// 가장 많이 사용하는 함수 호출 한줄로만 요약: from-base 표식 여부(피벗 슬롯 타깃 매칭으로 판정)
670+
private boolean isFromBaseMark(DecisionNode n) {
671+
BaseNode b = n.getBaseNode();
672+
if (b == null) return false;
673+
Long id = n.getId();
674+
return Objects.equals(b.getAltOpt1TargetDecisionId(), id)
675+
|| Objects.equals(b.getAltOpt2TargetDecisionId(), id);
676+
}
677+
678+
// 가장 중요한 함수 위에 한줄로만 요약 주석: 라인의 첫 분기 경계 나이 스냅샷 컨테이너
679+
private static final class LineBoundary {
680+
final Integer fromBaseAge; // null 허용
681+
final Integer forkAge; // null 허용
682+
LineBoundary(Integer fromBaseAge, Integer forkAge) {
683+
this.fromBaseAge = fromBaseAge;
684+
this.forkAge = forkAge;
685+
}
686+
// 한줄 요약: 첫 분기 경계 나이(없으면 무한대)
687+
int firstDivergenceAge() {
688+
Integer a = (forkAge != null) ? forkAge : fromBaseAge;
689+
return (a != null) ? a : Integer.MAX_VALUE;
690+
}
691+
}
536692
}

back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,155 @@ void success_aiHints_persisted_and_mapped_on_line_detail() throws Exception {
736736
assertThat(childNode2.path("aiNextRecommendedOption").asText()).isEqualTo("테스트-추천");
737737
}
738738

739+
@Nested
740+
@DisplayName("옵션 동기화(증분)")
741+
class OptionSyncIncremental {
742+
743+
// 같은 기원 세그먼트에서 ["A"] → ["A","B"] 로 한 개 추가 시, 기존 라인의 옵션이 ["A","B"]로 증분 반영되고 순서 보존
744+
@Test
745+
@DisplayName("성공 : from-base(라인2)에서 옵션을 한 개 추가하면 라인1 동일 구간 옵션이 증분 반영되고 기존 순서가 유지된다")
746+
void success_incremental_one_append_preserves_order() throws Exception {
747+
aiCallBudget.reset(0);
748+
749+
var baseInfo = createBaseLineAndPickFirstPivot(userId);
750+
751+
// 라인1 생성: 옵션 ["A"], selectedIndex=0
752+
String fromBase1 = """
753+
{
754+
"userId": %d,
755+
"baseLineId": %d,
756+
"pivotAge": %d,
757+
"selectedAltIndex": 0,
758+
"category": "%s",
759+
"situation": "증분-1",
760+
"options": ["A"],
761+
"selectedIndex": 0
762+
}
763+
""".formatted(userId, baseInfo.baseLineId, baseInfo.pivotAge, NodeCategory.EDUCATION);
764+
765+
var r1 = mockMvc.perform(post("/api/v1/decision-flow/from-base")
766+
.with(csrf()).with(authed(userId))
767+
.contentType(MediaType.APPLICATION_JSON)
768+
.content(fromBase1))
769+
.andExpect(status().isCreated())
770+
.andReturn();
771+
JsonNode body1 = om.readTree(r1.getResponse().getContentAsString());
772+
long line1Id = body1.get("decisionLineId").asLong();
773+
long head1Id = body1.get("id").asLong();
774+
775+
// 라인2 생성: 옵션 ["A","B"] (한 개 추가) — 동기화 트리거
776+
String fromBase2 = """
777+
{
778+
"userId": %d,
779+
"baseLineId": %d,
780+
"pivotAge": %d,
781+
"selectedAltIndex": 1,
782+
"category": "%s",
783+
"situation": "증분-2",
784+
"options": ["A","B"],
785+
"selectedIndex": 1
786+
}
787+
""".formatted(userId, baseInfo.baseLineId, baseInfo.pivotAge, NodeCategory.EDUCATION);
788+
789+
mockMvc.perform(post("/api/v1/decision-flow/from-base")
790+
.with(csrf()).with(authed(userId))
791+
.contentType(MediaType.APPLICATION_JSON)
792+
.content(fromBase2))
793+
.andExpect(status().isCreated()); // 생성이 튕기지 않아야 함
794+
795+
// 라인1 상세 재조회 → 헤드 노드 옵션이 ["A","B"]로 증분 반영, 순서 보존, 선택 인덱스(0)는 유효
796+
var line1Detail = mockMvc.perform(get("/api/v1/decision-lines/{id}", line1Id)
797+
.with(authed(userId)))
798+
.andExpect(status().isOk())
799+
.andReturn();
800+
JsonNode nodes1 = om.readTree(line1Detail.getResponse().getContentAsString()).path("nodes");
801+
802+
JsonNode head1 = null;
803+
for (JsonNode n : nodes1) if (n.path("id").asLong() == head1Id) head1 = n;
804+
805+
Assertions.assertNotNull(head1, "라인1 헤드 노드를 찾지 못했습니다");
806+
Assertions.assertTrue(head1.path("options").isArray());
807+
Assertions.assertEquals(2, head1.path("options").size());
808+
Assertions.assertEquals("A", head1.path("options").get(0).asText());
809+
Assertions.assertEquals("B", head1.path("options").get(1).asText());
810+
// 선택 인덱스는 기존(0) 유지되어야 함
811+
Assertions.assertEquals(0, head1.path("selectedIndex").asInt());
812+
}
813+
814+
// 같은 기원 세그먼트에서 ["A","B"] → ["A","B","C"] 로 다시 한 개 추가 시, 기존 라인의 옵션이 ["A","B","C"]로 증분 반영되고 순서 보존
815+
@Test
816+
@DisplayName("성공 : from-base(라인3)에서 또 한 개 추가하면 라인1 동일 구간 옵션이 [A,B,C]가 되고 순서가 유지된다")
817+
void success_incremental_second_append_preserves_order() throws Exception {
818+
aiCallBudget.reset(0);
819+
820+
var baseInfo = createBaseLineAndPickFirstPivot(userId);
821+
822+
// 라인1: ["A"]
823+
String fromBase1 = """
824+
{
825+
"userId": %d,
826+
"baseLineId": %d,
827+
"pivotAge": %d,
828+
"selectedAltIndex": 0,
829+
"category": "%s",
830+
"situation": "증분-1",
831+
"options": ["A"],
832+
"selectedIndex": 0
833+
}
834+
""".formatted(userId, baseInfo.baseLineId, baseInfo.pivotAge, NodeCategory.EDUCATION);
835+
836+
var r1 = mockMvc.perform(post("/api/v1/decision-flow/from-base")
837+
.with(csrf()).with(authed(userId))
838+
.contentType(MediaType.APPLICATION_JSON)
839+
.content(fromBase1))
840+
.andExpect(status().isCreated())
841+
.andReturn();
842+
JsonNode body1 = om.readTree(r1.getResponse().getContentAsString());
843+
long line1Id = body1.get("decisionLineId").asLong();
844+
long head1Id = body1.get("id").asLong();
845+
846+
// 라인2: ["A","B"]
847+
String fromBase2 = """
848+
{
849+
"userId": %d,
850+
"baseLineId": %d,
851+
"pivotAge": %d,
852+
"selectedAltIndex": 1,
853+
"category": "%s",
854+
"situation": "증분-2",
855+
"options": ["A","B"],
856+
"selectedIndex": 1
857+
}
858+
""".formatted(userId, baseInfo.baseLineId, baseInfo.pivotAge, NodeCategory.EDUCATION);
859+
860+
mockMvc.perform(post("/api/v1/decision-flow/from-base")
861+
.with(csrf()).with(authed(userId))
862+
.contentType(MediaType.APPLICATION_JSON)
863+
.content(fromBase2))
864+
.andExpect(status().isCreated());
865+
866+
867+
// 라인1 상세 재조회 → 헤드 노드 옵션이 ["A","B","C"]로 증분 반영, 순서 보존
868+
var line1Detail = mockMvc.perform(get("/api/v1/decision-lines/{id}", line1Id)
869+
.with(authed(userId)))
870+
.andExpect(status().isOk())
871+
.andReturn();
872+
JsonNode nodes1 = om.readTree(line1Detail.getResponse().getContentAsString()).path("nodes");
873+
874+
JsonNode head1 = null;
875+
for (JsonNode n : nodes1) if (n.path("id").asLong() == head1Id) head1 = n;
876+
877+
Assertions.assertNotNull(head1, "라인1 헤드 노드를 찾지 못했습니다");
878+
Assertions.assertTrue(head1.path("options").isArray());
879+
Assertions.assertEquals(2, head1.path("options").size());
880+
Assertions.assertEquals("A", head1.path("options").get(0).asText());
881+
Assertions.assertEquals("B", head1.path("options").get(1).asText());
882+
// 기존 선택 인덱스(0)는 여전히 유효해야 함
883+
Assertions.assertEquals(0, head1.path("selectedIndex").asInt());
884+
}
885+
}
886+
887+
739888
// ===========================
740889
// 공통 헬퍼
741890
// ===========================

0 commit comments

Comments
 (0)