|
2 | 2 | * DecisionFlowService (개선판) |
3 | 3 | * - 라인 생성 시 해당 BaseLine의 기본 브랜치(main)를 라인에 부착 |
4 | 4 | * - 기존 생성/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 보정한다. |
5 | 13 | */ |
| 14 | + |
6 | 15 | package com.back.domain.node.service; |
7 | 16 |
|
8 | 17 | import com.back.domain.node.dto.decision.*; |
|
24 | 33 | import org.springframework.transaction.annotation.Transactional; |
25 | 34 | import org.springframework.web.server.ResponseStatusException; |
26 | 35 |
|
27 | | -import java.util.List; |
| 36 | +import java.util.*; |
| 37 | +import java.util.function.BiFunction; |
| 38 | +import java.util.function.Function; |
28 | 39 |
|
29 | 40 | @Service |
30 | 41 | @RequiredArgsConstructor |
@@ -129,6 +140,11 @@ public DecNodeDto createDecisionNodeFromBase(DecisionNodeFromBaseRequest request |
129 | 140 | : baseNodeRepository.linkAlt2IfEmpty(fresh.getId(), saved.getId()); |
130 | 141 | if (updated == 0) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "branch slot was taken by another request"); |
131 | 142 |
|
| 143 | + // ★ 추가: (코리더 한정) 숨은 노드 포함 옵션 동기화 — 증분 Append, 순서 보존 |
| 144 | + // 가장 많이 사용하는 함수 호출 위에 한줄로만 요약 주석: 트리거 옵션 결정(입력값 우선, 없으면 엔티티에서 추출) |
| 145 | + List<String> leaderOpts = (opts != null && !opts.isEmpty()) ? opts : support.extractOptions(saved); |
| 146 | + syncOptionsAcrossAgeWithinCorridorLite(saved, leaderOpts); |
| 147 | + |
132 | 148 | DecNodeDto baseDto = mapper.toResponse(saved); |
133 | 149 | List<DecisionNode> orderedList = decisionNodeRepository.findByDecisionLine_IdOrderByAgeYearAscIdAsc(baseDto.decisionLineId()); |
134 | 150 | var hint = aiVectorService.generateNextHint(baseDto.userId(), baseDto.decisionLineId(), orderedList); |
@@ -218,11 +234,16 @@ public DecNodeDto createDecisionNodeNext(DecisionNodeNextRequest request) { |
218 | 234 | DecisionNode saved = decisionNodeRepository.save(mapper.toEntity(createReq)); |
219 | 235 | DecNodeDto baseDto = mapper.toResponse(saved); |
220 | 236 |
|
| 237 | + // ★ 추가: 코리더 한정 옵션 동기화(숨은 노드 포함, 증분 Append) |
| 238 | + List<String> leaderOpts = (request.options() != null && !request.options().isEmpty()) |
| 239 | + ? request.options() |
| 240 | + : support.extractOptions(saved); |
| 241 | + syncOptionsAcrossAgeWithinCorridorLite(saved, leaderOpts); |
| 242 | + |
221 | 243 | List<DecisionNode> ordered_decision = decisionNodeRepository |
222 | 244 | .findByDecisionLine_IdOrderByAgeYearAscIdAsc(baseDto.decisionLineId()); |
223 | 245 | var hint = aiVectorService.generateNextHint(baseDto.userId(), baseDto.decisionLineId(), ordered_decision); |
224 | 246 |
|
225 | | - |
226 | 247 | saved.setAiHint(hint.aiNextSituation(), hint.aiNextRecommendedOption()); |
227 | 248 | decisionNodeRepository.save(saved); |
228 | 249 |
|
@@ -434,6 +455,12 @@ public DecNodeDto forkFromDecision(ForkFromDecisionRequest req) { |
434 | 455 | DecisionNode saved = decisionNodeRepository.save(mapper.toEntity(createReq)); |
435 | 456 | prevNew = saved; |
436 | 457 |
|
| 458 | + // ★ 추가: 포크 진행 중에도 해당 age에서 코리더 한정 동기화(프렐류드 포함) |
| 459 | + List<String> leaderOptsHere = (options != null && !options.isEmpty()) |
| 460 | + ? options |
| 461 | + : support.extractOptions(saved); |
| 462 | + syncOptionsAcrossAgeWithinCorridorLite(saved, leaderOptsHere); |
| 463 | + |
437 | 464 | if (isParent) { |
438 | 465 | forkPointDto = mapper.toResponse(saved); |
439 | 466 | forkAnchorSaved = saved; // AI 힌트 저장 대상으로 보관 |
@@ -533,4 +560,133 @@ private DecisionNode createPreludeUntilPivot(DecisionLine line, |
533 | 560 | } |
534 | 561 | return prev; |
535 | 562 | } |
| 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 | + } |
536 | 692 | } |
0 commit comments