diff --git a/back/src/main/java/com/back/domain/search/entity/AgeTheme.java b/back/src/main/java/com/back/domain/search/entity/AgeTheme.java new file mode 100644 index 0000000..e96dde7 --- /dev/null +++ b/back/src/main/java/com/back/domain/search/entity/AgeTheme.java @@ -0,0 +1,42 @@ +/* + * 이 파일은 연령대+카테고리 테마 사전 엔티티를 정의한다. + * 흐름: 테마 문자열/임베딩 보관 → min_age~max_age 및 category로 필터링 + */ +package com.back.domain.search.entity; + +import com.back.domain.node.entity.NodeCategory; +import com.back.infra.pgvector.PgVectorConverter; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Table(name = "age_theme") +@Getter @Setter +@NoArgsConstructor @AllArgsConstructor @Builder +public class AgeTheme { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "min_age", nullable = false) + private int minAge; + + @Column(name = "max_age", nullable = false) + private int maxAge; + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false, length = 32) + private NodeCategory category; + + @Column(name = "theme", nullable = false, columnDefinition = "text") + private String theme; + + @JdbcTypeCode(SqlTypes.OTHER) + @Convert(converter = PgVectorConverter.class) + @Column(name = "embedding", nullable = false, columnDefinition = "vector(768)") + private float[] embedding; +} diff --git a/back/src/main/java/com/back/domain/search/entity/VocabTerm.java b/back/src/main/java/com/back/domain/search/entity/VocabTerm.java new file mode 100644 index 0000000..d74902b --- /dev/null +++ b/back/src/main/java/com/back/domain/search/entity/VocabTerm.java @@ -0,0 +1,31 @@ +/* + * [코드 흐름 요약] + * - '도메인 용어 사전'을 pgvector로 관리하기 위한 엔티티. + * - term(용어)와 embedding(vector)을 저장한다. + * - 검색은 임베딩 유사도(<=>)로 수행한다. + */ +package com.back.domain.search.entity; + +import com.back.infra.pgvector.PgVectorConverter; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Table(name = "vocab_term") +@Getter @Setter +@NoArgsConstructor @AllArgsConstructor @Builder +public class VocabTerm { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "term", nullable = false, unique = true, length = 128) + private String term; + + @JdbcTypeCode(SqlTypes.OTHER) + @Convert(converter = PgVectorConverter.class) + @Column(name = "embedding", nullable = false, columnDefinition = "vector(768)") + private float[] embedding; +} diff --git a/back/src/main/java/com/back/domain/search/repository/AgeThemeRepository.java b/back/src/main/java/com/back/domain/search/repository/AgeThemeRepository.java new file mode 100644 index 0000000..6af5c41 --- /dev/null +++ b/back/src/main/java/com/back/domain/search/repository/AgeThemeRepository.java @@ -0,0 +1,41 @@ +/* + * [코드 흐름 요약] + * - age 범위 + (선택) category로 필터하고 pgvector 유사도로 상위 K 테마를 가져온다. + * - 시더 중복 방지용으로 기존 테마 문자열을 조회하는 메서드를 제공한다. + */ +package com.back.domain.search.repository; + +import com.back.domain.node.entity.NodeCategory; +import com.back.domain.search.entity.AgeTheme; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface AgeThemeRepository extends JpaRepository { + + // next 노드 생성 + @Query(value = """ + SELECT theme FROM age_theme + WHERE :age BETWEEN min_age AND max_age + AND (:cat IS NULL OR category = :cat) + ORDER BY embedding <=> CAST(:q AS vector) + LIMIT :k + """, nativeQuery = true) + List topKThemesByAgeAndCategory( + @Param("age") int age, + @Param("cat") String categoryOrNull, // Enum이면 cat.name()으로 전달 + @Param("q") String vectorLiteral, + @Param("k") int k + ); + + // 무결성 검증 + long countByMinAge(int minAge); + long countByMinAgeAndCategory(int minAge, NodeCategory category); + + // 중복 방지용(시더에서 필요할 때만 사용) + @Query("select a.theme from AgeTheme a where a.minAge = :minAge and a.category = :category") + List findThemesByMinAgeAndCategory(@Param("minAge") int minAge, + @Param("category") NodeCategory category); +} diff --git a/back/src/main/java/com/back/domain/search/repository/VocabTermRepository.java b/back/src/main/java/com/back/domain/search/repository/VocabTermRepository.java new file mode 100644 index 0000000..3626faa --- /dev/null +++ b/back/src/main/java/com/back/domain/search/repository/VocabTermRepository.java @@ -0,0 +1,35 @@ +/* + * [코드 흐름 요약] + * - 쿼리 임베딩과 가까운 용어를 유사도(<=>) 순으로 상위 K개 조회한다. + * - 임베딩은 문자열 리터럴(CAST(:q AS vector))로 전달한다. + */ +package com.back.domain.search.repository; + +import com.back.domain.search.entity.VocabTerm; +import org.springframework.data.jpa.repository.*; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface VocabTermRepository extends JpaRepository { + + @Query(value = """ + SELECT * FROM vocab_term + ORDER BY embedding <=> CAST(:q AS vector) + LIMIT :k + """, nativeQuery = true) + List searchTopK( + @Param("q") String vectorLiteral, + @Param("k") int k + ); + + @Query(value = """ + SELECT term FROM vocab_term + ORDER BY embedding <=> CAST(:q AS vector) + LIMIT :k + """, nativeQuery = true) + List searchTopKTerms( + @Param("q") String vectorLiteral, + @Param("k") int k + ); +} diff --git a/back/src/main/java/com/back/global/ai/bootstrap/AgeThemePreseedScheduler.java b/back/src/main/java/com/back/global/ai/bootstrap/AgeThemePreseedScheduler.java new file mode 100644 index 0000000..02634ca --- /dev/null +++ b/back/src/main/java/com/back/global/ai/bootstrap/AgeThemePreseedScheduler.java @@ -0,0 +1,59 @@ +/* + * [코드 흐름 요약] + * - 최초 워밍업: 30초 간격 10회(≈5분) → 이후 5분 간격 전환(기존 유지). + * - 각 틱마다 age 커서를 1씩 이동하며 ensureSeedForAgeAsync(age, MIN_PER_CATEGORY) 호출(기존 유지). + * - 10만건 목표를 위해 MIN_PER_CATEGORY=120, 연령 구간을 3~120으로 확장. + */ +package com.back.global.ai.bootstrap; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicInteger; + +@Component +@Profile("dev") +@RequiredArgsConstructor +public class AgeThemePreseedScheduler { + + private final AgeThemeSeeder seeder; + + // 무결성 검증 + private static final int WARMUP_RUNS = 10; // 30초 간격으로 10회 + private static final int MIN_PER_CATEGORY = 120; // 카테고리당 최소 시드 개수(확대) + private static final int MIN_AGE = 3; // 순회 시작 연령(확장) + private static final int MAX_AGE = 120; // 순회 종료 연령(확장) + + private final AtomicInteger warmupCount = new AtomicInteger(0); + private final AtomicInteger ageCursor = new AtomicInteger(MIN_AGE); + + // next 노드 생성 + @Scheduled(initialDelay = 5_000, fixedRate = 30_000) // 첫 실행 5초 후, 30초 주기 + public void warmupPhase() { + int n = warmupCount.get(); + if (n >= WARMUP_RUNS) return; // 무결성 검증 + tick(); + warmupCount.incrementAndGet(); + } + + // 무결성 검증 + @Scheduled(initialDelay = 5 * 60_000, fixedRate = 5 * 60_000) // 5분 주기 + public void steadyPhase() { + if (warmupCount.get() < WARMUP_RUNS) return; // 워밍업 완료 전엔 대기 + tick(); + } + + // next 노드 생성 + private void tick() { + int age = nextAge(); + seeder.ensureSeedForAgeAsync(age, MIN_PER_CATEGORY); + } + + // 무결성 검증 + private int nextAge() { + int cur = ageCursor.getAndUpdate(v -> (v >= MAX_AGE) ? MIN_AGE : (v + 1)); + return cur; + } +} diff --git a/back/src/main/java/com/back/global/ai/bootstrap/AgeThemeSeeder.java b/back/src/main/java/com/back/global/ai/bootstrap/AgeThemeSeeder.java new file mode 100644 index 0000000..e888e77 --- /dev/null +++ b/back/src/main/java/com/back/global/ai/bootstrap/AgeThemeSeeder.java @@ -0,0 +1,299 @@ +/* + * [코드 흐름 요약] + * - ensureSeedForAgeAsync: 쓰로틀/중복 작업 가드 후 비동기 제출(기존 유지). + * - doSeed: 필요량 산출 → 대량 후보 생성(템플릿 조합) → 임베딩 배치 → DB 청크 저장. + * - 10만건 목표를 위해 배치 임베딩(256), 저장 청크(500), 텍스트 중복 방지(Set) 적용. + */ +package com.back.global.ai.bootstrap; + +import com.back.domain.node.entity.NodeCategory; +import com.back.domain.search.entity.AgeTheme; +import com.back.domain.search.repository.AgeThemeRepository; +import com.back.global.ai.vector.EmbeddingClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AgeThemeSeeder { + + private final AgeThemeRepository repo; + private final EmbeddingClient embedding; + + private final Set inProgress = ConcurrentHashMap.newKeySet(); + private final ExecutorService exec = Executors.newFixedThreadPool(2); // next 노드 생성 + + // 무결성 검증 + private final ConcurrentHashMap lastCheckMs = new ConcurrentHashMap<>(); + private final long THROTTLE_MS = 10 * 60 * 1000L; // 10분으로 조정(중복 경쟁 감소) + + // 무결성 검증 + private static final int EMBED_BATCH = 256; + private static final int SAVE_BATCH = 500; + private static final int MAX_GEN_MULTIPLIER = 4; // 필요량 대비 후보 오버샘플 + private static final int MAX_NEW_PER_AGE = 1000; + // next 노드 생성 + public void ensureSeedForAgeAsync(int age, int minPerCategory) { + long now = System.currentTimeMillis(); + long prev = lastCheckMs.getOrDefault(age, 0L); + if (now - prev < THROTTLE_MS) { + log.debug("[SEEDER] throttle skip age={} remainMs={}", age, (THROTTLE_MS - (now - prev))); + return; + } + lastCheckMs.put(age, now); + + long have = repo.countByMinAge(age); + long needThreshold = (long) minPerCategory * NodeCategory.values().length; + if (have >= needThreshold) { + log.debug("[SEEDER] enough data age={} have={} need>={}", age, have, needThreshold); + return; + } + if (!inProgress.add(age)) { + log.debug("[SEEDER] already in progress age={}", age); + return; + } + + log.info("[SEEDER] submit task age={} minPerCategory={} have={} need>={}", age, minPerCategory, have, needThreshold); + exec.submit(() -> { + try { + doSeed(age, minPerCategory); + log.info("[SEEDER] done age={}", age); + } catch (Exception e) { + log.error("[SEEDER] failed age={} msg={}", age, e.getMessage(), e); + } finally { + inProgress.remove(age); + } + }); + } + + // 무결성 검증 + @Transactional(propagation = Propagation.REQUIRES_NEW) + protected void doSeed(int age, int minPerCategory) { + int insertedTotal = 0; + for (NodeCategory cat : NodeCategory.values()) { + if (insertedTotal >= MAX_NEW_PER_AGE) break; + int have = Math.toIntExact(repo.countByMinAgeAndCategory(age, cat)); + int need = Math.max(0, minPerCategory - have); + if (need == 0) { + log.debug("[SEEDER] category skip age={} category={} have>={}", age, cat, minPerCategory); + continue; + } + + Set existing = new HashSet<>(repo.findThemesByMinAgeAndCategory(age, cat)); + List acts = new ArrayList<>(actions(cat)); + List hints = new ArrayList<>(hintsForAge(age)); + if (acts.isEmpty() || hints.isEmpty()) { + log.warn("[SEEDER] no template age={} category={} acts={} hints={}", age, cat, acts.size(), hints.size()); + continue; + } + + // next 노드 생성 + List candidates = generateCandidates(age, cat, hints, acts, need * MAX_GEN_MULTIPLIER); + if (candidates.isEmpty()) continue; + + // 무결성 검증 + List uniques = new ArrayList<>(need); + for (String c : candidates) { + String t = tidy(c); + if (existing.add(t)) uniques.add(t); + if (uniques.size() >= need) break; + } + if (uniques.isEmpty()) { + log.debug("[SEEDER] no unique candidates age={} category={}", age, cat); + continue; + } + + // next 노드 생성 + List vectors = embedBatch(uniques); + + List buffer = new ArrayList<>(SAVE_BATCH); + for (int i = 0; i < uniques.size(); i++) { + if (insertedTotal >= MAX_NEW_PER_AGE) break; // 무결성 검증 + buffer.add(AgeTheme.builder() + .minAge(age).maxAge(age).category(cat) + .theme(uniques.get(i)).embedding(vectors.get(i)) + .build()); + if (buffer.size() == SAVE_BATCH || i == uniques.size() - 1) { + repo.saveAll(buffer); // next 노드 생성 + insertedTotal += buffer.size(); + buffer.clear(); + } + } + log.info("[SEEDER] saved age={} category={} added={} total={}", + age, cat, Math.min(need, MAX_NEW_PER_AGE - insertedTotal + need), insertedTotal); + } + } + + // 무결성 검증 + private List embedBatch(List texts) { + List out = new ArrayList<>(texts.size()); + for (int i = 0; i < texts.size(); i += EMBED_BATCH) { + int to = Math.min(texts.size(), i + EMBED_BATCH); + List sub = texts.subList(i, to); + try { + out.addAll(embedding.embedBatch(sub)); + } catch (Throwable t) { + for (String s : sub) out.add(embedding.embed(s)); + } + } + return out; + } + + // 무결성 검증 + private String tidy(String s) { return s.replaceAll("\\s+", " ").trim(); } + + // next 노드 생성 + private List generateCandidates( + int age, NodeCategory cat, + List hints, List acts, + int max + ) { + // 무결성 검증: 불변 리스트 -> 가변 리스트로 복사 + List H = new ArrayList<>(hints); + List A = new ArrayList<>(acts); + List actors = new ArrayList<>(actors(cat)); + List objects = new ArrayList<>(objects(cat)); + List mods = new ArrayList<>(modifiers()); + List times = new ArrayList<>(timePhrases()); + List places = new ArrayList<>(placePhrases(cat)); + + // 무결성 검증 + if (H.isEmpty() || A.isEmpty() || actors.isEmpty() || objects.isEmpty()) { + return Collections.emptyList(); + } + + List templates = List.of( + "%s | %s %s %s", + "%s | %s %s %s (%s)", + "%s | %s %s %s - %s", + "%s | %s %s %s @%s", + "%s | %s가 %s %s (%s @%s)" + ); + + // 무결성 검증: 결정적 셔플(재현성) + Random r = new Random(31_557_600L + age * 131 + cat.ordinal() * 17); + Collections.shuffle(H, r); + Collections.shuffle(A, r); + Collections.shuffle(actors, r); + Collections.shuffle(objects, r); + Collections.shuffle(mods, r); + Collections.shuffle(times, r); + Collections.shuffle(places, r); + + // next 노드 생성 + List out = new ArrayList<>(max); + for (int i = 0; i < max; i++) { + String t = templates.get(i % templates.size()); + String s = switch (t) { + case "%s | %s %s %s" -> + String.format(t, H.get(i % H.size()), actors.get(i % actors.size()), A.get(i % A.size()), objects.get(i % objects.size())); + case "%s | %s %s %s (%s)" -> + String.format(t, H.get(i % H.size()), actors.get(i % actors.size()), A.get(i % A.size()), objects.get(i % objects.size()), times.get(i % times.size())); + case "%s | %s %s %s - %s" -> + String.format(t, H.get(i % H.size()), actors.get(i % actors.size()), A.get(i % A.size()), objects.get(i % objects.size()), mods.get(i % mods.size())); + case "%s | %s %s %s @%s" -> + String.format(t, H.get(i % H.size()), actors.get(i % actors.size()), A.get(i % A.size()), objects.get(i % objects.size()), places.get(i % places.size())); + default -> + String.format(t, H.get(i % H.size()), actors.get(i % actors.size()), A.get(i % A.size()), objects.get(i % objects.size()), times.get(i % times.size()), places.get(i % places.size())); + }; + out.add(s); + } + return out; + } + + // next 노드 생성 + private List actions(NodeCategory cat) { + return switch (cat) { + case EDUCATION -> List.of("과제 정리","강의 복습","프로젝트 준비","발표 연습","스터디 합류","요약 노트", + "모의고사 분석","멘토 질의","레포트 초안","탐구 설계","팀 프로젝트 조율","피드백 반영"); + case CAREER -> List.of("이력서 업데이트","포트폴리오 정리","인턴 지원","멘토 미팅","면접 준비","기업 분석", + "직무 과제 연습","링크드인 정비","사내 세미나 참가","자격증 학습","직무 독서","해커톤 준비"); + case RELATIONSHIP -> List.of("팀 협업","피드백 교환","네트워킹","동아리 활동","행사 운영","면담 준비", + "협업 회의","갈등 중재","튜터링","스터디 운영","커뮤니티 참여","봉사 연계"); + case FINANCE -> List.of("예산 점검","지출 기록","지원금 탐색","저축 계획","보험 점검","가계부 정리", + "투자 포트폴리오 점검","세금 공제 확인","비상금 적립","구독 정리","비용 최적화","장학금 신청"); + case HEALTH -> List.of("수면 루틴","운동 습관","식단 관리","스트레칭","정기 검진","휴식 스케줄", + "명상 루틴","보행 기록","물 섭취 체크","자세 교정","체력 측정","건강 일지"); + case LOCATION -> List.of("동선 최적화","캠퍼스 맵 확인","시설 파악","교통패스 점검","학기 동선 계획","스터디카페 탐색", + "통학 시간 계산","근처 병원 파악","헬스장 등록","서점/도서관 루트","코워킹스페이스 탐색","야간 귀가 경로"); + case ETC -> List.of("시간 관리","디지털 정리","기록 루틴","취미 프로젝트","주간 회고","도구 템플릿 정비", + "자동화 스크립트","알림 규칙 정비","백업 점검","버전 관리","목표 설정","루틴 최적화"); + }; + } + + // 무결성 검증 + private List hintsForAge(int age) { + if (age <= 7) return List.of("기초 루틴","놀이 중심","정서 안정","생활 습관","부모 참여","기초 체력"); + if (age <= 13) return List.of("중간·기말","수행평가","학습 습관","독서 루틴","기초 코딩","기초 수학"); + if (age <= 16) return List.of("고입 준비","내신 관리","진로 탐색","동아리 심화","실험 보고","탐구 기록"); + if (age <= 19) return List.of("모의고사","수능 대비","자기소개서","학생부 보강","로드맵 설계","컨디션 관리"); + if (age == 20) return List.of("전공 기초","OT/동아리","캠퍼스 리소스","교양 확장","멘토 찾기","학사 시스템"); + if (age <= 23) return List.of("전공 심화","현장실습","포트폴리오","학회/세미나","프로젝트 리딩","자격 준비"); + if (age <= 29) return List.of("인턴/첫 직장","직무 역량","멘토링","사내 적응","사이드 프로젝트","업계 네트워킹"); + if (age <= 39) return List.of("전문성","리더십","성과 관리","영향력 확대","스킬 매트릭스","OKR/성과지표"); + if (age <= 49) return List.of("코칭","팀 빌딩","로드맵","조직 협업","프로세스 개선","후배 양성"); + if (age <= 59) return List.of("세컨 커리어","건강/재정","은퇴 준비","가족 계획","지역 커뮤니티","지식 기여"); + if (age <= 69) return List.of("퇴직 설계","커뮤니티","건강 루틴","소일거리","자원봉사","여가 기획"); + if (age <= 79) return List.of("사회공헌","관계 유지","여가 설계","건강 모니터링","경험 공유","멘토 역할"); + if (age <= 89) return List.of("생활 단순화","회상","가족 소통","취미 심화","건강 관리","지역 서비스"); + return List.of("돌봄 연계","일상 기능","의료 연계","지역 지원망","생활 안정","정서적 교류"); + } + + // 무결성 검증 + private List actors(NodeCategory cat) { + return switch (cat) { + case EDUCATION -> List.of("나","팀원","멘토","조교","교수","튜터"); + case CAREER -> List.of("나","동료","리쿠루터","팀 리드","멘토","코치"); + case RELATIONSHIP -> List.of("나","친구","동아리원","파트너","동료","커뮤니티"); + case FINANCE -> List.of("나","가족","설계사","상담사","회계사","은행원"); + case HEALTH -> List.of("나","트레이너","의사","치료사","동호회","코치"); + case LOCATION -> List.of("나","동행자","안내센터","시설 담당","사서","매니저"); + case ETC -> List.of("나","동료","운영진","관리자","봇","시스템"); + }; + } + + // 무결성 검증 + private List objects(NodeCategory cat) { + return switch (cat) { + case EDUCATION -> List.of("자료","발표","보고서","레포트","실험","과제"); + case CAREER -> List.of("이력서","포트폴리오","직무과제","커버레터","평판요청","과제제출"); + case RELATIONSHIP -> List.of("회의안건","협업툴","피드백노트","행사기획","네트워킹리스트","연락처"); + case FINANCE -> List.of("예산표","가계부","보험내역","투자현황","납부일정","청구서"); + case HEALTH -> List.of("식단표","운동계획","검진표","수면기록","활동량","케어플랜"); + case LOCATION -> List.of("이동경로","시설목록","정기권","주차정보","좌석배치","출입권한"); + case ETC -> List.of("템플릿","백업","자동화","버전노트","태스크보드","알림규칙"); + }; + } + + // 무결성 검증 + private List modifiers() { + return List.of("신속히","정확히","가볍게","심화로","체계적으로","꾸준히","집중해서","효율적으로","간결하게","확장해서"); + } + + // 무결성 검증 + private List timePhrases() { + return List.of("오늘","이번 주","다음 주","월초","월말","분기 내","주말","야간","아침 루틴","점심 시간"); + } + + // 무결성 검증 + private List placePhrases(NodeCategory cat) { + return switch (cat) { + case EDUCATION -> List.of("도서관","공학관","스터디룸","랩실","온라인"); + case CAREER -> List.of("사무실","회의실","온보딩룸","온라인","코워킹스페이스"); + case RELATIONSHIP -> List.of("동아리방","라운지","행사장","카페","온라인"); + case FINANCE -> List.of("은행","가정","상담센터","앱","온라인"); + case HEALTH -> List.of("헬스장","공원","병원","재활센터","집"); + case LOCATION -> List.of("캠퍼스","지하철","버스터미널","주차장","도서관"); + case ETC -> List.of("집","작업실","서버룸","클라우드","온라인"); + }; + } +} diff --git a/back/src/main/java/com/back/global/ai/bootstrap/SeedCronRunner.java b/back/src/main/java/com/back/global/ai/bootstrap/SeedCronRunner.java new file mode 100644 index 0000000..51d5424 --- /dev/null +++ b/back/src/main/java/com/back/global/ai/bootstrap/SeedCronRunner.java @@ -0,0 +1,114 @@ +/* + * [코드 흐름 요약] + * - @Scheduled로 30초 틱, 10틱마다(=5분) 시더 호출(기존 유지). + * - 10만건 목표를 위해 minPerCat=120로 상향, pickTargetAgeHeuristic는 추후 교체 지점으로 유지. + */ +package com.back.global.ai.bootstrap; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +@Component +@Profile("dev") +@RequiredArgsConstructor +public class SeedCronRunner { + + private final AgeThemeSeeder seeder; + + // 무결성 검증 + private volatile Mode mode = Mode.IDLE; + private volatile long silenceUntilMs = 0L; + private volatile long lastRequestMs = 0L; + private final AtomicInteger activeTicks = new AtomicInteger(0); + + private enum Mode { IDLE, ACTIVE, SLEEP } + + // next 노드 생성 + public void onAiRequest() { + lastRequestMs = System.currentTimeMillis(); + silenceUntilMs = lastRequestMs + Duration.ofMinutes(30).toMillis(); + if (mode != Mode.SLEEP) { + mode = Mode.IDLE; + activeTicks.set(0); + log.info("[SEED-CRON] request received → mode=IDLE silence=30m"); + } else { + log.info("[SEED-CRON] request received while SLEEP → stay SLEEP"); + } + } + + // next 노드 생성 + public void wakeUp() { + if (mode == Mode.SLEEP) { + mode = Mode.IDLE; + log.info("[SEED-CRON] wakeUp → mode=IDLE"); + } + } + + // 무결성 검증 + public void sleepIfIdle() { + long now = System.currentTimeMillis(); + if (mode == Mode.ACTIVE && now - lastRequestMs >= Duration.ofHours(1).toMillis()) { + mode = Mode.SLEEP; + activeTicks.set(0); + log.info("[SEED-CRON] no requests for 1h → mode=SLEEP"); + } + } + + // next 노드 생성 + @Scheduled(fixedDelayString = "PT30S") + public void tick() { + long now = System.currentTimeMillis(); + + if (now < silenceUntilMs) { + if (mode != Mode.IDLE) mode = Mode.IDLE; + log.debug("[SEED-CRON] tick skipped (silence) remainMs={}", (silenceUntilMs - now)); + return; + } + + if (mode == Mode.SLEEP) { + log.debug("[SEED-CRON] tick ignored (SLEEP)"); + return; + } + + if (mode == Mode.IDLE) { + mode = Mode.ACTIVE; + activeTicks.set(0); + log.info("[SEED-CRON] switch → mode=ACTIVE (start 5m cadence)"); + } + + int n = activeTicks.incrementAndGet(); + boolean doWork = (n % 10 == 0); // 30초 × 10 = 5분 + log.debug("[SEED-CRON] tick n={} doWork={}", n, doWork); + + if (doWork) { + int targetAge = pickTargetAgeHeuristic(); // 유지 + int minPerCat = 120; // 확대 + log.info("[SEED-CRON] run seeder age={} minPerCat={}", targetAge, minPerCat); + seeder.ensureSeedForAgeAsync(targetAge, minPerCat); + } + + if (n >= 120) { // 5분 간격 × 12회 = 60분 + if (System.currentTimeMillis() - lastRequestMs >= Duration.ofHours(1).toMillis()) { + mode = Mode.SLEEP; + activeTicks.set(0); + log.info("[SEED-CRON] 12 runs done & idle 1h → mode=SLEEP"); + } else { + mode = Mode.IDLE; + activeTicks.set(0); + log.info("[SEED-CRON] 12 runs done with request seen → mode=IDLE"); + } + } + } + + // 무결성 검증 + private int pickTargetAgeHeuristic() { + return 24; // 임시값(유지) + } +} diff --git a/back/src/main/java/com/back/global/ai/bootstrap/SeedOrchestrator.java b/back/src/main/java/com/back/global/ai/bootstrap/SeedOrchestrator.java new file mode 100644 index 0000000..4e49103 --- /dev/null +++ b/back/src/main/java/com/back/global/ai/bootstrap/SeedOrchestrator.java @@ -0,0 +1,181 @@ +/* + * [코드 흐름 요약] + * 1) 앱 시작 시 제한 병렬 워밍업 제출 후 Quiet 전환(기존 유지). + * 2) Quiet 30분 무요청 시 5분 간격 주기 시드 시작, 12회 후 SLEEP(기존 유지). + * 3) 10만건 목표를 위해 기본값: minPerCategory=120, 연령 범위 3~120로 확대. + */ +package com.back.global.ai.bootstrap; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +@Component +@Profile({"test","prod"}) +@RequiredArgsConstructor +public class SeedOrchestrator { + + private final AgeThemeSeeder seeder; + + // 무결성 검증 + private enum State { STARTUP_WARMUP, QUIET_WAIT, PERIODIC_SEEDING, SLEEP } + + // next 노드 생성 + @Value("${seed.min-per-category:120}") + private int minPerCategory; + + // 무결성 검증 + @Value("${seed.startup.max-minutes:5}") + private int startupMaxMinutes; + + // 무결성 검증 + @Value("${seed.periodic.interval-minutes:5}") + private int periodicIntervalMinutes; + + // 무결성 검증 + @Value("${seed.quiet.wait-minutes:30}") + private int quietWaitMinutes; + + // 무결성 검증 + @Value("${seed.periodic.max-iterations:12}") + private int periodicMaxIterations; + + // next 노드 생성 + @Value("${seed.age.min:3}") + private int minAge; + @Value("${seed.age.max:120}") + private int maxAge; + + // 무결성 검증 + private final AtomicLong lastRequestAt = new AtomicLong(0L); + private final AtomicLong lastSeedTickAt = new AtomicLong(0L); + private final AtomicInteger periodicCount = new AtomicInteger(0); + private final AtomicInteger ageCursor = new AtomicInteger(0); + + // 무결성 검증 + private volatile State state = State.STARTUP_WARMUP; + + // next 노드 생성 + private final ExecutorService startupPool = new ThreadPoolExecutor( + Math.max(1, Math.min(Runtime.getRuntime().availableProcessors() / 2, 4)), + Math.max(1, Math.min(Runtime.getRuntime().availableProcessors() / 2, 4)), + 30L, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(64), + r -> { + Thread t = new Thread(r, "seed-startup"); + t.setDaemon(true); + return t; + }, + new ThreadPoolExecutor.DiscardPolicy() + ); + + // 무결성 검증 + @EventListener(ApplicationReadyEvent.class) + public void onReady() { + ageCursor.set(minAge); + long deadlineMs = System.currentTimeMillis() + Duration.ofMinutes(startupMaxMinutes).toMillis(); + + // next 노드 생성 + for (int age = minAge; age <= maxAge; age++) { + if (System.currentTimeMillis() >= deadlineMs) break; + final int a = age; + startupPool.submit(() -> seeder.ensureSeedForAgeAsync(a, minPerCategory)); + } + + // 무결성 검증 + startupPool.shutdown(); + state = State.QUIET_WAIT; + lastRequestAt.set(System.currentTimeMillis()); + periodicCount.set(0); + lastSeedTickAt.set(0L); + } + + // next 노드 생성 + public void onAiRequestEvent() { + lastRequestAt.set(System.currentTimeMillis()); + periodicCount.set(0); + lastSeedTickAt.set(0L); + state = State.QUIET_WAIT; + } + + // 무결성 검증 + @Scheduled(fixedRate = 60_000, initialDelay = 60_000) + public void heartbeat() { + long now = System.currentTimeMillis(); + + switch (state) { + case QUIET_WAIT -> { + long quietMs = Duration.ofMinutes(quietWaitMinutes).toMillis(); + long lastReq = lastRequestAt.get(); + if (lastReq > 0 && now - lastReq >= quietMs) { + state = State.PERIODIC_SEEDING; + periodicCount.set(0); + lastSeedTickAt.set(0L); + } + } + case PERIODIC_SEEDING -> { + if (recentRequestWithin(Duration.ofMinutes(quietWaitMinutes).toMillis(), now)) { + state = State.QUIET_WAIT; + periodicCount.set(0); + lastSeedTickAt.set(0L); + return; + } + long intervalMs = Duration.ofMinutes(periodicIntervalMinutes).toMillis(); + long lastTick = lastSeedTickAt.get(); + if (lastTick == 0L || now - lastTick >= intervalMs) { + performOnePeriodicTick(); + lastSeedTickAt.set(now); + int c = periodicCount.incrementAndGet(); + if (c >= periodicMaxIterations && !recentRequestWithin(intervalMs * periodicMaxIterations, now)) { + state = State.SLEEP; + } + } + } + case SLEEP -> { + if (recentRequestWithin(Duration.ofMinutes(quietWaitMinutes).toMillis(), now)) { + state = State.QUIET_WAIT; + } + } + case STARTUP_WARMUP -> { + state = State.QUIET_WAIT; + } + } + } + + // 무결성 검증 + private boolean recentRequestWithin(long windowMs, long nowMs) { + long lastReq = lastRequestAt.get(); + return lastReq > 0 && (nowMs - lastReq) < windowMs; + } + + // next 노드 생성 + private void performOnePeriodicTick() { + int a = nextAge(); + seeder.ensureSeedForAgeAsync(a, minPerCategory); + } + + // 무결성 검증 + private int nextAge() { + int cur = ageCursor.get(); + if (cur < minAge || cur > maxAge) { + cur = minAge; + ageCursor.set(cur); + return cur; + } + int next = (cur >= maxAge) ? minAge : (cur + 1); + ageCursor.set(next); + return cur; + } + + // 무결성 검증 + public State currentState() { return state; } +} diff --git a/back/src/main/java/com/back/global/ai/config/SchedulingConfig.java b/back/src/main/java/com/back/global/ai/config/SchedulingConfig.java new file mode 100644 index 0000000..beec4d1 --- /dev/null +++ b/back/src/main/java/com/back/global/ai/config/SchedulingConfig.java @@ -0,0 +1,17 @@ +/* + * [코드 흐름 요약] + * - Spring @Scheduled 기능 전역 활성화. + * - 프로필 제약 없음(스케줄러 자체는 @Profile("!test")로 제한). + */ +package com.back.global.ai.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { + // next 노드 생성 + // 빈 없음: EnableScheduling 활성화 전용 + // 무결성 검증 +} diff --git a/back/src/main/java/com/back/global/ai/vector/AIVectorServiceImpl.java b/back/src/main/java/com/back/global/ai/vector/AIVectorServiceImpl.java index 1532d3b..8c18554 100644 --- a/back/src/main/java/com/back/global/ai/vector/AIVectorServiceImpl.java +++ b/back/src/main/java/com/back/global/ai/vector/AIVectorServiceImpl.java @@ -1,11 +1,18 @@ /* - * 이 파일은 pgvector 기반 얇은 콘텍스트와 이전 경로 요약으로 초경량 프롬프트를 생성하여 - * 제미나이를 동기 호출하고 JSON 2필드(situation, recommendedOption)만 추출한다. - * 라인 전환 섞임 방지를 위해 항상 lineId/age 윈도우 필터를 사용한다. + * [코드 흐름 요약] + * - DB 조회 최소화(요청 단위 캐시) + 폴라리티 안정화 + 도메인 정렬: + * 1) 트리거-카테고리 도메인 일치 시에만 강제 부정(약부정은 Phase에서 무시), 키워드 합계<2는 약부정으로 처리. + * 2) 위기 종료 시 '회복 보장 락(positiveLock)'으로 N회 무조건 긍정 유지. + * 3) 카테고리 정렬 few-shot(긍/부정)을 사용하여 재무 문장으로 끌리는 현상 방지. + * 4) recent tail은 decision 중심 요약으로 부정 단어 누수 차단. + * 5) 헤더 제외 본문만으로 콘텍스트/테마/폴라리티/검증/재시도를 1회 계산. */ package com.back.global.ai.vector; import com.back.domain.node.entity.DecisionNode; +import com.back.domain.node.entity.NodeCategory; +import com.back.global.ai.bootstrap.AgeThemeSeeder; +import com.back.global.ai.bootstrap.SeedOrchestrator; import com.back.global.ai.client.text.TextAiClient; import com.back.global.ai.config.SituationAiProperties; import com.back.global.ai.dto.AiRequest; @@ -15,8 +22,9 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import java.util.List; -import java.util.Map; +import java.util.*; +import java.util.regex.Pattern; +import java.util.stream.Collectors; @Service @Profile("!test & !test-pg") @@ -27,42 +35,405 @@ public class AIVectorServiceImpl implements AIVectorService { private final AIVectorServiceSupportDomain support; private final SituationAiProperties props; private final ObjectMapper objectMapper; + private final VocabTermSearchService vocabSearch; + private final AgeThemeSearchService ageThemeSearch; + private final AgeThemeSeeder ageThemeSeeder; + private final SeedOrchestrator seedOrchestrator; - // 프로퍼티 바인딩(기본값은 예시이며 yml로 조정) private int topK = 1; private int contextCharLimit = 200; - private int maxOutputTokens = 48; + private int maxOutputTokens = 64; public void setTopK(int topK) { this.topK = topK; } public void setContextCharLimit(int contextCharLimit) { this.contextCharLimit = contextCharLimit; } public void setMaxOutputTokens(int maxOutputTokens) { this.maxOutputTokens = maxOutputTokens; } - // 경로 요약 + 라인/나이 윈도우 RAG로 프롬프트를 최소화한 뒤 AI 힌트를 생성한다. + // 무결성 검증 + private enum Polarity { SEVERE_NEGATIVE, POSITIVE } + + // 무결성 검증 + private enum NegTrigger { + FINANCE_CRYPTO(new String[]{"비트코인","코인","가상화폐"}), + FINANCE_STOCKS(new String[]{"주식","종목","etf","선물","옵션","레버리지","인버스","공매도"}), + FINANCE_RISK(new String[]{"빚투","영끌","대출연체","신용불량","파산","압류","추심"}), + EDU_FAIL(new String[]{"성적하락","낙제","유급","재수강","중퇴","수능폭망","입시실패","논문리젝","지도교수갈등"}), + CAREER_LOSS(new String[]{"해고","권고사직","실직","계약해지","평가하락","프로젝트실패","성과부진"}), + REL_BREAK(new String[]{"이별","파혼","이혼","연락두절","갈등심화","신뢰붕괴"}), + HEALTH_STRAIN(new String[]{"과로","번아웃","수면장애","부상","질병진단","건강악화"}), + LOC_HOUSING(new String[]{"전세사기","퇴거통보","주거불안","이사파기","침수피해","정전","단수"}), + ETC_FAMILY(new String[]{"돌봄위기","가족간갈등","갑작스런사고","반려동물질병","일정전면취소"}); + + final String[] keywords; + NegTrigger(String[] k){ this.keywords = k; } + } + + // 무결성 검증 + private record NegMatch(NegTrigger trigger, boolean severe, int hitCount) {} + + // next 노드 생성 + private record PhaseDecision(Polarity polarity, AIVectorServiceSupportDomain.Phase phase, NegMatch negHint) {} + + // 무결성 검증 + private enum Domain { FINANCE, EDUCATION, CAREER, RELATION, HEALTH, HOUSING, FAMILY, GENERIC } + + // 무결성 검증 + private Domain mapCategory(NodeCategory cat) { + if (cat == null) return Domain.GENERIC; + return switch (cat) { + case FINANCE -> Domain.FINANCE; + case EDUCATION -> Domain.EDUCATION; + case CAREER -> Domain.CAREER; + case RELATIONSHIP -> Domain.RELATION; + case HEALTH -> Domain.HEALTH; + case LOCATION -> Domain.HOUSING; + default -> Domain.GENERIC; + }; + } + + // 무결성 검증 + private Domain mapTrigger(NegTrigger t) { + return switch (t) { + case FINANCE_CRYPTO, FINANCE_STOCKS, FINANCE_RISK -> Domain.FINANCE; + case EDU_FAIL -> Domain.EDUCATION; + case CAREER_LOSS -> Domain.CAREER; + case REL_BREAK -> Domain.RELATION; + case HEALTH_STRAIN -> Domain.HEALTH; + case LOC_HOUSING -> Domain.HOUSING; + case ETC_FAMILY -> Domain.FAMILY; + }; + } + @Override public AiNextHint generateNextHint(Long userId, Long decisionLineId, List orderedNodes) { - if (orderedNodes == null || orderedNodes.isEmpty()) { - return new AiNextHint(null, null); + // next 노드 생성 + seedOrchestrator.onAiRequestEvent(); + if (orderedNodes == null || orderedNodes.isEmpty()) return new AiNextHint(null, null); + + try { + // 무결성 검증 + support.beginRequestCache(); + + List body = dropHeader(orderedNodes); + if (body.isEmpty()) return new AiNextHint(null, null); + + DecisionNode last = body.get(body.size() - 1); + int currAge = last.getAgeYear(); + + Integer targetAge = suggestNextAgeHybrid(decisionLineId, currAge); + NodeCategory effectiveCategory = resolveEffectiveCategory(decisionLineId, last); + + // next 노드 생성 + String recent = buildRecentTail(body, 2); + List prevOptions = collectRecentDecisions(body, 5); + + // next 노드 생성 + String queryForSearch = support.buildQueryFromNodes(body); + List ctxSnippets = support.searchRelatedContexts( + decisionLineId, targetAge, queryForSearch, topK, Math.max(120, contextCharLimit / Math.max(1, topK)) + ); + String relatedContext = support.joinWithLimit(ctxSnippets, contextCharLimit); + + // next 노드 생성 + List ageThemes = safeAgeThemes(decisionLineId, targetAge, effectiveCategory, recent, relatedContext, 12); + String requiredTheme = pickRequiredThemeRelaxed(ageThemes); + Set banned = new HashSet<>(labelStopWords()); + String grounding = buildGroundingForOptions(targetAge, recent, relatedContext, ageThemes, banned, prevOptions); + String hidden = fetchHiddenContext(decisionLineId); + + String triggerSource = ((recent == null ? "" : recent) + " " + (relatedContext == null ? "" : relatedContext)).trim(); + + // 무결성 검증 + AIVectorServiceSupportDomain.Phase phase = support.currentPhase(decisionLineId); + boolean lastSev = support.lastPolarityWasSevere(decisionLineId); + + // 무결성 검증 + Polarity pol0 = decidePolaritySeeded( + decisionLineId, targetAge, triggerSource, phase, lastSev, effectiveCategory); + NegMatch negMatch = detectGlobalNegative(triggerSource); + + // next 노드 생성 + PhaseDecision pdec = decidePhaseAndOverride(decisionLineId, targetAge, triggerSource, effectiveCategory, pol0, negMatch); + + String prompt1 = buildPromptFreeSituationWithPolarityAndNegHint( + requiredTheme, ageThemes, banned, recent, relatedContext, grounding, hidden, prevOptions, + pdec.polarity(), pdec.negHint(), effectiveCategory + ) + buildRecoveryBlockIfNeeded(pdec.phase()); + + AiNextHint hint = callOnce(prompt1); + + boolean needRetry = + !isSituationForm(hint.aiNextSituation()) + || !isOptionForm(hint.aiNextRecommendedOption()) + || hasDigitsOrAges(hint.aiNextSituation()) + || violatesRequiredThemeRelaxed(hint.aiNextSituation(), requiredTheme) + || !matchesPolarity(hint.aiNextSituation(), hint.aiNextRecommendedOption(), pdec.polarity()) + || violatesSafetyPolicy(hint.aiNextSituation(), hint.aiNextRecommendedOption()); + + if (needRetry) { + String prompt2 = buildPromptFreeSituationHard( + requiredTheme, ageThemes, banned, recent, relatedContext, grounding, hidden, prevOptions + ) + "\n[결과 경향]\n" + buildPolarityBlock(pdec.polarity()) + + "\n" + buildFewShotForPolarity(pdec.polarity(), effectiveCategory) + + "\n" + buildNegTriggerHint(pdec.negHint()) + + buildRecoveryBlockIfNeeded(pdec.phase()) + + "\n[안전 수칙]\n- 자해/증오/불법 조장 금지. 위반 표현 발견 시 안전한 대안으로 치환."; + hint = callOnce(prompt2); + } + + // 무결성 검증 + support.tickPhase(decisionLineId, pdec.phase()); + support.rememberPolarity(decisionLineId, pdec.polarity() == Polarity.POSITIVE ? "POS" : "SEV"); + + return new AiNextHint(emptyToNull(hint.aiNextSituation()), emptyToNull(hint.aiNextRecommendedOption())); + } finally { + // 무결성 검증 + support.clearCache(); } + } - int currAge = orderedNodes.get(orderedNodes.size() - 1).getAgeYear(); + // next 노드 생성 + private List dropHeader(List nodes) { + if (nodes == null || nodes.isEmpty()) return List.of(); + if (nodes.get(0).getParent() == null) { + if (nodes.size() == 1) return List.of(); + return new ArrayList<>(nodes.subList(1, nodes.size())); + } + List filtered = new ArrayList<>(nodes.size()); + for (DecisionNode n : nodes) if (n.getParent() != null) filtered.add(n); + return filtered; + } - // 질의(경로 요약) - String query = support.buildQueryFromNodes(orderedNodes); + private NodeCategory resolveEffectiveCategory(Long decisionLineId, DecisionNode lastNode) { + NodeCategory cat = toNodeCategory(lastNode != null ? lastNode.getCategory() : null); + if (cat == null && lastNode != null && lastNode.getBaseNode() != null) { + cat = toNodeCategory(lastNode.getBaseNode().getCategory()); + } + if (cat == null) { + try { + Object fromBaseCat = support.fromBaseCategory(decisionLineId); + cat = toNodeCategory(fromBaseCat); + } catch (Exception ignore) {} + } + return cat; + } - // 관련 스니펫 상위 K - List ctxSnippets = support.searchRelatedContexts( - decisionLineId, currAge, query, topK, Math.max(120, contextCharLimit / Math.max(1, topK)) - ); - String relatedContext = support.joinWithLimit(ctxSnippets, contextCharLimit); + private Integer suggestNextAgeHybrid(Long decisionLineId, int currAge) { + try { + Integer viaSupport = support.suggestNextAgeForLine(decisionLineId, currAge); + if (viaSupport != null && viaSupport > currAge) return viaSupport; + int step = (currAge < 19) ? 1 : 2; + return currAge + step; + } catch (Exception ignore) { + int step = (currAge < 19) ? 1 : 2; + return currAge + step; + } + } + + // 무결성 검증 + private String buildRecentTail(List body, int n) { + int from = Math.max(0, body.size() - n); + StringBuilder sb = new StringBuilder(); + for (int i = from; i < body.size(); i++) { + DecisionNode d = body.get(i); + if (d.getDecision() != null) sb.append(d.getDecision()).append(' '); + // 상황 텍스트는 부정 트리거 노이즈가 많아 제외 + } + return stripAges(sb.toString().trim()); + } + + private List collectRecentDecisions(List body, int n) { + int from = Math.max(0, body.size() - n); + List out = new ArrayList<>(); + for (int i = from; i < body.size(); i++) { + DecisionNode d = body.get(i); + if (d.getDecision() != null) out.add(d.getDecision().trim()); + } + return out.stream().filter(s -> s != null && !s.isBlank()).distinct().toList(); + } + + private String buildPromptFreeSituation(String requiredTheme, + List themes, + Set banned, + String prev, + String ctx, + String groundingForOptions, + String hidden, + List prevOptions) { + String req = (requiredTheme == null) ? "(없음)" : requiredTheme; + String aux = (themes == null || themes.isEmpty()) ? "(연령대 테마 없음)" + : String.join(", ", themes.stream().limit(6).toList()); + String bannedS = banned.isEmpty() ? "(없음)" : String.join(", ", banned); + String ctxS = (ctx == null || ctx.isBlank()) ? "(관련 콘텍스트 없음)" : ctx; + String prevS = (prev == null || prev.isBlank()) ? "(요약 없음)" : prev; + String gkS = (groundingForOptions == null || groundingForOptions.isBlank()) ? "(키워드 없음)" : groundingForOptions; + String hidS = (hidden == null || hidden.isBlank()) ? "(은닉 맥락 없음)" : hidden; + String prevOptS = (prevOptions == null || prevOptions.isEmpty()) ? "(이전 선택 없음)" : String.join(", ", prevOptions); + + return """ + 아래 지시를 철저히 따르세요. + + [출력 형식] + - JSON 한 줄만 출력: {"situation":"문장","recommendedOption":"문장"} + - 한국어, 개행/탭/백틱/백슬래시 금지, 숫자(연도/나이/학년/점수 등) 금지 + + [상황(situation) 생성 규칙 - 자유도 최대] + - situation은 현재 나이대·카테고리에 '자연스럽고 현실적인' 완전히 새로운 상황으로 작성한다. + - 과거 선택/경험/결과/라인 특성은 절대 언급하지 않는다(회상/인칭/이전 노드 금지). + - 상황 문장은 "…한 상황이다."로 끝난다. + - 아래 [연령/카테고리 테마]를 0~1회 참고하되, 문장 자연스러움을 우선한다. + + [선택지(recommendedOption) 생성 규칙 - 이전 선택 영향] + - recommendedOption은 상황과 '의미적으로 맞물리는' 실행 전략 1가지만 제안한다. + - 이전에 선택했던 옵션을 암시적으로 반영해 '연속성'을 갖되, 상황과 동떨어지지 않는다. + - 필요 시 [그라운딩 키워드], [관련 콘텍스트], [은닉 맥락]을 '직접 명시하지 말고' 암시적으로 활용한다. + - 문장은 "…한다"로 끝나며 20자 이내로 간결하게 쓴다. + + [연령/카테고리 테마] + %s + + [최소 금지어(메타 라벨)] + %s + + [이전 선택 요약(상황에 사용 금지)] + %s + + [이전 선택 목록(선택지 참고)] + %s + + [관련 콘텍스트(발췌, 상황에 사용 금지)] + %s + + [그라운딩 키워드(선택지 보조)] + %s + + [은닉 맥락(선택지 보조, 직접 언급 금지)] + %s + """.formatted(formatThemeBlock(req, aux), bannedS, prevS, prevOptS, ctxS, gkS, hidS); + } + + private String buildPromptFreeSituationHard(String requiredTheme, + List themes, + Set banned, + String prev, + String ctx, + String groundingForOptions, + String hidden, + List prevOptions) { + String base = buildPromptFreeSituation(requiredTheme, themes, banned, prev, ctx, groundingForOptions, hidden, prevOptions); + return base.replace("[상황(situation) 생성 규칙 - 자유도 최대]", + "[상황(situation) 생성 규칙 - 자유도 최대]\n- 상황은 반드시 '완전히 새로운' 문장으로 작성하고 과거/이전 노드 언급 금지.\n- 문장 어미는 꼭 \"…한 상황이다.\"로 마무리.\n- 숫자 전면 금지.\n- 연령/카테고리 테마는 자연스러움 저해 시 생략 가능(0~1회)."); + } + + private String formatThemeBlock(String requiredTheme, String auxThemes) { + if ("(없음)".equals(requiredTheme)) return auxThemes; + return requiredTheme + " | " + auxThemes; + } + + private String buildGroundingForOptions(int age, + String recent, + String relatedContext, + List ageThemes, + Set bannedAll, + List prevOptions) { + String base = stripAges(((recent == null ? "" : recent) + " " + (relatedContext == null ? "" : relatedContext)).trim()); + + List fromVocab = List.of(); + try { fromVocab = vocabSearch.topKTermsByQuery(base, 12); } catch (Exception ignore) {} + + List cand = cleanTokens(fromVocab); + if (cand.size() < 2) cand = cleanTokens(Arrays.asList(base.replaceAll("[^가-힣\\s]", " ").split("\\s+"))); + + cand = cand.stream().filter(t -> !bannedAll.contains(t)).toList(); + + Set prevKey = cleanTokens(prevOptions).stream().collect(Collectors.toCollection(LinkedHashSet::new)); + Set themeLex = themeLexicon(ageThemes); + + List merged = new ArrayList<>(); + merged.addAll(prevKey); + for (String t : cand) if (!merged.contains(t)) merged.add(t); + for (String t : themeLex) if (!merged.contains(t)) merged.add(t); + + if (merged.isEmpty()) merged = List.of("계획", "실천"); + if (merged.size() == 1) merged.add("실천"); + + return merged.stream().distinct().limit(8).collect(Collectors.joining(", ")); + } + + private List cleanTokens(Collection raw) { + if (raw == null) return List.of(); + List stop = List.of("그리고","하지만","그러나","또는","또","및","대한","관련","고민","선택","준비","시간","문제","해결","필요","가능","전략","계획","방안"); + return raw.stream() + .filter(Objects::nonNull) + .map(String::trim) + .map(s -> s.replaceAll("\\b\\d+\\b", "")) + .map(s -> s.replaceAll("[^가-힣]", "")) + .filter(s -> s.length() >= 2 && s.length() <= 12) + .filter(s -> !stop.contains(s)) + .distinct() + .toList(); + } + + private Set labelStopWords() { + return new HashSet<>(List.of("기준","전략","계획","점검","관리","목표","원칙","규칙","방안","체계","프로세스")); + } + + private Set themeLexicon(List themes) { + if (themes == null) return Set.of(); + Set out = new HashSet<>(); + for (String t : themes) { + if (t == null) continue; + for (String tok : t.replaceAll("[^가-힣\\s]", " ").split("\\s+")) { + String z = tok.trim(); + if (z.length() >= 2 && z.length() <= 12) out.add(z); + } + } + return out; + } + + private List safeAgeThemes(Long decisionLineId, + int targetAge, + NodeCategory effectiveCategory, + String recent, + String relatedContext, + int k) { + try { + ageThemeSeeder.ensureSeedForAgeAsync(targetAge, 12); + NodeCategory cat = effectiveCategory; + String q = ((recent == null ? "" : recent) + " " + (relatedContext == null ? "" : relatedContext)).trim(); + return ageThemeSearch.topK(targetAge, cat, q, k); + } catch (Exception ignore) { + return List.of(); + } + } + + private NodeCategory toNodeCategory(Object category) { + if (category == null) return null; + if (category instanceof NodeCategory nc) return nc; + try { return NodeCategory.valueOf(category.toString().trim().toUpperCase()); } + catch (Exception e) { return null; } + } - // 초경량 RAG 프롬프트 - String prompt = buildRagPrompt(query, relatedContext); + private String pickRequiredThemeRelaxed(List ageThemes) { + if (ageThemes == null || ageThemes.isEmpty()) return null; + for (String t : ageThemes) { + if (t == null) continue; + String z = t.trim(); + if (!z.isBlank()) return z; + } + return null; + } - // 제미나이 동기 호출(JSON 반환 유도 옵션 포함 권장) + private boolean violatesRequiredThemeRelaxed(String s, String requiredTheme) { + if (requiredTheme == null) return false; + if (s == null) return true; + return !s.contains(requiredTheme); + } + + private AiNextHint callOnce(String prompt) { AiRequest req = new AiRequest( prompt, Map.of( - "temperature", 0.2, + "temperature", 0.7, "topP", 0.9, "topK", 1, "candidateCount", 1, @@ -71,41 +442,354 @@ public AiNextHint generateNextHint(Long userId, Long decisionLineId, List hidden = support.hiddenFactsAndBadges(decisionLineId); + if (hidden == null || hidden.isEmpty()) return ""; + List parts = new ArrayList<>(); + Object badges = hidden.get("badges"); + Object hooks = hidden.get("next_hooks"); + if (badges != null) parts.add("badges=" + String.valueOf(badges)); + if (hooks != null) parts.add("hooks=" + String.valueOf(hooks)); + return String.join(" | ", parts); + } catch (Exception ignore) { + return ""; + } + } + + private boolean matchesPolarity(String situation, String option, Polarity pol) { + String z = ((situation==null?"":situation)+" "+(option==null?"":option)); + Set pos = Set.of("기회","성장","호재","안정","성과","확대","강화","재투자","정비","개선","회복","안도"); + Set neg = Set.of("불안","난관","상실","파산","주거","퇴거","절박","압박","길바닥","집을 잃","악화","괴로움"); + Set safe = Set.of("동결한다","중단한다","축소한다","보류한다","도움을 요청한다","임시 거처를 찾는다","상담을 신청한다","지원 제도를 확인한다","채무조정을 신청한다"); + if (pol == Polarity.POSITIVE) return pos.stream().anyMatch(z::contains); + return neg.stream().anyMatch(z::contains) && safe.stream().anyMatch(z::contains); + } + + private String buildPolarityBlock(Polarity pol) { + if (pol == Polarity.POSITIVE) return """ + 톤: 긍정적/기회 중심 + - 상황: 성과·호재·안정 신호를 드러낸다. + - 선택지: 확장/강화/재투자 같은 적극 전략을 선호하되 과도한 약속 금지. + """; + return """ + 톤: 극단 부정(파국 가정, 공포·상실·불안) + - 상황: 큰 손실과 생활 불안이 발생한 상황을 사실적으로 서술한다(자해·타해·불법·혐오 금지). + - 선택지: 위험 통제·생계 보호·법적/공적 지원(지출 동결, 활동 중단/축소, 임시 주거, 상담, 법률·채무조정, 도움 요청)만 제안한다. + """; + } + + // 무결성 검증 + private String buildFewShotForPolarity(Polarity pol, NodeCategory cat) { + String c = (cat == null) ? "GEN" : cat.name(); + if (pol == Polarity.POSITIVE) { + return switch (c) { + case "EDUCATION" -> """ + [예시(긍정-EDU)] + 출력→ {"situation":"학습 루틴이 안정되어 집중력이 오르는 상황이다.","recommendedOption":"핵심 과목 복습 루틴을 고정한다"} + """; + case "CAREER" -> """ + [예시(긍정-CAREER)] + 출력→ {"situation":"업무 협업이 매끄러워 성과가 보이는 상황이다.","recommendedOption":"역할을 명확히 나누고 진행을 점검한다"} + """; + default -> """ + [예시(긍정)] + 출력→ {"situation":"일정이 안정되어 계획을 재정비하는 상황이다.","recommendedOption":"우선순위를 재정렬해 실행한다"} + """; + }; + } + return switch (c) { + case "EDUCATION" -> """ + [예시(부정-EDU)] + 출력→ {"situation":"과제 부담이 늘어 체력이 떨어진 상황이다.","recommendedOption":"과목 수를 줄이고 상담을 신청한다"} + """; + case "CAREER" -> """ + [예시(부정-CAREER)] + 출력→ {"situation":"프로젝트 압박으로 업무 집중이 흔들리는 상황이다.","recommendedOption":"업무 범위를 조정하고 도움을 요청한다"} + """; + case "HOUSING" -> """ + [예시(부정-HOUSING)] + 출력→ {"situation":"거주 불안으로 생활이 흔들리는 상황이다.","recommendedOption":"임시 거처를 확보하고 지출을 동결한다"} + """; + default -> """ + [예시(부정)] + 출력→ {"situation":"일상 리듬이 무너져 불안이 커진 상황이다.","recommendedOption":"일정을 축소하고 상담을 신청한다"} + """; + }; + } + + private String buildNegTriggerHint(NegMatch match){ + if (match == null) return ""; + return switch (match.trigger()) { + case FINANCE_CRYPTO, FINANCE_STOCKS, FINANCE_RISK -> """ + [부정 트리거 힌트] + - 재무 손실·현금흐름 악화·채무 압박·주거 유지 곤란 같은 '결과'를 사실적으로 드러내되 과도한 자책/비하 금지. + - 선택지는 '피해 최소화/안전/합법적 지원'(지출 동결, 투자 중단, 임시 주거, 상담, 법률·채무조정, 가족·공적 지원) 위주. + """; + case EDU_FAIL -> """ + [부정 트리거 힌트] + - 학업 성과 저하/평가 실패의 압박을 기술하되 모욕적 표현 금지. + - 선택지는 일정 조정·전략 재설계·상담/튜터링·과목 축소. + """; + case CAREER_LOSS -> """ + [부정 트리거 힌트] + - 고용 불안/성과 하락/계약 해지의 여파를 기술하되 불법/보복 암시 금지. + - 선택지는 구직 전환·지출 관리·네트워킹·재교육/자격 검토. + """; + case REL_BREAK -> """ + [부정 트리거 힌트] + - 관계 단절/갈등으로 인한 생활 동요를 묘사하되 혐오/폭력 암시 금지. + - 선택지는 거리두기·상담·일정 조정·지원망 확보. + """; + case HEALTH_STRAIN -> """ + [부정 트리거 힌트] + - 과로/건강 악화로 인한 기능 저하를 묘사(자해·타해·의학 디테일 금지). + - 선택지는 휴식·검진 예약·업무 강도 조정·수면 위생 개선. + """; + case LOC_HOUSING -> """ + [부정 트리거 힌트] + - 주거 불안/퇴거 위험/재해 피해로 인한 차질을 묘사. + - 선택지는 임시 거처·공공 지원·계약 검토·지출 동결. + """; + case ETC_FAMILY -> """ + [부정 트리거 힌트] + - 돌봄/가족 이슈로 인한 일정 붕괴/감정 소모를 묘사. + - 선택지는 도움 요청·일정 축소·임시 대안 확보. + """; + }; + } + + private static final Pattern BIG_MONEY = Pattern.compile(".*(\\d{8,}|억|조).*"); + + private boolean isBlank(String s){ return s==null || s.isBlank(); } + + private String norm(String utter){ + return isBlank(utter) ? "" : utter.replaceAll("\\s+","").toLowerCase(Locale.ROOT); + } + + private boolean hasHighRiskInvestTrigger(String source) { + String u = norm(source); + if (u.isEmpty()) return false; + boolean hasAsset = Arrays.stream(new String[]{ + "비트코인","코인","가상화폐","주식","종목","etf","선물","옵션","레버리지","인버스","공매도" + }).anyMatch(u::contains); + boolean hasAction = Arrays.stream(new String[]{"투자","매수","올인","몰빵","풀매수","빚투","영끌"}).anyMatch(u::contains); + boolean big = BIG_MONEY.matcher(u).matches(); + return hasAsset && hasAction && big; + } + + // 무결성 검증 + private NegMatch detectGlobalNegative(String source) { + String u = norm(source); + if (u.isEmpty()) return null; + + int hits = 0; + NegMatch chosen = null; + + if (Arrays.stream(NegTrigger.FINANCE_RISK.keywords).anyMatch(u::contains)) { + hits += 2; + chosen = new NegMatch(NegTrigger.FINANCE_RISK, true, hits); + } + + for (NegTrigger t : NegTrigger.values()) { + int local = 0; + for (String k : t.keywords) if (u.contains(k)) local++; + if (local > 0) { + hits += local; + boolean severe = switch (t) { + case FINANCE_CRYPTO, FINANCE_STOCKS, FINANCE_RISK, LOC_HOUSING -> true; + default -> false; + }; + chosen = new NegMatch(t, severe, hits); + } + } + + if (chosen == null) return null; + return new NegMatch(chosen.trigger(), chosen.severe(), hits); + } + + // 무결성 검증 + private boolean shouldForceNegativeByTrigger(AIVectorServiceSupportDomain.Phase phase, + NegMatch nm, + boolean lastSevere, + Domain catDom) { + if (nm == null) return false; + Domain trigDom = mapTrigger(nm.trigger()); + boolean domainAligned = (trigDom == catDom) || (trigDom == Domain.FINANCE && catDom == Domain.GENERIC); + + if (!domainAligned) return false; + + boolean weak = nm.hitCount() < 2; + if (weak) { + if (phase == AIVectorServiceSupportDomain.Phase.RECOVERY) return false; + if (phase == AIVectorServiceSupportDomain.Phase.POS_STREAK) return false; + if (lastSevere) return false; + return true; + } + if (phase == AIVectorServiceSupportDomain.Phase.POS_STREAK) return false; + return true; + } + + // 무결성 검증 + private Polarity softenByPhase(AIVectorServiceSupportDomain.Phase phase, + Polarity initial, + NegMatch nm) { + if (phase == AIVectorServiceSupportDomain.Phase.RECOVERY && (nm == null || nm.hitCount() < 2)) + return Polarity.POSITIVE; + return initial; + } + + // 무결성 검증 + private boolean coinFlipSeeded(long seed, double positiveProb) { + return new Random(seed).nextDouble() < positiveProb; + } + + // 무결성 검증 + private Polarity decidePolaritySeeded(Long decisionLineId, + int targetAge, + String triggerSource, + AIVectorServiceSupportDomain.Phase phase, + boolean lastSevere, + NodeCategory effectiveCategory) { + if (support.consumePositiveLock(decisionLineId)) return Polarity.POSITIVE; + + NegMatch nm = detectGlobalNegative(triggerSource); + Domain catDom = mapCategory(effectiveCategory); + + if (shouldForceNegativeByTrigger(phase, nm, lastSevere, catDom)) { + return Polarity.SEVERE_NEGATIVE; + } + + final double POSITIVE_PROB = + (catDom == Domain.EDUCATION || catDom == Domain.CAREER) && nm == null ? 0.9 : 0.7; + + boolean hasHR = hasHighRiskInvestTrigger(triggerSource); + long seed = Objects.hash(decisionLineId, targetAge, hasHR ? "HR" : "CF"); + boolean positive = coinFlipSeeded(seed, POSITIVE_PROB); + + Polarity base = positive ? Polarity.POSITIVE : Polarity.SEVERE_NEGATIVE; + return softenByPhase(phase, base, nm); + } + + // 무결성 검증 + private String buildRecoveryBlockIfNeeded(AIVectorServiceSupportDomain.Phase phase) { + if (phase != AIVectorServiceSupportDomain.Phase.RECOVERY) return ""; + return """ + + [회복 지시] + - 상황: 과도한 낙담/공포 없이 '안정 회복의 단서'를 부드럽게 드러낸다(직접 과거 언급 금지). + - 선택지: 소액부터 재정립, 지원 제도 확인, 작은 루틴 회복 등 '현실적·즉시 가능한' 행동으로 제시. + - 표현: 과장/약속 금지, 숫자 금지, 문장 어미는 "…한다". + """; + } + + // 무결성 검증 + private PhaseDecision decidePhaseAndOverride(Long decisionLineId, + int targetAge, + String triggerSource, + NodeCategory effectiveCategory, + Polarity initialPolarity, + NegMatch negHint) { + AIVectorServiceSupportDomain.Phase phase = support.currentPhase(decisionLineId); + + boolean highRisk = hasHighRiskInvestTrigger(triggerSource); + boolean strongNeg = negHint != null && (negHint.severe() || negHint.hitCount() >= 2); + + if (phase == AIVectorServiceSupportDomain.Phase.CRISIS) { + support.markPhaseUsed(decisionLineId); + return new PhaseDecision(Polarity.SEVERE_NEGATIVE, phase, negHint); + } + if (phase == AIVectorServiceSupportDomain.Phase.RECOVERY) { + support.markPhaseUsed(decisionLineId); + return new PhaseDecision(Polarity.POSITIVE, phase, negHint); + } + if (phase == AIVectorServiceSupportDomain.Phase.POS_STREAK) { + support.markPhaseUsed(decisionLineId); + return new PhaseDecision(Polarity.POSITIVE, phase, negHint); + } + + if (highRisk || strongNeg) { + int window = 1 + (int)(Math.floorMod(Objects.hash(decisionLineId, targetAge, "CRISIS"), 2)); // 1~2회 + support.enterCrisis(decisionLineId, window); + support.armPositiveLock(decisionLineId, 2); // 위기 직후 2회 긍정 보장 + return new PhaseDecision(Polarity.SEVERE_NEGATIVE, AIVectorServiceSupportDomain.Phase.CRISIS, negHint); + } + + if (initialPolarity == Polarity.POSITIVE && support.shouldEnterPositiveStreak(decisionLineId, targetAge)) { + int span = support.pickStreakWindow(decisionLineId, targetAge); // 2~5 + support.enterPositiveStreak(decisionLineId, span); + return new PhaseDecision(Polarity.POSITIVE, AIVectorServiceSupportDomain.Phase.POS_STREAK, negHint); + } + + return new PhaseDecision(initialPolarity, AIVectorServiceSupportDomain.Phase.NORMAL, negHint); + } + + private String buildPromptFreeSituationWithPolarityAndNegHint( + String requiredTheme, List themes, Set banned, String prev, String ctx, + String groundingForOptions, String hidden, List prevOptions, Polarity pol, NegMatch negHint, + NodeCategory cat + ) { + String core = buildPromptFreeSituation(requiredTheme, themes, banned, prev, ctx, groundingForOptions, hidden, prevOptions); + String polBlock= buildPolarityBlock(pol); + String fewShot = buildFewShotForPolarity(pol, cat); + String hint = buildNegTriggerHint(negHint); + + return """ + %s + + [결과 경향] + %s + + %s + %s + """.formatted(core, polBlock, fewShot, hint); + } + private boolean violatesSafetyPolicy(String situation, String option) { + String z = ((situation == null ? "" : situation) + " " + (option == null ? "" : option)); + + Set selfHarm = Set.of("자해", "스스로 해치", "목숨", "죽"); + Set hate = Set.of("혐오", "비하", "차별", "증오"); + Set illegal = Set.of("불법", "사기", "폭력", "협박"); + + return selfHarm.stream().anyMatch(z::contains) + || hate.stream().anyMatch(z::contains) + || illegal.stream().anyMatch(z::contains); + } } diff --git a/back/src/main/java/com/back/global/ai/vector/AIVectorServiceSupportDomain.java b/back/src/main/java/com/back/global/ai/vector/AIVectorServiceSupportDomain.java index 36115bb..3da342e 100644 --- a/back/src/main/java/com/back/global/ai/vector/AIVectorServiceSupportDomain.java +++ b/back/src/main/java/com/back/global/ai/vector/AIVectorServiceSupportDomain.java @@ -1,15 +1,26 @@ /* - * 이 파일은 경로 요약 문자열 생성, pgvector 기반 관련 스니펫 검색, 스니펫 병합 유틸리티를 제공한다. - * 항상 lineId와 현재 나이를 받아 라인 전환 시 섞임을 방지한다. + * [코드 흐름 요약] + * - 요청 단위 캐시(LineCtx)로 line/노드/피벗/카테고리/히든컨텍스트를 1회 로드 후 재사용해 DB 조회를 최소화. + * - Phase 상태(NORMAL/CRISIS/RECOVERY/POS_STREAK)와 모멘텀/잔여회수 + 회복 보장 락(Positive Lock)을 메모리에서 관리. + * - 공개 API 시그니처 유지: buildQueryFromNodes, searchRelatedContexts, joinWithLimit, hiddenFactsAndBadges, + * resolveEffectiveCategory, suggestNextAgeForLine, baseLineIdOfDecisionLine, pivotAgesForBaseLine, + * hasDecisionAtAge, fromBaseCategory. */ + package com.back.global.ai.vector; +import com.back.domain.node.entity.BaseNode; +import com.back.domain.node.entity.DecisionLine; import com.back.domain.node.entity.DecisionNode; +import com.back.domain.node.entity.NodeCategory; +import com.back.domain.node.repository.DecisionLineRepository; +import com.back.domain.node.service.NodeDomainSupport; import com.back.domain.search.entity.NodeSnippet; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @Component @@ -18,8 +29,189 @@ public class AIVectorServiceSupportDomain { private final PgVectorSearchService vectorSearch; private final EmbeddingClient embeddingClient; + private final DecisionLineRepository decisionLineRepository; + private final NodeDomainSupport support; + + // 무결성 검증 + private static final ThreadLocal> CTX = ThreadLocal.withInitial(HashMap::new); + + // 무결성 검증 + public enum Phase { NORMAL, CRISIS, RECOVERY, POS_STREAK } + + // 무결성 검증 + private static final class PhaseState { + Phase phase = Phase.NORMAL; + int remain = 0; + boolean lastSevere = false; + double positivityMomentum = 0.0; + long touchedAt = System.currentTimeMillis(); + int positiveLock = 0; // 회복 보장 락 + } + + // 무결성 검증 + private static final Map PHASE = new ConcurrentHashMap<>(); + + // 무결성 검증 + public void beginRequestCache() { + // next 노드 생성 + CTX.get().clear(); + } + + // 무결성 검증 + public void clearCache() { + // next 노드 생성 + CTX.get().clear(); + } + + // ===== Phase 오케스트레이션 ===== + + // next 노드 생성 + public Phase currentPhase(Long lineId) { + PhaseState st = PHASE.computeIfAbsent(lineId, k -> new PhaseState()); + st.touchedAt = System.currentTimeMillis(); + return st.phase; + } + + // next 노드 생성 + public void enterCrisis(Long lineId, int window) { + PhaseState st = PHASE.computeIfAbsent(lineId, k -> new PhaseState()); + st.phase = Phase.CRISIS; + st.remain = Math.max(1, Math.min(2, window)); + st.touchedAt = System.currentTimeMillis(); + } + + // next 노드 생성 + public void enterRecovery(Long lineId, int window) { + PhaseState st = PHASE.computeIfAbsent(lineId, k -> new PhaseState()); + st.phase = Phase.RECOVERY; + st.remain = Math.max(1, Math.min(2, window)); + st.touchedAt = System.currentTimeMillis(); + } + + // next 노드 생성 + public void enterPositiveStreak(Long lineId, int window) { + PhaseState st = PHASE.computeIfAbsent(lineId, k -> new PhaseState()); + st.phase = Phase.POS_STREAK; + st.remain = Math.max(1, Math.min(8, window)); // 2~5 권장, 상한 8 + st.touchedAt = System.currentTimeMillis(); + } + + // next 노드 생성 + public void markPhaseUsed(Long lineId) { + PhaseState st = PHASE.computeIfAbsent(lineId, k -> new PhaseState()); + if (st.remain > 0) st.remain--; + st.touchedAt = System.currentTimeMillis(); + } + + // 무결성 검증 + public void tickPhase(Long lineId, Phase observed) { + PhaseState st = PHASE.computeIfAbsent(lineId, k -> new PhaseState()); + if ((observed == Phase.CRISIS || observed == Phase.POS_STREAK || observed == Phase.RECOVERY) && st.remain <= 0) { + if (observed == Phase.CRISIS) { enterRecovery(lineId, 1); return; } // 위기→회복 1회 보장 + st.phase = Phase.NORMAL; + st.touchedAt = System.currentTimeMillis(); + } + } + + // 무결성 검증 + public boolean lastPolarityWasSevere(Long lineId) { + PhaseState st = PHASE.computeIfAbsent(lineId, k -> new PhaseState()); + return st.lastSevere; + } + + // 무결성 검증 + public void rememberPolarity(Long lineId, String tag) { + PhaseState st = PHASE.computeIfAbsent(lineId, k -> new PhaseState()); + st.lastSevere = "SEV".equals(tag); + + // next 노드 생성 + if ("POS".equals(tag)) { + st.positivityMomentum = Math.max(-0.35, st.positivityMomentum - 0.10); + if (st.phase == Phase.RECOVERY && st.remain <= 0) st.phase = Phase.NORMAL; + } else { + st.positivityMomentum = Math.min(0.0, st.positivityMomentum + 0.20); + } + st.touchedAt = System.currentTimeMillis(); + } + + // next 노드 생성 + public boolean shouldEnterPositiveStreak(Long lineId, int targetAge) { + PhaseState st = PHASE.computeIfAbsent(lineId, k -> new PhaseState()); + if (st.phase != Phase.NORMAL && st.phase != Phase.RECOVERY) return false; + + double baseP = 0.50; + double fatigue = Math.max(-0.35, Math.min(0.0, st.positivityMomentum)); // 음수만 반영 + double p = Math.max(0.15, Math.min(0.50, baseP + fatigue)); + + long h = Objects.hash(lineId, targetAge, "LUCKY"); + double draw = (new Random(h)).nextDouble(); // 무결성 검증 + return draw < p; + } + + // next 노드 생성 + public int pickStreakWindow(Long lineId, int targetAge) { + long h = Objects.hash(lineId, targetAge, "LUCKY_WIN"); + return 2 + Math.toIntExact(Math.floorMod(h, 4)); // 2~5 + } + + // 무결성 검증 + public void armPositiveLock(Long lineId, int n) { + PhaseState st = PHASE.computeIfAbsent(lineId, k -> new PhaseState()); + st.positiveLock = Math.max(st.positiveLock, Math.max(1, n)); + } + + // 무결성 검증 + public boolean consumePositiveLock(Long lineId) { + PhaseState st = PHASE.computeIfAbsent(lineId, k -> new PhaseState()); + if (st.positiveLock > 0) { + st.positiveLock--; + return true; + } + return false; + } + + // ===== 요청 캐시 및 공개 API ===== + + // 무결성 검증 + private LineCtx getCtx(Long decisionLineId) { + Map map = CTX.get(); + LineCtx ctx = map.get(decisionLineId); + if (ctx != null) return ctx; + + DecisionLine line = decisionLineRepository.findById(decisionLineId).orElse(null); + + List ordered = List.of(); + if (line != null && line.getDecisionNodes() != null) { + ordered = line.getDecisionNodes().stream() + .filter(Objects::nonNull) + .sorted(Comparator.comparingInt(DecisionNode::getAgeYear).thenComparingLong(DecisionNode::getId)) + .toList(); + } + + Long baseLineId = (line == null || line.getBaseLine() == null) ? null : line.getBaseLine().getId(); - // 이전 결정 경로를 간단한 요약 문자열로 만든다. + List pivots = List.of(); + NodeCategory fromBaseCat = null; + if (baseLineId != null) { + List baseOrdered = support.getOrderedBaseNodes(baseLineId); + pivots = support.allowedPivotAges(baseOrdered); + fromBaseCat = resolveFromBaseHeaderCategory(baseOrdered); + } + + // 무결성 검증 + Set ageSet = ordered.stream() + .map(DecisionNode::getAgeYear) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + // 무결성 검증 + Map hidden = buildHiddenFacts(ordered); + + ctx = new LineCtx(line, ordered, baseLineId, pivots, fromBaseCat, ageSet, hidden); + map.put(decisionLineId, ctx); + return ctx; + } + + // next 노드 생성 public String buildQueryFromNodes(List nodes) { return nodes.stream() .map(n -> String.format("- (%d세) %s → %s", @@ -29,22 +221,31 @@ public String buildQueryFromNodes(List nodes) { .collect(Collectors.joining("\n")); } - // 라인/나이 윈도우로 제한하여 관련 스니펫을 상위 K개 가져온다. + // next 노드 생성 public List searchRelatedContexts(Long lineId, int currAge, String query, int topK, int eachSnippetLimit) { - float[] qEmb = embeddingClient.embed(query); - List top = vectorSearch.topK(lineId, currAge, 2, qEmb, Math.max(topK, 1)); - List out = new ArrayList<>(); - for (NodeSnippet s : top) { - String t = s.getText(); - if (t == null || t.isBlank()) continue; - out.add(trim(t, eachSnippetLimit)); + try { + String q = (query == null || query.isBlank()) ? "(empty)" : query; + float[] qEmb = embeddingClient.embed(q); + // 무결성 검증 + List top = vectorSearch.topK(lineId, currAge, 2, qEmb, Math.max(topK, 1)); + if (top == null || top.isEmpty()) return List.of(); + List out = new ArrayList<>(top.size()); + for (NodeSnippet s : top) { + String t = (s == null) ? null : s.getText(); + if (t == null || t.isBlank()) continue; + out.add(trim(t, eachSnippetLimit)); + } + return out; + } catch (Exception ignore) { + return List.of(); } - return out; } - // 여러 스니펫을 결합하되 총 길이를 제한한다. + // 무결성 검증 public String joinWithLimit(List snippets, int totalCharLimit) { - StringBuilder sb = new StringBuilder(); + if (snippets == null || snippets.isEmpty()) return ""; + // next 노드 생성 + StringBuilder sb = new StringBuilder(Math.min(totalCharLimit, 256)); for (String s : snippets) { if (s == null || s.isBlank()) continue; if (sb.length() + s.length() + 1 > totalCharLimit) break; @@ -54,10 +255,143 @@ public String joinWithLimit(List snippets, int totalCharLimit) { return sb.toString(); } - private String safe(String s) { return s == null ? "" : s.trim(); } - private String trim(String s, int limit) { + // next 노드 생성 + public Map hiddenFactsAndBadges(Long decisionLineId) { + return getCtx(decisionLineId).hiddenFacts(); + } + + // 무결성 검증 + public NodeCategory resolveEffectiveCategory(Object lastCategory, Long decisionLineId, BaseNode lastBaseNode) { + if (lastCategory instanceof NodeCategory nc) return nc; + if (lastCategory != null) { + try { return NodeCategory.valueOf(lastCategory.toString().trim().toUpperCase()); } + catch (Exception ignore) {} + } + if (lastBaseNode != null && lastBaseNode.getCategory() != null) return lastBaseNode.getCategory(); + return fromBaseCategory(decisionLineId); + } + + // next 노드 생성 + public Integer suggestNextAgeForLine(Long decisionLineId, int currAge) { + LineCtx ctx = getCtx(decisionLineId); + + if (ctx.pivotAges() != null && !ctx.pivotAges().isEmpty()) { + for (Integer a : ctx.pivotAges()) { + if (a != null && a > currAge && !ctx.ageExists(a)) return a; + } + } + + int step = (currAge < 19) ? 1 : 2; + int candidate = currAge + step; + + int guard = 0; + while (guard++ < 16 && ctx.ageExists(candidate)) { + Integer nextPivot = nextGreater(ctx.pivotAges(), candidate); + candidate = (nextPivot != null) ? nextPivot : (candidate + step); + } + return candidate; + } + + // 무결성 검증 + public Long baseLineIdOfDecisionLine(Long decisionLineId) { + return getCtx(decisionLineId).baseLineId(); + } + + // 무결성 검증 + public List pivotAgesForBaseLine(Long baseLineId) { + // next 노드 생성 + for (LineCtx c : CTX.get().values()) { + if (Objects.equals(c.baseLineId(), baseLineId)) return c.pivotAges(); + } + List ordered = support.getOrderedBaseNodes(baseLineId); + return support.allowedPivotAges(ordered); + } + + // 무결성 검증 + public boolean hasDecisionAtAge(Long decisionLineId, int ageYear) { + return getCtx(decisionLineId).ageExists(ageYear); + } + + // 무결성 검증 + public NodeCategory fromBaseCategory(Long decisionLineId) { + return getCtx(decisionLineId).fromBaseCategory(); + } + + // ===== 내부 헬퍼 ===== + + // 무결성 검증 + private static Integer nextGreater(List sorted, int val) { + if (sorted == null || sorted.isEmpty()) return null; + for (Integer a : sorted) if (a != null && a > val) return a; + return null; + } + + // 무결성 검증 + private static String safe(String s) { return (s == null) ? "" : s.trim(); } + + // 무결성 검증 + private static 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)) + "..."; } + + // 무결성 검증 + private static NodeCategory resolveFromBaseHeaderCategory(List baseOrdered) { + if (baseOrdered == null || baseOrdered.isEmpty()) return null; + for (BaseNode b : baseOrdered) if (b.getParent() == null) return b.getCategory(); + return baseOrdered.get(0).getCategory(); + } + + // next 노드 생성 + private static Map buildHiddenFacts(List ordered) { + if (ordered == null || ordered.isEmpty()) return Map.of(); + + List badges = new ArrayList<>(); + List hooks = new ArrayList<>(); + + for (DecisionNode d : ordered) { + String text = (safe(d.getSituation()) + " " + safe(d.getDecision())).trim(); + + if (text.matches(".*(공모전|수상|수상경력).*")) addOnce(badges, "공모전 수상"); + if (text.matches(".*(전시|아트페어|포트폴리오 리뷰).*")) addOnce(badges, "전시 참여"); + if (text.matches(".*(인턴|현장실습|스튜디오 어시).*")) addOnce(badges, "인턴 경험"); + if (text.matches(".*(자격증|자격 취득).*")) addOnce(badges, "자격증"); + if (text.matches(".*(장학금|장학).*")) addOnce(badges, "장학"); + if (text.matches(".*(군필|병역|복무 완료).*")) addOnce(badges, "군필"); + } + + if (badges.contains("군필")) addOnce(hooks, "군필"); + if (badges.contains("공모전 수상")) addOnce(hooks, "수상 1회"); + + if (badges.isEmpty() && hooks.isEmpty()) return Map.of(); + Map out = new LinkedHashMap<>(); + out.put("badges", badges); + out.put("next_hooks", hooks); + return out; + } + + // 무결성 검증 + private static void addOnce(List list, String v) { + if (v == null || v.isBlank()) return; + if (!list.contains(v)) list.add(v); + } + + // 무결성 검증 + private record LineCtx( + DecisionLine line, + List orderedNodes, + Long baseLineId, + List pivotAges, + NodeCategory fromBaseCategory, + Set ageSet, + Map hiddenFacts + ) { + // next 노드 생성 + boolean ageExists(int age) { return ageSet != null && ageSet.contains(age); } + public Long baseLineId() { return baseLineId; } + public List pivotAges() { return pivotAges; } + public NodeCategory fromBaseCategory() { return fromBaseCategory; } + public Map hiddenFacts() { return hiddenFacts; } + } } diff --git a/back/src/main/java/com/back/global/ai/vector/AgeThemeSearchService.java b/back/src/main/java/com/back/global/ai/vector/AgeThemeSearchService.java new file mode 100644 index 0000000..9872137 --- /dev/null +++ b/back/src/main/java/com/back/global/ai/vector/AgeThemeSearchService.java @@ -0,0 +1,58 @@ +/* + * [코드 흐름 요약] + * - 쿼리 임베딩을 pgvector 리터럴로 변환하되, null/빈 입력 시 768차원 0-벡터를 사용. + * - 카테고리 필터와 함께 상위 K 테마를 조회. + */ +package com.back.global.ai.vector; + +import com.back.domain.node.entity.NodeCategory; +import com.back.domain.search.repository.AgeThemeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AgeThemeSearchService { + + private final EmbeddingClient embeddingClient; + private final AgeThemeRepository repo; + + // 무결성 검증 + private static final int DIM = 768; + private static final String ZERO_LIT = zeroVectorLiteral(); + + // next 노드 생성 + public List topK(int age, NodeCategory category, String query, int k) { + float[] emb = (query == null) ? null : embeddingClient.embed(query); + String lit = toVectorLiteralOrZero(emb); + String cat = (category == null) ? null : category.name(); + return repo.topKThemesByAgeAndCategory(age, cat, lit, k); + } + + // 무결성 검증 + private static String toVectorLiteralOrZero(float[] v) { + if (v == null || v.length == 0) return ZERO_LIT; + if (v.length != DIM) return ZERO_LIT; + StringBuilder sb = new StringBuilder(v.length * 8 + 2); + sb.append('['); + for (int i = 0; i < v.length; i++) { + if (i > 0) sb.append(','); + float x = v[i]; + if (Float.isNaN(x) || Float.isInfinite(x)) x = 0f; + sb.append(Float.toString(x)); + } + sb.append(']'); + return sb.toString(); + } + + // 무결성 검증 + private static String zeroVectorLiteral() { + char[] zeros = "0".toCharArray(); + String[] arr = new String[DIM]; + Arrays.fill(arr, new String(zeros)); + return "[" + String.join(",", arr) + "]"; + } +} diff --git a/back/src/main/java/com/back/global/ai/vector/EmbeddingClient.java b/back/src/main/java/com/back/global/ai/vector/EmbeddingClient.java index 3a3871a..6547755 100644 --- a/back/src/main/java/com/back/global/ai/vector/EmbeddingClient.java +++ b/back/src/main/java/com/back/global/ai/vector/EmbeddingClient.java @@ -1,11 +1,24 @@ /* - * 이 파일은 텍스트를 임베딩 벡터(float[])로 변환하는 클라이언트 인터페이스를 정의한다. - * 구현체는 OpenAI/Vertex/사내 API 등으로 자유롭게 교체 가능하다. + * [코드 흐름 요약] + * - 단일 텍스트 임베딩(embed)과 컬렉션 배치 임베딩(embedBatch)을 정의한다. + * - 기본 구현은 embedBatch가 embed를 루프 호출하도록 제공해 하위 호환을 보장한다. */ package com.back.global.ai.vector; +import java.util.ArrayList; +import java.util.List; + public interface EmbeddingClient { // 입력 텍스트를 임베딩 벡터로 변환한다. float[] embed(String text); + + // 무결성 검증 + default List embedBatch(List texts) { + int n = texts == null ? 0 : texts.size(); + List out = new ArrayList<>(n); + if (n == 0) return out; + for (String s : texts) out.add(embed(s)); + return out; + } } diff --git a/back/src/main/java/com/back/global/ai/vector/EmbeddingProperties.java b/back/src/main/java/com/back/global/ai/vector/EmbeddingProperties.java index 04c3279..8527e93 100644 --- a/back/src/main/java/com/back/global/ai/vector/EmbeddingProperties.java +++ b/back/src/main/java/com/back/global/ai/vector/EmbeddingProperties.java @@ -1,17 +1,41 @@ /* - * 이 파일은 임베딩 관련 설정(dim)을 프로퍼티에서 주입받기 위한 구성 클래스를 제공한다. - * 기본값은 768이며, ai.embedding.dim 으로 변경할 수 있다. + * [코드 흐름 요약] + * - 프로퍼티 바인딩으로 임베딩 설정을 주입(dim, useBigram, useCharShingle). + * - 애플리케이션 기동 시 유효성 검증과 보정(최소/최대 범위) 수행. */ package com.back.global.ai.vector; +import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.Setter; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; +@Slf4j @Configuration @ConfigurationProperties(prefix = "ai.embedding") @Getter @Setter public class EmbeddingProperties { + + // 무결성 검증 + private static final int MIN_DIM = 32; + private static final int MAX_DIM = 4096; + + // 무결성 검증 private int dim = 768; + private boolean useBigram = false; + private boolean useCharShingle = false; + + // 무결성 검증 + @PostConstruct + public void validateAndClamp() { + int original = dim; + if (dim < MIN_DIM) dim = MIN_DIM; + if (dim > MAX_DIM) dim = MAX_DIM; + if (original != dim) { + log.info("[EmbeddingProperties] dim 보정: {} -> {} (허용범위: {}~{})", + original, dim, MIN_DIM, MAX_DIM); + } + } } diff --git a/back/src/main/java/com/back/global/ai/vector/LocalHashEmbeddingClient.java b/back/src/main/java/com/back/global/ai/vector/LocalHashEmbeddingClient.java index 69689f8..f72eead 100644 --- a/back/src/main/java/com/back/global/ai/vector/LocalHashEmbeddingClient.java +++ b/back/src/main/java/com/back/global/ai/vector/LocalHashEmbeddingClient.java @@ -1,6 +1,8 @@ /* - * 이 파일은 외부 API 없이 텍스트를 고정 차원(float[])으로 변환하는 경량 임베딩 클라이언트를 제공한다. - * 토큰을 해시해서 차원에 매핑한 뒤 L2 정규화한다. 품질은 간이지만 개발/테스트/임시 운영에 충분하다. + * [코드 흐름 요약] + * - 입력 텍스트를 해시 기반 고정 차원 벡터로 변환하고 L2 정규화한다. + * - 토큰 단위 해시에 더해 바이그램/문자 셰이플릿을 선택적으로 사용해 희소성·구별력을 높인다. + * - 배치 임베딩(embedBatch)을 제공해 대량 처리 시 호출 비용과 GC 압력을 줄인다. */ package com.back.global.ai.vector; @@ -8,6 +10,8 @@ import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; @Component @RequiredArgsConstructor @@ -15,28 +19,76 @@ public class LocalHashEmbeddingClient implements EmbeddingClient { private final EmbeddingProperties props; + // 무결성 검증 + private int dim() { return Math.max(32, props.getDim()); } + // 텍스트를 고정 차원 해시 임베딩으로 변환한다. @Override public float[] embed(String text) { - int dim = Math.max(32, props.getDim()); - float[] v = new float[dim]; + int d = dim(); + float[] v = new float[d]; if (text == null || text.isBlank()) return v; - String[] toks = text.toLowerCase() - .replaceAll("[^\\p{L}\\p{Nd}\\s]", " ") - .trim() - .split("\\s+"); + String[] toks = simpleTokenize(text); - for (String t : toks) { + // next 노드 생성 + for (int i = 0; i < toks.length; i++) { + String t = toks[i]; if (t.isBlank()) continue; - int h = murmur32(t.getBytes(StandardCharsets.UTF_8)); - int idx = Math.floorMod(h, dim); - v[idx] += 1.0f; + + // 1-그램 + addHashed(v, t, 1f); + + // 바이그램(선택) + if (props.isUseBigram() && i + 1 < toks.length) { + addHashed(v, t + "_" + toks[i + 1], 1f); + } + + // 문자 셰이플릿(선택) + if (props.isUseCharShingle()) { + for (String s : charShingles(t, 3)) addHashed(v, s, 0.3f); + } } + l2NormalizeInPlace(v); return v; } + // 배치 임베딩(단순 루프, 구현체 일관성 보장) + @Override + public List embedBatch(List texts) { + int n = texts == null ? 0 : texts.size(); + List out = new ArrayList<>(n); + if (n == 0) return out; + for (String s : texts) out.add(embed(s)); + return out; + } + + // 무결성 검증 + private String[] simpleTokenize(String text) { + return text.toLowerCase() + .replaceAll("[^\\p{L}\\p{Nd}\\s]", " ") + .trim() + .split("\\s+"); + } + + // 무결성 검증 + private List charShingles(String t, int k) { + List r = new ArrayList<>(); + if (t.length() < k) return r; + for (int i = 0; i <= t.length() - k; i++) r.add(t.substring(i, i + k)); + return r; + } + + // 무결성 검증 + private void addHashed(float[] v, String token, float w) { + int h = murmur32(token.getBytes(StandardCharsets.UTF_8)); + int idx = Math.floorMod(h, v.length); + // 서명 해싱으로 편향 보정 + float sign = ((h >>> 1) & 1) == 0 ? +1f : -1f; + v[idx] += sign * w; + } + // L2 정규화 수행 private void l2NormalizeInPlace(float[] v) { double s = 0.0; diff --git a/back/src/main/java/com/back/global/ai/vector/PgVectorSearchService.java b/back/src/main/java/com/back/global/ai/vector/PgVectorSearchService.java index f6f77ac..211e8d4 100644 --- a/back/src/main/java/com/back/global/ai/vector/PgVectorSearchService.java +++ b/back/src/main/java/com/back/global/ai/vector/PgVectorSearchService.java @@ -1,6 +1,6 @@ /* - * 이 파일은 pgvector 리포지토리를 감싸 라인/나이 윈도우로 유사 스니펫을 조회하는 서비스를 제공한다. - * 쿼리 임베딩은 float[]로 받아 SQL 캐스트 가능한 문자열 "[...]"로 변환한다. + * [코드 흐름 요약] + * - 라인/나이 윈도우로 후보를 좁히고, 빈/누락 쿼리에도 768차원 0-벡터를 사용해 안전 검색. */ package com.back.global.ai.vector; @@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.util.Arrays; import java.util.List; @Service @@ -17,31 +18,45 @@ public class PgVectorSearchService { private final NodeSnippetRepository repo; - // 라인/나이 윈도우 필터 + pgvector 유사도 검색을 수행한다. + // 무결성 검증 + private static final int DIM = 768; + private static final String ZERO_LIT = zeroVectorLiteral(); + + // next 노드 생성 public List topK(Long lineId, int currAge, int deltaAge, float[] queryEmbedding, int k) { - String q = toVectorLiteral(queryEmbedding); + String q = toVectorLiteralOrZero(queryEmbedding); int minAge = currAge - deltaAge; int maxAge = currAge + deltaAge; return repo.searchTopKByLineAndAgeWindow(lineId, minAge, maxAge, q, k); } public List topKText(Long lineId, int currAge, int deltaAge, float[] queryEmbedding, int k) { - String q = toVectorLiteral(queryEmbedding); + String q = toVectorLiteralOrZero(queryEmbedding); int minAge = currAge - deltaAge; int maxAge = currAge + deltaAge; return repo.searchTopKTextByLineAndAgeWindow(lineId, minAge, maxAge, q, k); } - // float[] 임베딩을 "[a,b,c]" 형식으로 변환한다. - private String toVectorLiteral(float[] v) { - if (v == null || v.length == 0) return "[]"; + // 무결성 검증 + private static String toVectorLiteralOrZero(float[] v) { + if (v == null || v.length == 0) return ZERO_LIT; + if (v.length != DIM) return ZERO_LIT; StringBuilder sb = new StringBuilder(v.length * 8 + 2); sb.append('['); for (int i = 0; i < v.length; i++) { if (i > 0) sb.append(','); - sb.append(Float.toString(v[i])); + float x = v[i]; + if (Float.isNaN(x) || Float.isInfinite(x)) x = 0f; + sb.append(Float.toString(x)); } sb.append(']'); return sb.toString(); } + + // next 노드 생성 + private static String zeroVectorLiteral() { + String[] arr = new String[DIM]; + Arrays.fill(arr, "0"); + return "[" + String.join(",", arr) + "]"; + } } diff --git a/back/src/main/java/com/back/global/ai/vector/VocabTermSearchService.java b/back/src/main/java/com/back/global/ai/vector/VocabTermSearchService.java new file mode 100644 index 0000000..9e53905 --- /dev/null +++ b/back/src/main/java/com/back/global/ai/vector/VocabTermSearchService.java @@ -0,0 +1,53 @@ +/* + * [코드 흐름 요약] + * - 쿼리 문자열이 비어도 768차원 0-벡터로 안전하게 유사도 검색을 수행한다. + */ +package com.back.global.ai.vector; + +import com.back.domain.search.repository.VocabTermRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class VocabTermSearchService { + + private final EmbeddingClient embeddingClient; + private final VocabTermRepository repo; + + // 무결성 검증 + private static final int DIM = 768; + private static final String ZERO_LIT = zeroVectorLiteral(); + + public List topKTermsByQuery(String query, int k) { + float[] q = (query == null) ? null : embeddingClient.embed(query); + String qLit = toVectorLiteralOrZero(q); + return repo.searchTopKTerms(qLit, k); + } + + // 무결성 검증 + private static String toVectorLiteralOrZero(float[] v) { + if (v == null || v.length == 0) return ZERO_LIT; + if (v.length != DIM) return ZERO_LIT; + StringBuilder sb = new StringBuilder(v.length * 8 + 2); + sb.append('['); + for (int i = 0; i < v.length; i++) { + if (i > 0) sb.append(','); + float x = v[i]; + if (Float.isNaN(x) || Float.isInfinite(x)) x = 0f; + sb.append(Float.toString(x)); + } + sb.append(']'); + return sb.toString(); + } + + // next 노드 생성 + private static String zeroVectorLiteral() { + String[] arr = new String[DIM]; + Arrays.fill(arr, "0"); + return "[" + String.join(",", arr) + "]"; + } +} diff --git a/back/src/main/java/com/back/global/initdata/InitData.java b/back/src/main/java/com/back/global/initdata/InitData.java index fafb9ce..e7f5307 100644 --- a/back/src/main/java/com/back/global/initdata/InitData.java +++ b/back/src/main/java/com/back/global/initdata/InitData.java @@ -1,533 +1,533 @@ -package com.back.global.initdata; - -import com.back.domain.comment.entity.Comment; -import com.back.domain.comment.repository.CommentRepository; -import com.back.domain.node.dto.PivotListDto; -import com.back.domain.node.dto.base.BaseLineBulkCreateRequest; -import com.back.domain.node.dto.base.BaseLineBulkCreateResponse; -import com.back.domain.node.dto.decision.DecNodeDto; -import com.back.domain.node.dto.decision.DecisionNodeFromBaseRequest; -import com.back.domain.node.dto.decision.DecisionNodeNextRequest; -import com.back.domain.node.entity.BaseLine; -import com.back.domain.node.entity.DecisionLine; -import com.back.domain.node.entity.NodeCategory; -import com.back.domain.node.repository.BaseLineRepository; -import com.back.domain.node.repository.DecisionLineRepository; -import com.back.domain.node.service.NodeService; -import com.back.domain.post.entity.Post; -import com.back.domain.post.enums.PostCategory; -import com.back.domain.post.repository.PostRepository; -import com.back.domain.scenario.entity.*; -import com.back.domain.scenario.repository.ScenarioRepository; -import com.back.domain.scenario.repository.SceneCompareRepository; -import com.back.domain.scenario.repository.SceneTypeRepository; -import com.back.domain.user.entity.Gender; -import com.back.domain.user.entity.Mbti; -import com.back.domain.user.entity.Role; -import com.back.domain.user.entity.User; -import com.back.domain.user.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.CommandLineRunner; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Profile; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Component; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -/** - * [요약] 기동 시 admin·user1 생성 → user1에 베이스라인(총7: 헤더+피벗5+테일) 1개와 결정라인 2개(첫 번째 6노드, 두 번째 2노드) 시드 주입. - * 게시글 30개(일반20 + 투표10)과 댓글 14개(마지막 2개 글에 각 7개) 생성. - * 시나리오 3개(베이스 1개 + 완성 1개 + 처리중 1개)와 지표 10개(완성된 시나리오 2개에 각 5개씩) 생성. - * - * [주의] 프로덕션 환경에서는 실행되지 않습니다. (@Profile 설정) - */ -@Slf4j -@Component -@Profile("!prod") // prod 프로파일에서는 실행 안 함 -@RequiredArgsConstructor -@ConditionalOnProperty(name = "app.initdata.enabled", havingValue = "true", matchIfMissing = true) -public class InitData implements CommandLineRunner { - - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - private final NodeService nodeService; - - private final PostRepository postRepository; - private final CommentRepository commentRepository; - - private final BaseLineRepository baseLineRepository; - private final DecisionLineRepository decisionLineRepository; - private final ScenarioRepository scenarioRepository; - private final SceneTypeRepository sceneTypeRepository; - private final SceneCompareRepository sceneCompareRepository; - - // user1을 만들고 베이스라인(7)과 결정라인(5)을 시드로 주입한다 - @Override - public void run(String... args) { - // 전체 InitData 실행 여부 체크 (user1 존재 여부로 판단) - if (userRepository.findByEmail("user1@example.com").isPresent()) { - log.info("[InitData] 이미 초기화 데이터가 존재합니다. 스킵합니다."); - return; - } - - log.info("[InitData] 초기화 데이터 생성을 시작합니다..."); - - if (userRepository.findByEmail("admin@example.com").isEmpty()) { - var admin = User.builder() - .email("admin@example.com") - .password(passwordEncoder.encode("admin1234!")) - .role(Role.ADMIN) - .username("관리자") - .nickname("관리자닉네임") - .birthdayAt(LocalDateTime.of(1990, 1, 1, 0, 0)) - .gender(Gender.M) - .mbti(Mbti.INTJ) - .beliefs("합리주의") - .build(); - userRepository.save(admin); - } - - var user1 = userRepository.findByEmail("user1@example.com") - .orElseGet(() -> userRepository.save( - User.builder() - .email("user1@example.com") - .password(passwordEncoder.encode("user1234!")) - .role(Role.USER) - .username("사용자1") - .nickname("사용자닉네임") - .birthdayAt(LocalDateTime.of(1995, 5, 10, 0, 0)) - .gender(Gender.F) - .mbti(Mbti.ENFP) - .beliefs("개인주의") - .lifeSatis(6) - .relationship(7) - .workLifeBal(8) - .riskAvoid(5) - .build() - )); - - BaseLineBulkCreateResponse baseRes = nodeService.createBaseLineWithNodes( - new BaseLineBulkCreateRequest( - user1.getId(), - "user1-기본 라인", - List.of( - new BaseLineBulkCreateRequest.BaseNodePayload( - NodeCategory.EDUCATION, "중학교 진학", "일반계 선택", 18, "중등 입학 및 진로 탐색 시작" - ), - new BaseLineBulkCreateRequest.BaseNodePayload( - NodeCategory.EDUCATION, "고교 진학", "이과 트랙", 20, "수학·물리 집중 선택" - ), - new BaseLineBulkCreateRequest.BaseNodePayload( - NodeCategory.EDUCATION, "대학 합격", "컴공 전공", 22, "알고리즘/네트워크 관심" - ), - new BaseLineBulkCreateRequest.BaseNodePayload( - NodeCategory.CAREER, "인턴 경험", "백엔드 인턴", 24, "스프링 부트 실무 체험" - ), - new BaseLineBulkCreateRequest.BaseNodePayload( - NodeCategory.CAREER, "첫 직장", "주니어 백엔드", 26, "API/DB 설계 중심" - ) - ) - ) - ); - - Long baseLineId = baseRes.baseLineId(); - PivotListDto pivots = nodeService.getPivotBaseNodes(baseLineId); - if (pivots.pivots() == null || pivots.pivots().isEmpty()) return; - - DecNodeDto d0 = nodeService.createDecisionNodeFromBase( - new DecisionNodeFromBaseRequest( - user1.getId(), - baseLineId, - 0, - null, - 0, - NodeCategory.CAREER, - "개발자 커리어 진입", - List.of("자바/스프링", "파이썬/데이터"), - 0, - "백엔드 중심 트랙을 초기 선택지로 제시" - ) - ); - - DecNodeDto d1 = nodeService.createDecisionNodeNext( - new DecisionNodeNextRequest( - user1.getId(), - d0.id(), - NodeCategory.CAREER, - "클라우드 기초", - null, - List.of("AWS 기초", "GCP 기초"), - 0, - 0, - "EC2/RDS·CI/CD 파이프라인 구축" - ) - ); - - DecNodeDto d2 = nodeService.createDecisionNodeNext( - new DecisionNodeNextRequest( - user1.getId(), - d1.id(), - NodeCategory.CAREER, - "보안 기초", - null, - List.of("웹 보안", "네트워크 보안"), - 0, - 0, - "JWT·세션·CSRF/XSS 대응 심화" - ) - ); - - DecNodeDto d3 = nodeService.createDecisionNodeNext( - new DecisionNodeNextRequest( - user1.getId(), - d2.id(), - NodeCategory.CAREER, - "대용량 처리", - null, - List.of("캐시·큐", "검색"), - 0, - 0, - "Redis·Kafka·Elasticsearch 실습" - ) - ); - - DecNodeDto d4 = nodeService.createDecisionNodeNext( - new DecisionNodeNextRequest( - user1.getId(), - d3.id(), - NodeCategory.CAREER, - "운영/관측성", - null, - List.of("로그·모니터링", "SLO/알림"), - 0, - 0, - "프로덕션 운영 지표와 알림 체계 정착" - ) - ); - - // 두 번째 DecisionLine 생성 (processingScenario 테스트용) - DecNodeDto d5 = nodeService.createDecisionNodeFromBase( - new DecisionNodeFromBaseRequest( - user1.getId(), - baseLineId, - 1, // 두 번째 피벗 - null, - 0, - NodeCategory.CAREER, - "스타트업 창업", - List.of("단독 창업", "공동 창업"), - 0, - "기술 스타트업 설립 선택지" - ) - ); - - DecNodeDto d6 = nodeService.createDecisionNodeNext( - new DecisionNodeNextRequest( - user1.getId(), - d5.id(), - NodeCategory.FINANCE, - "초기 투자 유치", - null, - List.of("엔젤 투자", "시드 투자"), - 0, - 0, - "초기 자금 확보 전략" - ) - ); - - // 잠담 게시글 20개 생성 - List posts = new ArrayList<>(); - for (int i = 1; i <= 20; i++) { - Post post = Post.builder() - .title("일반 게시글 " + i) - .content("일반 게시글 내용 " + i) - .category(PostCategory.CHAT) - .user(user1) - .hide(false) - .likeCount(0) - .build(); - posts.add(post); - } - - // 투표 게시글 10개 생성 - for (int i = 1; i <= 10; i++) { - String voteContent = """ - { - "pollUid": "%s", - "options": [ - {"index": 1, "text": "첫 번째 옵션"}, - {"index": 2, "text": "두 번째 옵션"}, - {"index": 3, "text": "세 번째 옵션"} - ] - } - """.formatted(UUID.randomUUID().toString()); - - Post pollPost = Post.builder() - .title("투표 게시글 " + i) - .content("투표 게시글 내용 " + i) - .category(PostCategory.POLL) - .user(user1) - .voteContent(voteContent) - .hide(false) - .likeCount(0) - .build(); - posts.add(pollPost); - } - - postRepository.saveAll(posts); - - // 마지막 2개의 게시글만 댓글 7개 작성 - List lastPosts = posts.subList(posts.size() - 2, posts.size()); - - List comments = new ArrayList<>(); - - for (Post post : lastPosts) { - for (int i = 1; i <= 7; i++) { - Comment comment = Comment.builder() - .post(post) - .user(user1) // 댓글 작성자 - .content(post.getTitle() + "에 대한 댓글 " + i) - .hide(false) - .likeCount(0) - .build(); - comments.add(comment); - } - } - commentRepository.saveAll(comments); - - // ========== Scenario InitData 생성 ========== - - // BaseLine 조회 (위에서 생성한 baseLineId 사용) - BaseLine baseLine = baseLineRepository.findById(baseLineId) - .orElseThrow(() -> new IllegalStateException("BaseLine not found")); - - // DecisionLine 조회 (user1의 DecisionLine - BaseLine에 연결된 것) - List decisionLines = decisionLineRepository.findByBaseLine_Id(baseLineId); - if (decisionLines.size() < 2) { - throw new IllegalStateException("Expected 2 DecisionLines but found: " + decisionLines.size()); - } - - Long decisionLineId1 = d0.decisionLineId(); - Long decisionLineId2 = d5.decisionLineId(); - - DecisionLine decisionLine1 = decisionLineRepository.findById(decisionLineId1) - .orElseThrow(() -> new IllegalStateException("DecisionLine not found: " + decisionLineId1)); - DecisionLine decisionLine2 = decisionLineRepository.findById(decisionLineId2) - .orElseThrow(() -> new IllegalStateException("DecisionLine not found: " + decisionLineId2)); - - if (decisionLine1.getId().equals(decisionLine2.getId())) { - throw new IllegalStateException("Two scenarios cannot share the same DecisionLine: " + decisionLine1.getId()); - } - - // 1. 베이스 시나리오 생성 (현재 삶 기준) - Scenario baseScenario = Scenario.builder() - .user(user1) - .baseLine(baseLine) - .decisionLine(null) // 베이스 시나리오는 DecisionLine 없음 - .status(ScenarioStatus.COMPLETED) - .job("주니어 백엔드 개발자") - .total(350) - .summary("현재 커리어 경로를 유지하며 안정적으로 성장하는 시나리오입니다.") - .description(""" - 대학 졸업 후 백엔드 개발자로 시작하여 주니어에서 시니어로 성장하는 과정입니다. - 안정적인 중견기업에서 경력을 쌓으며, 스프링 부트와 API 설계 전문성을 키워나갑니다. - 팀 내에서 신뢰받는 개발자로 성장하며, 워라밸을 유지하면서 건강한 개발 생활을 이어갑니다. - """) - .timelineTitles(""" - { - "2025": "시니어 개발자 승진", - "2027": "테크 리드 역할 수행", - "2030": "개발팀 리더" - } - """) - .img("https://picsum.photos/seed/base-scenario/400/300") - .build(); - scenarioRepository.save(baseScenario); - - // 베이스 시나리오 지표 생성 (5개) - createSceneTypes(baseScenario, 70, 75, 70, 65, 70); - - // 2. 완성된 시나리오 생성 (첫 번째 DecisionLine 기반) - if (!scenarioRepository.existsByDecisionLine_Id(decisionLine1.getId())) { - Scenario completedScenario = Scenario.builder() - .user(user1) - .baseLine(baseLine) - .decisionLine(decisionLine1) - .status(ScenarioStatus.COMPLETED) - .job("클라우드 아키텍트") - .total(430) - .summary("클라우드와 보안 전문성을 갖춘 시니어 아키텍트로 성장하는 시나리오입니다.") - .description(""" - AWS/GCP 클라우드 플랫폼 심화 학습과 보안 인증을 통해 시스템 아키텍트로 성장합니다. - 대용량 트래픽 처리 경험과 인프라 자동화 능력을 갖추어 기술 리더로 인정받습니다. - 컨설팅 프로젝트와 기술 강연을 통해 업계 전문가로 자리매김하며, - 궁극적으로 스타트업 CTO 또는 대기업 기술 이사로 성장합니다. - """) - .timelineTitles(""" - { - "2025": "AWS Solutions Architect 자격증 취득", - "2027": "솔루션 아키텍트 승진", - "2030": "기술 이사 (CTO)" - } - """) - .img("https://picsum.photos/seed/decision-scenario/400/300") - .build(); - scenarioRepository.save(completedScenario); - - - // 완성 시나리오 지표 생성 (더 높은 점수) - createSceneTypes(completedScenario, 90, 85, 80, 88, 87); - - // 완성 시나리오 비교 분석 데이터 생성 (baseScenario와 비교) - createSceneCompares(completedScenario, baseScenario); - } - - - // 3. 처리 중 시나리오 생성 (두 번째 DecisionLine 기반, 폴링 테스트용) - if (!scenarioRepository.existsByDecisionLine_Id(decisionLine2.getId())) { - Scenario processingScenario = Scenario.builder() - .user(user1) - .baseLine(baseLine) - .decisionLine(decisionLine2) - .status(ScenarioStatus.PROCESSING) - .build(); - scenarioRepository.save(processingScenario); - } - - log.info("[InitData] 초기화 데이터 생성 완료!"); - log.info("[InitData] - 사용자: admin, user1"); - log.info("[InitData] - 게시글: {} 개", postRepository.count()); - log.info("[InitData] - 댓글: {} 개", commentRepository.count()); - log.info("[InitData] - 시나리오: {} 개", scenarioRepository.count()); - } - - /** - * 시나리오에 대한 5개 지표(SceneType) 데이터를 생성합니다. - * @param scenario 대상 시나리오 - * @param eco 경제 점수 - * @param happy 행복 점수 - * @param rel 관계 점수 - * @param career 직업 점수 - * @param health 건강 점수 - */ - private void createSceneTypes(Scenario scenario, int eco, int happy, int rel, int career, int health) { - List sceneTypes = List.of( - SceneType.builder() - .scenario(scenario) - .type(Type.경제) - .point(eco) - .analysis(eco >= 85 - ? "클라우드 전문가로 높은 연봉과 컨설팅 수입을 통해 경제적 자유를 확보했습니다." - : "안정적인 중견기업 재직으로 평균 이상의 경제력을 유지하고 있습니다.") - .build(), - SceneType.builder() - .scenario(scenario) - .type(Type.행복) - .point(happy) - .analysis(happy >= 80 - ? "전문성 인정과 도전적인 업무를 통해 높은 직무 만족도를 느낍니다." - : "업무 만족도가 높고 워라밸이 좋은 환경에서 일하고 있습니다.") - .build(), - SceneType.builder() - .scenario(scenario) - .type(Type.관계) - .point(rel) - .analysis(rel >= 80 - ? "리더십 역할을 통해 업계 네트워크를 넓히고 멘토 관계를 형성했습니다." - : "팀원들과 원만한 관계를 유지하며 개인 시간도 충분히 확보하고 있습니다.") - .build(), - SceneType.builder() - .scenario(scenario) - .type(Type.직업) - .point(career) - .analysis(career >= 85 - ? "클라우드 및 보안 분야 최고 전문가로 인정받고 있습니다." - : "백엔드 개발 전문성은 확보했으나 리더십 경험이 다소 부족합니다.") - .build(), - SceneType.builder() - .scenario(scenario) - .type(Type.건강) - .point(health) - .analysis(health >= 85 - ? "체계적인 건강 관리와 규칙적인 운동 루틴을 유지하고 있습니다." - : "규칙적인 생활과 적당한 운동으로 건강을 유지하고 있습니다.") - .build() - ); - - sceneTypeRepository.saveAll(sceneTypes); - } - - /** - * 시나리오 비교 분석 데이터를 생성합니다. - * 베이스 시나리오와 비교하여 6개 비교 결과(TOTAL + 5개 지표)를 생성합니다. - * @param decisionScenario 비교 대상 결정 시나리오 - * @param baseScenario 비교 기준 베이스 시나리오 - */ - private void createSceneCompares(Scenario decisionScenario, Scenario baseScenario) { - List sceneCompares = List.of( - SceneCompare.builder() - .scenario(decisionScenario) - .resultType(SceneCompareResultType.TOTAL) - .compareResult(""" - 전문성 심화와 기술 리더십 역할 수행으로 경제적·직업적 성취가 크게 향상되었습니다. - 다만 초기 학습 곡선과 업무 강도 증가로 인한 스트레스 관리가 필요합니다. - 장기적으로는 더 높은 만족도와 영향력을 기대할 수 있는 경로입니다. - """) - .build(), - SceneCompare.builder() - .scenario(decisionScenario) - .resultType(SceneCompareResultType.경제) - .compareResult(""" - 클라우드 전문가 프리미엄으로 연봉이 30% 이상 증가했습니다. - 컨설팅 사이드 프로젝트와 기술 강연으로 추가 수입원이 생겼습니다. - AWS/GCP 자격증 보유로 시장 가치가 크게 상승했습니다. - """) - .build(), - SceneCompare.builder() - .scenario(decisionScenario) - .resultType(SceneCompareResultType.행복) - .compareResult(""" - 전문 분야 인정으로 직무 만족도가 높아졌습니다. - 도전적인 프로젝트와 기술 성장으로 성취감이 증대되었습니다. - 하지만 초기 학습 스트레스와 업무 강도 증가가 있었습니다. - """) - .build(), - SceneCompare.builder() - .scenario(decisionScenario) - .resultType(SceneCompareResultType.관계) - .compareResult(""" - 기술 커뮤니티 활동과 컨퍼런스 참여로 네트워크가 확장되었습니다. - 멘토·멘티 관계 형성으로 의미있는 인맥이 생겼습니다. - 프로젝트 중심 업무로 협업 기회가 증가했습니다. - """) - .build(), - SceneCompare.builder() - .scenario(decisionScenario) - .resultType(SceneCompareResultType.직업) - .compareResult(""" - 클라우드 아키텍처 설계 능력으로 기술 리더로 인정받았습니다. - AWS Solutions Architect Professional 자격증 취득으로 전문성 입증했습니다. - 대규모 인프라 마이그레이션 프로젝트 리드 경험을 쌓았습니다. - CTO 또는 기술 이사로의 커리어 경로가 열렸습니다. - """) - .build(), - SceneCompare.builder() - .scenario(decisionScenario) - .resultType(SceneCompareResultType.건강) - .compareResult(""" - 높은 연봉으로 프리미엄 헬스케어와 피트니스 서비스를 이용했습니다. - 재택근무 옵션으로 출퇴근 스트레스가 감소했습니다. - 체계적인 운동 루틴과 건강 관리 투자가 가능해졌습니다. - 다만 초기 학습 기간 중 일시적으로 수면 부족이 있었습니다. - """) - .build() - ); - - sceneCompareRepository.saveAll(sceneCompares); - } -} +//package com.back.global.initdata; +// +//import com.back.domain.comment.entity.Comment; +//import com.back.domain.comment.repository.CommentRepository; +//import com.back.domain.node.dto.PivotListDto; +//import com.back.domain.node.dto.base.BaseLineBulkCreateRequest; +//import com.back.domain.node.dto.base.BaseLineBulkCreateResponse; +//import com.back.domain.node.dto.decision.DecNodeDto; +//import com.back.domain.node.dto.decision.DecisionNodeFromBaseRequest; +//import com.back.domain.node.dto.decision.DecisionNodeNextRequest; +//import com.back.domain.node.entity.BaseLine; +//import com.back.domain.node.entity.DecisionLine; +//import com.back.domain.node.entity.NodeCategory; +//import com.back.domain.node.repository.BaseLineRepository; +//import com.back.domain.node.repository.DecisionLineRepository; +//import com.back.domain.node.service.NodeService; +//import com.back.domain.post.entity.Post; +//import com.back.domain.post.enums.PostCategory; +//import com.back.domain.post.repository.PostRepository; +//import com.back.domain.scenario.entity.*; +//import com.back.domain.scenario.repository.ScenarioRepository; +//import com.back.domain.scenario.repository.SceneCompareRepository; +//import com.back.domain.scenario.repository.SceneTypeRepository; +//import com.back.domain.user.entity.Gender; +//import com.back.domain.user.entity.Mbti; +//import com.back.domain.user.entity.Role; +//import com.back.domain.user.entity.User; +//import com.back.domain.user.repository.UserRepository; +//import lombok.RequiredArgsConstructor; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.boot.CommandLineRunner; +//import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +//import org.springframework.context.annotation.Profile; +//import org.springframework.security.crypto.password.PasswordEncoder; +//import org.springframework.stereotype.Component; +// +//import java.time.LocalDateTime; +//import java.util.ArrayList; +//import java.util.List; +//import java.util.UUID; +// +///** +// * [요약] 기동 시 admin·user1 생성 → user1에 베이스라인(총7: 헤더+피벗5+테일) 1개와 결정라인 2개(첫 번째 6노드, 두 번째 2노드) 시드 주입. +// * 게시글 30개(일반20 + 투표10)과 댓글 14개(마지막 2개 글에 각 7개) 생성. +// * 시나리오 3개(베이스 1개 + 완성 1개 + 처리중 1개)와 지표 10개(완성된 시나리오 2개에 각 5개씩) 생성. +// * +// * [주의] 프로덕션 환경에서는 실행되지 않습니다. (@Profile 설정) +// */ +//@Slf4j +//@Component +//@Profile("!prod") // prod 프로파일에서는 실행 안 함 +//@RequiredArgsConstructor +//@ConditionalOnProperty(name = "app.initdata.enabled", havingValue = "true", matchIfMissing = true) +//public class InitData implements CommandLineRunner { +// +// private final UserRepository userRepository; +// private final PasswordEncoder passwordEncoder; +// private final NodeService nodeService; +// +// private final PostRepository postRepository; +// private final CommentRepository commentRepository; +// +// private final BaseLineRepository baseLineRepository; +// private final DecisionLineRepository decisionLineRepository; +// private final ScenarioRepository scenarioRepository; +// private final SceneTypeRepository sceneTypeRepository; +// private final SceneCompareRepository sceneCompareRepository; +// +// // user1을 만들고 베이스라인(7)과 결정라인(5)을 시드로 주입한다 +// @Override +// public void run(String... args) { +// // 전체 InitData 실행 여부 체크 (user1 존재 여부로 판단) +// if (userRepository.findByEmail("user1@example.com").isPresent()) { +// log.info("[InitData] 이미 초기화 데이터가 존재합니다. 스킵합니다."); +// return; +// } +// +// log.info("[InitData] 초기화 데이터 생성을 시작합니다..."); +// +// if (userRepository.findByEmail("admin@example.com").isEmpty()) { +// var admin = User.builder() +// .email("admin@example.com") +// .password(passwordEncoder.encode("admin1234!")) +// .role(Role.ADMIN) +// .username("관리자") +// .nickname("관리자닉네임") +// .birthdayAt(LocalDateTime.of(1990, 1, 1, 0, 0)) +// .gender(Gender.M) +// .mbti(Mbti.INTJ) +// .beliefs("합리주의") +// .build(); +// userRepository.save(admin); +// } +// +// var user1 = userRepository.findByEmail("user1@example.com") +// .orElseGet(() -> userRepository.save( +// User.builder() +// .email("user1@example.com") +// .password(passwordEncoder.encode("user1234!")) +// .role(Role.USER) +// .username("사용자1") +// .nickname("사용자닉네임") +// .birthdayAt(LocalDateTime.of(1995, 5, 10, 0, 0)) +// .gender(Gender.F) +// .mbti(Mbti.ENFP) +// .beliefs("개인주의") +// .lifeSatis(6) +// .relationship(7) +// .workLifeBal(8) +// .riskAvoid(5) +// .build() +// )); +// +// BaseLineBulkCreateResponse baseRes = nodeService.createBaseLineWithNodes( +// new BaseLineBulkCreateRequest( +// user1.getId(), +// "user1-기본 라인", +// List.of( +// new BaseLineBulkCreateRequest.BaseNodePayload( +// NodeCategory.EDUCATION, "중학교 진학", "일반계 선택", 18, "중등 입학 및 진로 탐색 시작" +// ), +// new BaseLineBulkCreateRequest.BaseNodePayload( +// NodeCategory.EDUCATION, "고교 진학", "이과 트랙", 20, "수학·물리 집중 선택" +// ), +// new BaseLineBulkCreateRequest.BaseNodePayload( +// NodeCategory.EDUCATION, "대학 합격", "컴공 전공", 22, "알고리즘/네트워크 관심" +// ), +// new BaseLineBulkCreateRequest.BaseNodePayload( +// NodeCategory.CAREER, "인턴 경험", "백엔드 인턴", 24, "스프링 부트 실무 체험" +// ), +// new BaseLineBulkCreateRequest.BaseNodePayload( +// NodeCategory.CAREER, "첫 직장", "주니어 백엔드", 26, "API/DB 설계 중심" +// ) +// ) +// ) +// ); +// +// Long baseLineId = baseRes.baseLineId(); +// PivotListDto pivots = nodeService.getPivotBaseNodes(baseLineId); +// if (pivots.pivots() == null || pivots.pivots().isEmpty()) return; +// +// DecNodeDto d0 = nodeService.createDecisionNodeFromBase( +// new DecisionNodeFromBaseRequest( +// user1.getId(), +// baseLineId, +// 0, +// null, +// 0, +// NodeCategory.CAREER, +// "개발자 커리어 진입", +// List.of("자바/스프링", "파이썬/데이터"), +// 0, +// "백엔드 중심 트랙을 초기 선택지로 제시" +// ) +// ); +// +// DecNodeDto d1 = nodeService.createDecisionNodeNext( +// new DecisionNodeNextRequest( +// user1.getId(), +// d0.id(), +// NodeCategory.CAREER, +// "클라우드 기초", +// null, +// List.of("AWS 기초", "GCP 기초"), +// 0, +// 0, +// "EC2/RDS·CI/CD 파이프라인 구축" +// ) +// ); +// +// DecNodeDto d2 = nodeService.createDecisionNodeNext( +// new DecisionNodeNextRequest( +// user1.getId(), +// d1.id(), +// NodeCategory.CAREER, +// "보안 기초", +// null, +// List.of("웹 보안", "네트워크 보안"), +// 0, +// 0, +// "JWT·세션·CSRF/XSS 대응 심화" +// ) +// ); +// +// DecNodeDto d3 = nodeService.createDecisionNodeNext( +// new DecisionNodeNextRequest( +// user1.getId(), +// d2.id(), +// NodeCategory.CAREER, +// "대용량 처리", +// null, +// List.of("캐시·큐", "검색"), +// 0, +// 0, +// "Redis·Kafka·Elasticsearch 실습" +// ) +// ); +// +// DecNodeDto d4 = nodeService.createDecisionNodeNext( +// new DecisionNodeNextRequest( +// user1.getId(), +// d3.id(), +// NodeCategory.CAREER, +// "운영/관측성", +// null, +// List.of("로그·모니터링", "SLO/알림"), +// 0, +// 0, +// "프로덕션 운영 지표와 알림 체계 정착" +// ) +// ); +// +// // 두 번째 DecisionLine 생성 (processingScenario 테스트용) +// DecNodeDto d5 = nodeService.createDecisionNodeFromBase( +// new DecisionNodeFromBaseRequest( +// user1.getId(), +// baseLineId, +// 1, // 두 번째 피벗 +// null, +// 0, +// NodeCategory.CAREER, +// "스타트업 창업", +// List.of("단독 창업", "공동 창업"), +// 0, +// "기술 스타트업 설립 선택지" +// ) +// ); +// +// DecNodeDto d6 = nodeService.createDecisionNodeNext( +// new DecisionNodeNextRequest( +// user1.getId(), +// d5.id(), +// NodeCategory.FINANCE, +// "초기 투자 유치", +// null, +// List.of("엔젤 투자", "시드 투자"), +// 0, +// 0, +// "초기 자금 확보 전략" +// ) +// ); +// +// // 잠담 게시글 20개 생성 +// List posts = new ArrayList<>(); +// for (int i = 1; i <= 20; i++) { +// Post post = Post.builder() +// .title("일반 게시글 " + i) +// .content("일반 게시글 내용 " + i) +// .category(PostCategory.CHAT) +// .user(user1) +// .hide(false) +// .likeCount(0) +// .build(); +// posts.add(post); +// } +// +// // 투표 게시글 10개 생성 +// for (int i = 1; i <= 10; i++) { +// String voteContent = """ +// { +// "pollUid": "%s", +// "options": [ +// {"index": 1, "text": "첫 번째 옵션"}, +// {"index": 2, "text": "두 번째 옵션"}, +// {"index": 3, "text": "세 번째 옵션"} +// ] +// } +// """.formatted(UUID.randomUUID().toString()); +// +// Post pollPost = Post.builder() +// .title("투표 게시글 " + i) +// .content("투표 게시글 내용 " + i) +// .category(PostCategory.POLL) +// .user(user1) +// .voteContent(voteContent) +// .hide(false) +// .likeCount(0) +// .build(); +// posts.add(pollPost); +// } +// +// postRepository.saveAll(posts); +// +// // 마지막 2개의 게시글만 댓글 7개 작성 +// List lastPosts = posts.subList(posts.size() - 2, posts.size()); +// +// List comments = new ArrayList<>(); +// +// for (Post post : lastPosts) { +// for (int i = 1; i <= 7; i++) { +// Comment comment = Comment.builder() +// .post(post) +// .user(user1) // 댓글 작성자 +// .content(post.getTitle() + "에 대한 댓글 " + i) +// .hide(false) +// .likeCount(0) +// .build(); +// comments.add(comment); +// } +// } +// commentRepository.saveAll(comments); +// +// // ========== Scenario InitData 생성 ========== +// +// // BaseLine 조회 (위에서 생성한 baseLineId 사용) +// BaseLine baseLine = baseLineRepository.findById(baseLineId) +// .orElseThrow(() -> new IllegalStateException("BaseLine not found")); +// +// // DecisionLine 조회 (user1의 DecisionLine - BaseLine에 연결된 것) +// List decisionLines = decisionLineRepository.findByBaseLine_Id(baseLineId); +// if (decisionLines.size() < 2) { +// throw new IllegalStateException("Expected 2 DecisionLines but found: " + decisionLines.size()); +// } +// +// Long decisionLineId1 = d0.decisionLineId(); +// Long decisionLineId2 = d5.decisionLineId(); +// +// DecisionLine decisionLine1 = decisionLineRepository.findById(decisionLineId1) +// .orElseThrow(() -> new IllegalStateException("DecisionLine not found: " + decisionLineId1)); +// DecisionLine decisionLine2 = decisionLineRepository.findById(decisionLineId2) +// .orElseThrow(() -> new IllegalStateException("DecisionLine not found: " + decisionLineId2)); +// +// if (decisionLine1.getId().equals(decisionLine2.getId())) { +// throw new IllegalStateException("Two scenarios cannot share the same DecisionLine: " + decisionLine1.getId()); +// } +// +// // 1. 베이스 시나리오 생성 (현재 삶 기준) +// Scenario baseScenario = Scenario.builder() +// .user(user1) +// .baseLine(baseLine) +// .decisionLine(null) // 베이스 시나리오는 DecisionLine 없음 +// .status(ScenarioStatus.COMPLETED) +// .job("주니어 백엔드 개발자") +// .total(350) +// .summary("현재 커리어 경로를 유지하며 안정적으로 성장하는 시나리오입니다.") +// .description(""" +// 대학 졸업 후 백엔드 개발자로 시작하여 주니어에서 시니어로 성장하는 과정입니다. +// 안정적인 중견기업에서 경력을 쌓으며, 스프링 부트와 API 설계 전문성을 키워나갑니다. +// 팀 내에서 신뢰받는 개발자로 성장하며, 워라밸을 유지하면서 건강한 개발 생활을 이어갑니다. +// """) +// .timelineTitles(""" +// { +// "2025": "시니어 개발자 승진", +// "2027": "테크 리드 역할 수행", +// "2030": "개발팀 리더" +// } +// """) +// .img("https://picsum.photos/seed/base-scenario/400/300") +// .build(); +// scenarioRepository.save(baseScenario); +// +// // 베이스 시나리오 지표 생성 (5개) +// createSceneTypes(baseScenario, 70, 75, 70, 65, 70); +// +// // 2. 완성된 시나리오 생성 (첫 번째 DecisionLine 기반) +// if (!scenarioRepository.existsByDecisionLine_Id(decisionLine1.getId())) { +// Scenario completedScenario = Scenario.builder() +// .user(user1) +// .baseLine(baseLine) +// .decisionLine(decisionLine1) +// .status(ScenarioStatus.COMPLETED) +// .job("클라우드 아키텍트") +// .total(430) +// .summary("클라우드와 보안 전문성을 갖춘 시니어 아키텍트로 성장하는 시나리오입니다.") +// .description(""" +// AWS/GCP 클라우드 플랫폼 심화 학습과 보안 인증을 통해 시스템 아키텍트로 성장합니다. +// 대용량 트래픽 처리 경험과 인프라 자동화 능력을 갖추어 기술 리더로 인정받습니다. +// 컨설팅 프로젝트와 기술 강연을 통해 업계 전문가로 자리매김하며, +// 궁극적으로 스타트업 CTO 또는 대기업 기술 이사로 성장합니다. +// """) +// .timelineTitles(""" +// { +// "2025": "AWS Solutions Architect 자격증 취득", +// "2027": "솔루션 아키텍트 승진", +// "2030": "기술 이사 (CTO)" +// } +// """) +// .img("https://picsum.photos/seed/decision-scenario/400/300") +// .build(); +// scenarioRepository.save(completedScenario); +// +// +// // 완성 시나리오 지표 생성 (더 높은 점수) +// createSceneTypes(completedScenario, 90, 85, 80, 88, 87); +// +// // 완성 시나리오 비교 분석 데이터 생성 (baseScenario와 비교) +// createSceneCompares(completedScenario, baseScenario); +// } +// +// +// // 3. 처리 중 시나리오 생성 (두 번째 DecisionLine 기반, 폴링 테스트용) +// if (!scenarioRepository.existsByDecisionLine_Id(decisionLine2.getId())) { +// Scenario processingScenario = Scenario.builder() +// .user(user1) +// .baseLine(baseLine) +// .decisionLine(decisionLine2) +// .status(ScenarioStatus.PROCESSING) +// .build(); +// scenarioRepository.save(processingScenario); +// } +// +// log.info("[InitData] 초기화 데이터 생성 완료!"); +// log.info("[InitData] - 사용자: admin, user1"); +// log.info("[InitData] - 게시글: {} 개", postRepository.count()); +// log.info("[InitData] - 댓글: {} 개", commentRepository.count()); +// log.info("[InitData] - 시나리오: {} 개", scenarioRepository.count()); +// } +// +// /** +// * 시나리오에 대한 5개 지표(SceneType) 데이터를 생성합니다. +// * @param scenario 대상 시나리오 +// * @param eco 경제 점수 +// * @param happy 행복 점수 +// * @param rel 관계 점수 +// * @param career 직업 점수 +// * @param health 건강 점수 +// */ +// private void createSceneTypes(Scenario scenario, int eco, int happy, int rel, int career, int health) { +// List sceneTypes = List.of( +// SceneType.builder() +// .scenario(scenario) +// .type(Type.경제) +// .point(eco) +// .analysis(eco >= 85 +// ? "클라우드 전문가로 높은 연봉과 컨설팅 수입을 통해 경제적 자유를 확보했습니다." +// : "안정적인 중견기업 재직으로 평균 이상의 경제력을 유지하고 있습니다.") +// .build(), +// SceneType.builder() +// .scenario(scenario) +// .type(Type.행복) +// .point(happy) +// .analysis(happy >= 80 +// ? "전문성 인정과 도전적인 업무를 통해 높은 직무 만족도를 느낍니다." +// : "업무 만족도가 높고 워라밸이 좋은 환경에서 일하고 있습니다.") +// .build(), +// SceneType.builder() +// .scenario(scenario) +// .type(Type.관계) +// .point(rel) +// .analysis(rel >= 80 +// ? "리더십 역할을 통해 업계 네트워크를 넓히고 멘토 관계를 형성했습니다." +// : "팀원들과 원만한 관계를 유지하며 개인 시간도 충분히 확보하고 있습니다.") +// .build(), +// SceneType.builder() +// .scenario(scenario) +// .type(Type.직업) +// .point(career) +// .analysis(career >= 85 +// ? "클라우드 및 보안 분야 최고 전문가로 인정받고 있습니다." +// : "백엔드 개발 전문성은 확보했으나 리더십 경험이 다소 부족합니다.") +// .build(), +// SceneType.builder() +// .scenario(scenario) +// .type(Type.건강) +// .point(health) +// .analysis(health >= 85 +// ? "체계적인 건강 관리와 규칙적인 운동 루틴을 유지하고 있습니다." +// : "규칙적인 생활과 적당한 운동으로 건강을 유지하고 있습니다.") +// .build() +// ); +// +// sceneTypeRepository.saveAll(sceneTypes); +// } +// +// /** +// * 시나리오 비교 분석 데이터를 생성합니다. +// * 베이스 시나리오와 비교하여 6개 비교 결과(TOTAL + 5개 지표)를 생성합니다. +// * @param decisionScenario 비교 대상 결정 시나리오 +// * @param baseScenario 비교 기준 베이스 시나리오 +// */ +// private void createSceneCompares(Scenario decisionScenario, Scenario baseScenario) { +// List sceneCompares = List.of( +// SceneCompare.builder() +// .scenario(decisionScenario) +// .resultType(SceneCompareResultType.TOTAL) +// .compareResult(""" +// 전문성 심화와 기술 리더십 역할 수행으로 경제적·직업적 성취가 크게 향상되었습니다. +// 다만 초기 학습 곡선과 업무 강도 증가로 인한 스트레스 관리가 필요합니다. +// 장기적으로는 더 높은 만족도와 영향력을 기대할 수 있는 경로입니다. +// """) +// .build(), +// SceneCompare.builder() +// .scenario(decisionScenario) +// .resultType(SceneCompareResultType.경제) +// .compareResult(""" +// 클라우드 전문가 프리미엄으로 연봉이 30% 이상 증가했습니다. +// 컨설팅 사이드 프로젝트와 기술 강연으로 추가 수입원이 생겼습니다. +// AWS/GCP 자격증 보유로 시장 가치가 크게 상승했습니다. +// """) +// .build(), +// SceneCompare.builder() +// .scenario(decisionScenario) +// .resultType(SceneCompareResultType.행복) +// .compareResult(""" +// 전문 분야 인정으로 직무 만족도가 높아졌습니다. +// 도전적인 프로젝트와 기술 성장으로 성취감이 증대되었습니다. +// 하지만 초기 학습 스트레스와 업무 강도 증가가 있었습니다. +// """) +// .build(), +// SceneCompare.builder() +// .scenario(decisionScenario) +// .resultType(SceneCompareResultType.관계) +// .compareResult(""" +// 기술 커뮤니티 활동과 컨퍼런스 참여로 네트워크가 확장되었습니다. +// 멘토·멘티 관계 형성으로 의미있는 인맥이 생겼습니다. +// 프로젝트 중심 업무로 협업 기회가 증가했습니다. +// """) +// .build(), +// SceneCompare.builder() +// .scenario(decisionScenario) +// .resultType(SceneCompareResultType.직업) +// .compareResult(""" +// 클라우드 아키텍처 설계 능력으로 기술 리더로 인정받았습니다. +// AWS Solutions Architect Professional 자격증 취득으로 전문성 입증했습니다. +// 대규모 인프라 마이그레이션 프로젝트 리드 경험을 쌓았습니다. +// CTO 또는 기술 이사로의 커리어 경로가 열렸습니다. +// """) +// .build(), +// SceneCompare.builder() +// .scenario(decisionScenario) +// .resultType(SceneCompareResultType.건강) +// .compareResult(""" +// 높은 연봉으로 프리미엄 헬스케어와 피트니스 서비스를 이용했습니다. +// 재택근무 옵션으로 출퇴근 스트레스가 감소했습니다. +// 체계적인 운동 루틴과 건강 관리 투자가 가능해졌습니다. +// 다만 초기 학습 기간 중 일시적으로 수면 부족이 있었습니다. +// """) +// .build() +// ); +// +// sceneCompareRepository.saveAll(sceneCompares); +// } +//} diff --git a/back/src/main/java/com/back/infra/pgvector/PgVectorConverter.java b/back/src/main/java/com/back/infra/pgvector/PgVectorConverter.java index 8215784..78b87ab 100644 --- a/back/src/main/java/com/back/infra/pgvector/PgVectorConverter.java +++ b/back/src/main/java/com/back/infra/pgvector/PgVectorConverter.java @@ -1,9 +1,7 @@ /* * [파일 요약/코드 흐름] * - JPA <-> PostgreSQL(pgvector) 매핑 컨버터 - * - DB 저장 시 float[]를 PGobject(type="vector")로 바인딩하여 드라이버가 네이티브 vector 타입으로 전달 - * - DB 조회 시 PGobject 또는 문자열("[a,b,c]")을 안전하게 파싱해 float[]로 복원 - * - 문자열로 바인딩할 때 발생하던 "column is of type vector but expression is of type character varying" 오류를 제거 + * - DB 저장/조회 시 차원 검증(768)과 NaN/Inf 정리로 무결성 강화 */ package com.back.infra.pgvector; @@ -14,54 +12,56 @@ @Converter(autoApply = false) public class PgVectorConverter implements AttributeConverter { - // 가장 중요한 함수: float[] -> PGobject(vector)로 직렬화해 네이티브 타입 바인딩 + // 무결성 검증 + private static final int DIM = 768; + + // next 노드 생성 @Override public Object convertToDatabaseColumn(float[] attribute) { - if (attribute == null || attribute.length == 0) return null; // NOT NULL 컬럼이면 상위에서 보장 + if (attribute == null || attribute.length == 0) return null; + if (attribute.length != DIM) { + throw new IllegalArgumentException("vector dim must be " + DIM + " but got " + attribute.length); + } try { PGobject obj = new PGobject(); - obj.setType("vector"); // pgvector 타입 지정 - obj.setValue(toLiteral(attribute)); // "[a,b,c]" 형식 값 설정 + obj.setType("vector"); + obj.setValue(toLiteral(attribute)); return obj; } catch (Exception e) { throw new IllegalArgumentException("Failed to convert float[] to PGobject(vector)", e); } } - // 가장 많이 사용하는 함수: PGobject/문자열 -> float[]로 역직렬화 + // 무결성 검증 @Override public float[] convertToEntityAttribute(Object dbData) { if (dbData == null) return new float[0]; - String s; - if (dbData instanceof PGobject pgo) { - s = pgo.getValue(); - } else { - s = dbData.toString(); - } + String s = (dbData instanceof PGobject pgo) ? pgo.getValue() : dbData.toString(); if (s == null) return new float[0]; s = s.trim(); if (s.isEmpty() || "[]".equals(s)) return new float[0]; - - if (s.startsWith("[") && s.endsWith("]")) { - s = s.substring(1, s.length() - 1); - } + if (s.startsWith("[") && s.endsWith("]")) s = s.substring(1, s.length() - 1); if (s.isBlank()) return new float[0]; String[] parts = s.split("\\s*,\\s*"); float[] out = new float[parts.length]; for (int i = 0; i < parts.length; i++) { - out[i] = Float.parseFloat(parts[i]); + float x = Float.parseFloat(parts[i]); + // 무결성 검증 + out[i] = (Float.isNaN(x) || Float.isInfinite(x)) ? 0f : x; } return out; } - // 가장 중요한 함수: float[]를 pgvector 리터럴("[...]") 문자열로 변환 + // 무결성 검증 private String toLiteral(float[] v) { StringBuilder sb = new StringBuilder(v.length * 8 + 2); sb.append('['); for (int i = 0; i < v.length; i++) { if (i > 0) sb.append(','); - sb.append(Float.toString(v[i])); + float x = v[i]; + if (Float.isNaN(x) || Float.isInfinite(x)) x = 0f; + sb.append(Float.toString(x)); } sb.append(']'); return sb.toString(); diff --git a/back/src/main/resources/db/migration/V9__add_ageTheme_and_vocabTerm.sql b/back/src/main/resources/db/migration/V9__add_ageTheme_and_vocabTerm.sql new file mode 100644 index 0000000..5d1f2f0 --- /dev/null +++ b/back/src/main/resources/db/migration/V9__add_ageTheme_and_vocabTerm.sql @@ -0,0 +1,53 @@ +/* + * 이 파일은 pgvector 기반 실서비스용 스키마를 생성한다. + * 흐름: 확장 활성화 → age_theme(제약/인덱스) → vocab_term(제약/인덱스) → ANALYZE + */ + +-- next 확장 활성화 +CREATE EXTENSION IF NOT EXISTS vector; + +-- next age_theme 테이블 생성 +CREATE TABLE IF NOT EXISTS age_theme ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + min_age INT NOT NULL, + max_age INT NOT NULL, + category VARCHAR(32) NOT NULL + CHECK (category IN ('EDUCATION','CAREER','RELATIONSHIP','FINANCE','HEALTH','LOCATION','ETC')), + theme TEXT NOT NULL, + embedding VECTOR(768) NOT NULL, + -- 무결성 검증 + CHECK (min_age >= 0 AND max_age >= min_age) + ); + +-- next 범주/연령대 필터 인덱스 +CREATE INDEX IF NOT EXISTS idx_age_theme_cat_age + ON age_theme(category, min_age, max_age); + +-- next 코사인 KNN 인덱스(저사양 고려 ivfflat) +CREATE INDEX IF NOT EXISTS idx_age_theme_embedding_ivfflat + ON age_theme USING ivfflat (embedding vector_cosine_ops) + WITH (lists = 50); + +-- next vocab_term 테이블 생성 (term 유니크) +CREATE TABLE IF NOT EXISTS vocab_term ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + term VARCHAR(128) NOT NULL UNIQUE, + embedding VECTOR(768) NOT NULL + ); + +-- next 코사인 KNN 인덱스 +CREATE INDEX IF NOT EXISTS idx_vocab_term_embedding_ivfflat + ON vocab_term USING ivfflat (embedding vector_cosine_ops) + WITH (lists = 50); + +-- next 통계 수집(IVFFLAT 생성 후 권장) +ANALYZE age_theme; +ANALYZE vocab_term; + +/* +-- 옵션: 메모리 여유 & PG ≥ 16이면 HNSW 사용 +-- CREATE INDEX IF NOT EXISTS idx_age_theme_embedding_hnsw +-- ON age_theme USING hnsw (embedding vector_cosine_ops); +-- CREATE INDEX IF NOT EXISTS idx_vocab_term_embedding_hnsw +-- ON vocab_term USING hnsw (embedding vector_cosine_ops); +*/ diff --git a/back/src/test/java/com/back/domain/node/controller/AiOnceDelegateTestConfig.java b/back/src/test/java/com/back/domain/node/controller/AiOnceDelegateTestConfig.java index 384764a..4a226d9 100644 --- a/back/src/test/java/com/back/domain/node/controller/AiOnceDelegateTestConfig.java +++ b/back/src/test/java/com/back/domain/node/controller/AiOnceDelegateTestConfig.java @@ -1,14 +1,22 @@ -/** - * [TEST-ONLY] AI 1회 실호출 래퍼 - * - 첫 호출만 실제 구현으로 위임하고, 이후 호출은 스텁 결과를 반환한다. +/* + * [코드 흐름 요약] + * - 테스트 프로필에서 AI 호출을 1회만 실제 구현으로 위임하고, 이후에는 스텁 응답을 반환한다. + * - 예산 빈(AiCallBudget)의 consume()이 true면 실제 구현(AIVectorServiceImpl) 실행, 아니면 스텁 반환. + * - 실제 구현은 VocabTermSearchService, AgeThemeSearchService, AgeThemeSeeder, SeedOrchestrator를 주입해 동작한다. + * - 실제 구현 경로로 들어가면 AIVectorServiceImpl이 SeedOrchestrator.onAiRequestEvent()를 호출하여 + * 30분 Quiet Wait 로직이 작동한다. */ package com.back.domain.node.controller; +import com.back.global.ai.bootstrap.AgeThemeSeeder; +import com.back.global.ai.bootstrap.SeedOrchestrator; // ★ 추가 import com.back.global.ai.client.text.TextAiClient; import com.back.global.ai.config.SituationAiProperties; import com.back.global.ai.vector.AIVectorService; import com.back.global.ai.vector.AIVectorServiceImpl; import com.back.global.ai.vector.AIVectorServiceSupportDomain; +import com.back.global.ai.vector.AgeThemeSearchService; +import com.back.global.ai.vector.VocabTermSearchService; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @@ -19,15 +27,11 @@ @Profile("test") public class AiOnceDelegateTestConfig { - // 한줄 요약: 테스트가 실호출 횟수를 동적으로 제어할 수 있게 예산 빈을 제공한다 @Bean public AiCallBudget aiCallBudget() { return new AiCallBudget(); } - - - // 한줄 요약: 예산>0 이면 실제 구현, 아니면 스텁 값을 반환한다 @Bean @Primary public AIVectorService aiOnceDelegate( @@ -35,12 +39,32 @@ public AIVectorService aiOnceDelegate( AIVectorServiceSupportDomain support, SituationAiProperties props, ObjectMapper objectMapper, + VocabTermSearchService vocabSearch, + AgeThemeSearchService ageThemeSearch, + AgeThemeSeeder ageThemeSeeder, + SeedOrchestrator seedOrchestrator, AiCallBudget budget ) { - AIVectorService real = new AIVectorServiceImpl(textAiClient, support, props, objectMapper); - AIVectorService stub = (u, d, nodes) -> new AIVectorService.AiNextHint("테스트-상황(한 문장)", "테스트-추천"); + // 실제 구현 인스턴스 (SeedOrchestrator 포함) + AIVectorService real = new AIVectorServiceImpl( + textAiClient, + support, + props, + objectMapper, + vocabSearch, + ageThemeSearch, + ageThemeSeeder, + seedOrchestrator + ); + + // 스텁 구현 + AIVectorService stub = (u, d, nodes) -> + new AIVectorService.AiNextHint("테스트-상황이다.", "테스트-추천한다"); + + // 1회만 실제 호출, 이후 스텁 return (userId, lineId, orderedNodes) -> - budget.consume() ? real.generateNextHint(userId, lineId, orderedNodes) + budget.consume() + ? real.generateNextHint(userId, lineId, orderedNodes) : stub.generateNextHint(userId, lineId, orderedNodes); } } diff --git a/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java b/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java index 27fbdd8..0133003 100644 --- a/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java +++ b/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java @@ -266,8 +266,8 @@ void success_createNextDecision() throws Exception { .content(nextReq)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.parentId").value(parentId)) - .andExpect(jsonPath("$.aiNextSituation").value("테스트-상황(한 문장)")) - .andExpect(jsonPath("$.aiNextRecommendedOption").value("테스트-추천")) + .andExpect(jsonPath("$.aiNextSituation").value("테스트-상황이다.")) + .andExpect(jsonPath("$.aiNextRecommendedOption").value("테스트-추천한다")) .andReturn(); JsonNode body = om.readTree(res.getResponse().getContentAsString()); @@ -732,8 +732,8 @@ void success_aiHints_persisted_and_mapped_on_line_detail() throws Exception { assertThat(childNode2.path("aiNextRecommendedOption").asText()).isNotBlank(); // (테스트 더미 AI 고정값을 사용하는 환경이면 아래 주석 해제해서 정확 값까지 검증 가능) - assertThat(childNode2.path("aiNextSituation").asText()).isEqualTo("테스트-상황(한 문장)"); - assertThat(childNode2.path("aiNextRecommendedOption").asText()).isEqualTo("테스트-추천"); + assertThat(childNode2.path("aiNextSituation").asText()).isEqualTo("테스트-상황이다."); + assertThat(childNode2.path("aiNextRecommendedOption").asText()).isEqualTo("테스트-추천한다"); } @Nested diff --git a/back/src/test/java/com/back/global/ai/vector/DecisionAssistVectorFlowIT.java b/back/src/test/java/com/back/global/ai/vector/DecisionAssistVectorFlowIT.java index 4222eaa..19f6834 100644 --- a/back/src/test/java/com/back/global/ai/vector/DecisionAssistVectorFlowIT.java +++ b/back/src/test/java/com/back/global/ai/vector/DecisionAssistVectorFlowIT.java @@ -32,7 +32,6 @@ //import org.testcontainers.junit.jupiter.Testcontainers; //import org.testcontainers.utility.DockerImageName; // -//import java.time.LocalDateTime; //import java.util.List; //import java.util.concurrent.CompletableFuture; // @@ -200,7 +199,6 @@ // .category(category) // .text(text) // .embedding(emb != null ? emb : embeddingClient.embed(text)) -// .updatedAt(LocalDateTime.now()) // .build(); // snippetRepo.save(s); // } diff --git a/back/src/test/java/com/back/global/ai/vector/DecisionAssistVectorLatencyIT.java b/back/src/test/java/com/back/global/ai/vector/DecisionAssistVectorLatencyIT.java index 6b6292e..c2cf33b 100644 --- a/back/src/test/java/com/back/global/ai/vector/DecisionAssistVectorLatencyIT.java +++ b/back/src/test/java/com/back/global/ai/vector/DecisionAssistVectorLatencyIT.java @@ -1,8 +1,8 @@ ///* // * [코드 흐름 요약] -// * 1) 테스트 컨텍스트에서 AIVectorService를 실구현으로 강제 오버라이드(@Primary) -// * 2) AiLatencyProbeConfig로 Gemini HTTP 왕복 시간(ms) 계측 -// * 3) budget.reset(1)로 1회 실호출 강제, 호출 후 realAi=true + e2e/http 시간 출력 +// * 1) 헤더 1개(parent==null) + 본문 3개(체인) = 총 4개의 DecisionNode 생성 유틸 추가 +// * 2) 테스트에서 단일 노드 대신 전체 체인을 전달하여 dropHeader 이후에도 본문이 남도록 보장 +// * 3) 마지막 본문 나이를 22로 맞춰 RAG 검색(age=22)과 정합성 유지 // */ //package com.back.global.ai.vector; // @@ -11,9 +11,6 @@ //import com.back.domain.node.entity.DecisionNode; //import com.back.domain.search.entity.NodeSnippet; //import com.back.domain.search.repository.NodeSnippetRepository; -//import com.back.global.ai.client.text.TextAiClient; -//import com.back.global.ai.config.SituationAiProperties; -//import com.fasterxml.jackson.databind.ObjectMapper; //import org.junit.jupiter.api.Assumptions; //import org.junit.jupiter.api.BeforeEach; //import org.junit.jupiter.api.DisplayName; @@ -32,8 +29,8 @@ //import org.testcontainers.junit.jupiter.Testcontainers; //import org.testcontainers.utility.DockerImageName; // -//import java.time.LocalDateTime; //import java.util.Arrays; +//import java.util.ArrayList; //import java.util.List; // //import static org.assertj.core.api.Assertions.assertThat; @@ -43,10 +40,8 @@ //@TestPropertySource(properties = { // "app.initdata.enabled=false" //}) -// -//@ActiveProfiles("test") // 스텁이 떠 있어도 아래 @Primary 오버라이드가 이긴다 -//@Import({DecisionAssistVectorLatencyIT.RealAiOverrideConfig.class, AiLatencyProbeConfig.class, -//AiOnceDelegateTestConfig.class}) +//@ActiveProfiles("test-pg") +//@Import({AiLatencyProbeConfig.class, AiOnceDelegateTestConfig.class}) //class DecisionAssistVectorLatencyIT { // // // Docker 선기동 + pgvector @@ -87,11 +82,11 @@ // // @Autowired EmbeddingClient embeddingClient; // @Autowired PgVectorSearchService vectorSearch; -// @Autowired AIVectorService aivectorService; // 오버라이드된 실구현 주입 -// @Autowired DecisionAssistVectorFlowITTestOps ops; // 단순 세이브 헬퍼 +// @Autowired AIVectorService aivectorService; +// @Autowired DecisionAssistVectorFlowITTestOps ops; // // @Autowired -// AiCallBudget budget; // 예산 주입 +// AiCallBudget budget; // // @BeforeEach // void setup() { @@ -101,38 +96,36 @@ // @Test // @DisplayName("실제 Gemini 호출 레이턴시 측정(E2E / HTTP)") // void latency_real_ai() { -// // 1) 실호출 1회 강제 +// // next 노드 생성 // budget.reset(1); // -// // 2) RAG 콘텍스트 준비 -// long lineId = 900L; int age = 22; -// ops.saveSnippet(lineId, age, "수도권 컴공 진학 비용과 통학 고민", "EDUCATION"); -// ops.saveSnippet(lineId, age, "서울 스타트업 인턴 이력서 준비", "CAREER"); +// // RAG 콘텍스트 준비 +// long lineId = 900L; +// ops.saveSnippet(lineId, 22, "수도권 컴공 진학 비용과 통학 고민", "EDUCATION"); +// ops.saveSnippet(lineId, 22, "서울 스타트업 인턴 이력서 준비", "CAREER"); // -// // 3) 호출 +// // 호출 // long t0 = System.nanoTime(); -// DecisionNode last = DecisionNode.builder().ageYear(age).situation("컴공 고려").decision("컴공 선택").build(); -// var hint = aivectorService.generateNextHint(1L, lineId, List.of(last)); +// List nodes = buildNodesWithHeader(); // 무결성 검증 +// var hint = aivectorService.generateNextHint(1L, lineId, nodes); // long e2eMs = (System.nanoTime() - t0) / 1_000_000; // // long httpMs = AiLatencyProbeConfig.LAST_LATENCY_MS.get(); -// boolean realAi = httpMs >= 0; // 데코레이터가 시간 기록했으면 실호출 +// boolean realAi = httpMs >= 0; // // System.out.println("[LAT] realAi=" + realAi + " e2eMs=" + e2eMs + " httpMs=" + httpMs + // " situation=" + hint.aiNextSituation() + " option=" + hint.aiNextRecommendedOption()); // -// assertThat(realAi).isTrue(); // 반드시 실호출이어야 함 +// assertThat(realAi).isTrue(); // assertThat(hint.aiNextSituation()).isNotBlank(); // } // // @Test // @DisplayName("워밍업 후 레이턴시 P50 검증(상황 생성 경로)") // void latency_after_warmup_p50() { -// // next 노드 생성: 워밍업 1회(최소 출력·짧은 프롬프트 권장) +// // next 노드 생성 // budget.reset(1); // 무결성 검증 -// aivectorService.generateNextHint(1L, 900L, List.of( -// DecisionNode.builder().ageYear(22).situation("컴공 고려").decision("컴공 선택").build() -// )); +// aivectorService.generateNextHint(1L, 900L, buildNodesWithHeader()); // // // 측정 N회 // int N = 5; @@ -141,9 +134,7 @@ // for (int i = 0; i < N; i++) { // budget.reset(1); // 무결성 검증 // long t0 = System.nanoTime(); -// var hint = aivectorService.generateNextHint(1L, 900L, List.of( -// DecisionNode.builder().ageYear(22).situation("컴공 고려").decision("컴공 선택").build() -// )); +// var hint = aivectorService.generateNextHint(1L, 900L, buildNodesWithHeader()); // long e2eMs = (System.nanoTime() - t0) / 1_000_000; // long httpMs = AiLatencyProbeConfig.LAST_LATENCY_MS.get(); // e2e[i] = e2eMs; @@ -156,7 +147,6 @@ // long p50HTTP = http[N/2]; // System.out.println("[P50] e2e=" + p50E2E + "ms, http=" + p50HTTP + "ms"); // -// // 무결성 검증: 목표 상한(예: 1200ms)을 테스트 기준으로 잡아두기 // assertThat(p50HTTP).isLessThan(1200); // } // @@ -175,25 +165,62 @@ // void saveSnippet(Long lineId, int age, String text, String category) { // repo.save(NodeSnippet.builder() // .lineId(lineId).ageYear(age).category(category) -// .text(text).embedding(emb.embed(text)).updatedAt(LocalDateTime.now()) +// .text(text).embedding(emb.embed(text)) // .build()); // } // } // // @TestConfiguration -// static class RealAiOverrideConfig { -// // 한줄 요약: AIVectorService를 실구현으로 강제(@Primary)해서 test 스텁을 덮어쓴다 +// static class BudgetTestConfig { +// // 무결성 검증: 테스트용 예산 기본값 // @Bean -// AIVectorService realAIVectorService( -// TextAiClient textAiClient, -// AIVectorServiceSupportDomain support, -// SituationAiProperties props, -// ObjectMapper objectMapper -// ) { -// var impl = new AIVectorServiceImpl(textAiClient, support, props, objectMapper); -// impl.setTopK(1); impl.setContextCharLimit(160); impl.setMaxOutputTokens(48); -// return impl; +// AiCallBudget aiCallBudget() { +// // next 노드 생성 +// return new AiCallBudget(); // } +// } // +// // ===== 여기부터 추가: 헤더 포함 총 4개 노드 체인 ===== +// +// // 가장 많이 사용하는 함수 호출 위에 한줄로만 +// private static List buildNodesWithHeader() { +// List nodes = new ArrayList<>(); +// +// // next 노드 생성 +// DecisionNode header = DecisionNode.builder() +// .ageYear(19) +// .situation("타임라인 시작") +// .decision("루트") +// .build(); +// nodes.add(header); +// +// // next 노드 생성 +// DecisionNode n1 = DecisionNode.builder() +// .ageYear(20) +// .situation("전공 탐색 시작") +// .decision("과목 청강") +// .parent(header) // 무결성 검증 +// .build(); +// nodes.add(n1); +// +// // next 노드 생성 +// DecisionNode n2 = DecisionNode.builder() +// .ageYear(21) +// .situation("진로 상담 참여") +// .decision("멘토 미팅") +// .parent(n1) // 무결성 검증 +// .build(); +// nodes.add(n2); +// +// // next 노드 생성 +// DecisionNode n3 = DecisionNode.builder() +// .ageYear(22) // ← 스니펫과 동일 나이(22)로 정렬 +// .situation("컴공 고려") +// .decision("컴공 선택") +// .parent(n2) // 무결성 검증 +// .build(); +// nodes.add(n3); +// +// return nodes; // [header, n1, n2, n3] // } //} diff --git a/back/src/test/java/com/back/global/ai/vector/PgVectorSearchServiceIT.java b/back/src/test/java/com/back/global/ai/vector/PgVectorSearchServiceIT.java index dc91daa..5facf85 100644 --- a/back/src/test/java/com/back/global/ai/vector/PgVectorSearchServiceIT.java +++ b/back/src/test/java/com/back/global/ai/vector/PgVectorSearchServiceIT.java @@ -22,7 +22,6 @@ //import org.testcontainers.junit.jupiter.Testcontainers; //import org.testcontainers.utility.DockerImageName; // -//import java.time.LocalDateTime; //import java.util.List; // //import static org.assertj.core.api.Assertions.assertThat; @@ -105,7 +104,7 @@ // float[] emb = embeddingClient.embed(text); // NodeSnippet s = NodeSnippet.builder() // .lineId(lineId).ageYear(age).category(category) -// .text(text).embedding(emb).updatedAt(LocalDateTime.now()) +// .text(text).embedding(emb) // .build(); // snippetRepo.save(s); // } diff --git a/back/src/test/resources/sql/init_vector.sql b/back/src/test/resources/sql/init_vector.sql index e734541..26d385c 100644 --- a/back/src/test/resources/sql/init_vector.sql +++ b/back/src/test/resources/sql/init_vector.sql @@ -2,3 +2,21 @@ -- 1) pgvector 확장을 설치한다. -- 2) Hibernate가 이후에 vector(768) 컬럼을 포함한 테이블을 생성할 수 있게 준비한다. CREATE EXTENSION IF NOT EXISTS vector; + +-- age_theme 테이블 +CREATE TABLE IF NOT EXISTS age_theme ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + min_age INT NOT NULL, + max_age INT NOT NULL, + category VARCHAR(32) NOT NULL, + theme TEXT NOT NULL, + embedding BYTEA NULL, + CHECK (min_age >= 0 AND max_age >= min_age) + ); + +-- vocab_term 테이블 +CREATE TABLE IF NOT EXISTS vocab_term ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + term VARCHAR(128) NOT NULL UNIQUE, + embedding BYTEA NULL + ); \ No newline at end of file