diff --git a/config/hair-analysis.prod.env b/config/hair-analysis.prod.env index 6ae959d3..555a67d5 100644 --- a/config/hair-analysis.prod.env +++ b/config/hair-analysis.prod.env @@ -3,7 +3,7 @@ HAIR_ANALYSIS_ENABLED=true HAIR_ANALYSIS_MODEL_HOST_DIR=/home/ubuntu/heddy-models HAIR_ANALYSIS_MODEL_PATH=/opt/heddy/models/face-parsing.onnx -HAIR_ANALYSIS_MODEL_VERSION=segformer-b5-int8-758b82e-320-v1 +HAIR_ANALYSIS_MODEL_VERSION=segformer-b5-int8-758b82e-320-v1-metrics-v2 HAIR_ANALYSIS_INPUT_SIZE=320 HAIR_ANALYSIS_WORKER_THREADS=1 JAVA_TOOL_OPTIONS=-Xmx256m -Xms64m diff --git a/src/main/java/com/heddy/adapter/out/ai/HairMetricCalculator.java b/src/main/java/com/heddy/adapter/out/ai/HairMetricCalculator.java index 511374b2..d27a0bd7 100644 --- a/src/main/java/com/heddy/adapter/out/ai/HairMetricCalculator.java +++ b/src/main/java/com/heddy/adapter/out/ai/HairMetricCalculator.java @@ -67,7 +67,8 @@ HairAnalysisOutcome calculate(FaceParsingOutput parsing) { Roughness roughness = roughness(parsing, hair.count); double meanHairProbability = meanHairProbability(parsing); - double rollDegrees = Math.toDegrees(Math.abs(Math.atan2(axis.eyeDy, axis.eyeDx))); + // An eye axis is an undirected line: anatomical left/right labels may reverse its direction. + double rollDegrees = Math.toDegrees(Math.atan2(Math.abs(axis.eyeDy), Math.abs(axis.eyeDx))); double frontalness = frontalness(axis, nose, rollDegrees); double blurVariance = laplacianVariance(parsing); double lightingUniformity = lightingUniformity(parsing); @@ -91,7 +92,7 @@ HairAnalysisOutcome calculate(FaceParsingOutput parsing) { metrics.put(MetricType.ROUGHNESS, metric(roughness.score)); Map raw = new LinkedHashMap<>(); - raw.put("pipeline", "segformer-plus-deterministic-metrics-v1"); + raw.put("pipeline", "segformer-plus-deterministic-metrics-v2"); raw.put("hair_area_ratio", round(hairAreaRatio, 4)); raw.put("mean_hair_probability", round(meanHairProbability, 4)); raw.put("face_roll_degrees", round(rollDegrees, 2)); @@ -540,6 +541,8 @@ static Axis from(Region firstEye, Region secondEye) { if (Math.hypot(dx, dy) < 2) { throw new IllegalArgumentException("눈 중심점을 구분할 수 없습니다"); } + // Keep the same half-plane assignment when anatomical labels are reversed. + if (dx < 0 || (dx == 0 && dy < 0)) { dx = -dx; dy = -dy; } return new Axis((firstX + secondX) / 2, (firstY + secondY) / 2, dx, dy); } diff --git a/src/main/java/com/heddy/application/analysis/service/AnalysisApplicationService.java b/src/main/java/com/heddy/application/analysis/service/AnalysisApplicationService.java index 7ab9c5eb..80f33a2a 100644 --- a/src/main/java/com/heddy/application/analysis/service/AnalysisApplicationService.java +++ b/src/main/java/com/heddy/application/analysis/service/AnalysisApplicationService.java @@ -71,9 +71,11 @@ public AnalysisJob request(RequestAnalysisUseCase.Command command) { TreatmentRecord record = ownedRecord(command.requesterId(), command.recordId()); TreatmentPhoto photo = selectPhoto(record, command.photoId()); requireReadyInput(command.requesterId(), photo); - if (jobRepositoryPort.findInProgressByPhotoId(photo.photoId()).isPresent()) { - throw new ApplicationException(ErrorCode.ANALYSIS_ALREADY_IN_PROGRESS); - } + AnalysisJob inProgress = jobRepositoryPort.findInProgressByPhotoId(photo.photoId()) + .filter(job -> command.requesterId().equals(job.userId()) + && record.recordId().equals(job.recordId())).orElse(null); + // A reload loses the client's job ID. Reattach to existing work instead of returning 409. + if (inProgress != null) return inProgress; try { AnalysisJob saved = jobRepositoryPort.insert(AnalysisJob.create( command.requesterId(), record.recordId(), photo.photoId(), Instant.now())); 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 190cfc5e..71631ad2 100644 --- a/src/main/java/com/heddy/application/recommendation/service/RecommendationPhotoResolver.java +++ b/src/main/java/com/heddy/application/recommendation/service/RecommendationPhotoResolver.java @@ -50,7 +50,8 @@ var record = records.findByIdAndUserId(latest.recordId(), userId).orElse(null); 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(), evidence.modelVersion(), - "NEWER_PROFILE", evidence.observedLength(), false, evidence.hairShape(), 0)); + evidence.observedLength() == null ? evidence.status() : "NEWER_PROFILE", + evidence.observedLength(), false, evidence.hairShape(), 0)); } var observed = evidence.observedLength(); // A newer result photo updates current length in either direction. The newer-profile guard 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 886bd148..af95d99b 100644 --- a/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java +++ b/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java @@ -310,7 +310,7 @@ private String canonicalSnapshot( List candidates, TreatmentHistorySummary treatmentHistory ) { - StringBuilder value = new StringBuilder("RULE_BASED_V1:CURRENT_LENGTH_V6|"); + StringBuilder value = new StringBuilder("RULE_BASED_V1:PREFERENCE_PRIORITY_V7|"); value.append(explanationPort.cacheKey()).append('|'); HairProfile profile = context.hairProfile(); if (profile != null) { diff --git a/src/main/java/com/heddy/domain/recommendation/service/RecommendationDiversifier.java b/src/main/java/com/heddy/domain/recommendation/service/RecommendationDiversifier.java index 4edc3bea..3c0818e8 100644 --- a/src/main/java/com/heddy/domain/recommendation/service/RecommendationDiversifier.java +++ b/src/main/java/com/heddy/domain/recommendation/service/RecommendationDiversifier.java @@ -23,10 +23,13 @@ public List topThree( List orderedCandidates, Map exposureCounts ) { List selected = new ArrayList<>(3); - Map> tiers = new TreeMap<>(); + // Explicit preferences take priority over variety and refresh exposure counts. + // Nonmatching styles only fill remaining slots when matching candidates are insufficient. + Map> tiers = new TreeMap<>(); for (ScoredRecommendation candidate : orderedCandidates) { long exposures = exposureCounts.getOrDefault(candidate.candidate().hairstyleId(), 0L); - tiers.computeIfAbsent(exposures, ignored -> new ArrayList<>()).add(candidate); + Tier tier = new Tier(candidate.scoreBreakdown().preferredTagScore() > 0 ? 0 : 1, exposures); + tiers.computeIfAbsent(tier, ignored -> new ArrayList<>()).add(candidate); } for (List tier : tiers.values()) { addEligible(tier, selected, true, true); @@ -37,6 +40,13 @@ public List topThree( return List.copyOf(selected); } + private record Tier(int preferenceOrder, long exposures) implements Comparable { + public int compareTo(Tier other) { + int preference = Integer.compare(preferenceOrder, other.preferenceOrder); + return preference != 0 ? preference : Long.compare(exposures, other.exposures); + } + } + private void addEligible( List candidates, List selected, diff --git a/src/main/java/com/heddy/domain/recommendation/service/RuleBasedV1Scorer.java b/src/main/java/com/heddy/domain/recommendation/service/RuleBasedV1Scorer.java index eb04a185..c85361ec 100644 --- a/src/main/java/com/heddy/domain/recommendation/service/RuleBasedV1Scorer.java +++ b/src/main/java/com/heddy/domain/recommendation/service/RuleBasedV1Scorer.java @@ -51,9 +51,8 @@ public Optional score( if (context.recentTreatments().stream().anyMatch(record -> record.satisfaction() != null)) { availableMaximum += 40; } - if (!context.savedHairstyleIds().isEmpty() || !context.preferredTagIds().isEmpty()) { - availableMaximum += 30; - } + if (!context.savedHairstyleIds().isEmpty()) availableMaximum += 15; + if (!context.preferredTagIds().isEmpty()) availableMaximum += 15; availableMaximum += hair.availableMaximum(); if (context.hairProfile() != null && context.hairProfile().availableCareTimeMinutes() != null) { @@ -197,7 +196,8 @@ private double preferredTagScore( RecommendationReason.Code.PREFERRED_TAG_MATCH, Map.of("tag_id", representative.toString(), "tag_name", candidate.tags().get(representative)))); - return 15.0 * matches.size() / candidate.tags().size(); + // Extra catalog metadata must not dilute a user's explicit preference. + return 15.0 * matches.size() / context.preferredTagIds().size(); } private HairResult hairScore( 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 5c6152a0..0affbcec 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 @@ -378,8 +378,29 @@ void forcedRefreshStillCreatesANewRunAndFiltersLongStyles() throws Exception { assertThat(runCount()).isEqualTo(2); } + @Test + void preferredStyleStaysFirstOnRefreshAndExclusionImmediatelyRemovesIt() throws Exception { + UUID preferredStyle = UUID.fromString("94000000-0000-4000-8000-000000000001"); + generateRun(); + mockMvc.perform(post("/recommendations/generate").with(authentication(auth(USER_ID))) + .contentType(MediaType.APPLICATION_JSON).content("{\"force_refresh\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].hairstyle.hairstyle_id").value(preferredStyle.toString())); + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/me/style-preferences") + .with(authentication(auth(USER_ID))).contentType(MediaType.APPLICATION_JSON) + .content("{\"preferred_tag_ids\":[],\"excluded_tag_ids\":[\"" + PREFERRED_TAG_ID + "\"]}")) + .andExpect(status().isOk()); + String response = mockMvc.perform(get("/recommendations/latest").with(authentication(auth(USER_ID)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.recommendation_basis.style_preferences.excluded_count").value(1)) + .andReturn().getResponse().getContentAsString(); + assertThat(styleIdsForRun(runId(response))).doesNotContain(preferredStyle); + } + @Test void forcedRefreshExploresEligibleStylesAndLatestKeepsTheRefreshedSelection() throws Exception { + // Isolate this ranking signal from the explicit preference priority tested separately. + jdbcTemplate.update("DELETE FROM user_style_preferences WHERE user_id = ?", USER_ID); for (int i = 4; i <= 9; i++) { insertStyle("94000000-0000-4000-8000-%012d".formatted(i), "추가 스타일 " + i, "MEDIUM", "OVERLAY", 10 - i, false); @@ -444,6 +465,8 @@ private List styleIdsForRun(String runId) { @Test void cutShapeEditReranksLatestAndThenReusesItWithoutANewAccount() throws Exception { + // Isolate this ranking signal from the explicit preference priority tested separately. + jdbcTemplate.update("DELETE FROM user_style_preferences WHERE user_id = ?", USER_ID); jdbcTemplate.update("UPDATE treatment_records SET cut_shape = 'LAYERED' WHERE record_id = ?", treatmentRecordId); String oldId = generateRun(); mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch( @@ -580,6 +603,8 @@ INSERT INTO treatment_records (record_id, user_id, service_types, performed_at, @Test void replacingPhotoWithSameLengthChangesRankingAndPersistsItsEvidence() throws Exception { + // Isolate this ranking signal from the explicit preference priority tested separately. + jdbcTemplate.update("DELETE FROM user_style_preferences WHERE user_id = ?", USER_ID); UUID oldPhoto = attachAfterPhoto(); jdbcTemplate.update("UPDATE files SET created_at = now() - interval '1 hour' WHERE file_id = ?", oldPhoto); var left = java.util.stream.IntStream.range(0, 96).mapToObj(i -> i < 48 ? 1.0 : 0.0).toList(); diff --git a/src/test/java/com/heddy/adapter/out/ai/HairMetricCalculatorTest.java b/src/test/java/com/heddy/adapter/out/ai/HairMetricCalculatorTest.java index 9381eb53..e60d3e24 100644 --- a/src/test/java/com/heddy/adapter/out/ai/HairMetricCalculatorTest.java +++ b/src/test/java/com/heddy/adapter/out/ai/HairMetricCalculatorTest.java @@ -12,7 +12,7 @@ class HairMetricCalculatorTest { private final HairMetricCalculator calculator = new HairMetricCalculator( - new ObjectMapper(), "test-segformer-v1", 0); + new ObjectMapper(), "test-segformer-v1", 50); @Test void neverProducesScoresWithoutHairFromTheSegmentationModel() { @@ -76,6 +76,15 @@ void computesAllFourMetricsOnlyFromAValidModelSegmentation() { HairAnalysisOutcome outcome = calculator.calculate( new FaceParsingOutput(width, height, rgb, labels, hairProbability)); + byte[] swappedLabels = labels.clone(); + for (int i = 0; i < swappedLabels.length; i++) { + if (labels[i] == HairMetricCalculator.LEFT_EYE_LABEL) swappedLabels[i] = HairMetricCalculator.RIGHT_EYE_LABEL; + if (labels[i] == HairMetricCalculator.RIGHT_EYE_LABEL) swappedLabels[i] = HairMetricCalculator.LEFT_EYE_LABEL; + } + HairAnalysisOutcome reversed = calculator.calculate( + new FaceParsingOutput(width, height, rgb, swappedLabels, hairProbability)); + assertThat(reversed).as("anatomical eye label order must not change confidence or metrics").isEqualTo(outcome); + assertThat(outcome).isInstanceOfSatisfying(HairAnalysisOutcome.Succeeded.class, succeeded -> { assertThat(succeeded.prediction().modelVersion()) diff --git a/src/test/java/com/heddy/application/analysis/service/AnalysisApplicationServiceTest.java b/src/test/java/com/heddy/application/analysis/service/AnalysisApplicationServiceTest.java new file mode 100644 index 00000000..a16747f9 --- /dev/null +++ b/src/test/java/com/heddy/application/analysis/service/AnalysisApplicationServiceTest.java @@ -0,0 +1,42 @@ +package com.heddy.application.analysis.service; + +import com.heddy.domain.analysis.model.AnalysisJob; +import com.heddy.domain.analysis.port.in.RequestAnalysisUseCase; +import com.heddy.domain.analysis.port.out.*; +import com.heddy.domain.file.model.*; +import com.heddy.domain.file.port.out.FileRepositoryPort; +import com.heddy.domain.treatment.model.*; +import com.heddy.domain.treatment.port.out.TreatmentRecordRepositoryPort; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import java.time.Instant; +import java.util.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class AnalysisApplicationServiceTest { + @Test void reattachesToOwnedPendingJobWithoutDuplicateInferenceAfterReload() { + UUID user = UUID.randomUUID(), recordId = UUID.randomUUID(), photoId = UUID.randomUUID(); + var records = mock(TreatmentRecordRepositoryPort.class); + var files = mock(FileRepositoryPort.class); + var jobs = mock(AnalysisJobRepositoryPort.class); + var engine = mock(HairAnalysisEnginePort.class); + var events = mock(ApplicationEventPublisher.class); + var file = new StoredFile(UUID.randomUUID(), UUID.randomUUID(), user, FilePurpose.TREATMENT_PHOTO, + FileStatus.READY, "object", "image/jpeg", "photo.jpg", 100, null, null, null, + Instant.now(), Instant.now(), null); + var photo = new TreatmentPhoto(photoId, recordId, file.fileId(), ImageType.AFTER, 0, Instant.now()); + var record = new TreatmentRecord(recordId, user, Set.of(ServiceType.CUT), null, null, + Instant.now(), null, null, null, null, null, null, List.of(photo), Instant.now()); + var existing = AnalysisJob.create(user, recordId, photoId, Instant.now()); + when(engine.isReady()).thenReturn(true); + when(records.findByIdAndUserId(recordId, user)).thenReturn(Optional.of(record)); + when(files.findById(file.fileId())).thenReturn(Optional.of(file)); + when(jobs.findInProgressByPhotoId(photoId)).thenReturn(Optional.of(existing)); + var service = new AnalysisApplicationService(records, files, jobs, + mock(AnalysisResultRepositoryPort.class), mock(AnalysisOverlayRepositoryPort.class), engine, events, 3); + assertThat(service.request(new RequestAnalysisUseCase.Command(user, recordId, photoId))).isEqualTo(existing); + verify(jobs, never()).insert(any()); + verifyNoInteractions(events); + } +} 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 f57219fb..0ed1595c 100644 --- a/src/test/java/com/heddy/application/recommendation/service/RecommendationPhotoResolverTest.java +++ b/src/test/java/com/heddy/application/recommendation/service/RecommendationPhotoResolverTest.java @@ -48,6 +48,13 @@ var record = record(now, ImageType.AFTER, user); assertThat(resolved.evidence().status()).isEqualTo("NEWER_PROFILE"); verify(analyzer).analyze(any()); } + @Test void newerProfileDoesNotHidePhotoAnalysisFailure() { + var record = record(now, ImageType.AFTER, user); + when(analyzer.analyze(any())).thenReturn(PhotoHairEvidence.absent("UNAVAILABLE")); + var resolved = resolver.resolve(user, profile(HairLength.BELOW_CHIN, now.plusSeconds(60)), List.of(record)); + assertThat(resolved.evidence().status()).isEqualTo("UNAVAILABLE"); + assertThat(resolved.evidence().applied()).isFalse(); + } @Test void neverUsesBeforePhotoOrAnotherAccountsFile() { var before = record(now, ImageType.BEFORE, user); assertThat(resolver.resolve(user, null, List.of(before)).evidence().applied()).isFalse(); diff --git a/src/test/java/com/heddy/domain/recommendation/service/RecommendationDiversifierTest.java b/src/test/java/com/heddy/domain/recommendation/service/RecommendationDiversifierTest.java index 7036739d..598ce4e9 100644 --- a/src/test/java/com/heddy/domain/recommendation/service/RecommendationDiversifierTest.java +++ b/src/test/java/com/heddy/domain/recommendation/service/RecommendationDiversifierTest.java @@ -61,6 +61,27 @@ void fillsFromLeastExposedStylesWithoutDuplicatesWhenUnseenStylesRunOut() { assertThat(diversifier.topThree(List.of(frequent), counts)).containsExactly(frequent); } + @Test + void preferencesSurviveRefreshAndDiversityLimits() { + UUID tag = UUID.randomUUID(); + var preferred = List.of(preferredItem("A", tag, 90), preferredItem("A", tag, 80), + preferredItem("A", tag, 70)); + var unrelated = item("B", UUID.randomUUID(), 100); + var candidates = new java.util.ArrayList<>(List.of(unrelated)); + candidates.addAll(preferred); + var exposures = new java.util.HashMap(); + preferred.forEach(item -> exposures.put(item.candidate().hairstyleId(), 5L)); + assertThat(diversifier.topThree(candidates, exposures)).containsExactlyElementsOf(preferred); + assertThat(diversifier.topThree(List.of(unrelated, preferred.getFirst()), exposures)) + .containsExactly(preferred.getFirst(), unrelated); + } + + private ScoredRecommendation preferredItem(String category, UUID tag, double score) { + var original = item(category, tag, score); + return new ScoredRecommendation(original.candidate(), + new ScoreBreakdown(0, 0, 15, 0, 0, 15, 100, score), List.of(), null); + } + private ScoredRecommendation item(String category, UUID tagId, double score) { HairstyleCandidate candidate = new HairstyleCandidate(UUID.randomUUID(), "style", category, UUID.randomUUID(), "NONE", true, "1", Set.of(), Set.of(), Set.of(), Set.of(), Set.of(), diff --git a/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java b/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java index 84e2da4c..54395b53 100644 --- a/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java +++ b/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java @@ -75,10 +75,10 @@ void savedStylePreferredTagHairAndCareScoresAreCombined() { var result = scorer.score(candidate, context).orElseThrow().scoreBreakdown(); assertThat(result.savedStyleScore()).isEqualTo(15); - assertThat(result.preferredTagScore()).isEqualTo(7.5); + assertThat(result.preferredTagScore()).isEqualTo(15); assertThat(result.hairCompatibilityScore()).isEqualTo(20); assertThat(result.careTimeScore()).isEqualTo(10); - assertThat(result.finalScore()).isEqualTo(87.5); + assertThat(result.finalScore()).isEqualTo(100); } @Test