From 17ed9156ce85d327f4bdbd923fa2c1dcd86ce774 Mon Sep 17 00:00:00 2001 From: hoshino-love0102 Date: Fri, 16 Oct 2026 20:58:38 +0900 Subject: [PATCH 1/2] fix: apply photo length evidence to recommendations and audit short catalog --- Dockerfile | 9 +- docker-compose.prod.yml | 2 + docs/photo-length-model-LICENSE.txt | 202 ++++++++++++++++++ docs/photo-length-model-NOTICE.txt | 9 + docs/recommendation-photo-length.md | 35 +++ .../dto/RecommendationResponse.java | 16 +- .../out/ai/OnnxPhotoHairLengthAdapter.java | 113 ++++++++++ .../out/ai/PhotoHairLengthEstimator.java | 81 +++++++ .../ai/UnavailablePhotoHairLengthAdapter.java | 15 ++ .../RecommendationRunEntity.java | 20 +- .../service/RecommendationPhotoResolver.java | 58 +++++ .../service/RecommendationService.java | 26 ++- .../RecommendationReadOnlyAudit.java | 171 +++++++++++++++ .../exception/RecommendationError.java | 1 + .../model/PhotoHairEvidence.java | 15 ++ .../model/RecommendationBasis.java | 8 +- .../port/out/PhotoHairLengthPort.java | 8 + src/main/resources/application.yml | 3 + .../V33__correct_audited_short_catalog.sql | 20 ++ .../RecommendationApiIntegrationTest.java | 81 +++++++ .../ai/OnnxPhotoHairLengthAdapterTest.java | 45 ++++ .../out/ai/PhotoHairLengthEstimatorTest.java | 38 ++++ .../RecommendationPhotoResolverTest.java | 82 +++++++ 23 files changed, 1045 insertions(+), 13 deletions(-) create mode 100644 docs/photo-length-model-LICENSE.txt create mode 100644 docs/photo-length-model-NOTICE.txt create mode 100644 docs/recommendation-photo-length.md create mode 100644 src/main/java/com/heddy/adapter/out/ai/OnnxPhotoHairLengthAdapter.java create mode 100644 src/main/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimator.java create mode 100644 src/main/java/com/heddy/adapter/out/ai/UnavailablePhotoHairLengthAdapter.java create mode 100644 src/main/java/com/heddy/application/recommendation/service/RecommendationPhotoResolver.java create mode 100644 src/main/java/com/heddy/diagnostics/RecommendationReadOnlyAudit.java create mode 100644 src/main/java/com/heddy/domain/recommendation/model/PhotoHairEvidence.java create mode 100644 src/main/java/com/heddy/domain/recommendation/port/out/PhotoHairLengthPort.java create mode 100644 src/main/resources/db/migration/V33__correct_audited_short_catalog.sql create mode 100644 src/test/java/com/heddy/adapter/out/ai/OnnxPhotoHairLengthAdapterTest.java create mode 100644 src/test/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimatorTest.java create mode 100644 src/test/java/com/heddy/application/recommendation/service/RecommendationPhotoResolverTest.java diff --git a/Dockerfile b/Dockerfile index adedaf56..d640dae6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,9 +22,16 @@ RUN apt-get update \ && groupadd --system spring \ && useradd --system --gid spring spring +RUN mkdir -p /opt/heddy/photo-models \ + && curl --fail --location --retry 3 --max-time 180 \ + 'https://huggingface.co/senty-au/selfie_multiclass_256x256-ONNX/resolve/6db8421a7150ac20558f2c24675078eb3a1a04d0/onnx/model.onnx' \ + -o /opt/heddy/photo-models/selfie.onnx \ + && echo '35ec1ecd9ee7f85073c99c00020b7f6751b69506eeacf683bc8665f6117f85b0 /opt/heddy/photo-models/selfie.onnx' | sha256sum -c - +COPY docs/photo-length-model-LICENSE.txt /opt/heddy/photo-models/LICENSE.txt +COPY docs/photo-length-model-NOTICE.txt /opt/heddy/photo-models/NOTICE.txt COPY --from=build /app/build/libs/*.jar app.jar USER spring EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "/app/app.jar"] +ENTRYPOINT ["java", "-Djava.awt.headless=true", "-jar", "/app/app.jar"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index bd4c59c1..758a17b1 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -35,6 +35,8 @@ services: # 화면 체류 시간보다 짧으면 이미지가 403 으로 깨진다. DOWNLOAD_URL_TTL_SECONDS: ${DOWNLOAD_URL_TTL_SECONDS:-300} # 로컬 ONNX 얼굴 파싱 모델. 호스트의 읽기 전용 파일을 컨테이너에 마운트한다. + HAIR_PHOTO_LENGTH_ENABLED: ${HAIR_PHOTO_LENGTH_ENABLED:-true} + HAIR_PHOTO_LENGTH_MODEL_PATH: /opt/heddy/photo-models/selfie.onnx HAIR_ANALYSIS_ENABLED: ${HAIR_ANALYSIS_ENABLED:-false} HAIR_ANALYSIS_MODEL_PATH: ${HAIR_ANALYSIS_MODEL_PATH:-/opt/heddy/models/face-parsing.onnx} HAIR_ANALYSIS_MODEL_VERSION: ${HAIR_ANALYSIS_MODEL_VERSION:-segformer-face-parsing-cv-v1} diff --git a/docs/photo-length-model-LICENSE.txt b/docs/photo-length-model-LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/docs/photo-length-model-LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/docs/photo-length-model-NOTICE.txt b/docs/photo-length-model-NOTICE.txt new file mode 100644 index 00000000..b58ae63f --- /dev/null +++ b/docs/photo-length-model-NOTICE.txt @@ -0,0 +1,9 @@ +MediaPipe Selfie Multiclass (256 x 256), Google, Apache License 2.0. +Upstream: https://developers.google.com/edge/mediapipe/solutions/vision/image_segmenter +Source model: https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_multiclass_256x256/float32/1/selfie_multiclass_256x256.tflite +ONNX conversion by senty-au (Apache-2.0 model card): +https://huggingface.co/senty-au/selfie_multiclass_256x256-ONNX/tree/6db8421a7150ac20558f2c24675078eb3a1a04d0 +Pinned model SHA256: 35ec1ecd9ee7f85073c99c00020b7f6751b69506eeacf683bc8665f6117f85b0 +The unmodified ONNX conversion is distributed with this container. +License: https://www.apache.org/licenses/LICENSE-2.0 +Heddy adds a conservative visible hair-length heuristic; this is not a trained hair-length or gender classifier. diff --git a/docs/recommendation-photo-length.md b/docs/recommendation-photo-length.md new file mode 100644 index 00000000..a76274bd --- /dev/null +++ b/docs/recommendation-photo-length.md @@ -0,0 +1,35 @@ +# 사진과 추천 길이 연결 + +최신 시술의 AFTER 사진을 서버 내부 ONNX 모델로 분할하고, 얼굴 대비 머리 영역의 끝 위치를 이용해 보수적으로 길이를 추정한다. 사용자 사진을 외부 AI로 보내지 않으며 성별을 추론하지 않는다. 기존 색상 균일도·대칭·볼륨·거칠기 분석과는 별도 기능이다. 그 4개 지표의 분석 엔진을 이 모델로 대체하지 않는다. + +- 직접 입력한 머리 길이보다 긴 길이로 자동 변경하지 않는다. 사진 업로드 후 갱신한 프로필은 우선한다. +- 최신 시술에 AFTER 사진이 없으면 예전 시술 사진으로 대체하지 않는다. BEFORE/OTHER는 현재 길이 판단에 사용하지 않는다. +- 파일 소유자·READY·TREATMENT_PHOTO를 확인한다. 분석은 파일별 캐시 최대 256개로 제한하며 이미지 바이트는 보관하지 않는다. +- 사진·모델·분석 상태·적용 길이가 추천 입력 해시에 포함된다. latest 재조회는 달라진 입력을 재계산하고 같은 입력이면 재사용한다. +- 길이 입력도 없고 사진 길이도 판정할 수 없으면 `422 RECOMMENDATION_HAIR_LENGTH_REQUIRED`를 반환한다. 임의의 장발 기본 추천은 제공하지 않는다. +- `recommendation_basis.photo_analysis`는 `file_id`, `model_version`, `status`, `observed_length`, `applied`를 반환한다. `current_hair.hair_length`는 실제 점수/필터에 사용한 길이다. 프로필 DB 자체는 바꾸지 않는다. +- 상태: `ASSESSED`, `UNCERTAIN`, `UNREADABLE`, `UNAVAILABLE`, `DISABLED`, `NEWER_PROFILE`, `NO_RECENT_PHOTO`. 판정됐어도 입력 길이보다 길면 applied=false다. + +## 제약 + +길이 전용 학습 분류기가 아니라 머리/얼굴 분할 결과에 기반한 규칙이다. 뒷모습, 작은 얼굴, 여러 사람, 잘린 머리, 측면 등에서 판정을 보류한다. 묶은 머리나 가려진 머리끝은 짧게 보일 수 있으므로 사용자가 입력한 길이와 비교해 확인할 필요가 있다. JPEG/PNG만 읽을 수 있고 HEIC는 UNREADABLE이다. 어깨 아래를 가슴 아래로 세분화하지 않는다. 제공 모델의 픽셀 확률을 길이 분류의 정확도라고 표시하지 않는다. + +모델은 Docker 빌드에서 고정 커밋과 SHA256으로 받으며 라이선스를 동봉한다. 운영 compose의 `HAIR_PHOTO_LENGTH_ENABLED` 기본값은 true, 테스트/로컬 기본값은 false다. 기존 `HAIR_ANALYSIS_ENABLED`는 별도 설정이다. 롤백/비활성화 역시 GitHub/Actions 경로로 수행한다. + +## 검증 + +단위 테스트는 길이 경계, 불확실/잘림/복수 얼굴, 계정 소유권, BEFORE 배제, 최신 프로필 우선, 짧은 입력 보호를 검증한다. API/실제 PostgreSQL 테스트는 업로드 변경에 따른 latest 재생성과 분석 근거 저장/재사용 및 길이 누락 오류를 검증한다. + +실제 모델 smoke test는 `PHOTO_LENGTH_TEST_MODEL`에 고정 ONNX 경로, `PHOTO_LENGTH_TEST_IMAGES`에 검수한 SYSTEM 카탈로그 사진 0~5.jpg 경로를 설정해 실행한다. 사용자 사진은 테스트 fixture로 복사하지 않는다. 측면 장발/뒷모습 보브/손·안경에 가린 얼굴은 UNCERTAIN, 정면 숏 2개는 SHORT를 기대한다. + +배포 후 운영 컨테이너에서 읽기 전용 감사: + +```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 +``` + +Spring 서버나 마이그레이션을 새로 실행하지 않는다. DB 읽기 전용 트랜잭션, 기존 S3 내부 읽기, 실제 배포된 resolver/scorer만 사용한다. 최대 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 ba2aae58..787629c4 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 @@ -33,7 +33,8 @@ public record Basis( @JsonProperty("ar_candidate_style_count") int arCandidateStyleCount, @JsonProperty("style_preferences") StylePreferences stylePreferences, @JsonProperty("current_hair") CurrentHair currentHair, - @JsonProperty("available_care_time_minutes") Integer availableCareTimeMinutes + @JsonProperty("available_care_time_minutes") Integer availableCareTimeMinutes, + @JsonProperty("photo_analysis") PhotoAnalysis photoAnalysis ) { static Basis from(RecommendationBasis basis) { if (basis == null) { @@ -45,7 +46,18 @@ static Basis from(RecommendationBasis basis) { basis.arCandidateStyleCount(), new StylePreferences(basis.stylePreferences().preferredCount(), basis.stylePreferences().excludedCount()), - CurrentHair.from(basis.currentHair()), basis.availableCareTimeMinutes()); + CurrentHair.from(basis.currentHair()), basis.availableCareTimeMinutes(), + PhotoAnalysis.from(basis.photoAnalysis())); + } + } + + public record PhotoAnalysis(@JsonProperty("file_id") UUID fileId, + @JsonProperty("model_version") String modelVersion, String status, + @JsonProperty("observed_length") String observedLength, boolean applied) { + 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()); } } diff --git a/src/main/java/com/heddy/adapter/out/ai/OnnxPhotoHairLengthAdapter.java b/src/main/java/com/heddy/adapter/out/ai/OnnxPhotoHairLengthAdapter.java new file mode 100644 index 00000000..6a28f47d --- /dev/null +++ b/src/main/java/com/heddy/adapter/out/ai/OnnxPhotoHairLengthAdapter.java @@ -0,0 +1,113 @@ +package com.heddy.adapter.out.ai; + +import ai.onnxruntime.*; +import com.heddy.domain.file.model.StoredFile; +import com.heddy.domain.file.port.out.FileStoragePort; +import com.heddy.domain.recommendation.model.PhotoHairEvidence; +import com.heddy.domain.recommendation.port.out.PhotoHairLengthPort; +import jakarta.annotation.PreDestroy; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.nio.FloatBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; + +@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"; + private static final String MODEL_SHA256 = "35ec1ecd9ee7f85073c99c00020b7f6751b69506eeacf683bc8665f6117f85b0"; + private static final Logger log = LoggerFactory.getLogger(OnnxPhotoHairLengthAdapter.class); + private final OrtEnvironment environment = OrtEnvironment.getEnvironment(); + private final OrtSession session; + private final FileStoragePort storage; + private final PhotoHairLengthEstimator estimator = new PhotoHairLengthEstimator(); + // Only observations are retained, never image bytes. Ownership is checked before every cache lookup. + private final Map cache = new LinkedHashMap<>(256, .75f, true) { + protected boolean removeEldestEntry(Map.Entry entry) { return size() > 256; } + }; + + public OnnxPhotoHairLengthAdapter(FileStoragePort storage, + @Value("${app.photo-length.model-path:/opt/heddy/photo-models/selfie.onnx}") String path) { + this.storage = storage; + try { + String checksum = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(Files.readAllBytes(Path.of(path)))); + if (!MODEL_SHA256.equals(checksum)) throw new IllegalStateException("Photo length model checksum mismatch"); + try (var options = new OrtSession.SessionOptions()) { + options.setIntraOpNumThreads(1); + options.setInterOpNumThreads(1); + session = environment.createSession(path, options); + } + } catch (Exception failure) { + throw new IllegalStateException("Cannot load pinned photo length model", failure); + } + } + + @Override + public synchronized PhotoHairEvidence analyze(StoredFile file) { + String key = file.userId() + ":" + file.fileId() + ":" + file.sha256(); + PhotoHairEvidence previous = cache.get(key); + if (previous != null) return previous; + try { + byte[] bytes = storage.readObject(file, 10 * 1024 * 1024L); + BufferedImage source = decode(bytes); + if (source == null) return evidence(file, "UNREADABLE", null); + BufferedImage image = new BufferedImage(256, 256, BufferedImage.TYPE_INT_RGB); + var graphics = image.createGraphics(); + try { graphics.drawImage(source, 0, 0, 256, 256, null); } finally { graphics.dispose(); } + float[] rgb = new float[256 * 256 * 3]; + for (int y = 0; y < 256; y++) for (int x = 0; x < 256; x++) { + int color = image.getRGB(x, y), offset = (y * 256 + x) * 3; + rgb[offset] = (color >> 16 & 255) / 255f; + rgb[offset + 1] = (color >> 8 & 255) / 255f; + rgb[offset + 2] = (color & 255) / 255f; + } + try (var tensor = OnnxTensor.createTensor(environment, FloatBuffer.wrap(rgb), new long[]{1, 256, 256, 3}); + 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); + cache.put(key, observation); + return observation; + } + } catch (Exception failure) { + // Storage failures are transient: do not cache them or log URLs, object keys or image data. + log.warn("Photo length analysis unavailable ({})", failure.getClass().getSimpleName()); + return evidence(file, "UNAVAILABLE", null); + } + } + + private PhotoHairEvidence evidence(StoredFile file, String status, + com.heddy.domain.account.model.HairProfile.HairLength length) { + return new PhotoHairEvidence(file.fileId(), MODEL_VERSION, status, length, false); + } + + private static BufferedImage decode(byte[] bytes) throws java.io.IOException { + // Inspect dimensions before allocating a decoded image (upload byte limits alone are insufficient). + try (var stream = ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) { + var readers = ImageIO.getImageReaders(stream); + if (!readers.hasNext()) return null; + var reader = readers.next(); + try { + reader.setInput(stream, true, true); + int w = reader.getWidth(0), h = reader.getHeight(0); + if (w < 64 || h < 64 || (long) w * h > 20_000_000L) return null; + return reader.read(0); + } finally { reader.dispose(); } + } + } + @PreDestroy + public void close() throws OrtException { session.close(); } +} diff --git a/src/main/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimator.java b/src/main/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimator.java new file mode 100644 index 00000000..ac732992 --- /dev/null +++ b/src/main/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimator.java @@ -0,0 +1,81 @@ +package com.heddy.adapter.out.ai; + +import com.heddy.domain.account.model.HairProfile.HairLength; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** Conservative visible-length estimate from MediaPipe's hair/face segmentation, not a gender classifier. */ +final class PhotoHairLengthEstimator { + HairLength estimate(float[][][] logits) { + int h = logits.length, w = logits[0].length; + boolean[][] face = new boolean[h][w]; + List hairY = new ArrayList<>(), hairX = new ArrayList<>(); + double hairConfidence = 0, faceConfidence = 0; + int facePixels = 0; + 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]); + double probability = 1 / sum; + if (probability < .65) continue; + if (best == 1) { hairY.add(y); hairX.add(x); hairConfidence += probability; } + if (best == 3) { face[y][x] = true; facePixels++; faceConfidence += probability; } + } + if (hairY.size() < 300 || facePixels < 500 + || hairConfidence / hairY.size() < .80 || faceConfidence / facePixels < .80) return null; + List> faces = components(face); + faces.sort((a, b) -> Integer.compare(b.size(), a.size())); + if (faces.isEmpty() || (faces.size() > 1 && faces.get(1).size() > faces.get(0).size() * .20)) return null; + int[] fy = faces.getFirst().stream().mapToInt(p -> p / w).sorted().toArray(); + int[] fx = faces.getFirst().stream().mapToInt(p -> p % w).sorted().toArray(); + int[] hy = hairY.stream().mapToInt(Integer::intValue).sorted().toArray(); + int[] hx = hairX.stream().mapToInt(Integer::intValue).sorted().toArray(); + double faceTop = percentile(fy, .05), chin = percentile(fy, .95); + double faceHeight = chin - faceTop; + // Rear views, tiny faces, profiles/rotated faces and cropped hair cannot establish current length. + double faceWidth = percentile(fx, .95) - percentile(fx, .05); + if (faceHeight < 24 || faceWidth < 16 || faceWidth / faceHeight > 1.7 + || faceWidth / faceHeight < .35) return null; + if (percentile(hx, .01) <= 2 || percentile(hx, .99) >= w - 3 + || percentile(hy, .99) >= h - 3 || chin >= h - 8) return null; + double hairBottom = percentile(hy, .97); + // Hair must be attached spatially to the visible face, not a second person in the background. + if (percentile(hy, .05) > faceTop + faceHeight * .4 + || percentile(hx, .95) < percentile(fx, .05) + || percentile(hx, .05) > percentile(fx, .95)) return null; + double belowChin = (hairBottom - chin) / faceHeight; + if (belowChin < -.20) return HairLength.SHORT; + if (belowChin < .65) return HairLength.BELOW_CHIN; + // A face crop cannot reliably distinguish chest-length; use shoulder as the conservative bound. + return HairLength.BELOW_SHOULDER; + } + + private static int percentile(int[] sorted, double quantile) { + return sorted[Math.min(sorted.length - 1, (int) (sorted.length * quantile))]; + } + + private static List> components(boolean[][] mask) { + int h = mask.length, w = mask[0].length; + List> result = new ArrayList<>(); + int[] queue = new int[h * w]; + for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) { + if (!mask[y][x]) continue; + int read = 0, size = 1; queue[0] = y * w + x; mask[y][x] = false; + while (read < size) { + int pixel = queue[read++], py = pixel / w, px = pixel % w; + for (int[] d : DIRECTIONS) { + int ny = py + d[0], nx = px + d[1]; + if (ny >= 0 && ny < h && nx >= 0 && nx < w && mask[ny][nx]) { + mask[ny][nx] = false; queue[size++] = ny * w + nx; + } + } + } + if (size >= 100) result.add(Arrays.stream(queue, 0, size).boxed().toList()); + } + return result; + } + private static final int[][] DIRECTIONS = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}}; +} diff --git a/src/main/java/com/heddy/adapter/out/ai/UnavailablePhotoHairLengthAdapter.java b/src/main/java/com/heddy/adapter/out/ai/UnavailablePhotoHairLengthAdapter.java new file mode 100644 index 00000000..3f9ef6db --- /dev/null +++ b/src/main/java/com/heddy/adapter/out/ai/UnavailablePhotoHairLengthAdapter.java @@ -0,0 +1,15 @@ +package com.heddy.adapter.out.ai; + +import com.heddy.domain.file.model.StoredFile; +import com.heddy.domain.recommendation.model.PhotoHairEvidence; +import com.heddy.domain.recommendation.port.out.PhotoHairLengthPort; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(name = "app.photo-length.enabled", havingValue = "false", matchIfMissing = true) +public class UnavailablePhotoHairLengthAdapter implements PhotoHairLengthPort { + public PhotoHairEvidence analyze(StoredFile file) { + return new PhotoHairEvidence(file.fileId(), null, "DISABLED", null, false); + } +} 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 3b5b0d35..5c67c141 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 @@ -100,6 +100,16 @@ private static Map basisMap(RecommendationBasis basis) { value.put("current_hair", currentHair); } value.put("available_care_time_minutes", basis.availableCareTimeMinutes()); + if (basis.photoAnalysis() != null) { + var evidence = basis.photoAnalysis(); + Map photo = new LinkedHashMap<>(); + photo.put("file_id", evidence.fileId() == null ? null : evidence.fileId().toString()); + photo.put("model_version", evidence.modelVersion()); + photo.put("status", evidence.status()); + putEnum(photo, "observed_length", evidence.observedLength()); + photo.put("applied", evidence.applied()); + value.put("photo_analysis", photo); + } return value; } @@ -126,7 +136,15 @@ private RecommendationBasis recommendationBasis() { integer(stylePreferences, "preferred_count", 0), integer(stylePreferences, "excluded_count", 0)), hair, - integer(basis, "available_care_time_minutes")); + integer(basis, "available_care_time_minutes"), photoEvidence(map(basis.get("photo_analysis")))); + } + + private static com.heddy.domain.recommendation.model.PhotoHairEvidence photoEvidence(Map value) { + if (value.isEmpty()) return null; + 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"))); } 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 new file mode 100644 index 00000000..845d199c --- /dev/null +++ b/src/main/java/com/heddy/application/recommendation/service/RecommendationPhotoResolver.java @@ -0,0 +1,58 @@ +package com.heddy.application.recommendation.service; + +import com.heddy.domain.account.model.HairProfile; +import com.heddy.domain.file.model.FileOwnerType; +import com.heddy.domain.file.port.out.FileRepositoryPort; +import com.heddy.domain.recommendation.model.PhotoHairEvidence; +import com.heddy.domain.recommendation.port.out.PhotoHairLengthPort; +import com.heddy.domain.treatment.model.ImageType; +import com.heddy.domain.treatment.model.TreatmentRecord; +import com.heddy.domain.treatment.port.out.TreatmentRecordRepositoryPort; +import org.springframework.stereotype.Component; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + +@Component +public class RecommendationPhotoResolver { + private final TreatmentRecordRepositoryPort records; + private final FileRepositoryPort files; + private final PhotoHairLengthPort analyzer; + public RecommendationPhotoResolver(TreatmentRecordRepositoryPort records, + FileRepositoryPort files, PhotoHairLengthPort analyzer) { + this.records = records; this.files = files; this.analyzer = analyzer; + } + + 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. + TreatmentRecord latest = recent.stream().max(Comparator.comparing(TreatmentRecord::performedAt) + .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 photo = record.photos().stream().filter(p -> p.imageType() == ImageType.AFTER) + .min(Comparator.comparingInt(com.heddy.domain.treatment.model.TreatmentPhoto::sortOrder) + .thenComparing(com.heddy.domain.treatment.model.TreatmentPhoto::photoId)).orElse(null); + if (photo == null) return new Resolved(profile, PhotoHairEvidence.absent("NO_RECENT_PHOTO")); + var file = files.findById(photo.fileId()).orElse(null); + if (file == null || !file.isReady() || file.purpose() != com.heddy.domain.file.model.FilePurpose.TREATMENT_PHOTO + || file.ownerType() != FileOwnerType.USER + || !userId.equals(file.userId())) return new Resolved(profile, PhotoHairEvidence.absent("UNAVAILABLE")); + 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)); + } + 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 + || observed.ordinal() <= profile.hairLength().ordinal()); + if (!apply) return new Resolved(profile, evidence); + HairProfile effective = profile == null + ? HairProfile.create(userId, null, null, observed, null, null) + : profile.replace(profile.hairType(), profile.hairCondition(), observed, + profile.hairThickness(), profile.availableCareTimeMinutes()); + return new Resolved(effective, evidence.withApplied(true)); + } + 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 3ae5ffe5..5aa15636 100644 --- a/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java +++ b/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java @@ -71,6 +71,7 @@ public class RecommendationService implements GenerateRecommendationUseCase, private final RuleBasedV1Scorer scorer; private final RecommendationDiversifier diversifier; private final Clock clock; + private final RecommendationPhotoResolver photoResolver; @Autowired public RecommendationService( @@ -82,12 +83,13 @@ public RecommendationService( HairstyleCatalogRepositoryPort catalogRepositoryPort, RecommendationRepositoryPort recommendationRepositoryPort, FileRepositoryPort fileRepositoryPort, - FileStoragePort fileStoragePort + FileStoragePort fileStoragePort, + RecommendationPhotoResolver photoResolver ) { this(accountRepositoryPort, hairProfileRepositoryPort, preferenceRepositoryPort, savedStyleRepositoryPort, treatmentRepositoryPort, catalogRepositoryPort, recommendationRepositoryPort, fileRepositoryPort, fileStoragePort, - new RuleBasedV1Scorer(), new RecommendationDiversifier(), Clock.systemUTC()); + new RuleBasedV1Scorer(), new RecommendationDiversifier(), Clock.systemUTC(), photoResolver); } RecommendationService( @@ -102,7 +104,8 @@ public RecommendationService( FileStoragePort fileStoragePort, RuleBasedV1Scorer scorer, RecommendationDiversifier diversifier, - Clock clock + Clock clock, + RecommendationPhotoResolver photoResolver ) { this.accountRepositoryPort = accountRepositoryPort; this.hairProfileRepositoryPort = hairProfileRepositoryPort; @@ -116,6 +119,7 @@ public RecommendationService( this.scorer = scorer; this.diversifier = diversifier; this.clock = clock; + this.photoResolver = photoResolver; } @Override @@ -138,12 +142,17 @@ private RecommendationResult generateForLockedAccount(UUID userId, boolean force Set excluded = preferenceIds(preferences, UserStylePreference.PreferenceType.EXCLUDED); Set saved = Set.copyOf(savedStyleRepositoryPort.findHairstyleIdsByUserId(userId)); List treatments = treatmentRepositoryPort.findRecentByUserId(userId, 10); + var photo = photoResolver.resolve(userId, profile, treatments); + profile = photo.profile(); + if (profile == null || profile.hairLength() == null) { + throw new RecommendationException(RecommendationError.HAIR_LENGTH_REQUIRED); + } TreatmentHistorySummary treatmentHistory = treatmentRepositoryPort.summarizeByUserId(userId); List candidates = catalogRepositoryPort.findEligibleCandidates(); RecommendationContext context = new RecommendationContext( profile, preferred, excluded, saved, treatments, generatedAt); - String canonical = canonicalSnapshot(context, candidates, treatmentHistory); + String canonical = canonicalSnapshot(context, candidates, treatmentHistory) + "|photo:" + photo.evidence(); String inputHash = sha256(canonical); if (!forceRefresh) { RecommendationRun reusable = recommendationRepositoryPort.findActiveByInputHash( @@ -163,7 +172,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); + RecommendationBasis basis = recommendationBasis(context, treatmentHistory, selected, photo.evidence()); RecommendationRun run = new RecommendationRun(runId, userId, RecommendationRun.Strategy.RULE_BASED_V1, RecommendationRun.Status.ACTIVE, inputHash, context.coldStart(), basis, generatedAt, items); @@ -212,7 +221,8 @@ private RecommendationResult render(RecommendationRun run) { private RecommendationBasis recommendationBasis( RecommendationContext context, TreatmentHistorySummary treatmentHistory, - List selected + List selected, + com.heddy.domain.recommendation.model.PhotoHairEvidence photoEvidence ) { HairProfile profile = context.hairProfile(); RecommendationBasis.CurrentHair currentHair = profile == null ? null @@ -226,7 +236,7 @@ private RecommendationBasis recommendationBasis( new RecommendationBasis.StylePreferences( context.preferredTagIds().size(), context.excludedTagIds().size()), currentHair, - profile == null ? null : profile.availableCareTimeMinutes()); + profile == null ? null : profile.availableCareTimeMinutes(), photoEvidence); } private RecommendationItem toItem( @@ -257,7 +267,7 @@ private String canonicalSnapshot( List candidates, TreatmentHistorySummary treatmentHistory ) { - StringBuilder value = new StringBuilder("RULE_BASED_V1:MIN_LENGTH_V2|"); + StringBuilder value = new StringBuilder("RULE_BASED_V1:PHOTO_LENGTH_V3|"); HairProfile profile = context.hairProfile(); if (profile != null) { value.append(profile.hairLength()).append('|').append(profile.hairType()).append('|') diff --git a/src/main/java/com/heddy/diagnostics/RecommendationReadOnlyAudit.java b/src/main/java/com/heddy/diagnostics/RecommendationReadOnlyAudit.java new file mode 100644 index 00000000..56d14a3f --- /dev/null +++ b/src/main/java/com/heddy/diagnostics/RecommendationReadOnlyAudit.java @@ -0,0 +1,171 @@ +package com.heddy.diagnostics; + +import com.heddy.adapter.out.ai.OnnxPhotoHairLengthAdapter; +import com.heddy.application.recommendation.service.RecommendationPhotoResolver; +import com.heddy.domain.file.model.*; +import com.heddy.domain.file.port.out.*; +import com.heddy.domain.treatment.port.out.TreatmentRecordRepositoryPort; +import java.lang.reflect.Proxy; +import software.amazon.awssdk.auth.credentials.*; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import java.sql.*; +import java.time.*; +import java.util.*; +import com.heddy.domain.account.model.HairProfile; +import com.heddy.domain.account.model.HairProfile.*; +import com.heddy.domain.treatment.model.*; +import com.heddy.domain.recommendation.model.*; +import com.heddy.domain.recommendation.model.HairstyleCandidate.*; +import com.heddy.domain.recommendation.service.*; + +/** Operator-only entry point: no web endpoint, no Spring startup, SELECT-only transaction, no personal exports. */ +public final class RecommendationReadOnlyAudit { + static > E val(Class cls, String value) { + return value == null ? null : Enum.valueOf(cls, value); + } + static > Set enums(Class cls, String value) { + Set out = new HashSet<>(); + for (String s : value.replace("[", "").replace("]", "").replace("\"", "").split(",")) + if (!s.isBlank()) out.add(val(cls, s.trim())); + return out; + } + static Set ids(Connection c, String sql, UUID user) throws Exception { + Set out = new HashSet<>(); + try (PreparedStatement p = c.prepareStatement(sql)) { + p.setObject(1,user); + try (ResultSet r=p.executeQuery()) { while(r.next()) if(r.getObject(1)!=null) out.add(r.getObject(1,UUID.class)); } + } + return out; + } + static HairProfile profile(Connection c, UUID user) throws Exception { + try(PreparedStatement p=c.prepareStatement("SELECT hair_type,hair_condition,hair_length,hair_thickness,available_care_time_minutes,created_at,updated_at FROM hair_profiles WHERE user_id=?")) { + p.setObject(1,user); + try(ResultSet r=p.executeQuery()) { + if(!r.next()) return null; + return new HairProfile(null,user,val(HairType.class,r.getString(1)),val(HairCondition.class,r.getString(2)),val(HairLength.class,r.getString(3)),val(HairThickness.class,r.getString(4)),(Integer)r.getObject(5),r.getTimestamp(6).toInstant(),r.getTimestamp(7).toInstant()); + } + } + } + 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 FROM treatment_records WHERE user_id=? ORDER BY performed_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)),null)); + }} + } + 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), ""); + } + 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")) { + while(r.next()) { + UUID id=r.getObject("hairstyle_id",UUID.class); + out.add(new HairstyleCandidate(id,r.getString("style_name"),r.getString("category"),r.getObject("thumbnail_file_id",UUID.class),r.getString("ar_mode"),r.getBoolean("active"),r.getString("asset_version"), + enums(ServiceType.class,r.getString("service_types")),enums(HairLength.class,r.getString("compatible_hair_lengths")),enums(HairType.class,r.getString("compatible_hair_types")),enums(HairThickness.class,r.getString("compatible_hair_thicknesses")),enums(HairCondition.class,r.getString("compatible_hair_conditions")),enums(HairCondition.class,r.getString("contraindicated_hair_conditions")),val(HairLength.class,r.getString("minimum_hair_length")),r.getInt("estimated_daily_care_minutes"),val(ManagementDifficulty.class,r.getString("management_difficulty")),val(ChemicalStressLevel.class,r.getString("chemical_stress_level")),r.getInt("editorial_priority"),r.getString("metadata_version"),tags.getOrDefault(id,Map.of()))); + } + } + return out; + } + static List photos(Connection c, UUID record) throws Exception { + List photos = new ArrayList<>(); + try (var p = c.prepareStatement("SELECT photo_id,file_id,image_type,sort_order,created_at FROM treatment_record_photos WHERE record_id=? ORDER BY sort_order,photo_id")) { + p.setObject(1, record); + try (var rs = p.executeQuery()) { while (rs.next()) photos.add(new TreatmentPhoto( + rs.getObject(1,UUID.class), record, rs.getObject(2,UUID.class), + ImageType.valueOf(rs.getString(3)), rs.getInt(4), rs.getTimestamp(5).toInstant())); } + } + return photos; + } + static Optional file(Connection c, UUID fileId) throws Exception { + try (var p = c.prepareStatement("SELECT * FROM files WHERE file_id=?")) { + p.setObject(1, fileId); + try (var rs = p.executeQuery()) { + if (!rs.next()) return Optional.empty(); + return Optional.of(new StoredFile(fileId, rs.getObject("upload_id",UUID.class), + rs.getObject("user_id",UUID.class), FileOwnerType.valueOf(rs.getString("owner_type")), + FilePurpose.valueOf(rs.getString("purpose")), FileStatus.valueOf(rs.getString("status")), + rs.getString("object_key"), rs.getString("content_type"), rs.getString("file_name"), + rs.getLong("file_size"), rs.getString("sha256"), null, null, + rs.getTimestamp("expires_at").toInstant(), rs.getTimestamp("created_at").toInstant(), null)); + } + } + } + @SuppressWarnings("unchecked") + static T readOnlyPort(Class type, java.lang.reflect.InvocationHandler handler) { + return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, handler); + } + public static void main(String[] args) throws Exception { + String key = System.getenv().getOrDefault("AWS_S3_ACCESS_KEY", ""); + AwsCredentialsProvider credentials = key.isBlank() ? DefaultCredentialsProvider.create() + : StaticCredentialsProvider.create(AwsBasicCredentials.create(key, System.getenv("AWS_S3_SECRET_KEY"))); + try (S3Client s3 = S3Client.builder().region(Region.of(System.getenv("AWS_S3_REGION"))) + .credentialsProvider(credentials).build(); + Connection c = DriverManager.getConnection(System.getenv("DB_URL"),System.getenv("DB_USERNAME"),System.getenv("DB_PASSWORD"))) { + c.setReadOnly(true); c.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ); c.setAutoCommit(false); + try (var stmt = c.createStatement()) { stmt.execute("SET LOCAL statement_timeout='15s'"); } + FileStoragePort storage = readOnlyPort(FileStoragePort.class, (proxy, method, values) -> { + if (!method.getName().equals("readObject")) throw new UnsupportedOperationException("Audit only reads files"); + StoredFile file = (StoredFile) values[0]; long max = (Long) values[1]; + try (var stream = s3.getObject(GetObjectRequest.builder().bucket(System.getenv("AWS_S3_BUCKET")).key(file.objectKey()).build())) { + byte[] bytes = stream.readNBytes(Math.toIntExact(max + 1)); + if (bytes.length > max) throw new IllegalStateException("Input too large"); + return bytes; + } + }); + FileRepositoryPort files = readOnlyPort(FileRepositoryPort.class, (proxy, method, values) -> { + if (!method.getName().equals("findById")) throw new UnsupportedOperationException("Audit only reads files"); + return file(c, (UUID) values[0]); + }); + var analyzer = new OnnxPhotoHairLengthAdapter(storage, + System.getenv().getOrDefault("HAIR_PHOTO_LENGTH_MODEL_PATH", "/opt/heddy/photo-models/selfie.onnx")); + try { + List candidates = catalog(c); + var scorer = new RuleBasedV1Scorer(); var diversifier = new RecommendationDiversifier(); + 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")) { + 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); + TreatmentRecordRepositoryPort records = readOnlyPort(TreatmentRecordRepositoryPort.class, (proxy, method, values) -> { + 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); + 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() + + " observed=" + evidence.observedLength() + " applied=" + evidence.applied()); + if (effective == null || effective.hairLength() == null) { + System.out.println(" result=RECOMMENDATION_HAIR_LENGTH_REQUIRED"); continue; + } + var context = new RecommendationContext(effective, + 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 scores = scorer.scoreAll(candidates,context); + 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", + item.candidate().styleName(),item.candidate().category(),item.finalScore(),b.historyScore(), + b.hairCompatibilityScore(),b.preferredTagScore(),b.savedStyleScore()); + } + var withoutHistory = new RecommendationContext(effective,context.preferredTagIds(),context.excludedTagIds(),context.savedHairstyleIds(),List.of(),now); + System.out.println(" without_history=" + diversifier.topThree(scorer.scoreAll(candidates,withoutHistory)).stream().map(x -> x.candidate().styleName()).toList()); + } + } + } finally { analyzer.close(); c.rollback(); } + } + } +} diff --git a/src/main/java/com/heddy/domain/recommendation/exception/RecommendationError.java b/src/main/java/com/heddy/domain/recommendation/exception/RecommendationError.java index 800efb65..1d04f6f2 100644 --- a/src/main/java/com/heddy/domain/recommendation/exception/RecommendationError.java +++ b/src/main/java/com/heddy/domain/recommendation/exception/RecommendationError.java @@ -1,6 +1,7 @@ package com.heddy.domain.recommendation.exception; public enum RecommendationError { + HAIR_LENGTH_REQUIRED("RECOMMENDATION_HAIR_LENGTH_REQUIRED", "사진에서 현재 머리 길이를 확인할 수 없습니다. 프로필의 현재 머리 길이를 입력해 주세요."), NOT_FOUND("RECOMMENDATION_NOT_FOUND", "추천 결과를 찾을 수 없습니다."), NO_ELIGIBLE_CANDIDATES("RECOMMENDATION_CANDIDATES_UNAVAILABLE", "추천 가능한 헤어스타일이 없습니다."); diff --git a/src/main/java/com/heddy/domain/recommendation/model/PhotoHairEvidence.java b/src/main/java/com/heddy/domain/recommendation/model/PhotoHairEvidence.java new file mode 100644 index 00000000..b67b371e --- /dev/null +++ b/src/main/java/com/heddy/domain/recommendation/model/PhotoHairEvidence.java @@ -0,0 +1,15 @@ +package com.heddy.domain.recommendation.model; + +import com.heddy.domain.account.model.HairProfile.HairLength; +import java.util.UUID; + +/** 길이 추정은 모발 건강 지표 분석과 별개이며, 성별은 추론하지 않는다. */ +public record PhotoHairEvidence(UUID fileId, String modelVersion, String status, + HairLength observedLength, boolean applied) { + 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); + } +} diff --git a/src/main/java/com/heddy/domain/recommendation/model/RecommendationBasis.java b/src/main/java/com/heddy/domain/recommendation/model/RecommendationBasis.java index 7ba7d8c3..272e2924 100644 --- a/src/main/java/com/heddy/domain/recommendation/model/RecommendationBasis.java +++ b/src/main/java/com/heddy/domain/recommendation/model/RecommendationBasis.java @@ -11,8 +11,14 @@ public record RecommendationBasis( int arCandidateStyleCount, StylePreferences stylePreferences, CurrentHair currentHair, - Integer availableCareTimeMinutes + Integer availableCareTimeMinutes, + PhotoHairEvidence photoAnalysis ) { + public RecommendationBasis(TreatmentHistory history, int arCount, StylePreferences preferences, + CurrentHair hair, Integer careMinutes) { + this(history, arCount, preferences, hair, careMinutes, null); + } + public record TreatmentHistory(long count, Integer highestSatisfaction) { } public record StylePreferences(int preferredCount, int excludedCount) { } diff --git a/src/main/java/com/heddy/domain/recommendation/port/out/PhotoHairLengthPort.java b/src/main/java/com/heddy/domain/recommendation/port/out/PhotoHairLengthPort.java new file mode 100644 index 00000000..67ace2f3 --- /dev/null +++ b/src/main/java/com/heddy/domain/recommendation/port/out/PhotoHairLengthPort.java @@ -0,0 +1,8 @@ +package com.heddy.domain.recommendation.port.out; + +import com.heddy.domain.file.model.StoredFile; +import com.heddy.domain.recommendation.model.PhotoHairEvidence; + +public interface PhotoHairLengthPort { + PhotoHairEvidence analyze(StoredFile file); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index dabfa0ee..f856382e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -80,6 +80,9 @@ app: used-token-retention-days: ${USED_REAUTHENTICATION_TOKEN_RETENTION_DAYS:30} token-purge-interval-ms: ${USED_REAUTHENTICATION_TOKEN_PURGE_INTERVAL_MS:3600000} token-purge-initial-delay-ms: ${USED_REAUTHENTICATION_TOKEN_PURGE_INITIAL_DELAY_MS:60000} + photo-length: + enabled: ${HAIR_PHOTO_LENGTH_ENABLED:false} + model-path: ${HAIR_PHOTO_LENGTH_MODEL_PATH:/opt/heddy/photo-models/selfie.onnx} ai: # 사용자 사진은 외부 생성형 AI API 로 전송하지 않는다. 로컬 ONNX 모델이 없으면 # 분석 API 는 임의 점수를 만들지 않고 503 으로 접수를 거부한다. diff --git a/src/main/resources/db/migration/V33__correct_audited_short_catalog.sql b/src/main/resources/db/migration/V33__correct_audited_short_catalog.sql new file mode 100644 index 00000000..0c700718 --- /dev/null +++ b/src/main/resources/db/migration/V33__correct_audited_short_catalog.sql @@ -0,0 +1,20 @@ +-- Audited SYSTEM catalogue thumbnails, 2026-10-16. Preserve assets/history; correct only known seeded names. +UPDATE hairstyle_recommendation_profiles p +SET compatible_hair_lengths = p.compatible_hair_lengths - 'SHORT', + minimum_hair_length = 'BELOW_CHIN', metadata_version = 'length-audit-20261016', updated_at = now() +FROM hairstyle_assets a JOIN files f ON f.file_id = a.thumbnail_file_id AND f.owner_type = 'SYSTEM' +WHERE p.hairstyle_id = a.hairstyle_id + AND a.style_name IN ('클래식 보브', '컬리 스타일 01', '컬리 스타일 02', '컬리 스타일 03'); + +UPDATE hairstyle_recommendation_profiles p +SET compatible_hair_lengths = '["BELOW_SHOULDER", "BELOW_CHEST"]'::jsonb, + minimum_hair_length = 'BELOW_SHOULDER', metadata_version = 'length-audit-20261016', updated_at = now() +FROM hairstyle_assets a JOIN files f ON f.file_id = a.thumbnail_file_id AND f.owner_type = 'SYSTEM' +WHERE p.hairstyle_id = a.hairstyle_id + AND a.style_name IN ('컬리 스타일 08', '컬리 스타일 09', '컬리 스타일 10'); + +-- Group photos, tied hair and indistinct archival portraits cannot serve as a current haircut reference. +UPDATE hairstyle_assets a SET active = false, updated_at = now() +FROM files f WHERE f.file_id = a.thumbnail_file_id AND f.owner_type = 'SYSTEM' + AND a.style_name IN ('숏 스타일 05', '숏 스타일 06', '숏 스타일 07', '숏 스타일 08', + '컬리 스타일 04', '컬리 스타일 06', '컬리 스타일 07'); 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 cf5b43d2..e14602a7 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 @@ -51,6 +51,9 @@ AwsCredentialsProvider offlineAwsCredentialsProvider() { @Autowired JdbcTemplate jdbcTemplate; @Autowired EntityManager entityManager; + @org.springframework.test.context.bean.override.mockito.MockitoBean + com.heddy.domain.recommendation.port.out.PhotoHairLengthPort photoAnalyzer; + private UUID treatmentRecordId; @BeforeEach @@ -269,6 +272,84 @@ private void insertShortStyle() { """, UUID.fromString(id)); } + @Test + void auditedCatalogMigrationRemovesImpossibleLengthsAndAmbiguousReferences() { + insertStyle("94000000-0000-4000-8000-000000000004", "클래식 보브", "BOB", "NONE", 1, false); + insertStyle("94000000-0000-4000-8000-000000000005", "컬리 스타일 08", "CURLY", "NONE", 1, false); + insertStyle("94000000-0000-4000-8000-000000000006", "숏 스타일 05", "SHORT", "NONE", 1, false); + jdbcTemplate.update("UPDATE hairstyle_recommendation_profiles SET compatible_hair_lengths = '[\"SHORT\",\"BELOW_CHIN\",\"BELOW_SHOULDER\"]'::jsonb"); + jdbcTemplate.execute((org.springframework.jdbc.core.ConnectionCallback) connection -> { + org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(connection, + new org.springframework.core.io.ClassPathResource("db/migration/V33__correct_audited_short_catalog.sql")); + return null; + }); + assertThat(jdbcTemplate.queryForObject("SELECT minimum_hair_length FROM hairstyle_recommendation_profiles WHERE hairstyle_id = '94000000-0000-4000-8000-000000000004'", String.class)) + .isEqualTo("BELOW_CHIN"); + assertThat(jdbcTemplate.queryForObject("SELECT compatible_hair_lengths::text FROM hairstyle_recommendation_profiles WHERE hairstyle_id = '94000000-0000-4000-8000-000000000004'", String.class)) + .doesNotContain("SHORT"); + assertThat(jdbcTemplate.queryForObject("SELECT minimum_hair_length FROM hairstyle_recommendation_profiles WHERE hairstyle_id = '94000000-0000-4000-8000-000000000005'", String.class)) + .isEqualTo("BELOW_SHOULDER"); + assertThat(jdbcTemplate.queryForObject("SELECT active FROM hairstyle_assets WHERE hairstyle_id = '94000000-0000-4000-8000-000000000006'", Boolean.class)).isFalse(); + } + + @Test + void missingLengthDoesNotReturnArbitraryLongHair() throws Exception { + mockMvc.perform(post("/recommendations/generate").with(authentication(auth(OTHER_USER_ID))) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isUnprocessableContent()) + .andExpect(jsonPath("$.error.code").value("RECOMMENDATION_HAIR_LENGTH_REQUIRED")); + } + + @Test + void newPhotoChangesLatestRecommendationAndPersistsAnalysisEvidence() throws Exception { + String previous = generateRun(); + insertStyle("94000000-0000-4000-8000-000000000004", "짧은 테스트 컷", "SHORT", "NONE", 1, false); + jdbcTemplate.update("UPDATE hairstyle_recommendation_profiles SET compatible_hair_lengths = '[\"SHORT\"]'::jsonb WHERE hairstyle_id = ?", + UUID.fromString("94000000-0000-4000-8000-000000000004")); + UUID fileId = attachAfterPhoto(); + org.mockito.Mockito.when(photoAnalyzer.analyze(org.mockito.ArgumentMatchers.any())) + .thenAnswer(i -> new com.heddy.domain.recommendation.model.PhotoHairEvidence( + ((com.heddy.domain.file.model.StoredFile) i.getArgument(0)).fileId(), "test-model", "ASSESSED", + com.heddy.domain.account.model.HairProfile.HairLength.SHORT, false)); + entityManager.clear(); + String body = mockMvc.perform(get("/recommendations/latest").with(authentication(auth(USER_ID)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].hairstyle.style_name").value("짧은 테스트 컷")) + .andExpect(jsonPath("$.data.recommendation_basis.photo_analysis.file_id").value(fileId.toString())) + .andExpect(jsonPath("$.data.recommendation_basis.photo_analysis.applied").value(true)) + .andExpect(jsonPath("$.data.recommendation_basis.current_hair.hair_length").value("SHORT")) + .andReturn().getResponse().getContentAsString(); + String updated = runId(body); + assertThat(updated).isNotEqualTo(previous); + entityManager.clear(); + assertThat(latestRun()).isEqualTo(updated); + assertThat(jdbcTemplate.queryForObject("SELECT hair_length FROM hair_profiles WHERE user_id = ?", String.class, USER_ID)) + .isEqualTo("BELOW_SHOULDER"); + // Replacing the file behind the same photo ID must also invalidate a cached run. + UUID replacement = attachAfterPhoto(); + jdbcTemplate.update("DELETE FROM treatment_record_photos WHERE record_id = ? AND file_id = ?", treatmentRecordId, replacement); + jdbcTemplate.update("UPDATE treatment_record_photos SET file_id = ? WHERE record_id = ? AND file_id = ?", + replacement, treatmentRecordId, fileId); + entityManager.clear(); + assertThat(latestRun()).isNotEqualTo(updated); + } + + private UUID attachAfterPhoto() { + UUID id = UUID.randomUUID(); + jdbcTemplate.update("UPDATE hair_profiles SET updated_at = now() - interval '2 days' WHERE user_id = ?", USER_ID); + jdbcTemplate.update(""" + INSERT INTO files (file_id, upload_id, user_id, purpose, status, object_key, + content_type, file_name, file_size, expires_at) + VALUES (?, ?, ?, 'TREATMENT_PHOTO', 'READY', ?, 'image/jpeg', 'after.jpg', 1024, now() + interval '1 day') + """, id, UUID.randomUUID(), USER_ID, "test/" + id); + jdbcTemplate.update(""" + INSERT INTO treatment_record_photos (photo_id, record_id, file_id, image_type, sort_order) + VALUES (?, ?, ?, 'AFTER', 0) + """, UUID.randomUUID(), treatmentRecordId, id); + return id; + } + private String generateRun() throws Exception { return runId(mockMvc.perform(post("/recommendations/generate") .with(authentication(auth(USER_ID))) diff --git a/src/test/java/com/heddy/adapter/out/ai/OnnxPhotoHairLengthAdapterTest.java b/src/test/java/com/heddy/adapter/out/ai/OnnxPhotoHairLengthAdapterTest.java new file mode 100644 index 00000000..f280dd4a --- /dev/null +++ b/src/test/java/com/heddy/adapter/out/ai/OnnxPhotoHairLengthAdapterTest.java @@ -0,0 +1,45 @@ +package com.heddy.adapter.out.ai; + +import com.heddy.domain.file.model.*; +import com.heddy.domain.file.port.out.FileStoragePort; +import com.heddy.domain.account.model.HairProfile.HairLength; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class OnnxPhotoHairLengthAdapterTest { + @Test + @EnabledIfEnvironmentVariable(named = "PHOTO_LENGTH_TEST_IMAGES", matches = ".+") + void realPinnedModelDistinguishesAuditedCatalogPhotosAndCachesObservations() throws Exception { + FileStoragePort storage = mock(FileStoragePort.class); + try (var adapter = new CloseableAdapter(storage)) { + // Locally supplied, audited SYSTEM catalogue pictures. Never commit/export user photos. + // Side/rear views and a face obscured by glasses/hands have insufficient geometry: uncertain. + var expected = new HairLength[]{null, null, HairLength.SHORT, + null, null, HairLength.SHORT}; + for (int i = 0; i < expected.length; i++) { + var file = new StoredFile(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), + FilePurpose.TREATMENT_PHOTO, FileStatus.READY, "test", "image/jpeg", "test.jpg", + 1024, null, null, null, Instant.now(), Instant.now(), null); + byte[] bytes = Files.readAllBytes(Path.of(System.getenv("PHOTO_LENGTH_TEST_IMAGES"), i + ".jpg")); + when(storage.readObject(eq(file), anyLong())).thenReturn(bytes); + var evidence = adapter.delegate.analyze(file); + assertThat(evidence.observedLength()).as("catalog image %s (%s)", i, evidence.status()).isEqualTo(expected[i]); + assertThat(adapter.delegate.analyze(file)).isEqualTo(evidence); + verify(storage, times(1)).readObject(eq(file), anyLong()); + } + } + } + private static class CloseableAdapter implements AutoCloseable { + final OnnxPhotoHairLengthAdapter delegate; + CloseableAdapter(FileStoragePort storage) { + delegate = new OnnxPhotoHairLengthAdapter(storage, System.getenv("PHOTO_LENGTH_TEST_MODEL")); + } + public void close() throws Exception { delegate.close(); } + } +} diff --git a/src/test/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimatorTest.java b/src/test/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimatorTest.java new file mode 100644 index 00000000..743d858f --- /dev/null +++ b/src/test/java/com/heddy/adapter/out/ai/PhotoHairLengthEstimatorTest.java @@ -0,0 +1,38 @@ +package com.heddy.adapter.out.ai; + +import com.heddy.domain.account.model.HairProfile.HairLength; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class PhotoHairLengthEstimatorTest { + private final PhotoHairLengthEstimator estimator = new PhotoHairLengthEstimator(); + @Test void separatesShortChinAndLongVisibleHair() { + assertThat(estimator.estimate(portrait(80))).isEqualTo(HairLength.SHORT); + assertThat(estimator.estimate(portrait(140))).isEqualTo(HairLength.BELOW_CHIN); + assertThat(estimator.estimate(portrait(220))).isEqualTo(HairLength.BELOW_SHOULDER); + } + @Test void rejectsBackViewsInsteadOfInventingLength() { + var image = portrait(140); rectangle(image, 90, 70, 160, 140, 0); + assertThat(estimator.estimate(image)).isNull(); + } + @Test void rejectsMultiplePeopleAndCroppedHair() { + var image = portrait(140); rectangle(image, 15, 70, 60, 130, 3); + assertThat(estimator.estimate(image)).isNull(); + assertThat(estimator.estimate(portrait(256))).isNull(); + } + @Test void rejectsUncertainSegmentation() { + assertThat(estimator.estimate(new float[256][256][6])).isNull(); + } + private float[][][] portrait(int hairBottom) { + float[][][] result = new float[256][256][6]; + rectangle(result, 0, 0, 256, 256, 0); + rectangle(result, 65, 30, 185, hairBottom, 1); + rectangle(result, 90, 70, 160, 140, 3); + return result; + } + private void rectangle(float[][][] image, int left, int top, int right, int bottom, int label) { + for (int y = top; y < bottom; y++) for (int x = left; x < right; x++) { + java.util.Arrays.fill(image[y][x], -8); image[y][x][label] = 8; + } + } +} diff --git a/src/test/java/com/heddy/application/recommendation/service/RecommendationPhotoResolverTest.java b/src/test/java/com/heddy/application/recommendation/service/RecommendationPhotoResolverTest.java new file mode 100644 index 00000000..93f45d3a --- /dev/null +++ b/src/test/java/com/heddy/application/recommendation/service/RecommendationPhotoResolverTest.java @@ -0,0 +1,82 @@ +package com.heddy.application.recommendation.service; + +import com.heddy.domain.account.model.HairProfile; +import com.heddy.domain.account.model.HairProfile.HairLength; +import com.heddy.domain.file.model.*; +import com.heddy.domain.file.port.out.FileRepositoryPort; +import com.heddy.domain.recommendation.model.PhotoHairEvidence; +import com.heddy.domain.recommendation.port.out.PhotoHairLengthPort; +import com.heddy.domain.treatment.model.*; +import com.heddy.domain.treatment.port.out.TreatmentRecordRepositoryPort; +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.util.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class RecommendationPhotoResolverTest { + final UUID user = UUID.randomUUID(); + final Instant now = Instant.parse("2026-10-16T10:00:00Z"); + final TreatmentRecordRepositoryPort records = mock(TreatmentRecordRepositoryPort.class); + final FileRepositoryPort files = mock(FileRepositoryPort.class); + final PhotoHairLengthPort analyzer = mock(PhotoHairLengthPort.class); + final RecommendationPhotoResolver resolver = new RecommendationPhotoResolver(records, files, analyzer); + + @Test void latestShortPhotoCapsOldLongProfileWithoutWritingIt() { + var record = record(now, ImageType.AFTER, user); + var original = profile(HairLength.BELOW_SHOULDER, now.minusSeconds(60)); + var resolved = resolver.resolve(user, original, List.of(record)); + assertThat(resolved.profile().hairLength()).isEqualTo(HairLength.SHORT); + assertThat(resolved.evidence().applied()).isTrue(); + assertThat(original.hairLength()).isEqualTo(HairLength.BELOW_SHOULDER); + } + @Test void longerPhotoNeverOverridesExplicitShortProfile() { + var record = record(now, ImageType.AFTER, user); + when(analyzer.analyze(any())).thenAnswer(i -> new PhotoHairEvidence(((StoredFile)i.getArgument(0)).fileId(), + "test", "ASSESSED", HairLength.BELOW_SHOULDER, false)); + var resolved = resolver.resolve(user, profile(HairLength.SHORT, now.minusSeconds(60)), List.of(record)); + assertThat(resolved.profile().hairLength()).isEqualTo(HairLength.SHORT); + assertThat(resolved.evidence().applied()).isFalse(); + } + @Test void newerExplicitProfileWinsAndSkipsInference() { + 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); + } + @Test void neverUsesBeforePhotoOrAnotherAccountsFile() { + var before = record(now, ImageType.BEFORE, user); + assertThat(resolver.resolve(user, null, List.of(before)).evidence().applied()).isFalse(); + var foreign = record(now, ImageType.AFTER, UUID.randomUUID()); + assertThat(resolver.resolve(user, null, List.of(foreign)).evidence().applied()).isFalse(); + verifyNoInteractions(analyzer); + } + @Test void noPhotoOnLatestRecordDoesNotResurrectOldLength() { + var old = record(now.minusSeconds(3600), ImageType.AFTER, user); + var latest = record(now, ImageType.BEFORE, user); + assertThat(resolver.resolve(user, null, List.of(old, latest)).evidence().status()).isEqualTo("NO_RECENT_PHOTO"); + verifyNoInteractions(analyzer); + } + @Test void uncertaintyPreservesProfileAndMissingProfileCanBeResolvedFromPhoto() { + var record = record(now, ImageType.AFTER, user); + assertThat(resolver.resolve(user, null, List.of(record)).profile().hairLength()).isEqualTo(HairLength.SHORT); + when(analyzer.analyze(any())).thenReturn(PhotoHairEvidence.absent("UNCERTAIN")); + var original = profile(HairLength.BELOW_CHIN, now.minusSeconds(60)); + assertThat(resolver.resolve(user, original, List.of(record)).profile()).isEqualTo(original); + } + private HairProfile profile(HairLength length, Instant updated) { + return new HairProfile(UUID.randomUUID(), user, null, null, length, null, null, now.minusSeconds(3600), updated); + } + private TreatmentRecord record(Instant performed, ImageType type, UUID owner) { + UUID id = UUID.randomUUID(), fileId = UUID.randomUUID(); + var photo = new TreatmentPhoto(UUID.randomUUID(), id, fileId, type, 0, performed); + var record = new TreatmentRecord(id, user, Set.of(ServiceType.CUT), null, null, performed, 5, + null, null, null, null, null, List.of(photo), performed); + var file = new StoredFile(fileId, UUID.randomUUID(), owner, FilePurpose.TREATMENT_PHOTO, + FileStatus.READY, "test", "image/jpeg", "test.jpg", 1024, null, null, null, now, performed, null); + when(records.findByIdAndUserId(id, user)).thenReturn(Optional.of(record)); + when(files.findById(fileId)).thenReturn(Optional.of(file)); + lenient().when(analyzer.analyze(file)).thenReturn(new PhotoHairEvidence(fileId, "test", "ASSESSED", HairLength.SHORT, false)); + return record; + } +} From cb6664c964ab1b68ecc947d34df537cac1b9ee09 Mon Sep 17 00:00:00 2001 From: hoshino-love0102 Date: Fri, 16 Oct 2026 21:02:26 +0900 Subject: [PATCH 2/2] fix: select newest after upload for same-day treatments --- docs/recommendation-photo-length.md | 1 + .../TreatmentPersistenceAdapter.java | 2 +- .../TreatmentRecordJpaRepository.java | 2 +- .../service/RecommendationPhotoResolver.java | 21 +++++++++------- .../RecommendationReadOnlyAudit.java | 10 ++++---- .../RecommendationApiIntegrationTest.java | 24 +++++++++++++++++++ .../RecommendationPhotoResolverTest.java | 2 +- 7 files changed, 47 insertions(+), 15 deletions(-) diff --git a/docs/recommendation-photo-length.md b/docs/recommendation-photo-length.md index a76274bd..7042ecfe 100644 --- a/docs/recommendation-photo-length.md +++ b/docs/recommendation-photo-length.md @@ -3,6 +3,7 @@ 최신 시술의 AFTER 사진을 서버 내부 ONNX 모델로 분할하고, 얼굴 대비 머리 영역의 끝 위치를 이용해 보수적으로 길이를 추정한다. 사용자 사진을 외부 AI로 보내지 않으며 성별을 추론하지 않는다. 기존 색상 균일도·대칭·볼륨·거칠기 분석과는 별도 기능이다. 그 4개 지표의 분석 엔진을 이 모델로 대체하지 않는다. - 직접 입력한 머리 길이보다 긴 길이로 자동 변경하지 않는다. 사진 업로드 후 갱신한 프로필은 우선한다. +- 시술 날짜가 같으면 기록 생성 시각으로 최신 기록을 정하며, 그 기록에서 가장 최근 업로드한 AFTER 파일을 사용한다. 기존 사진을 남겨둔 추가 업로드도 새 입력으로 반영한다. - 최신 시술에 AFTER 사진이 없으면 예전 시술 사진으로 대체하지 않는다. BEFORE/OTHER는 현재 길이 판단에 사용하지 않는다. - 파일 소유자·READY·TREATMENT_PHOTO를 확인한다. 분석은 파일별 캐시 최대 256개로 제한하며 이미지 바이트는 보관하지 않는다. - 사진·모델·분석 상태·적용 길이가 추천 입력 해시에 포함된다. latest 재조회는 달라진 입력을 재계산하고 같은 입력이면 재사용한다. diff --git a/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentPersistenceAdapter.java b/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentPersistenceAdapter.java index cdca9862..1893c47a 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentPersistenceAdapter.java +++ b/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentPersistenceAdapter.java @@ -121,7 +121,7 @@ public List findRecentByUserId(UUID userId, int limit) { } // V1 상한은 10건이다. 사진은 점수에 쓰지 않으므로 추가 조회하지 않는다. return recordRepository - .findTop10ByUserIdOrderByPerformedAtDescRecordIdDesc(userId) + .findTop10ByUserIdOrderByPerformedAtDescCreatedAtDescRecordIdDesc(userId) .stream().limit(Math.min(limit, 10)) .map(entity -> entity.toDomain(List.of())) .toList(); diff --git a/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentRecordJpaRepository.java b/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentRecordJpaRepository.java index a76ae590..72d4ddaa 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentRecordJpaRepository.java +++ b/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentRecordJpaRepository.java @@ -21,7 +21,7 @@ interface TreatmentRecordJpaRepository extends JpaRepository findTop10ByUserIdOrderByPerformedAtDescRecordIdDesc(UUID userId); + List findTop10ByUserIdOrderByPerformedAtDescCreatedAtDescRecordIdDesc(UUID userId); long countByUserId(UUID userId); 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 845d199c..504f7d89 100644 --- a/src/main/java/com/heddy/application/recommendation/service/RecommendationPhotoResolver.java +++ b/src/main/java/com/heddy/application/recommendation/service/RecommendationPhotoResolver.java @@ -27,17 +27,22 @@ public Resolved resolve(UUID userId, HairProfile profile, List 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. 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 photo = record.photos().stream().filter(p -> p.imageType() == ImageType.AFTER) - .min(Comparator.comparingInt(com.heddy.domain.treatment.model.TreatmentPhoto::sortOrder) - .thenComparing(com.heddy.domain.treatment.model.TreatmentPhoto::photoId)).orElse(null); - if (photo == null) return new Resolved(profile, PhotoHairEvidence.absent("NO_RECENT_PHOTO")); - var file = files.findById(photo.fileId()).orElse(null); - if (file == null || !file.isReady() || file.purpose() != com.heddy.domain.file.model.FilePurpose.TREATMENT_PHOTO - || file.ownerType() != FileOwnerType.USER - || !userId.equals(file.userId())) return new Resolved(profile, PhotoHairEvidence.absent("UNAVAILABLE")); + var afterIds = record.photos().stream().filter(p -> p.imageType() == ImageType.AFTER) + .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. + 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 + && userId.equals(f.userId())) + .max(Comparator.comparing(com.heddy.domain.file.model.StoredFile::createdAt, + Comparator.nullsFirst(Comparator.naturalOrder())) + .thenComparing(com.heddy.domain.file.model.StoredFile::fileId)).orElse(null); + if (file == null) return new Resolved(profile, PhotoHairEvidence.absent("UNAVAILABLE")); 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)); diff --git a/src/main/java/com/heddy/diagnostics/RecommendationReadOnlyAudit.java b/src/main/java/com/heddy/diagnostics/RecommendationReadOnlyAudit.java index 56d14a3f..79ea1aad 100644 --- a/src/main/java/com/heddy/diagnostics/RecommendationReadOnlyAudit.java +++ b/src/main/java/com/heddy/diagnostics/RecommendationReadOnlyAudit.java @@ -50,11 +50,11 @@ 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 FROM treatment_records WHERE user_id=? ORDER BY performed_at DESC,record_id DESC LIMIT 10")) { + 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")) { 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)),null)); + 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())); }} } return out; @@ -121,8 +121,10 @@ public static void main(String[] args) throws Exception { } }); FileRepositoryPort files = readOnlyPort(FileRepositoryPort.class, (proxy, method, values) -> { - if (!method.getName().equals("findById")) throw new UnsupportedOperationException("Audit only reads files"); - return file(c, (UUID) values[0]); + if (!method.getName().equals("findAllById")) throw new UnsupportedOperationException("Audit only reads files"); + List found = new ArrayList<>(); + for (Object id : (Collection) values[0]) file(c, (UUID) id).ifPresent(found::add); + return found; }); var analyzer = new OnnxPhotoHairLengthAdapter(storage, System.getenv().getOrDefault("HAIR_PHOTO_LENGTH_MODEL_PATH", "/opt/heddy/photo-models/selfie.onnx")); 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 e14602a7..31c0a197 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 @@ -335,6 +335,30 @@ void newPhotoChangesLatestRecommendationAndPersistsAnalysisEvidence() throws Exc assertThat(latestRun()).isNotEqualTo(updated); } + @Test + void newestUploadOnLatestSameDayRecordBecomesTheAnalysisInput() throws Exception { + UUID olderFile = attachAfterPhoto(); + UUID newerFile = attachAfterPhoto(); + jdbcTemplate.update("UPDATE files SET created_at = now() - interval '1 hour' WHERE file_id = ?", olderFile); + // The newest record deliberately has the smallest UUID; UUID ordering must not select old photos. + UUID latestRecord = UUID.fromString("00000000-0000-4000-8000-000000000001"); + jdbcTemplate.update("UPDATE treatment_records SET created_at = now() - interval '1 hour' WHERE record_id = ?", treatmentRecordId); + jdbcTemplate.update(""" + INSERT INTO treatment_records (record_id, user_id, service_types, performed_at, satisfaction) + SELECT ?, user_id, service_types, performed_at, satisfaction FROM treatment_records WHERE record_id = ? + """, latestRecord, treatmentRecordId); + jdbcTemplate.update("UPDATE treatment_record_photos SET record_id = ? WHERE record_id = ?", latestRecord, treatmentRecordId); + org.mockito.Mockito.when(photoAnalyzer.analyze(org.mockito.ArgumentMatchers.any())) + .thenAnswer(i -> new com.heddy.domain.recommendation.model.PhotoHairEvidence( + ((com.heddy.domain.file.model.StoredFile) i.getArgument(0)).fileId(), "test-model", "UNCERTAIN", null, false)); + entityManager.clear(); + mockMvc.perform(post("/recommendations/generate").with(authentication(auth(USER_ID))) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.recommendation_basis.photo_analysis.file_id").value(newerFile.toString())); + org.mockito.Mockito.verify(photoAnalyzer).analyze(org.mockito.ArgumentMatchers.argThat(f -> f.fileId().equals(newerFile))); + } + 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/application/recommendation/service/RecommendationPhotoResolverTest.java b/src/test/java/com/heddy/application/recommendation/service/RecommendationPhotoResolverTest.java index 93f45d3a..f7701efd 100644 --- a/src/test/java/com/heddy/application/recommendation/service/RecommendationPhotoResolverTest.java +++ b/src/test/java/com/heddy/application/recommendation/service/RecommendationPhotoResolverTest.java @@ -75,7 +75,7 @@ var record = new TreatmentRecord(id, user, Set.of(ServiceType.CUT), null, null, var file = new StoredFile(fileId, UUID.randomUUID(), owner, FilePurpose.TREATMENT_PHOTO, FileStatus.READY, "test", "image/jpeg", "test.jpg", 1024, null, null, null, now, performed, null); when(records.findByIdAndUserId(id, user)).thenReturn(Optional.of(record)); - when(files.findById(fileId)).thenReturn(Optional.of(file)); + when(files.findAllById(List.of(fileId))).thenReturn(List.of(file)); lenient().when(analyzer.analyze(file)).thenReturn(new PhotoHairEvidence(fileId, "test", "ASSESSED", HairLength.SHORT, false)); return record; }