From 712d4c50a675f5dcc8e521ccf147d092264a6518 Mon Sep 17 00:00:00 2001 From: hoshino-love0102 Date: Sat, 17 Oct 2026 20:42:55 +0900 Subject: [PATCH 1/2] fix: personalize recommendations with photo silhouettes and treatment details --- docs/recommendation-photo-length.md | 23 ++-- .../dto/RecommendationResponse.java | 14 ++- .../out/ai/OnnxPhotoHairLengthAdapter.java | 6 +- .../out/ai/PhotoHairLengthEstimator.java | 41 +++++++ .../RecommendationItemEntity.java | 3 +- .../RecommendationPersistenceAdapter.java | 2 +- .../RecommendationReferenceEntity.java | 13 ++- ...ecommendationReferenceQueryRepository.java | 4 +- .../RecommendationRunEntity.java | 4 +- .../service/RecommendationPhotoResolver.java | 38 ++++++- .../service/RecommendationService.java | 23 ++-- .../RecommendationReadOnlyAudit.java | 26 +++-- .../model/PhotoHairEvidence.java | 16 ++- .../model/RecommendationContext.java | 8 +- .../model/RecommendationReason.java | 2 + .../recommendation/model/ScoreBreakdown.java | 7 +- .../service/HairShapeSimilarity.java | 19 ++++ .../service/RuleBasedV1Scorer.java | 64 ++++++----- .../service/TreatmentStyleMatcher.java | 104 ++++++++++++++++++ ...35__snapshot_recommendation_references.sql | 12 ++ .../RecommendationApiIntegrationTest.java | 66 +++++++++++ .../out/ai/PhotoHairLengthEstimatorTest.java | 14 +++ .../RecommendationPhotoResolverTest.java | 32 +++++- .../service/RuleBasedV1ScorerTest.java | 89 ++++++++++++++- 24 files changed, 550 insertions(+), 80 deletions(-) create mode 100644 src/main/java/com/heddy/domain/recommendation/service/HairShapeSimilarity.java create mode 100644 src/main/java/com/heddy/domain/recommendation/service/TreatmentStyleMatcher.java create mode 100644 src/main/resources/db/migration/V35__snapshot_recommendation_references.sql diff --git a/docs/recommendation-photo-length.md b/docs/recommendation-photo-length.md index 163ca62f..dca9163e 100644 --- a/docs/recommendation-photo-length.md +++ b/docs/recommendation-photo-length.md @@ -1,19 +1,28 @@ -# 사진과 추천 길이 연결 +# 사진·시술기록과 추천 연결 최신 시술의 AFTER 사진을 서버 내부 ONNX 모델로 분할하고, 얼굴 대비 머리 영역의 끝 위치를 이용해 보수적으로 길이를 추정한다. 사용자 사진을 외부 AI로 보내지 않으며 성별을 추론하지 않는다. 기존 색상 균일도·대칭·볼륨·거칠기 분석과는 별도 기능이다. 그 4개 지표의 분석 엔진을 이 모델로 대체하지 않는다. - 직접 입력한 머리 길이보다 긴 길이로 자동 변경하지 않는다. 사진 업로드 후 갱신한 프로필은 우선한다. - 시술 날짜가 같으면 기록 생성 시각으로 최신 기록을 정하며, 그 기록에서 가장 최근 업로드한 AFTER 파일을 사용한다. 기존 사진을 남겨둔 추가 업로드도 새 입력으로 반영한다. -- 최신 시술에 AFTER 사진이 없으면 예전 시술 사진으로 대체하지 않는다. BEFORE/OTHER는 현재 길이 판단에 사용하지 않는다. +- 최신 시술에 AFTER 사진이 없으면 해당 기록의 OTHER 사진을 사용한다. 둘 다 없으면 과거 기록 사진으로 대체하지 않는다. BEFORE는 사용하지 않는다. - 파일 소유자·READY·TREATMENT_PHOTO를 확인한다. 분석은 파일별 캐시 최대 256개로 제한하며 이미지 바이트는 보관하지 않는다. -- 사진·모델·분석 상태·적용 길이가 추천 입력 해시에 포함된다. latest 재조회는 달라진 입력을 재계산하고 같은 입력이면 재사용한다. +- 사진·모델·분석 상태·적용 길이·윤곽 비교 점수와 커트 길이/형태·펌 종류가 추천 입력 해시에 포함된다. latest 재조회는 달라진 입력을 재계산하고 같은 입력이면 재사용한다. - 길이 입력도 없고 사진 길이도 판정할 수 없으면 `422 RECOMMENDATION_HAIR_LENGTH_REQUIRED`를 반환한다. 임의의 장발 기본 추천은 제공하지 않는다. -- `recommendation_basis.photo_analysis`는 `file_id`, `model_version`, `status`, `observed_length`, `applied`를 반환한다. `current_hair.hair_length`는 실제 점수/필터에 사용한 길이다. 프로필 DB 자체는 바꾸지 않는다. +- `recommendation_basis.photo_analysis`는 `file_id`, `model_version`, `status`, `observed_length`, `applied`, `compared_style_count`를 반환한다. `applied`는 길이 적용 여부, `compared_style_count`는 실제 윤곽 비교를 수행한 후보 수다. `current_hair.hair_length`는 실제 점수/필터에 사용한 길이다. 프로필 DB 자체는 바꾸지 않는다. - 상태: `ASSESSED`, `UNCERTAIN`, `UNREADABLE`, `UNAVAILABLE`, `DISABLED`, `NEWER_PROFILE`, `NO_RECENT_PHOTO`. 판정됐어도 입력 길이보다 길면 applied=false다. +## 사진 윤곽과 시술 상세 점수 + +- 기존 로컬 분할 모델로 얻은 머리 영역을 얼굴 위치/크기로 정규화한 8×12 점유율 격자로 비교한다. 얼굴 식별 임베딩이나 성별은 사용하지 않는다. 후보는 먼저 길이·금기 조건을 통과해야 하며 READY 상태의 SYSTEM 카탈로그 썸네일만 비교한다. +- 윤곽 비교는 최대 30점이다. 두 사진의 머리 영역 교집합/합집합 비율을 사용하며 0.60 이상일 때만 `PHOTO_SHAPE_MATCH` 근거를 반환한다. 이 수치는 경험적 규칙이며 검증된 정확도/만족 확률이 아니다. +- 최신 프로필이 길이 판단을 우선하더라도 시술 사진의 윤곽 비교는 수행한다. 판정 불가 사진에는 윤곽 일치 근거를 만들지 않는다. +- 시술 종류만 같으면 이력 점수는 최대 10점이며 `HISTORY_SERVICE_MATCH`로 한계를 설명한다. 커트 형태·길이·펌 종류가 카탈로그 이름/태그/분류와 일치하면 최대 30점을 추가한다. 알려진 영문/한글 표현을 정규화하며 모르는 표현은 정확히 일치할 때만 인정한다. +- 만족도 4~5점만 긍정 이력으로 사용한다. 3점은 중립, 1~2점의 구체적 스타일 일치는 감점한다. 긍정 근거에는 실제 일치 항목과 만족도를 표시한다. +- V35는 추천 참고기록의 시술일/만족도를 생성 당시 값으로 저장한다. 과거 원본 값은 복구할 수 없어 기존 행은 마이그레이션 시점 값으로 고정한다. 새 알고리즘 입력 버전으로 `/latest` 조회 시 이전 결과를 재계산한다. + ## 제약 -길이 전용 학습 분류기가 아니라 머리/얼굴 분할 결과에 기반한 규칙이다. 뒷모습, 작은 얼굴, 여러 사람, 잘린 머리, 측면 등에서 판정을 보류한다. 묶은 머리나 가려진 머리끝은 짧게 보일 수 있으므로 사용자가 입력한 길이와 비교해 확인할 필요가 있다. JPEG/PNG만 읽을 수 있고 HEIC는 UNREADABLE이다. 어깨 아래를 가슴 아래로 세분화하지 않는다. 제공 모델의 픽셀 확률을 길이 분류의 정확도라고 표시하지 않는다. +길이·스타일 전용 학습 분류기가 아니라 머리/얼굴 분할 결과에 기반한 규칙이다. 윤곽은 머리 모양의 대략적인 유사성만 반영하며 앞머리 종류·질감·컬·색상을 완전히 인식하지 않는다. 카탈로그에 가까운 스타일이 부족하면 후보 자체는 여전히 겹칠 수 있다. 뒷모습, 작은 얼굴, 여러 사람, 잘린 머리, 측면 등에서 판정을 보류한다. 묶은 머리나 가려진 머리끝은 짧게 보일 수 있으므로 사용자가 입력한 길이와 비교해 확인할 필요가 있다. JPEG/PNG만 읽을 수 있고 HEIC는 UNREADABLE이다. 어깨 아래를 가슴 아래로 세분화하지 않는다. 제공 모델의 픽셀 확률을 길이 분류의 정확도라고 표시하지 않는다. 모델은 Docker 빌드에서 고정 커밋과 SHA256으로 받으며 라이선스를 동봉한다. 운영 compose의 `HAIR_PHOTO_LENGTH_ENABLED` 기본값은 true, 테스트/로컬 기본값은 false다. 기존 `HAIR_ANALYSIS_ENABLED`는 별도 설정이다. 롤백/비활성화 역시 GitHub/Actions 경로로 수행한다. @@ -28,10 +37,10 @@ ```sh docker exec heddy-v2-app java -Djava.awt.headless=true -Xmx256m \ -Dloader.main=com.heddy.diagnostics.RecommendationReadOnlyAudit \ - -cp /app/app.jar org.springframework.boot.loader.launch.PropertiesLauncher + -cp /app/app.jar org.springframework.boot.loader.launch.PropertiesLauncher --latest-account ``` -Spring 서버나 마이그레이션을 새로 실행하지 않는다. DB 읽기 전용 트랜잭션, 기존 S3 내부 읽기, 실제 배포된 resolver/scorer만 사용한다. 최대 100개 활성 계정을 검사하며 계정 해시, 입력·적용 길이, 분석 상태와 추천 이름/점수만 출력한다. 사진·연락처·기록 원문·키를 출력하거나 추천/프로필을 저장하지 않는다. +Spring 서버나 마이그레이션을 새로 실행하지 않는다. DB 읽기 전용 트랜잭션, 기존 S3 내부 읽기, 실제 배포된 resolver/scorer만 사용한다. `--latest-account`는 가장 최근 추천을 생성한 활성 계정 1개만 검사하며 (생략 시 최대 100개), 계정 해시, 입력·적용 길이, 분석 상태와 추천 이름/점수만 출력한다. 사진·연락처·기록 원문·키를 출력하거나 추천/프로필을 저장하지 않는다. V33은 실제 SYSTEM 썸네일을 검수해 확인된 길이 오분류를 교정하고 그룹/묶은머리/불명확한 옛 사진 7개를 추천에서 제외한다. 사용자 기록이나 파일은 삭제하지 않는다. diff --git a/src/main/java/com/heddy/adapter/in/web/recommendation/dto/RecommendationResponse.java b/src/main/java/com/heddy/adapter/in/web/recommendation/dto/RecommendationResponse.java index 787629c4..e8887a2e 100644 --- a/src/main/java/com/heddy/adapter/in/web/recommendation/dto/RecommendationResponse.java +++ b/src/main/java/com/heddy/adapter/in/web/recommendation/dto/RecommendationResponse.java @@ -53,11 +53,12 @@ static Basis from(RecommendationBasis basis) { public record PhotoAnalysis(@JsonProperty("file_id") UUID fileId, @JsonProperty("model_version") String modelVersion, String status, - @JsonProperty("observed_length") String observedLength, boolean applied) { + @JsonProperty("observed_length") String observedLength, boolean applied, + @JsonProperty("compared_style_count") int comparedStyleCount) { static PhotoAnalysis from(com.heddy.domain.recommendation.model.PhotoHairEvidence evidence) { return evidence == null ? null : new PhotoAnalysis(evidence.fileId(), evidence.modelVersion(), evidence.status(), evidence.observedLength() == null ? null : evidence.observedLength().name(), - evidence.applied()); + evidence.applied(), evidence.comparedStyleCount()); } } @@ -126,7 +127,14 @@ static Reason from(RecommendationReason reason) { private static String message(RecommendationReason reason) { return switch (reason.code()) { - case SIMILAR_HIGH_SATISFACTION_HISTORY -> "이전에 만족한 시술과 유사합니다."; + case PHOTO_SHAPE_MATCH -> "최근 시술 사진의 머리 윤곽이 ‘" + + reason.params().getOrDefault("style_name", "이 스타일") + "’의 참고 사진과 유사합니다."; + case SIMILAR_HIGH_SATISFACTION_HISTORY -> reason.params().getOrDefault("style_name", "이 스타일") + + "은 만족도 " + reason.params().getOrDefault("satisfaction", "4") + + "점 기록의 " + reason.params().getOrDefault("matched_details", "시술 특성") + "와 일치합니다."; + case HISTORY_SERVICE_MATCH -> "만족도 " + reason.params().getOrDefault("satisfaction", "4") + + "점 기록과 시술 종류(" + reason.params().getOrDefault("service_types", "") + + ")가 같습니다. 세부 스타일의 유사성은 확인되지 않았습니다."; case SAVED_STYLE_MATCH -> "이전에 저장한 스타일입니다."; case PREFERRED_TAG_MATCH -> "선호하는 ‘" + reason.params().getOrDefault("tag_name", "스타일") + "’ 태그와 일치합니다."; diff --git a/src/main/java/com/heddy/adapter/out/ai/OnnxPhotoHairLengthAdapter.java b/src/main/java/com/heddy/adapter/out/ai/OnnxPhotoHairLengthAdapter.java index 6a28f47d..88ff8553 100644 --- a/src/main/java/com/heddy/adapter/out/ai/OnnxPhotoHairLengthAdapter.java +++ b/src/main/java/com/heddy/adapter/out/ai/OnnxPhotoHairLengthAdapter.java @@ -26,7 +26,7 @@ @Component @ConditionalOnProperty(name = "app.photo-length.enabled", havingValue = "true") public class OnnxPhotoHairLengthAdapter implements PhotoHairLengthPort { - public static final String MODEL_VERSION = "selfie-multiclass-v1-length-v1"; + public static final String MODEL_VERSION = "selfie-multiclass-v1-shape-v2"; private static final String MODEL_SHA256 = "35ec1ecd9ee7f85073c99c00020b7f6751b69506eeacf683bc8665f6117f85b0"; private static final Logger log = LoggerFactory.getLogger(OnnxPhotoHairLengthAdapter.class); private final OrtEnvironment environment = OrtEnvironment.getEnvironment(); @@ -78,7 +78,9 @@ public synchronized PhotoHairEvidence analyze(StoredFile file) { var result = session.run(Map.of("input_29", tensor))) { float[][][] logits = ((float[][][][]) result.get(0).getValue())[0]; var length = estimator.estimate(logits); - var observation = evidence(file, length == null ? "UNCERTAIN" : "ASSESSED", length); + var observation = new PhotoHairEvidence(file.fileId(), MODEL_VERSION, + length == null ? "UNCERTAIN" : "ASSESSED", length, false, + length == null ? java.util.List.of() : estimator.shape(logits), 0); cache.put(key, observation); return observation; } diff --git a/src/main/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimator.java b/src/main/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimator.java index ac732992..ccbb53b2 100644 --- a/src/main/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimator.java +++ b/src/main/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimator.java @@ -53,6 +53,47 @@ HairLength estimate(float[][][] logits) { return HairLength.BELOW_SHOULDER; } + /** Face-relative hair occupancy; rejects profiles and incomplete framing before comparing silhouettes. */ + List shape(float[][][] logits) { + if (estimate(logits) == null) return List.of(); + int h = logits.length, w = logits[0].length; + List faceX = new ArrayList<>(), faceY = new ArrayList<>(); + boolean[][] hair = new boolean[h][w]; + for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) { + float[] scores = logits[y][x]; + int best = 0; + for (int c = 1; c < scores.length; c++) if (scores[c] > scores[best]) best = c; + double sum = 0; + for (float score : scores) sum += Math.exp(score - scores[best]); + if (1 / sum < .80) continue; + if (best == 3) { faceX.add(x); faceY.add(y); } + hair[y][x] = best == 1; + } + if (faceX.size() < 500) return List.of(); + int[] fx = faceX.stream().mapToInt(Integer::intValue).sorted().toArray(); + int[] fy = faceY.stream().mapToInt(Integer::intValue).sorted().toArray(); + double left = percentile(fx, .05), right = percentile(fx, .95); + double top = percentile(fy, .05), bottom = percentile(fy, .95); + double fw = right - left, fh = bottom - top, cx = (left + right) / 2; + if (fw / fh < .55 || fw / fh > 1.25) return List.of(); + // A fixed grid in face coordinates makes scale/background independent comparisons possible. + int columns = 8, rows = 12; + double x0 = cx - fw * 1.25, y0 = top - fh * .9; + double cellW = fw * 2.5 / columns, cellH = fh * 3.4 / rows; + List shape = new ArrayList<>(columns * rows); + for (int gy = 0; gy < rows; gy++) for (int gx = 0; gx < columns; gx++) { + int seen = 0, occupied = 0; + for (int y = (int)Math.floor(y0 + gy * cellH); y < y0 + (gy + 1) * cellH; y++) { + for (int x = (int)Math.floor(x0 + gx * cellW); x < x0 + (gx + 1) * cellW; x++) { + seen++; + if (y >= 0 && y < h && x >= 0 && x < w && hair[y][x]) occupied++; + } + } + shape.add(seen == 0 ? 0.0 : Math.round(occupied * 1000.0 / seen) / 1000.0); + } + return List.copyOf(shape); + } + private static int percentile(int[] sorted, double quantile) { return sorted[Math.min(sorted.length - 1, (int) (sorted.length * quantile))]; } diff --git a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationItemEntity.java b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationItemEntity.java index 9ec004ae..2ed0e8e4 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationItemEntity.java +++ b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationItemEntity.java @@ -70,6 +70,7 @@ score, toBreakdown(), reasons.stream().map(RecommendationItemEntity::toReason).t private static Map breakdownMap(ScoreBreakdown value) { Map result = new LinkedHashMap<>(); + result.put("photo_similarity", value.photoSimilarityScore()); result.put("history", value.historyScore()); result.put("saved_style", value.savedStyleScore()); result.put("preferred_tag", value.preferredTagScore()); @@ -84,7 +85,7 @@ private static Map breakdownMap(ScoreBreakdown value) { private ScoreBreakdown toBreakdown() { return new ScoreBreakdown(value("history"), value("saved_style"), value("preferred_tag"), value("hair_compatibility"), value("care_time"), value("raw"), - value("available_maximum"), value("final")); + value("available_maximum"), value("final"), value("photo_similarity")); } private double value(String key) { diff --git a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationPersistenceAdapter.java b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationPersistenceAdapter.java index 7f942efa..dd0b10e6 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationPersistenceAdapter.java +++ b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationPersistenceAdapter.java @@ -30,7 +30,7 @@ public RecommendationRun insert(RecommendationRun run, String canonicalInputSnap List references = run.items().stream() .filter(item -> item.referenceRecord() != null) .map(item -> new RecommendationReferenceEntity(item.recommendationItemId(), - item.referenceRecord().recordId(), item.referenceRecord().reasonCode())) + item.referenceRecord())) .toList(); if (!references.isEmpty()) { referenceRepository.saveAllAndFlush(references); diff --git a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceEntity.java b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceEntity.java index 94d17281..a474b122 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceEntity.java +++ b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceEntity.java @@ -21,12 +21,19 @@ class RecommendationReferenceEntity { @Column(name = "reference_reason", nullable = false, length = 255, updatable = false) private String referenceReasonCode; + @Column(name = "performed_at_snapshot", nullable = false, updatable = false) + private java.time.Instant performedAtSnapshot; + @Column(name = "satisfaction_snapshot", updatable = false) + private Integer satisfactionSnapshot; + protected RecommendationReferenceEntity() { } - RecommendationReferenceEntity(UUID recommendationItemId, UUID recordId, String reasonCode) { + RecommendationReferenceEntity(UUID recommendationItemId, com.heddy.domain.recommendation.model.RecommendationReference reference) { this.recommendationItemId = recommendationItemId; - this.recordId = recordId; - this.referenceReasonCode = reasonCode; + this.recordId = reference.recordId(); + this.referenceReasonCode = reference.reasonCode(); + this.performedAtSnapshot = reference.performedAt(); + this.satisfactionSnapshot = reference.satisfaction(); } static class Key implements Serializable { diff --git a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceQueryRepository.java b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceQueryRepository.java index e9124dfc..4d8afbf3 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceQueryRepository.java +++ b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceQueryRepository.java @@ -22,9 +22,9 @@ Map findByItemIds(Collection itemIds) { Map result = new LinkedHashMap<>(); jdbcTemplate.query(""" SELECT reference.recommendation_item_id, reference.record_id, - reference.reference_reason, record.performed_at, record.satisfaction + reference.reference_reason, reference.performed_at_snapshot AS performed_at, + reference.satisfaction_snapshot AS satisfaction FROM recommendation_reference_records reference - JOIN treatment_records record ON record.record_id = reference.record_id WHERE reference.recommendation_item_id IN (:ids) ORDER BY reference.recommendation_item_id, reference.record_id """, Map.of("ids", itemIds), (org.springframework.jdbc.core.RowCallbackHandler) rows -> result.put( diff --git a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationRunEntity.java b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationRunEntity.java index 5c67c141..cea3931b 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationRunEntity.java +++ b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationRunEntity.java @@ -108,6 +108,7 @@ private static Map basisMap(RecommendationBasis basis) { photo.put("status", evidence.status()); putEnum(photo, "observed_length", evidence.observedLength()); photo.put("applied", evidence.applied()); + photo.put("compared_style_count", evidence.comparedStyleCount()); value.put("photo_analysis", photo); } return value; @@ -144,7 +145,8 @@ private static com.heddy.domain.recommendation.model.PhotoHairEvidence photoEvid return new com.heddy.domain.recommendation.model.PhotoHairEvidence( value.get("file_id") == null ? null : UUID.fromString(value.get("file_id").toString()), (String) value.get("model_version"), (String) value.get("status"), - enumValue(value, "observed_length", HairLength.class), Boolean.TRUE.equals(value.get("applied"))); + enumValue(value, "observed_length", HairLength.class), Boolean.TRUE.equals(value.get("applied")), + List.of(), integer(value, "compared_style_count", 0)); } private static void putEnum(Map target, String key, Enum value) { diff --git a/src/main/java/com/heddy/application/recommendation/service/RecommendationPhotoResolver.java b/src/main/java/com/heddy/application/recommendation/service/RecommendationPhotoResolver.java index 504f7d89..ae791870 100644 --- a/src/main/java/com/heddy/application/recommendation/service/RecommendationPhotoResolver.java +++ b/src/main/java/com/heddy/application/recommendation/service/RecommendationPhotoResolver.java @@ -25,16 +25,19 @@ public RecommendationPhotoResolver(TreatmentRecordRepositoryPort records, public Resolved resolve(UUID userId, HairProfile profile, List recent) { if (recent.isEmpty()) return new Resolved(profile, PhotoHairEvidence.absent("NO_RECENT_PHOTO")); - // Never use an older treatment merely because the newest record has no AFTER image. + // Never use an older treatment merely because the newest record has no usable result photo. TreatmentRecord latest = recent.stream().max(Comparator.comparing(TreatmentRecord::performedAt) .thenComparing(TreatmentRecord::createdAt, Comparator.nullsFirst(Comparator.naturalOrder())) .thenComparing(TreatmentRecord::recordId)).orElseThrow(); var record = records.findByIdAndUserId(latest.recordId(), userId).orElse(null); if (record == null) return new Resolved(profile, PhotoHairEvidence.absent("NO_RECENT_PHOTO")); - var afterIds = record.photos().stream().filter(p -> p.imageType() == ImageType.AFTER) + // OTHER can hold an unclassified current photo. BEFORE must never describe the current result. + var inputType = record.photos().stream().anyMatch(p -> p.imageType() == ImageType.AFTER) + ? ImageType.AFTER : ImageType.OTHER; + var afterIds = record.photos().stream().filter(p -> p.imageType() == inputType) .map(com.heddy.domain.treatment.model.TreatmentPhoto::fileId).distinct().toList(); if (afterIds.isEmpty()) return new Resolved(profile, PhotoHairEvidence.absent("NO_RECENT_PHOTO")); - // Uploading a new AFTER image must replace the former input, even if the old photo remains attached. + // A newer upload replaces the former input, even if the old photo remains attached. var file = files.findAllById(afterIds).stream() .filter(f -> afterIds.contains(f.fileId()) && f.isReady() && f.ownerType() == FileOwnerType.USER && f.purpose() == com.heddy.domain.file.model.FilePurpose.TREATMENT_PHOTO @@ -43,11 +46,12 @@ var record = records.findByIdAndUserId(latest.recordId(), userId).orElse(null); Comparator.nullsFirst(Comparator.naturalOrder())) .thenComparing(com.heddy.domain.file.model.StoredFile::fileId)).orElse(null); if (file == null) return new Resolved(profile, PhotoHairEvidence.absent("UNAVAILABLE")); + PhotoHairEvidence evidence = analyzer.analyze(file); if (profile != null && profile.hairLength() != null && profile.updatedAt() != null && file.createdAt() != null && !profile.updatedAt().isBefore(file.createdAt())) { - return new Resolved(profile, new PhotoHairEvidence(file.fileId(), null, "NEWER_PROFILE", null, false)); + return new Resolved(profile, new PhotoHairEvidence(file.fileId(), evidence.modelVersion(), + "NEWER_PROFILE", evidence.observedLength(), false, evidence.hairShape(), 0)); } - PhotoHairEvidence evidence = analyzer.analyze(file); var observed = evidence.observedLength(); // An inferred longer length must never undo a user's explicit short-hair constraint. boolean apply = observed != null && (profile == null || profile.hairLength() == null @@ -59,5 +63,29 @@ var record = records.findByIdAndUserId(latest.recordId(), userId).orElse(null); profile.hairThickness(), profile.availableCareTimeMinutes()); return new Resolved(effective, evidence.withApplied(true)); } + public java.util.Map similarities(PhotoHairEvidence evidence, + List candidates) { + if (evidence.hairShape().isEmpty()) return java.util.Map.of(); + var ids = candidates.stream().map(com.heddy.domain.recommendation.model.HairstyleCandidate::thumbnailFileId) + .filter(java.util.Objects::nonNull).distinct().toList(); + var observations = new java.util.HashMap(); + for (var file : files.findAllById(ids)) { + if (ids.contains(file.fileId()) && file.isReady() && file.ownerType() == FileOwnerType.SYSTEM + && file.purpose() == com.heddy.domain.file.model.FilePurpose.HAIRSTYLE_THUMBNAIL) { + observations.put(file.fileId(), analyzer.analyze(file)); + } + } + var result = new java.util.TreeMap(); + for (var candidate : candidates) { + var observation = observations.get(candidate.thumbnailFileId()); + if (observation == null || !java.util.Objects.equals(evidence.modelVersion(), observation.modelVersion()) + || observation.hairShape().isEmpty()) continue; + Double similarity = com.heddy.domain.recommendation.service.HairShapeSimilarity.compare( + evidence.hairShape(), observation.hairShape()); + if (similarity != null) result.put(candidate.hairstyleId(), similarity); + } + return java.util.Map.copyOf(result); + } + public record Resolved(HairProfile profile, PhotoHairEvidence evidence) { } } diff --git a/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java b/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java index 5aa15636..f942cd4b 100644 --- a/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java +++ b/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java @@ -152,7 +152,13 @@ private RecommendationResult generateForLockedAccount(UUID userId, boolean force RecommendationContext context = new RecommendationContext( profile, preferred, excluded, saved, treatments, generatedAt); - String canonical = canonicalSnapshot(context, candidates, treatmentHistory) + "|photo:" + photo.evidence(); + // Hard filters run before downloading catalog images. ONNX observations are cached in memory. + var eligible = scorer.scoreAll(candidates, context).stream().map(ScoredRecommendation::candidate).toList(); + var similarities = photoResolver.similarities(photo.evidence(), eligible); + var photoEvidence = photo.evidence().withComparedStyles(similarities.size()); + context = new RecommendationContext(profile, preferred, excluded, saved, treatments, generatedAt, similarities); + String canonical = canonicalSnapshot(context, candidates, treatmentHistory) + "|photo:" + photoEvidence + + "|similarities:" + new java.util.TreeMap<>(similarities); String inputHash = sha256(canonical); if (!forceRefresh) { RecommendationRun reusable = recommendationRepositoryPort.findActiveByInputHash( @@ -172,7 +178,7 @@ private RecommendationResult generateForLockedAccount(UUID userId, boolean force UUID runId = UUID.randomUUID(); List items = java.util.stream.IntStream.range(0, selected.size()) .mapToObj(index -> toItem(selected.get(index), index + 1, records)).toList(); - RecommendationBasis basis = recommendationBasis(context, treatmentHistory, selected, photo.evidence()); + RecommendationBasis basis = recommendationBasis(context, treatmentHistory, selected, photoEvidence); RecommendationRun run = new RecommendationRun(runId, userId, RecommendationRun.Strategy.RULE_BASED_V1, RecommendationRun.Status.ACTIVE, inputHash, context.coldStart(), basis, generatedAt, items); @@ -247,7 +253,8 @@ private RecommendationItem toItem( TreatmentRecord source = records.get(scored.referenceRecordId()); RecommendationReference reference = source == null ? null : new RecommendationReference( source.recordId(), source.performedAt(), source.satisfaction(), - RecommendationReason.Code.SIMILAR_HIGH_SATISFACTION_HISTORY.name()); + scored.reasons().stream().filter(reason -> reason.params().containsKey("record_id")) + .map(reason -> reason.code().name()).findFirst().orElseThrow()); return new RecommendationItem(UUID.randomUUID(), scored.candidate().hairstyleId(), null, rank, BigDecimal.valueOf(scored.finalScore()).setScale(2, RoundingMode.HALF_UP), scored.scoreBreakdown(), scored.reasons(), scored.candidate().managementDifficulty(), @@ -267,7 +274,7 @@ private String canonicalSnapshot( List candidates, TreatmentHistorySummary treatmentHistory ) { - StringBuilder value = new StringBuilder("RULE_BASED_V1:PHOTO_LENGTH_V3|"); + StringBuilder value = new StringBuilder("RULE_BASED_V1:PHOTO_SHAPE_TREATMENT_V5|"); HairProfile profile = context.hairProfile(); if (profile != null) { value.append(profile.hairLength()).append('|').append(profile.hairType()).append('|') @@ -282,10 +289,12 @@ private String canonicalSnapshot( context.recentTreatments().stream().sorted(Comparator.comparing(TreatmentRecord::recordId)) .forEach(record -> value.append('|').append(record.recordId()).append(':') .append(record.performedAt()).append(':').append(record.satisfaction()).append(':') - .append(record.serviceTypes().stream().map(Enum::name).sorted().toList())); + .append(record.serviceTypes().stream().map(Enum::name).sorted().toList()) + .append(':').append(record.cutLength()).append(':').append(record.cutShape()) + .append(':').append(record.permType())); candidates.stream().sorted(Comparator.comparing(HairstyleCandidate::hairstyleId)) .forEach(candidate -> value.append('|').append(candidate.hairstyleId()).append(':') - .append(candidate.assetVersion()).append(':').append(candidate.metadataVersion()) + .append(candidate.styleName()).append(':').append(candidate.assetVersion()).append(':').append(candidate.metadataVersion()) .append(':').append(candidate.arMode()) .append(':').append(candidate.category()) .append(':').append(candidate.thumbnailFileId()) @@ -301,7 +310,7 @@ private String canonicalSnapshot( .append(':').append(candidate.compatibleHairThicknesses().stream().sorted().toList()) .append(':').append(candidate.compatibleHairConditions().stream().sorted().toList()) .append(':').append(candidate.contraindicatedHairConditions().stream().sorted().toList()) - .append(':').append(candidate.tags().keySet().stream().sorted().toList())); + .append(':').append(new java.util.TreeMap<>(candidate.tags()))); return value.toString(); } diff --git a/src/main/java/com/heddy/diagnostics/RecommendationReadOnlyAudit.java b/src/main/java/com/heddy/diagnostics/RecommendationReadOnlyAudit.java index 79ea1aad..8cbb381a 100644 --- a/src/main/java/com/heddy/diagnostics/RecommendationReadOnlyAudit.java +++ b/src/main/java/com/heddy/diagnostics/RecommendationReadOnlyAudit.java @@ -50,19 +50,19 @@ static HairProfile profile(Connection c, UUID user) throws Exception { } static List history(Connection c, UUID user) throws Exception { List out = new ArrayList<>(); - try(PreparedStatement p=c.prepareStatement("SELECT record_id,service_types::text,performed_at,satisfaction,created_at FROM treatment_records WHERE user_id=? ORDER BY performed_at DESC,created_at DESC,record_id DESC LIMIT 10")) { + try(PreparedStatement p=c.prepareStatement("SELECT record_id,service_types::text,performed_at,satisfaction,created_at,cut_length,cut_shape,perm_type FROM treatment_records WHERE user_id=? ORDER BY performed_at DESC,created_at DESC,record_id DESC LIMIT 10")) { p.setObject(1,user); try(ResultSet r=p.executeQuery()) { while(r.next()) { int score=r.getInt(4); Integer satisfaction=r.wasNull()?null:score; - out.add(new TreatmentRecord(r.getObject(1,UUID.class), user,enums(ServiceType.class,r.getString(2)),null,null,r.getTimestamp(3).toInstant(),satisfaction,null,null,null,null,null,photos(c,r.getObject(1,UUID.class)),r.getTimestamp(5).toInstant())); + out.add(new TreatmentRecord(r.getObject(1,UUID.class), user,enums(ServiceType.class,r.getString(2)),null,null,r.getTimestamp(3).toInstant(),satisfaction,null,null,null,null,null,null,null,"Asia/Seoul",r.getString(6),r.getString(7),r.getString(8),null,List.of(),photos(c,r.getObject(1,UUID.class)),r.getTimestamp(5).toInstant())); }} } return out; } static List catalog(Connection c) throws Exception { Map> tags = new HashMap<>(); - try(Statement s=c.createStatement();ResultSet r=s.executeQuery("SELECT hairstyle_id,style_tag_id FROM hairstyle_style_tags")) { - while(r.next()) tags.computeIfAbsent(r.getObject(1,UUID.class), ignored->new HashMap<>()).put(r.getObject(2,UUID.class), ""); + try(Statement s=c.createStatement();ResultSet r=s.executeQuery("SELECT h.hairstyle_id,h.style_tag_id,t.tag_name FROM hairstyle_style_tags h JOIN style_tags t ON t.style_tag_id=h.style_tag_id")) { + while(r.next()) tags.computeIfAbsent(r.getObject(1,UUID.class), ignored->new HashMap<>()).put(r.getObject(2,UUID.class), r.getString(3)); } List out=new ArrayList<>(); try(Statement s=c.createStatement();ResultSet r=s.executeQuery("SELECT a.*,p.* FROM hairstyle_assets a JOIN hairstyle_recommendation_profiles p USING(hairstyle_id) JOIN files f ON f.file_id=a.thumbnail_file_id WHERE a.active AND f.status='READY' ORDER BY a.hairstyle_id")) { @@ -134,7 +134,9 @@ public static void main(String[] args) throws Exception { Instant now = Instant.now(); System.out.println("READ_ONLY_RECOMMENDATION_AUDIT catalog=" + candidates.size()); try (var stmt = c.createStatement(); var rs = stmt.executeQuery( - "SELECT user_id,left(md5(user_id::text),8) alias FROM users WHERE status='ACTIVE' ORDER BY alias LIMIT 100")) { + args.length > 0 && args[0].equals("--latest-account") + ? "SELECT u.user_id,left(md5(u.user_id::text),8) alias FROM users u JOIN recommendation_runs r USING(user_id) WHERE u.status='ACTIVE' ORDER BY r.generated_at DESC LIMIT 1" + : "SELECT user_id,left(md5(user_id::text),8) alias FROM users WHERE status='ACTIVE' ORDER BY alias LIMIT 100")) { while (rs.next()) { UUID id = rs.getObject(1,UUID.class); String alias = rs.getString(2); HairProfile original = profile(c,id); List history = history(c,id); @@ -142,7 +144,8 @@ public static void main(String[] args) throws Exception { if (!method.getName().equals("findByIdAndUserId")) throw new UnsupportedOperationException("Audit only reads records"); return history.stream().filter(record -> record.recordId().equals(values[0]) && id.equals(values[1])).findFirst(); }); - var resolved = new RecommendationPhotoResolver(records,files,analyzer).resolve(id,original,history); + var resolver = new RecommendationPhotoResolver(records,files,analyzer); + var resolved = resolver.resolve(id,original,history); HairProfile effective = resolved.profile(); var evidence = resolved.evidence(); System.out.println("account=" + alias + " entered=" + (original == null ? null : original.hairLength()) + " effective=" + (effective == null ? null : effective.hairLength()) + " photo=" + evidence.status() @@ -154,16 +157,21 @@ public static void main(String[] args) throws Exception { ids(c,"SELECT style_tag_id FROM user_style_preferences WHERE user_id=? AND preference_type='PREFERRED'",id), ids(c,"SELECT style_tag_id FROM user_style_preferences WHERE user_id=? AND preference_type='EXCLUDED'",id), ids(c,"SELECT hairstyle_id FROM saved_styles WHERE user_id=?",id),history,now); + var eligible = scorer.scoreAll(candidates,context).stream().map(ScoredRecommendation::candidate).toList(); + var similarities = resolver.similarities(evidence, eligible); + context = new RecommendationContext(effective,context.preferredTagIds(),context.excludedTagIds(), + context.savedHairstyleIds(),history,now,similarities); var scores = scorer.scoreAll(candidates,context); + System.out.println(" photo_compared_styles=" + similarities.size()); System.out.println(" history=" + history.size() + " preferred=" + context.preferredTagIds().size() + " saved=" + context.savedHairstyleIds().size() + " eligible=" + scores.size()); for (var item : diversifier.topThree(scores)) { var b = item.scoreBreakdown(); - System.out.printf(Locale.ROOT," %s [%s] score=%.2f history=%.2f hair=%.2f preferred=%.2f saved=%.2f%n", + System.out.printf(Locale.ROOT," %s [%s] score=%.2f history=%.2f hair=%.2f preferred=%.2f saved=%.2f photo=%.2f%n", item.candidate().styleName(),item.candidate().category(),item.finalScore(),b.historyScore(), - b.hairCompatibilityScore(),b.preferredTagScore(),b.savedStyleScore()); + b.hairCompatibilityScore(),b.preferredTagScore(),b.savedStyleScore(),b.photoSimilarityScore()); } - var withoutHistory = new RecommendationContext(effective,context.preferredTagIds(),context.excludedTagIds(),context.savedHairstyleIds(),List.of(),now); + var withoutHistory = new RecommendationContext(effective,context.preferredTagIds(),context.excludedTagIds(),context.savedHairstyleIds(),List.of(),now,similarities); System.out.println(" without_history=" + diversifier.topThree(scorer.scoreAll(candidates,withoutHistory)).stream().map(x -> x.candidate().styleName()).toList()); } } diff --git a/src/main/java/com/heddy/domain/recommendation/model/PhotoHairEvidence.java b/src/main/java/com/heddy/domain/recommendation/model/PhotoHairEvidence.java index b67b371e..b9754755 100644 --- a/src/main/java/com/heddy/domain/recommendation/model/PhotoHairEvidence.java +++ b/src/main/java/com/heddy/domain/recommendation/model/PhotoHairEvidence.java @@ -1,15 +1,25 @@ package com.heddy.domain.recommendation.model; import com.heddy.domain.account.model.HairProfile.HairLength; +import java.util.List; import java.util.UUID; -/** 길이 추정은 모발 건강 지표 분석과 별개이며, 성별은 추론하지 않는다. */ +/** Local segmentation evidence. Shape contains hair occupancy only, never face embeddings or image bytes. */ public record PhotoHairEvidence(UUID fileId, String modelVersion, String status, - HairLength observedLength, boolean applied) { + HairLength observedLength, boolean applied, List hairShape, int comparedStyleCount) { + public PhotoHairEvidence { + hairShape = hairShape == null ? List.of() : List.copyOf(hairShape); + } + public PhotoHairEvidence(UUID fileId, String modelVersion, String status, HairLength length, boolean applied) { + this(fileId, modelVersion, status, length, applied, List.of(), 0); + } public static PhotoHairEvidence absent(String status) { return new PhotoHairEvidence(null, null, status, null, false); } public PhotoHairEvidence withApplied(boolean value) { - return new PhotoHairEvidence(fileId, modelVersion, status, observedLength, value); + return new PhotoHairEvidence(fileId, modelVersion, status, observedLength, value, hairShape, comparedStyleCount); + } + public PhotoHairEvidence withComparedStyles(int count) { + return new PhotoHairEvidence(fileId, modelVersion, status, observedLength, applied, hairShape, count); } } diff --git a/src/main/java/com/heddy/domain/recommendation/model/RecommendationContext.java b/src/main/java/com/heddy/domain/recommendation/model/RecommendationContext.java index 8f10c002..770caaa0 100644 --- a/src/main/java/com/heddy/domain/recommendation/model/RecommendationContext.java +++ b/src/main/java/com/heddy/domain/recommendation/model/RecommendationContext.java @@ -14,9 +14,15 @@ public record RecommendationContext( Set excludedTagIds, Set savedHairstyleIds, List recentTreatments, - Instant generatedAt + Instant generatedAt, + java.util.Map photoSimilarities ) { + public RecommendationContext(HairProfile profile, Set preferred, Set excluded, + Set saved, List treatments, Instant generatedAt) { + this(profile, preferred, excluded, saved, treatments, generatedAt, java.util.Map.of()); + } public RecommendationContext { + photoSimilarities = photoSimilarities == null ? java.util.Map.of() : java.util.Map.copyOf(photoSimilarities); preferredTagIds = preferredTagIds == null ? Set.of() : Set.copyOf(preferredTagIds); excludedTagIds = excludedTagIds == null ? Set.of() : Set.copyOf(excludedTagIds); savedHairstyleIds = savedHairstyleIds == null ? Set.of() : Set.copyOf(savedHairstyleIds); diff --git a/src/main/java/com/heddy/domain/recommendation/model/RecommendationReason.java b/src/main/java/com/heddy/domain/recommendation/model/RecommendationReason.java index 5fa977b1..c453894b 100644 --- a/src/main/java/com/heddy/domain/recommendation/model/RecommendationReason.java +++ b/src/main/java/com/heddy/domain/recommendation/model/RecommendationReason.java @@ -5,6 +5,8 @@ public record RecommendationReason(Code code, Map params) { public enum Code { SIMILAR_HIGH_SATISFACTION_HISTORY, + HISTORY_SERVICE_MATCH, + PHOTO_SHAPE_MATCH, SAVED_STYLE_MATCH, PREFERRED_TAG_MATCH, HAIR_PROFILE_COMPATIBLE, diff --git a/src/main/java/com/heddy/domain/recommendation/model/ScoreBreakdown.java b/src/main/java/com/heddy/domain/recommendation/model/ScoreBreakdown.java index dc265d5b..7572325d 100644 --- a/src/main/java/com/heddy/domain/recommendation/model/ScoreBreakdown.java +++ b/src/main/java/com/heddy/domain/recommendation/model/ScoreBreakdown.java @@ -8,6 +8,11 @@ public record ScoreBreakdown( double careTimeScore, double rawScore, double availableMaximumScore, - double finalScore + double finalScore, + double photoSimilarityScore ) { + public ScoreBreakdown(double history, double saved, double preferred, double hair, double care, + double raw, double maximum, double score) { + this(history, saved, preferred, hair, care, raw, maximum, score, 0); + } } diff --git a/src/main/java/com/heddy/domain/recommendation/service/HairShapeSimilarity.java b/src/main/java/com/heddy/domain/recommendation/service/HairShapeSimilarity.java new file mode 100644 index 00000000..77e98104 --- /dev/null +++ b/src/main/java/com/heddy/domain/recommendation/service/HairShapeSimilarity.java @@ -0,0 +1,19 @@ +package com.heddy.domain.recommendation.service; + +import java.util.List; + +/** Soft intersection-over-union of normalized hair masks, not a face or identity comparison. */ +public final class HairShapeSimilarity { + private HairShapeSimilarity() { } + public static Double compare(List first, List second) { + if (first.size() != 96 || second.size() != 96) return null; + double intersection = 0, union = 0; + for (int i = 0; i < first.size(); i++) { + double a = first.get(i), b = second.get(i); + if (!Double.isFinite(a) || !Double.isFinite(b) || a < 0 || b < 0 || a > 1 || b > 1) return null; + intersection += Math.min(a, b); + union += Math.max(a, b); + } + return union == 0 ? null : Math.round(intersection / union * 1000) / 1000.0; + } +} diff --git a/src/main/java/com/heddy/domain/recommendation/service/RuleBasedV1Scorer.java b/src/main/java/com/heddy/domain/recommendation/service/RuleBasedV1Scorer.java index b775e50e..0abc5b52 100644 --- a/src/main/java/com/heddy/domain/recommendation/service/RuleBasedV1Scorer.java +++ b/src/main/java/com/heddy/domain/recommendation/service/RuleBasedV1Scorer.java @@ -12,7 +12,6 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -61,7 +60,14 @@ public Optional score( availableMaximum += 10; } - double raw = history.score() + savedStyleScore + preferredTagScore + double similarity = context.photoSimilarities().getOrDefault(candidate.hairstyleId(), 0.0); + double photoScore = 30 * similarity; + if (!context.photoSimilarities().isEmpty()) availableMaximum += 30; + if (similarity >= .60) { + reasons.addFirst(new RecommendationReason(RecommendationReason.Code.PHOTO_SHAPE_MATCH, + Map.of("style_name", candidate.styleName(), "similarity", number(similarity)))); + } + double raw = photoScore + history.score() + savedStyleScore + preferredTagScore + hair.score() + careTimeScore; double finalScore = availableMaximum == 0 ? 0 : raw / availableMaximum * 100; if (availableMaximum == 0) { @@ -70,7 +76,7 @@ public Optional score( } ScoreBreakdown breakdown = new ScoreBreakdown( history.score(), savedStyleScore, preferredTagScore, hair.score(), careTimeScore, - raw, availableMaximum, clamp(finalScore)); + raw, availableMaximum, clamp(finalScore), photoScore); return Optional.of(new ScoredRecommendation( candidate, breakdown, reasons, history.referenceRecordId())); } @@ -109,31 +115,44 @@ private HistoryResult historyScore( RecommendationContext context, List reasons ) { - double best = 0; + double best = 0, penalty = 0; TreatmentRecord bestRecord = null; + TreatmentStyleMatcher.Match bestMatch = null; + var matcher = new TreatmentStyleMatcher(); for (TreatmentRecord record : context.recentTreatments()) { - if (record.satisfaction() == null) { + if (record.satisfaction() == null || record.satisfaction() == 3) continue; + var match = matcher.match(candidate, record); + long daysAgo = Math.max(0, Duration.between(record.performedAt(), context.generatedAt()).toDays()); + double recency = Math.pow(0.5, daysAgo / 180.0); + if (record.satisfaction() <= 2) { + // Only a specific match can penalize a style: disliking one haircut does not reject all CUTs. + if (!match.details().isEmpty()) { + penalty = Math.max(penalty, match.score() * (3 - record.satisfaction()) / 2.0 * recency); + } continue; } - double similarity = jaccard(candidate.serviceTypes(), record.serviceTypes()); - double satisfaction = (record.satisfaction() - 1.0) / 4.0; - long daysAgo = Math.max(0, Duration.between( - record.performedAt(), context.generatedAt()).toDays()); - double recency = Math.pow(0.5, daysAgo / 180.0); - double score = 40 * similarity * satisfaction * recency; + double score = match.score() * (record.satisfaction() - 3) / 2.0 * recency; if (score > best || (score == best && bestRecord != null && record.recordId().compareTo(bestRecord.recordId()) < 0)) { best = score; bestRecord = record; + bestMatch = match; } } - if (bestRecord != null && best > 0) { - reasons.add(new RecommendationReason( - RecommendationReason.Code.SIMILAR_HIGH_SATISFACTION_HISTORY, - Map.of("record_id", bestRecord.recordId().toString(), - "satisfaction", bestRecord.satisfaction().toString()))); + if (bestRecord != null && best > penalty) { + var params = new HashMap(); + params.put("record_id", bestRecord.recordId().toString()); + params.put("satisfaction", bestRecord.satisfaction().toString()); + params.put("style_name", candidate.styleName()); + params.put("matched_details", String.join(", ", bestMatch.details())); + params.put("service_types", bestRecord.serviceTypes().stream() + .filter(candidate.serviceTypes()::contains).map(Enum::name).sorted() + .collect(java.util.stream.Collectors.joining(", "))); + reasons.add(new RecommendationReason(bestMatch.details().isEmpty() + ? RecommendationReason.Code.HISTORY_SERVICE_MATCH + : RecommendationReason.Code.SIMILAR_HIGH_SATISFACTION_HISTORY, params)); } - return new HistoryResult(best, bestRecord == null ? null : bestRecord.recordId()); + return new HistoryResult(best - penalty, best > penalty && bestRecord != null ? bestRecord.recordId() : null); } private double savedStyleScore( @@ -239,17 +258,6 @@ private static boolean disjoint(Set first, Set second) { return first.stream().noneMatch(second::contains); } - private static double jaccard(Set first, Set second) { - if (first.isEmpty() && second.isEmpty()) { - return 0; - } - Set union = new HashSet<>(first); - union.addAll(second); - Set intersection = new HashSet<>(first); - intersection.retainAll(second); - return intersection.size() / (double) union.size(); - } - private static double clamp(double value) { return Math.max(0, Math.min(100, value)); } diff --git a/src/main/java/com/heddy/domain/recommendation/service/TreatmentStyleMatcher.java b/src/main/java/com/heddy/domain/recommendation/service/TreatmentStyleMatcher.java new file mode 100644 index 00000000..5b3e593f --- /dev/null +++ b/src/main/java/com/heddy/domain/recommendation/service/TreatmentStyleMatcher.java @@ -0,0 +1,104 @@ +package com.heddy.domain.recommendation.service; + +import com.heddy.domain.recommendation.model.HairstyleCandidate; +import com.heddy.domain.treatment.model.ServiceType; +import com.heddy.domain.treatment.model.TreatmentRecord; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** Matches explicit treatment attributes to catalog names/tags; never infers them from a photo. */ +final class TreatmentStyleMatcher { + private static final Map> SHAPES = Map.ofEntries( + Map.entry("LAYERED", List.of("LAYERED", "레이어드")), + Map.entry("BOB", List.of("BOB", "보브", "단발")), + Map.entry("PIXIE", List.of("PIXIE", "픽시")), + Map.entry("BUZZ", List.of("BUZZ", "버즈")), + Map.entry("TWO_BLOCK", List.of("TWOBLOCK", "투블럭", "투블록")), + Map.entry("ONE_LENGTH", List.of("ONELENGTH", "원랭스", "원렝스")), + Map.entry("SHAG", List.of("SHAG", "샤기", "샤기컷")), + Map.entry("WOLF", List.of("WOLF", "울프"))); + private static final Map> PERMS = Map.ofEntries( + Map.entry("C_CURL", List.of("CCURL", "C컬")), + Map.entry("S_CURL", List.of("SCURL", "S컬")), + Map.entry("WAVE", List.of("WAVE", "웨이브")), + Map.entry("VOLUME", List.of("VOLUME", "볼륨")), + Map.entry("DOWN", List.of("DOWNPERM", "다운펌")), + Map.entry("SETTING", List.of("SETTING", "셋팅", "세팅")), + Map.entry("DIGITAL", List.of("DIGITAL", "디지털")), + Map.entry("MAGIC", List.of("MAGIC", "매직")), + Map.entry("HIPPIE", List.of("HIPPIE", "히피"))); + + Match match(HairstyleCandidate candidate, TreatmentRecord record) { + List details = new ArrayList<>(); + List labels = new ArrayList<>(candidate.tags().values()); + labels.add(candidate.styleName()); + boolean cut = record.serviceTypes().contains(ServiceType.CUT) + && candidate.serviceTypes().contains(ServiceType.CUT); + boolean perm = record.serviceTypes().contains(ServiceType.PERM) + && candidate.serviceTypes().contains(ServiceType.PERM); + double detailWeight = 0, matchedWeight = 0; + if (record.cutShape() != null && record.serviceTypes().contains(ServiceType.CUT)) { + detailWeight += 3; + if (cut && matches(record.cutShape(), labels, SHAPES)) { + matchedWeight += 3; + details.add("커트 형태 ‘" + record.cutShape() + "’"); + } + } + if (record.permType() != null && record.serviceTypes().contains(ServiceType.PERM)) { + detailWeight += 3; + if (perm && matches(record.permType(), labels, PERMS)) { + matchedWeight += 3; + details.add("펌 종류 ‘" + record.permType() + "’"); + } + } + if (record.cutLength() != null && record.serviceTypes().contains(ServiceType.CUT)) { + detailWeight += 2; + if (cut && matchesLength(record.cutLength(), candidate.category())) { + matchedWeight += 2; + details.add("커트 길이 ‘" + record.cutLength() + "’"); + } + } + Set union = new java.util.HashSet<>(record.serviceTypes()); + union.addAll(candidate.serviceTypes()); + long common = record.serviceTypes().stream().filter(candidate.serviceTypes()::contains).count(); + double service = common / (double) union.size(); + // CUT alone is weak evidence, not a 40-point endorsement of every haircut. + double score = 10 * service + (detailWeight == 0 ? 0 : 30 * matchedWeight / detailWeight); + return new Match(score, List.copyOf(details)); + } + + private boolean matches(String input, List labels, Map> aliases) { + String normalized = normalize(input); + List terms = aliases.entrySet().stream() + .filter(e -> normalize(e.getKey()).equals(normalized) + || e.getValue().stream().anyMatch(v -> normalize(v).equals(normalized))) + .map(Map.Entry::getValue).findFirst().orElse(List.of(normalized)); + // Unknown input is only an exact match; generic text such as CUT/PERM is not a style. + if (!aliases.values().contains(terms)) { + return normalized.length() >= 2 && labels.stream().anyMatch(v -> normalize(v).equals(normalized)); + } + return labels.stream().map(TreatmentStyleMatcher::normalize) + .anyMatch(label -> terms.stream().map(TreatmentStyleMatcher::normalize).anyMatch(label::contains)); + } + + private boolean matchesLength(String input, String category) { + String length = switch (normalize(input)) { + case "SHORT", "숏", "숏컷", "짧은머리" -> "SHORT"; + case "BOB", "BELOWCHIN", "단발", "턱아래" -> "BOB"; + case "MEDIUM", "BELOWSHOULDER", "미디엄", "중간머리", "어깨아래" -> "MEDIUM"; + case "LONG", "BELOWCHEST", "롱", "긴머리", "가슴아래" -> "LONG"; + default -> ""; + }; + return !length.isEmpty() && length.equals(normalize(category)); + } + + private static String normalize(String value) { + return value.toUpperCase(Locale.ROOT).replaceAll("[\\s_-]", ""); + } + + record Match(double score, List details) { } +} diff --git a/src/main/resources/db/migration/V35__snapshot_recommendation_references.sql b/src/main/resources/db/migration/V35__snapshot_recommendation_references.sql new file mode 100644 index 00000000..4b4f9020 --- /dev/null +++ b/src/main/resources/db/migration/V35__snapshot_recommendation_references.sql @@ -0,0 +1,12 @@ +-- Preserve the evidence shown when a recommendation was generated. +-- Historical originals are unavailable; freeze the current values for legacy runs. +ALTER TABLE recommendation_reference_records + ADD COLUMN performed_at_snapshot TIMESTAMP WITH TIME ZONE, + ADD COLUMN satisfaction_snapshot INTEGER; + +UPDATE recommendation_reference_records reference +SET performed_at_snapshot = record.performed_at, + satisfaction_snapshot = record.satisfaction +FROM treatment_records record WHERE record.record_id = reference.record_id; + +ALTER TABLE recommendation_reference_records ALTER COLUMN performed_at_snapshot SET NOT NULL; diff --git a/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java index 20113ee4..dac198df 100644 --- a/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java @@ -262,6 +262,41 @@ void forcedRefreshStillCreatesANewRunAndFiltersLongStyles() throws Exception { assertThat(runCount()).isEqualTo(2); } + @Test + void cutShapeEditReranksLatestAndThenReusesItWithoutANewAccount() throws Exception { + jdbcTemplate.update("UPDATE treatment_records SET cut_shape = 'LAYERED' WHERE record_id = ?", treatmentRecordId); + String oldId = generateRun(); + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch( + "/treatment-records/{id}", treatmentRecordId) + .with(authentication(auth(USER_ID))).contentType(MediaType.APPLICATION_JSON) + .content("{\"cut_shape\":\"BOB\"}")) + .andExpect(status().isOk()); + entityManager.clear(); + String response = mockMvc.perform(get("/recommendations/latest").with(authentication(auth(USER_ID)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].hairstyle.style_name").value("내추럴 보브")) + .andExpect(jsonPath("$.data.items[0].reasons[0].message").value(org.hamcrest.Matchers.containsString("BOB"))) + .andReturn().getResponse().getContentAsString(); + assertThat(runId(response)).isNotEqualTo(oldId); + assertThat(latestRun()).isEqualTo(runId(response)); + } + + @Test + void historicalReferenceDateAndRatingStayFrozenAfterRecordEdit() throws Exception { + String oldId = generateRun(); + String before = mockMvc.perform(get("/recommendations/{id}", oldId).with(authentication(auth(USER_ID)))) + .andReturn().getResponse().getContentAsString(); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + var reference = mapper.readTree(before).path("data").path("items").get(0).path("reference_records"); + jdbcTemplate.update("UPDATE treatment_records SET performed_at = now() - interval '7 days', satisfaction = 2 WHERE record_id = ?", + treatmentRecordId); + entityManager.clear(); + String after = mockMvc.perform(get("/recommendations/{id}", oldId).with(authentication(auth(USER_ID)))) + .andReturn().getResponse().getContentAsString(); + assertThat(mapper.readTree(after).path("data").path("items").get(0).path("reference_records")).isEqualTo(reference); + assertThat(latestRun()).isNotEqualTo(oldId); + } + private void insertShortStyle() { String id = "94000000-0000-4000-8000-000000000004"; insertStyle(id, "클린 픽시컷", "SHORT", "NONE", 60, false); @@ -363,6 +398,37 @@ INSERT INTO treatment_records (record_id, user_id, service_types, performed_at, org.mockito.Mockito.verify(photoAnalyzer).analyze(org.mockito.ArgumentMatchers.argThat(f -> f.fileId().equals(newerFile))); } + @Test + void replacingPhotoWithSameLengthChangesRankingAndPersistsItsEvidence() throws Exception { + UUID oldPhoto = attachAfterPhoto(); + jdbcTemplate.update("UPDATE files SET created_at = now() - interval '1 hour' WHERE file_id = ?", oldPhoto); + var left = java.util.stream.IntStream.range(0, 96).mapToObj(i -> i < 48 ? 1.0 : 0.0).toList(); + var right = java.util.stream.IntStream.range(0, 96).mapToObj(i -> i < 48 ? 0.0 : 1.0).toList(); + org.mockito.Mockito.when(photoAnalyzer.analyze(org.mockito.ArgumentMatchers.any())).thenAnswer(invocation -> { + com.heddy.domain.file.model.StoredFile file = invocation.getArgument(0); + boolean first = file.fileId().equals(oldPhoto) || file.fileName().equals("레이어드 C컬.jpg"); + return new com.heddy.domain.recommendation.model.PhotoHairEvidence(file.fileId(), "shape-test", + "ASSESSED", com.heddy.domain.account.model.HairProfile.HairLength.BELOW_SHOULDER, + false, first ? left : right, 0); + }); + String oldId = generateRun(); + UUID newPhoto = attachAfterPhoto(); + entityManager.clear(); + String response = mockMvc.perform(get("/recommendations/latest").with(authentication(auth(USER_ID)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].hairstyle.style_name").value("내추럴 보브")) + .andExpect(jsonPath("$.data.items[0].reasons[0].code").value("PHOTO_SHAPE_MATCH")) + .andExpect(jsonPath("$.data.recommendation_basis.photo_analysis.file_id").value(newPhoto.toString())) + .andExpect(jsonPath("$.data.recommendation_basis.photo_analysis.compared_style_count").value(3)) + .andReturn().getResponse().getContentAsString(); + assertThat(runId(response)).isNotEqualTo(oldId); + entityManager.clear(); + assertThat(latestRun()).isEqualTo(runId(response)); + mockMvc.perform(get("/recommendations/{id}", runId(response)).with(authentication(auth(USER_ID)))) + .andExpect(jsonPath("$.data.items[0].reasons[0].code").value("PHOTO_SHAPE_MATCH")) + .andExpect(jsonPath("$.data.recommendation_basis.photo_analysis.compared_style_count").value(3)); + } + private UUID attachAfterPhoto() { UUID id = UUID.randomUUID(); jdbcTemplate.update("UPDATE hair_profiles SET updated_at = now() - interval '2 days' WHERE user_id = ?", USER_ID); diff --git a/src/test/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimatorTest.java b/src/test/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimatorTest.java index 743d858f..6f7fcaf0 100644 --- a/src/test/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimatorTest.java +++ b/src/test/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimatorTest.java @@ -23,6 +23,20 @@ class PhotoHairLengthEstimatorTest { @Test void rejectsUncertainSegmentation() { assertThat(estimator.estimate(new float[256][256][6])).isNull(); } + @Test void distinguishesHairSilhouettesWithTheSameLength() { + var full = portrait(80); + var closeCut = portrait(80); + rectangle(closeCut, 65, 30, 185, 55, 0); + assertThat(estimator.estimate(full)).isEqualTo(estimator.estimate(closeCut)); + var a = estimator.shape(full); + var b = estimator.shape(closeCut); + assertThat(a).hasSize(96); + assertThat(b).hasSize(96); + assertThat(com.heddy.domain.recommendation.service.HairShapeSimilarity.compare(a, a)).isEqualTo(1); + assertThat(com.heddy.domain.recommendation.service.HairShapeSimilarity.compare(a, b)).isLessThan(.8); + assertThat(estimator.shape(new float[256][256][6])).isEmpty(); + } + private float[][][] portrait(int hairBottom) { float[][][] result = new float[256][256][6]; rectangle(result, 0, 0, 256, 256, 0); diff --git a/src/test/java/com/heddy/application/recommendation/service/RecommendationPhotoResolverTest.java b/src/test/java/com/heddy/application/recommendation/service/RecommendationPhotoResolverTest.java index f7701efd..0c801011 100644 --- a/src/test/java/com/heddy/application/recommendation/service/RecommendationPhotoResolverTest.java +++ b/src/test/java/com/heddy/application/recommendation/service/RecommendationPhotoResolverTest.java @@ -30,6 +30,10 @@ var record = record(now, ImageType.AFTER, user); assertThat(resolved.evidence().applied()).isTrue(); assertThat(original.hairLength()).isEqualTo(HairLength.BELOW_SHOULDER); } + @Test void unclassifiedPhotoIsUsedWhenNoAfterPhotoExists() { + var record = record(now, ImageType.OTHER, user); + assertThat(resolver.resolve(user, null, List.of(record)).evidence().applied()).isTrue(); + } @Test void longerPhotoNeverOverridesExplicitShortProfile() { var record = record(now, ImageType.AFTER, user); when(analyzer.analyze(any())).thenAnswer(i -> new PhotoHairEvidence(((StoredFile)i.getArgument(0)).fileId(), @@ -38,11 +42,11 @@ var record = record(now, ImageType.AFTER, user); assertThat(resolved.profile().hairLength()).isEqualTo(HairLength.SHORT); assertThat(resolved.evidence().applied()).isFalse(); } - @Test void newerExplicitProfileWinsAndSkipsInference() { + @Test void newerExplicitProfileKeepsLengthButStillAllowsShapeAnalysis() { var record = record(now, ImageType.AFTER, user); var resolved = resolver.resolve(user, profile(HairLength.BELOW_CHIN, now.plusSeconds(60)), List.of(record)); assertThat(resolved.evidence().status()).isEqualTo("NEWER_PROFILE"); - verifyNoInteractions(analyzer); + verify(analyzer).analyze(any()); } @Test void neverUsesBeforePhotoOrAnotherAccountsFile() { var before = record(now, ImageType.BEFORE, user); @@ -64,6 +68,30 @@ var record = record(now, ImageType.AFTER, user); var original = profile(HairLength.BELOW_CHIN, now.minusSeconds(60)); assertThat(resolver.resolve(user, original, List.of(record)).profile()).isEqualTo(original); } + @Test void shapeComparisonOnlyReadsReadySystemCatalogFiles() { + var shape = java.util.Collections.nCopies(96, .5); + var evidence = new PhotoHairEvidence(UUID.randomUUID(), "shape-v1", "ASSESSED", HairLength.SHORT, false, shape, 0); + var valid = catalogFile(FileOwnerType.SYSTEM, FileStatus.READY); + var foreign = catalogFile(FileOwnerType.USER, FileStatus.READY); + var pending = catalogFile(FileOwnerType.SYSTEM, FileStatus.PENDING); + var inputs = List.of(valid, foreign, pending); + when(files.findAllById(any())).thenReturn(inputs); + when(analyzer.analyze(valid)).thenReturn(new PhotoHairEvidence(valid.fileId(), "shape-v1", "ASSESSED", + HairLength.SHORT, false, shape, 0)); + var candidates = inputs.stream().map(file -> new com.heddy.domain.recommendation.model.HairstyleCandidate( + file.fileId(), "test", "SHORT", file.fileId(), "NONE", true, "1", Set.of(ServiceType.CUT), + Set.of(HairLength.SHORT), Set.of(), Set.of(), Set.of(), Set.of(), HairLength.SHORT, 0, + com.heddy.domain.recommendation.model.HairstyleCandidate.ManagementDifficulty.LOW, + com.heddy.domain.recommendation.model.HairstyleCandidate.ChemicalStressLevel.LOW, 0, "1", Map.of())).toList(); + assertThat(resolver.similarities(evidence, candidates)).containsExactlyEntriesOf(Map.of(valid.fileId(), 1.0)); + verify(analyzer).analyze(valid); + verifyNoMoreInteractions(analyzer); + } + private StoredFile catalogFile(FileOwnerType owner, FileStatus status) { + return new StoredFile(UUID.randomUUID(), UUID.randomUUID(), owner == FileOwnerType.USER ? UUID.randomUUID() : null, + owner, FilePurpose.HAIRSTYLE_THUMBNAIL, status, "test", "image/jpeg", "test.jpg", 1024, + null, null, null, now, now, null); + } private HairProfile profile(HairLength length, Instant updated) { return new HairProfile(UUID.randomUUID(), user, null, null, length, null, null, now.minusSeconds(3600), updated); } diff --git a/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java b/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java index 56c07e75..082d88ed 100644 --- a/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java +++ b/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java @@ -40,8 +40,8 @@ void exactServiceMatchScoresHigherThanPartialMatch() { double partial = scorer.score(candidate(Set.of(ServiceType.CUT), Map.of()), context) .orElseThrow().scoreBreakdown().historyScore(); - assertThat(exact).isEqualTo(40); - assertThat(partial).isEqualTo(20); + assertThat(exact).isEqualTo(10); + assertThat(partial).isEqualTo(5); } @Test @@ -58,8 +58,8 @@ void satisfactionAndRecencyApplyToHistoryScore() { .orElseThrow().scoreBreakdown().historyScore(); assertThat(onePoint).isZero(); - assertThat(recentFive).isEqualTo(40); - assertThat(oldFive).isCloseTo(20, org.assertj.core.data.Offset.offset(0.0001)); + assertThat(recentFive).isEqualTo(10); + assertThat(oldFive).isCloseTo(5, org.assertj.core.data.Offset.offset(0.0001)); } @Test @@ -178,6 +178,87 @@ void coldStartUsesZeroPersonalizedScore() { .containsExactly("EDITORIAL_FALLBACK"); } + @Test + void changingCutShapeChangesTheWinnerEvenWithIdenticalServiceTypes() { + var layered = named("레이어드 C컬", "MEDIUM", ServiceType.CUT); + var buzz = named("버즈컷", "SHORT", ServiceType.CUT); + var history = detailed("LAYERED", null, null, 5); + var first = scorer.scoreAll(List.of(buzz, layered), context(null, Set.of(), Set.of(), Set.of(), List.of(history))); + assertThat(first.getFirst().candidate()).isEqualTo(layered); + assertThat(first.getFirst().scoreBreakdown().historyScore()).isEqualTo(40); + assertThat(first.getFirst().reasons().getFirst().params().get("matched_details")).contains("LAYERED"); + var changed = detailed("BUZZ", null, null, 5); + assertThat(scorer.scoreAll(List.of(layered, buzz), context(null, Set.of(), Set.of(), Set.of(), List.of(changed))) + .getFirst().candidate()).isEqualTo(buzz); + } + + @Test + void lowSatisfactionDoesNotProducePositiveHistoryReasonOrReference() { + var style = named("레이어드 C컬", "MEDIUM", ServiceType.CUT); + var result = scorer.score(style, context(null, Set.of(), Set.of(), Set.of(), + List.of(detailed("LAYERED", null, null, 2)))).orElseThrow(); + assertThat(result.referenceRecordId()).isNull(); + assertThat(result.scoreBreakdown().historyScore()).isNegative(); + assertThat(result.finalScore()).isZero(); + assertThat(result.reasons()).noneMatch(r -> r.code().name().contains("HISTORY")); + } + + @Test + void genericServiceMatchDoesNotClaimSimilarStyle() { + var result = scorer.score(candidate(Set.of(ServiceType.CUT), Map.of()), context(null, + Set.of(), Set.of(), Set.of(), List.of(treatment(Set.of(ServiceType.CUT), 5, 0)))).orElseThrow(); + assertThat(result.reasons()).extracting(r -> r.code().name()).containsExactly("HISTORY_SERVICE_MATCH"); + } + + @Test + void lengthAndPermDetailsDistinguishCandidatesAndUnknownDetailsDoNotMatch() { + var shortCut = named("버즈컷", "SHORT", ServiceType.CUT); + var medium = named("레이어드 C컬", "MEDIUM", ServiceType.CUT); + var length = detailed(null, "MEDIUM", null, 5); + assertThat(scorer.scoreAll(List.of(shortCut, medium), context(null, Set.of(), Set.of(), Set.of(), List.of(length))) + .getFirst().candidate()).isEqualTo(medium); + var wave = named("내추럴 웨이브", "MEDIUM", ServiceType.PERM); + var cCurl = named("레이어드 C컬", "MEDIUM", ServiceType.PERM); + var perm = detailed(null, null, "C_CURL", 5); + assertThat(scorer.scoreAll(List.of(wave, cCurl), context(null, Set.of(), Set.of(), Set.of(), List.of(perm))) + .getFirst().candidate()).isEqualTo(cCurl); + var unknown = detailed("UNKNOWN", null, null, 5); + assertThat(scorer.score(medium, context(null, Set.of(), Set.of(), Set.of(), List.of(unknown))) + .orElseThrow().scoreBreakdown().historyScore()).isEqualTo(10); + } + + @Test + void photoShapeChangesRankingWithinTheSameHairLength() { + var fringe = named("사이드 프린지", "SHORT", ServiceType.CUT); + var buzz = named("버즈컷", "SHORT", ServiceType.CUT); + var profile = profile(HairLength.SHORT, null, null, null, null); + var firstPhoto = new RecommendationContext(profile, Set.of(), Set.of(), Set.of(), List.of(), NOW, + Map.of(fringe.hairstyleId(), .85, buzz.hairstyleId(), .15)); + var secondPhoto = new RecommendationContext(profile, Set.of(), Set.of(), Set.of(), List.of(), NOW, + Map.of(fringe.hairstyleId(), .20, buzz.hairstyleId(), .90)); + var first = scorer.scoreAll(List.of(buzz, fringe), firstPhoto).getFirst(); + assertThat(first.candidate()).isEqualTo(fringe); + assertThat(first.reasons().getFirst().code().name()).isEqualTo("PHOTO_SHAPE_MATCH"); + assertThat(scorer.scoreAll(List.of(fringe, buzz), secondPhoto).getFirst().candidate()).isEqualTo(buzz); + // Incompatible lengths remain excluded even with a perfect visual match. + var medium = candidate(Set.of(ServiceType.CUT), Map.of()); + assertThat(scorer.score(medium, new RecommendationContext(profile, Set.of(), Set.of(), Set.of(), List.of(), NOW, + Map.of(medium.hairstyleId(), 1.0)))).isEmpty(); + } + + private TreatmentRecord detailed(String shape, String length, String perm, int satisfaction) { + return new TreatmentRecord(UUID.randomUUID(), UUID.randomUUID(), + Set.of(perm == null ? ServiceType.CUT : ServiceType.PERM), null, null, NOW, satisfaction, + null, null, null, null, null, null, null, "Asia/Seoul", length, shape, perm, null, + List.of(), List.of(), NOW); + } + + private HairstyleCandidate named(String name, String category, ServiceType service) { + return new HairstyleCandidate(UUID.randomUUID(), name, category, UUID.randomUUID(), "NONE", true, "1", + Set.of(service), Set.of(HairLength.SHORT), Set.of(), Set.of(), Set.of(), Set.of(), + HairLength.SHORT, 0, ManagementDifficulty.LOW, ChemicalStressLevel.LOW, 1, "1", Map.of()); + } + private RecommendationContext context( HairProfile profile, Set preferred, Set excluded, Set saved, List treatments From 6f09e359fcdf47a055a759998603831e3419eb52 Mon Sep 17 00:00:00 2001 From: hoshino-love0102 Date: Sat, 17 Oct 2026 20:46:45 +0900 Subject: [PATCH 2/2] fix: present treatment recommendation details in Korean --- .../dto/RecommendationResponse.java | 14 ++++++++++- .../service/TreatmentStyleMatcher.java | 24 ++++++++++++++++--- .../RecommendationApiIntegrationTest.java | 2 +- .../service/RuleBasedV1ScorerTest.java | 2 +- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/heddy/adapter/in/web/recommendation/dto/RecommendationResponse.java b/src/main/java/com/heddy/adapter/in/web/recommendation/dto/RecommendationResponse.java index e8887a2e..efaf49ca 100644 --- a/src/main/java/com/heddy/adapter/in/web/recommendation/dto/RecommendationResponse.java +++ b/src/main/java/com/heddy/adapter/in/web/recommendation/dto/RecommendationResponse.java @@ -125,6 +125,18 @@ static Reason from(RecommendationReason reason) { return new Reason(reason.code().name(), message(reason), reason.params()); } + private static String serviceNames(String types) { + return java.util.Arrays.stream(types.split(", ")).map(type -> switch (type) { + case "CUT" -> "커트"; + case "PERM" -> "펌"; + case "COLOR" -> "염색"; + case "BLEACH" -> "탈색"; + case "CLINIC" -> "클리닉"; + case "STYLING" -> "스타일링"; + default -> "기타"; + }).collect(java.util.stream.Collectors.joining(", ")); + } + private static String message(RecommendationReason reason) { return switch (reason.code()) { case PHOTO_SHAPE_MATCH -> "최근 시술 사진의 머리 윤곽이 ‘" @@ -133,7 +145,7 @@ private static String message(RecommendationReason reason) { + "은 만족도 " + reason.params().getOrDefault("satisfaction", "4") + "점 기록의 " + reason.params().getOrDefault("matched_details", "시술 특성") + "와 일치합니다."; case HISTORY_SERVICE_MATCH -> "만족도 " + reason.params().getOrDefault("satisfaction", "4") - + "점 기록과 시술 종류(" + reason.params().getOrDefault("service_types", "") + + "점 기록과 시술 종류(" + serviceNames(reason.params().getOrDefault("service_types", "")) + ")가 같습니다. 세부 스타일의 유사성은 확인되지 않았습니다."; case SAVED_STYLE_MATCH -> "이전에 저장한 스타일입니다."; case PREFERRED_TAG_MATCH -> "선호하는 ‘" diff --git a/src/main/java/com/heddy/domain/recommendation/service/TreatmentStyleMatcher.java b/src/main/java/com/heddy/domain/recommendation/service/TreatmentStyleMatcher.java index 5b3e593f..1453b2f1 100644 --- a/src/main/java/com/heddy/domain/recommendation/service/TreatmentStyleMatcher.java +++ b/src/main/java/com/heddy/domain/recommendation/service/TreatmentStyleMatcher.java @@ -45,21 +45,21 @@ Match match(HairstyleCandidate candidate, TreatmentRecord record) { detailWeight += 3; if (cut && matches(record.cutShape(), labels, SHAPES)) { matchedWeight += 3; - details.add("커트 형태 ‘" + record.cutShape() + "’"); + details.add("커트 형태 ‘" + label(record.cutShape(), SHAPES) + "’"); } } if (record.permType() != null && record.serviceTypes().contains(ServiceType.PERM)) { detailWeight += 3; if (perm && matches(record.permType(), labels, PERMS)) { matchedWeight += 3; - details.add("펌 종류 ‘" + record.permType() + "’"); + details.add("펌 종류 ‘" + label(record.permType(), PERMS) + "’"); } } if (record.cutLength() != null && record.serviceTypes().contains(ServiceType.CUT)) { detailWeight += 2; if (cut && matchesLength(record.cutLength(), candidate.category())) { matchedWeight += 2; - details.add("커트 길이 ‘" + record.cutLength() + "’"); + details.add("커트 길이 ‘" + lengthLabel(candidate.category()) + "’"); } } Set union = new java.util.HashSet<>(record.serviceTypes()); @@ -71,6 +71,24 @@ Match match(HairstyleCandidate candidate, TreatmentRecord record) { return new Match(score, List.copyOf(details)); } + private String label(String input, Map> aliases) { + String normalized = normalize(input); + return aliases.entrySet().stream() + .filter(e -> normalize(e.getKey()).equals(normalized) + || e.getValue().stream().anyMatch(v -> normalize(v).equals(normalized))) + .map(e -> e.getValue().get(1)).findFirst().orElse(input); + } + + private String lengthLabel(String category) { + return switch (normalize(category)) { + case "SHORT" -> "숏"; + case "BOB" -> "단발"; + case "MEDIUM" -> "미디엄"; + case "LONG" -> "롱"; + default -> category; + }; + } + private boolean matches(String input, List labels, Map> aliases) { String normalized = normalize(input); List terms = aliases.entrySet().stream() diff --git a/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java index dac198df..9adb70c0 100644 --- a/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java @@ -275,7 +275,7 @@ void cutShapeEditReranksLatestAndThenReusesItWithoutANewAccount() throws Excepti String response = mockMvc.perform(get("/recommendations/latest").with(authentication(auth(USER_ID)))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.items[0].hairstyle.style_name").value("내추럴 보브")) - .andExpect(jsonPath("$.data.items[0].reasons[0].message").value(org.hamcrest.Matchers.containsString("BOB"))) + .andExpect(jsonPath("$.data.items[0].reasons[0].message").value(org.hamcrest.Matchers.containsString("보브"))) .andReturn().getResponse().getContentAsString(); assertThat(runId(response)).isNotEqualTo(oldId); assertThat(latestRun()).isEqualTo(runId(response)); diff --git a/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java b/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java index 082d88ed..58d8fd65 100644 --- a/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java +++ b/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java @@ -186,7 +186,7 @@ void changingCutShapeChangesTheWinnerEvenWithIdenticalServiceTypes() { var first = scorer.scoreAll(List.of(buzz, layered), context(null, Set.of(), Set.of(), Set.of(), List.of(history))); assertThat(first.getFirst().candidate()).isEqualTo(layered); assertThat(first.getFirst().scoreBreakdown().historyScore()).isEqualTo(40); - assertThat(first.getFirst().reasons().getFirst().params().get("matched_details")).contains("LAYERED"); + assertThat(first.getFirst().reasons().getFirst().params().get("matched_details")).contains("레이어드"); var changed = detailed("BUZZ", null, null, 5); assertThat(scorer.scoreAll(List.of(layered, buzz), context(null, Set.of(), Set.of(), Set.of(), List.of(changed))) .getFirst().candidate()).isEqualTo(buzz);