Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/recommendation-photo-length.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@

최신 시술의 AFTER 사진을 서버 내부 ONNX 모델로 분할하고, 얼굴 대비 머리 영역의 끝 위치를 이용해 보수적으로 길이를 추정한다. 사용자 사진을 외부 AI로 보내지 않으며 성별을 추론하지 않는다. 기존 색상 균일도·대칭·볼륨·거칠기 분석과는 별도 기능이다. 그 4개 지표의 분석 엔진을 이 모델로 대체하지 않는다.

- 직접 입력한 머리 길이보다 긴 길이로 자동 변경하지 않는다. 사진 업로드 후 갱신한 프로필은 우선한다.
- 기존 프로필보다 나중에 업로드한 최신 시술 사진은 길이를 짧게 또는 길게 갱신한다. 사진 업로드 후 갱신한 프로필은 우선한다. 프로필 DB 자체를 수정하는 것은 아니다.
- 시술 날짜가 같으면 기록 생성 시각으로 최신 기록을 정하며, 그 기록에서 가장 최근 업로드한 AFTER 파일을 사용한다. 기존 사진을 남겨둔 추가 업로드도 새 입력으로 반영한다.
- 최신 시술에 AFTER 사진이 없으면 해당 기록의 OTHER 사진을 사용한다. 둘 다 없으면 과거 기록 사진으로 대체하지 않는다. BEFORE는 사용하지 않는다.
- 파일 소유자·READY·TREATMENT_PHOTO를 확인한다. 분석은 파일별 캐시 최대 256개로 제한하며 이미지 바이트는 보관하지 않는다.
- 사진·모델·분석 상태·적용 길이·윤곽 비교 점수와 커트 길이/형태·펌 종류가 추천 입력 해시에 포함된다. latest 재조회는 달라진 입력을 재계산하고 같은 입력이면 재사용한다.
- 길이 입력도 없고 사진 길이도 판정할 수 없으면 `422 RECOMMENDATION_HAIR_LENGTH_REQUIRED`를 반환한다. 임의의 장발 기본 추천은 제공하지 않는다.
- `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다.
- 상태: `ASSESSED`, `UNCERTAIN`, `UNREADABLE`, `UNAVAILABLE`, `DISABLED`, `NEWER_PROFILE`, `NO_RECENT_PHOTO`. 사진보다 나중에 수정한 프로필이 우선하면 applied=false다.

## 현재 길이 유지

V6는 최소 길이를 만족한다는 이유만으로 장발 사용자에게 버즈컷을 추천하지 않는다. 기본 추천 후보는 현재 길이와 명시적으로 호환돼야 하며, SHORT 카테고리는 현재 길이가 SHORT일 때만 노출한다. 호환 길이가 없으면 명시된 최소 길이와 현재 길이가 같아야 한다. 짧게 자르는 별도 요청을 처리하는 API는 현재 없다. 이 정책으로 적합한 후보가 없다면 기존처럼 422를 반환하며 짧은 후보로 채우지 않는다.

## 사진 윤곽과 시술 상세 점수

Expand All @@ -28,7 +32,7 @@

## 검증

단위 테스트는 길이 경계, 불확실/잘림/복수 얼굴, 계정 소유권, BEFORE 배제, 최신 프로필 우선, 짧은 입력 보호를 검증한다. API/실제 PostgreSQL 테스트는 업로드 변경에 따른 latest 재생성과 분석 근거 저장/재사용 및 길이 누락 오류를 검증한다.
단위 테스트는 길이 경계, 불확실/잘림/복수 얼굴, 계정 소유권, BEFORE 배제, 최신 프로필 우선, 최신 사진의 양방향 길이 갱신을 검증한다. API/실제 PostgreSQL 테스트는 업로드 변경에 따른 latest 재생성과 분석 근거 저장/재사용 및 길이 누락 오류를 검증한다.

실제 모델 smoke test는 `PHOTO_LENGTH_TEST_MODEL`에 고정 ONNX 경로, `PHOTO_LENGTH_TEST_IMAGES`에 검수한 SYSTEM 카탈로그 사진 0~5.jpg 경로를 설정해 실행한다. 사용자 사진은 테스트 fixture로 복사하지 않는다. 측면 장발/뒷모습 보브/손·안경에 가린 얼굴은 UNCERTAIN, 정면 숏 2개는 SHORT를 기대한다.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ var record = records.findByIdAndUserId(latest.recordId(), userId).orElse(null);
"NEWER_PROFILE", evidence.observedLength(), false, evidence.hairShape(), 0));
}
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());
// A newer result photo updates current length in either direction. The newer-profile guard
// above already protects a user's subsequent correction; an old SHORT profile is not a goal.
boolean apply = observed != null;
if (!apply) return new Resolved(profile, evidence);
HairProfile effective = profile == null
? HairProfile.create(userId, null, null, observed, null, null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ private String canonicalSnapshot(
List<HairstyleCandidate> candidates,
TreatmentHistorySummary treatmentHistory
) {
StringBuilder value = new StringBuilder("RULE_BASED_V1:PHOTO_SHAPE_TREATMENT_V5|");
StringBuilder value = new StringBuilder("RULE_BASED_V1:CURRENT_LENGTH_V6|");
HairProfile profile = context.hairProfile();
if (profile != null) {
value.append(profile.hairLength()).append('|').append(profile.hairType()).append('|')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ private boolean eligible(HairstyleCandidate candidate, RecommendationContext con
if (minimumLength == null || profile.hairLength().compareTo(minimumLength) < 0) {
return false;
}
// The default recommendation preserves current length. Minimum length alone answers
// whether a cut is possible, not whether a drastic shortening was requested.
boolean compatible = candidate.compatibleHairLengths().isEmpty()
? profile.hairLength() == minimumLength
: candidate.compatibleHairLengths().contains(profile.hairLength());
if (!compatible || ("SHORT".equals(candidate.category())
&& profile.hairLength() != HairProfile.HairLength.SHORT)) {
return false;
}
}
return profile.hairCondition() == null
|| !candidate.contraindicatedHairConditions().contains(profile.hairCondition());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,32 @@ void replacingPhotoWithSameLengthChangesRankingAndPersistsItsEvidence() throws E
.andExpect(jsonPath("$.data.recommendation_basis.photo_analysis.compared_style_count").value(3));
}

@Test
void newLongTreatmentPhotoReplacesOldShortRunAndExcludesBuzzCuts() throws Exception {
insertShortStyle();
jdbcTemplate.update("UPDATE hair_profiles SET hair_length = 'SHORT' WHERE user_id = ?", USER_ID);
String oldId = generateRun();
UUID photo = attachAfterPhoto();
org.mockito.Mockito.when(photoAnalyzer.analyze(org.mockito.ArgumentMatchers.any())).thenAnswer(invocation ->
new com.heddy.domain.recommendation.model.PhotoHairEvidence(
((com.heddy.domain.file.model.StoredFile) invocation.getArgument(0)).fileId(),
"long-photo", "ASSESSED", com.heddy.domain.account.model.HairProfile.HairLength.BELOW_SHOULDER, false));
entityManager.clear();
String response = mockMvc.perform(get("/recommendations/latest").with(authentication(auth(USER_ID))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.recommendation_basis.current_hair.hair_length").value("BELOW_SHOULDER"))
.andExpect(jsonPath("$.data.recommendation_basis.photo_analysis.file_id").value(photo.toString()))
.andExpect(jsonPath("$.data.recommendation_basis.photo_analysis.applied").value(true))
.andExpect(jsonPath("$.data.items[*].hairstyle.style_name")
.value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.hasItem("클린 픽시컷"))))
.andReturn().getResponse().getContentAsString();
assertThat(runId(response)).isNotEqualTo(oldId);
assertThat(latestRun()).isEqualTo(runId(response));
// The derived recommendation length does not silently rewrite the user's saved profile.
assertThat(jdbcTemplate.queryForObject("SELECT hair_length FROM hair_profiles WHERE user_id = ?", String.class, USER_ID))
.isEqualTo("SHORT");
}

private UUID attachAfterPhoto() {
UUID id = UUID.randomUUID();
jdbcTemplate.update("UPDATE hair_profiles SET updated_at = now() - interval '2 days' WHERE user_id = ?", USER_ID);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ var record = record(now, ImageType.AFTER, user);
var record = record(now, ImageType.OTHER, user);
assertThat(resolver.resolve(user, null, List.of(record)).evidence().applied()).isTrue();
}
@Test void longerPhotoNeverOverridesExplicitShortProfile() {
@Test void newerLongPhotoReplacesOldShortProfile() {
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();
assertThat(resolved.profile().hairLength()).isEqualTo(HairLength.BELOW_SHOULDER);
assertThat(resolved.evidence().applied()).isTrue();
}
@Test void newerExplicitProfileKeepsLengthButStillAllowsShapeAnalysis() {
var record = record(now, ImageType.AFTER, user);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ void inferredMinimumAllowsOnlySufficientLength(HairLength length) {
var result = scorer.score(medium, context(profile(length, null, null, null, null),
Set.of(), Set.of(), Set.of(), List.of()));

assertThat(result.isPresent()).isEqualTo(length != HairLength.SHORT);
assertThat(result.isPresent()).isEqualTo(length == HairLength.BELOW_CHIN || length == HairLength.BELOW_SHOULDER);
}

@Test
Expand All @@ -155,10 +155,21 @@ void unknownCatalogLengthIsNotAssumedSuitableForKnownUserLength() {
}

@Test
void longerHairCanStillChooseShortCut() {
void longHairDoesNotImplicitlyRequestAShortCut() {
assertThat(scorer.score(withLengths(null, Set.of(HairLength.SHORT)),
context(profile(HairLength.BELOW_CHEST, null, null, null, null),
Set.of(), Set.of(), Set.of(), List.of()))).isPresent();
Set.of(), Set.of(), Set.of(), List.of()))).isEmpty();
}

@Test
void broadCompatibilityDoesNotMakeABuzzCutALongHairRecommendation() {
var buzz = new HairstyleCandidate(UUID.randomUUID(), "버즈컷", "SHORT", UUID.randomUUID(),
"NONE", true, "1", Set.of(ServiceType.CUT), Set.of(HairLength.SHORT, HairLength.BELOW_SHOULDER),
Set.of(), Set.of(), Set.of(), Set.of(), HairLength.SHORT, 0,
ManagementDifficulty.LOW, ChemicalStressLevel.LOW, 100, "1", Map.of());
assertThat(scorer.score(buzz, context(profile(HairLength.BELOW_SHOULDER, null, null, null, null),
Set.of(), Set.of(), Set.of(buzz.hairstyleId()),
List.of(treatment(Set.of(ServiceType.CUT), 5, 0))))).isEmpty();
}

private HairstyleCandidate withLengths(HairLength minimum, Set<HairLength> lengths) {
Expand Down
Loading