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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fasterxml.jackson.annotation.JsonProperty;
import com.heddy.domain.recommendation.model.RecommendationReason;
import com.heddy.domain.recommendation.model.RecommendationBasis;
import com.heddy.domain.recommendation.model.RecommendationReference;
import com.heddy.domain.recommendation.port.in.RecommendationResult;

Expand All @@ -17,14 +18,64 @@ public record RecommendationResponse(
String status,
@JsonProperty("generated_at") Instant generatedAt,
boolean fallback,
@JsonProperty("recommendation_basis") Basis recommendationBasis,
List<Item> items
) {
public static RecommendationResponse from(RecommendationResult result) {
return new RecommendationResponse(result.recommendationRunId(), result.strategy().name(),
result.status().name(), result.generatedAt(), result.fallback(),
Basis.from(result.recommendationBasis()),
result.items().stream().map(Item::from).toList());
}

public record Basis(
@JsonProperty("treatment_history") TreatmentHistory treatmentHistory,
@JsonProperty("ar_candidate_style_count") int arCandidateStyleCount,
@JsonProperty("style_preferences") StylePreferences stylePreferences,
@JsonProperty("current_hair") CurrentHair currentHair,
@JsonProperty("available_care_time_minutes") Integer availableCareTimeMinutes
) {
static Basis from(RecommendationBasis basis) {
if (basis == null) {
return null;
}
return new Basis(
new TreatmentHistory(basis.treatmentHistory().count(),
basis.treatmentHistory().highestSatisfaction()),
basis.arCandidateStyleCount(),
new StylePreferences(basis.stylePreferences().preferredCount(),
basis.stylePreferences().excludedCount()),
CurrentHair.from(basis.currentHair()), basis.availableCareTimeMinutes());
}
}

public record TreatmentHistory(
long count,
@JsonProperty("highest_satisfaction") Integer highestSatisfaction
) { }

public record StylePreferences(
@JsonProperty("preferred_count") int preferredCount,
@JsonProperty("excluded_count") int excludedCount
) { }

public record CurrentHair(
@JsonProperty("hair_type") String hairType,
@JsonProperty("hair_condition") String hairCondition,
@JsonProperty("hair_length") String hairLength,
@JsonProperty("hair_thickness") String hairThickness
) {
static CurrentHair from(RecommendationBasis.CurrentHair hair) {
return hair == null ? null : new CurrentHair(name(hair.hairType()),
name(hair.hairCondition()), name(hair.hairLength()),
name(hair.hairThickness()));
}

private static String name(Enum<?> value) {
return value == null ? null : value.name();
}
}

public record Item(
int rank,
BigDecimal score,
Expand All @@ -41,7 +92,7 @@ static Item from(RecommendationResult.Item value) {
return new Item(item.displayRank(), item.score(), new Hairstyle(
value.hairstyle().hairstyleId(), value.hairstyle().styleName(),
value.thumbnailUrl() == null ? null : value.thumbnailUrl().toString(),
value.hairstyle().assetVersion()), item.colorId(),
value.hairstyle().arMode(), value.hairstyle().assetVersion()), item.colorId(),
item.managementDifficulty().name(), item.estimatedDailyCareMinutes(),
item.reasons().stream().map(Reason::from).toList(),
reference == null ? List.of() : List.of(ReferenceRecord.from(reference)));
Expand All @@ -52,6 +103,7 @@ public record Hairstyle(
@JsonProperty("hairstyle_id") UUID hairstyleId,
@JsonProperty("style_name") String styleName,
@JsonProperty("thumbnail_url") String thumbnailUrl,
@JsonProperty("ar_mode") String arMode,
@JsonProperty("asset_version") String assetVersion
) { }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ class HairstyleAssetEntity extends BaseEntity {
@Column(name = "thumbnail_file_id")
private UUID thumbnailFileId;

@Column(name = "ar_mode", nullable = false, length = 30)
private String arMode;

@Column(nullable = false)
private boolean active;

Expand All @@ -36,6 +39,7 @@ protected HairstyleAssetEntity() { }
String styleName() { return styleName; }
String category() { return category; }
UUID thumbnailFileId() { return thumbnailFileId; }
String arMode() { return arMode; }
boolean active() { return active; }
String assetVersion() { return assetVersion; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ private HairstyleCandidate toDomain(
Map<UUID, String> tags
) {
return new HairstyleCandidate(asset.hairstyleId(), asset.styleName(), asset.category(),
asset.thumbnailFileId(), asset.active(), asset.assetVersion(),
asset.thumbnailFileId(), asset.arMode(), asset.active(), asset.assetVersion(),
parse(profile.serviceTypes(), ServiceType.class),
parse(profile.compatibleHairLengths(), HairLength.class),
parse(profile.compatibleHairTypes(), HairType.class),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package com.heddy.adapter.out.persistence.recommendation;

import com.heddy.domain.recommendation.model.RecommendationRun;
import com.heddy.domain.recommendation.model.RecommendationBasis;
import com.heddy.domain.account.model.HairProfile.HairCondition;
import com.heddy.domain.account.model.HairProfile.HairLength;
import com.heddy.domain.account.model.HairProfile.HairThickness;
import com.heddy.domain.account.model.HairProfile.HairType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
Expand Down Expand Up @@ -46,16 +51,115 @@ protected RecommendationRunEntity() { }
userId = run.userId();
strategy = run.strategy().name();
status = run.status().name();
inputSnapshot = Map.of("fallback", run.fallback(), "canonical", canonicalSnapshot);
inputSnapshot = inputSnapshot(run, canonicalSnapshot);
inputHash = run.inputHash();
generatedAt = run.generatedAt();
}

RecommendationRun toDomain(List<com.heddy.domain.recommendation.model.RecommendationItem> items) {
return new RecommendationRun(recommendationRunId, userId,
RecommendationRun.Strategy.valueOf(strategy), RecommendationRun.Status.valueOf(status),
inputHash, Boolean.TRUE.equals(inputSnapshot.get("fallback")), generatedAt, items);
inputHash, Boolean.TRUE.equals(inputSnapshot.get("fallback")),
recommendationBasis(), generatedAt, items);
}

UUID recommendationRunId() { return recommendationRunId; }

private static Map<String, Object> inputSnapshot(
RecommendationRun run,
String canonicalSnapshot
) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("fallback", run.fallback());
snapshot.put("canonical", canonicalSnapshot);
if (run.recommendationBasis() != null) {
snapshot.put("recommendation_basis", basisMap(run.recommendationBasis()));
}
return snapshot;
}

private static Map<String, Object> basisMap(RecommendationBasis basis) {
Map<String, Object> treatmentHistory = new LinkedHashMap<>();
treatmentHistory.put("count", basis.treatmentHistory().count());
treatmentHistory.put("highest_satisfaction", basis.treatmentHistory().highestSatisfaction());

Map<String, Object> stylePreferences = new LinkedHashMap<>();
stylePreferences.put("preferred_count", basis.stylePreferences().preferredCount());
stylePreferences.put("excluded_count", basis.stylePreferences().excludedCount());

Map<String, Object> value = new LinkedHashMap<>();
value.put("treatment_history", treatmentHistory);
value.put("ar_candidate_style_count", basis.arCandidateStyleCount());
value.put("style_preferences", stylePreferences);
if (basis.currentHair() != null) {
Map<String, Object> currentHair = new LinkedHashMap<>();
putEnum(currentHair, "hair_type", basis.currentHair().hairType());
putEnum(currentHair, "hair_condition", basis.currentHair().hairCondition());
putEnum(currentHair, "hair_length", basis.currentHair().hairLength());
putEnum(currentHair, "hair_thickness", basis.currentHair().hairThickness());
value.put("current_hair", currentHair);
}
value.put("available_care_time_minutes", basis.availableCareTimeMinutes());
return value;
}

private RecommendationBasis recommendationBasis() {
Object raw = inputSnapshot.get("recommendation_basis");
if (!(raw instanceof Map<?, ?> basis)) {
return null;
}
Map<?, ?> treatmentHistory = map(basis.get("treatment_history"));
Map<?, ?> stylePreferences = map(basis.get("style_preferences"));
Map<?, ?> currentHair = map(basis.get("current_hair"));
RecommendationBasis.CurrentHair hair = currentHair.isEmpty() ? null
: new RecommendationBasis.CurrentHair(
enumValue(currentHair, "hair_type", HairType.class),
enumValue(currentHair, "hair_condition", HairCondition.class),
enumValue(currentHair, "hair_length", HairLength.class),
enumValue(currentHair, "hair_thickness", HairThickness.class));
return new RecommendationBasis(
new RecommendationBasis.TreatmentHistory(
longValue(treatmentHistory, "count", 0),
integer(treatmentHistory, "highest_satisfaction")),
integer(basis, "ar_candidate_style_count", 0),
new RecommendationBasis.StylePreferences(
integer(stylePreferences, "preferred_count", 0),
integer(stylePreferences, "excluded_count", 0)),
hair,
integer(basis, "available_care_time_minutes"));
}

private static void putEnum(Map<String, Object> target, String key, Enum<?> value) {
if (value != null) {
target.put(key, value.name());
}
}

private static Map<?, ?> map(Object value) {
return value instanceof Map<?, ?> map ? map : Map.of();
}

private static Integer integer(Map<?, ?> source, String key) {
Object value = source.get(key);
return value instanceof Number number ? number.intValue() : null;
}

private static int integer(Map<?, ?> source, String key, int fallback) {
Integer value = integer(source, key);
return value == null ? fallback : value;
}

private static long longValue(Map<?, ?> source, String key, long fallback) {
Object value = source.get(key);
return value instanceof Number number ? number.longValue() : fallback;
}

private static <E extends Enum<E>> E enumValue(
Map<?, ?> source,
String key,
Class<E> type
) {
Object value = source.get(key);
return value == null ? null : Enum.valueOf(type, String.valueOf(value));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.heddy.domain.treatment.model.TreatmentPhoto;
import com.heddy.domain.treatment.model.TreatmentRecord;
import com.heddy.domain.treatment.model.TreatmentHistorySummary;
import com.heddy.domain.treatment.model.TreatmentRecordFilter;
import com.heddy.domain.treatment.model.TreatmentRecordPage;
import com.heddy.domain.treatment.port.out.TreatmentRecordRepositoryPort;
Expand Down Expand Up @@ -126,6 +127,13 @@ public List<TreatmentRecord> findRecentByUserId(UUID userId, int limit) {
.toList();
}

@Override
public TreatmentHistorySummary summarizeByUserId(UUID userId) {
Short highest = recordRepository.findHighestSatisfactionByUserId(userId);
return new TreatmentHistorySummary(recordRepository.countByUserId(userId),
highest == null ? null : highest.intValue());
}

private List<TreatmentPhoto> photosOf(UUID recordId) {
return photoRepository.findByRecordIdOrderBySortOrderAscCreatedAtAscPhotoIdAsc(recordId).stream()
.map(TreatmentPhotoEntity::toDomain)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ interface TreatmentRecordJpaRepository extends JpaRepository<TreatmentRecordEnti

List<TreatmentRecordEntity> findTop10ByUserIdOrderByPerformedAtDescRecordIdDesc(UUID userId);

long countByUserId(UUID userId);

@Query("select max(record.satisfaction) from TreatmentRecordEntity record "
+ "where record.userId = :userId")
Short findHighestSatisfactionByUserId(@Param("userId") UUID userId);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select record from TreatmentRecordEntity record where record.recordId = :recordId")
java.util.Optional<TreatmentRecordEntity> findByIdForUpdate(@Param("recordId") UUID recordId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import com.heddy.domain.recommendation.exception.RecommendationException;
import com.heddy.domain.recommendation.model.HairstyleCandidate;
import com.heddy.domain.recommendation.model.RecommendationContext;
import com.heddy.domain.recommendation.model.RecommendationBasis;
import com.heddy.domain.recommendation.model.RecommendationItem;
import com.heddy.domain.recommendation.model.RecommendationReason;
import com.heddy.domain.recommendation.model.RecommendationReference;
Expand All @@ -30,6 +31,7 @@
import com.heddy.domain.style.port.out.SavedStyleRepositoryPort;
import com.heddy.domain.style.port.out.UserStylePreferenceRepositoryPort;
import com.heddy.domain.treatment.model.TreatmentRecord;
import com.heddy.domain.treatment.model.TreatmentHistorySummary;
import com.heddy.domain.treatment.port.out.TreatmentRecordRepositoryPort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down Expand Up @@ -128,16 +130,17 @@ public RecommendationResult generate(UUID userId, boolean forceRefresh) {
Set<UUID> excluded = preferenceIds(preferences, UserStylePreference.PreferenceType.EXCLUDED);
Set<UUID> saved = Set.copyOf(savedStyleRepositoryPort.findHairstyleIdsByUserId(userId));
List<TreatmentRecord> treatments = treatmentRepositoryPort.findRecentByUserId(userId, 10);
TreatmentHistorySummary treatmentHistory = treatmentRepositoryPort.summarizeByUserId(userId);
List<HairstyleCandidate> candidates = catalogRepositoryPort.findEligibleCandidates();
RecommendationContext context = new RecommendationContext(
profile, preferred, excluded, saved, treatments, generatedAt);

String canonical = canonicalSnapshot(context, candidates);
String canonical = canonicalSnapshot(context, candidates, treatmentHistory);
String inputHash = sha256(canonical);
if (!forceRefresh) {
RecommendationRun reusable = recommendationRepositoryPort.findActiveByInputHash(
userId, RecommendationRun.Strategy.RULE_BASED_V1.name(), inputHash).orElse(null);
if (reusable != null) {
if (reusable != null && reusable.recommendationBasis() != null) {
return render(reusable);
}
}
Expand All @@ -152,9 +155,10 @@ public RecommendationResult generate(UUID userId, boolean forceRefresh) {
UUID runId = UUID.randomUUID();
List<RecommendationItem> 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);
RecommendationRun run = new RecommendationRun(runId, userId,
RecommendationRun.Strategy.RULE_BASED_V1, RecommendationRun.Status.ACTIVE,
inputHash, context.coldStart(), generatedAt, items);
inputHash, context.coldStart(), basis, generatedAt, items);
return render(recommendationRepositoryPort.insert(run, canonical));
}

Expand Down Expand Up @@ -190,7 +194,27 @@ private RecommendationResult render(RecommendationRun run) {
return new RecommendationResult.Item(item, hairstyle, url);
}).toList();
return new RecommendationResult(run.recommendationRunId(), run.strategy(), run.status(),
run.generatedAt(), run.fallback(), items);
run.generatedAt(), run.fallback(), run.recommendationBasis(), items);
}

private RecommendationBasis recommendationBasis(
RecommendationContext context,
TreatmentHistorySummary treatmentHistory,
List<ScoredRecommendation> selected
) {
HairProfile profile = context.hairProfile();
RecommendationBasis.CurrentHair currentHair = profile == null ? null
: new RecommendationBasis.CurrentHair(profile.hairType(), profile.hairCondition(),
profile.hairLength(), profile.hairThickness());
return new RecommendationBasis(
new RecommendationBasis.TreatmentHistory(
treatmentHistory.count(), treatmentHistory.highestSatisfaction()),
Math.toIntExact(selected.stream()
.filter(result -> result.candidate().supportsAr()).count()),
new RecommendationBasis.StylePreferences(
context.preferredTagIds().size(), context.excludedTagIds().size()),
currentHair,
profile == null ? null : profile.availableCareTimeMinutes());
}

private RecommendationItem toItem(
Expand Down Expand Up @@ -218,7 +242,8 @@ private Set<UUID> preferenceIds(

private String canonicalSnapshot(
RecommendationContext context,
List<HairstyleCandidate> candidates
List<HairstyleCandidate> candidates,
TreatmentHistorySummary treatmentHistory
) {
StringBuilder value = new StringBuilder("RULE_BASED_V1|");
HairProfile profile = context.hairProfile();
Expand All @@ -230,13 +255,16 @@ private String canonicalSnapshot(
appendSorted(value, context.preferredTagIds());
appendSorted(value, context.excludedTagIds());
appendSorted(value, context.savedHairstyleIds());
value.append('|').append(treatmentHistory.count()).append(':')
.append(treatmentHistory.highestSatisfaction());
context.recentTreatments().stream().sorted(Comparator.comparing(TreatmentRecord::recordId))
.forEach(record -> value.append('|').append(record.recordId()).append(':')
.append(record.performedAt()).append(':').append(record.satisfaction()).append(':')
.append(record.serviceTypes().stream().map(Enum::name).sorted().toList()));
candidates.stream().sorted(Comparator.comparing(HairstyleCandidate::hairstyleId))
.forEach(candidate -> value.append('|').append(candidate.hairstyleId()).append(':')
.append(candidate.assetVersion()).append(':').append(candidate.metadataVersion()));
.append(candidate.assetVersion()).append(':').append(candidate.metadataVersion())
.append(':').append(candidate.arMode()));
return value.toString();
}

Expand Down
Loading
Loading