diff --git a/backend/app/data/garment_vocabulary.json b/backend/app/data/garment_vocabulary.json index 42cdd8c90..bef2e1a56 100644 --- a/backend/app/data/garment_vocabulary.json +++ b/backend/app/data/garment_vocabulary.json @@ -13,7 +13,7 @@ {"value": "jacket", "role": "outer_layer", "wash_interval": 8}, {"value": "coat", "role": "outer_layer", "wash_interval": 10}, {"value": "sweater", "role": "base_top", "wash_interval": 5}, - {"value": "hoodie", "role": "outer_layer", "wash_interval": 4}, + {"value": "hoodie", "role": "outer_layer", "wash_interval": 4, "worn_alone": true}, {"value": "blazer", "role": "outer_layer", "wash_interval": 5}, {"value": "suit", "role": "suit", "wash_interval": 5}, {"value": "vest", "role": "mid_layer", "wash_interval": 5}, diff --git a/backend/app/services/item_scorer.py b/backend/app/services/item_scorer.py index 7c012c1a9..100068140 100644 --- a/backend/app/services/item_scorer.py +++ b/backend/app/services/item_scorer.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from datetime import date +from math import prod from statistics import median from uuid import UUID @@ -264,6 +265,42 @@ def _formality_score(item: ClothingItem, occasion: str) -> float: return 0.15 +def _fit_factors( + item: ClothingItem, + weather: WeatherData, + occasion: str, + current_season: str, + preferences: UserPreference | None, + learned_prefs: dict | None, +) -> tuple[float, float, float, float]: + return ( + _weather_score(item, weather, preferences), + _formality_score(item, occasion), + _season_score(item, current_season, weather, preferences), + _preference_score(item, preferences, learned_prefs), + ) + + +def rank_by_fit( + items: list[ClothingItem], + weather: WeatherData, + occasion: str, + current_season: str, + preferences: UserPreference | None, + learned_prefs: dict | None, +) -> list[UUID]: + # score_items leaves small wardrobes unscored, so filling a missing piece needs its own + # ranking or it would fall back to database order. Most items tie on fit in mild + # weather, so the least recently worn goes first to avoid adding the same piece every time. + def key(item: ClothingItem) -> tuple[float, date]: + fit = prod( + _fit_factors(item, weather, occasion, current_season, preferences, learned_prefs) + ) + return -fit, item.last_worn_at or date.min + + return [item.id for item in sorted(items, key=key)] + + def _season_score( item: ClothingItem, current_season: str, @@ -456,11 +493,10 @@ def score_items( scored = [] for item in items: - ws = _weather_score(item, weather, preferences) - fs = _formality_score(item, occasion) - ss = _season_score(item, current_season, weather, preferences) + ws, fs, ss, ps = _fit_factors( + item, weather, occasion, current_season, preferences, learned_prefs + ) rs = _recency_score(item, user_today, avoid_days, recently_worn_dates) - ps = _preference_score(item, preferences, learned_prefs) us = _usage_score(item, median_wear) if use_underused else 1.0 total = ws * fs * ss * rs * ps * us diff --git a/backend/app/services/pairing_service.py b/backend/app/services/pairing_service.py index 0c3055130..0df0b04f9 100644 --- a/backend/app/services/pairing_service.py +++ b/backend/app/services/pairing_service.py @@ -11,7 +11,7 @@ from app.models.outfit import FamilyOutfitRating, Outfit, OutfitItem, OutfitSource, OutfitStatus from app.models.user import User from app.services.ai_service import AIResponseTruncatedError, AIService, require_internal_ai -from app.utils.clothing import covered_body_slots, deduplicate_by_body_slot +from app.utils.clothing import covered_regions, deduplicate_by_body_slot from app.utils.garment_vocabulary import render_outfit_prompt from app.utils.prompts import load_prompt from app.utils.timezone import get_user_today @@ -270,13 +270,13 @@ async def generate_pairings( # mandatory: a full-body garment claims both the top and bottom slots first, so # without this the source would be dropped and the pairing persisted without the # item it is supposed to feature. - picked_slots = covered_body_slots(valid_ids, item_type_map) + picked_regions = covered_regions(valid_ids, item_type_map) valid_ids = deduplicate_by_body_slot( valid_ids, item_type_map, mandatory_item_ids={source_item.id} ) # Dedup must not leave the top or bottom bare where the model had covered it: a # dress dropped for a shirt source would leave the outfit with no bottom. - if covered_body_slots(valid_ids, item_type_map) != picked_slots: + if covered_regions(valid_ids, item_type_map) != picked_regions: logger.warning("Pairing silhouette does not fit the source item, skipping") continue diff --git a/backend/app/services/recommendation_service.py b/backend/app/services/recommendation_service.py index a54678983..72500b62e 100644 --- a/backend/app/services/recommendation_service.py +++ b/backend/app/services/recommendation_service.py @@ -27,6 +27,7 @@ from app.services.item_scorer import ( get_season, rain_likely, + rank_by_fit, score_items, scoring_temp_range, stays_above_hot_threshold, @@ -39,7 +40,15 @@ WeatherService, WeatherServiceError, ) -from app.utils.clothing import canonical_item_order, deduplicate_by_body_slot, remove_legwear +from app.utils.clothing import ( + can_complete_outfit, + canonical_item_order, + deduplicate_by_body_slot, + fill_missing_regions, + has_wearable_piece, + missing_regions, + remove_legwear, +) from app.utils.garment_vocabulary import render_outfit_prompt from app.utils.preference_defaults import ( DEFAULT_LAYERING_PREFERENCE, @@ -53,6 +62,7 @@ # How far the temperature may drift before a cached option, picked by the AI for the old # reading, is regenerated rather than served. CACHED_SUGGESTION_TEMP_TOLERANCE = 3.0 +INCOMPLETE_OUTFIT_ERROR = "AI suggested an outfit that cannot be completed from your wardrobe" SINGLE_OUTFIT_FORMAT = ( "Respond with valid JSON:\n" @@ -125,6 +135,67 @@ def cached_suggestion_fits( ) +def picked_item_ids(outfit_data: dict, number_map: dict[int, UUID]) -> list[UUID]: + picked: list[UUID] = [] + for num in outfit_data.get("items", []): + try: + num_int = int(num) + except (ValueError, TypeError): + logger.warning(f"AI returned non-numeric item: {num}") + continue + if num_int not in number_map: + logger.warning(f"AI selected invalid item number: {num}") + elif number_map[num_int] not in picked: + picked.append(number_map[num_int]) + return picked + + +def resolve_outfit_items( + outfit_data: dict, + number_map: dict[int, UUID], + item_type_map: dict[UUID, str], + mandatory_item_ids: set[UUID] | None, + drop_legwear: bool, +) -> list[UUID]: + valid_ids = picked_item_ids(outfit_data, number_map) + + numbered = set(number_map.values()) + for mid in mandatory_item_ids or (): + if mid in numbered and mid not in valid_ids: + logger.info(f"Adding missing mandatory item {mid} to outfit") + valid_ids.insert(0, mid) + + valid_ids = deduplicate_by_body_slot( + valid_ids, item_type_map, mandatory_item_ids=mandatory_item_ids + ) + # Prompt wording alone does not keep small models from adding tights on a hot + # day; tights the user asked for stay. + if drop_legwear: + valid_ids = remove_legwear(valid_ids, item_type_map, mandatory_item_ids or set()) + return valid_ids + + +def fill_best_partial( + partials: list[tuple[dict, list[UUID]]], + ranked_ids: list[UUID], + item_type_map: dict[UUID, str], +) -> tuple[dict, list[UUID]] | None: + filled = [ + (outfit_data, item_ids, fill_missing_regions(item_ids, ranked_ids, item_type_map)) + for outfit_data, item_ids in partials + ] + completed = [f for f in filled if not missing_regions(f[2], item_type_map)] + if not completed: + return None + # Prefer the option the fill changes least, because the AI's own picks are better + # matched than anything added here. + outfit_data, picked, item_ids = min(completed, key=lambda f: len(f[2]) - len(f[1])) + # The headline and styling tip were written for the AI's picks alone, so the saved + # response says which pieces it never chose. + outfit_data["_filled_item_ids"] = [str(iid) for iid in item_ids if iid not in picked] + return outfit_data, item_ids + + @dataclass class RecommendationContext: user: User @@ -640,65 +711,18 @@ def _parse_multi_outfit_response(self, content: str) -> list[dict]: return [parsed] - async def _materialize_outfit( + async def _save_outfit( self, outfit_data: dict, + item_ids: list[UUID], + item_type_map: dict[UUID, str], user: User, weather: WeatherData, occasion: str, source: OutfitSource, - number_map: dict[int, UUID], scheduled_date: date | None = None, - mandatory_item_ids: set[UUID] | None = None, - drop_legwear: bool = False, ) -> Outfit: - selected_numbers = outfit_data.get("items", []) - valid_ids = [] - - for num in selected_numbers: - try: - num_int = int(num) - if num_int in number_map: - valid_ids.append(number_map[num_int]) - else: - logger.warning(f"AI selected invalid item number: {num}") - except (ValueError, TypeError): - logger.warning(f"AI returned non-numeric item: {num}") - - seen = set() - unique_ids = [] - for item_id in valid_ids: - if item_id not in seen: - seen.add(item_id) - unique_ids.append(item_id) - valid_ids = unique_ids - - # Ensure all mandatory items are included in valid_ids if they were candidates - if mandatory_item_ids: - mandatory_in_candidates = { - item_id for item_id in mandatory_item_ids if item_id in number_map.values() - } - for mid in mandatory_in_candidates: - if mid not in valid_ids: - logger.info(f"Adding missing mandatory item {mid} to outfit") - valid_ids.insert(0, mid) - - # Deduplicate by body slot (e.g. prevent shorts + pants) - items_result = await self.db.execute( - select(ClothingItem.id, ClothingItem.type).where(ClothingItem.id.in_(valid_ids)) - ) - item_type_map = {row.id: (row.type or "").lower() for row in items_result} - valid_ids = deduplicate_by_body_slot( - valid_ids, item_type_map, mandatory_item_ids=mandatory_item_ids - ) - # Prompt wording alone does not keep small models from adding tights on a hot - # day; tights the user asked for stay. - if drop_legwear: - valid_ids = remove_legwear(valid_ids, item_type_map, mandatory_item_ids or set()) - if not valid_ids: - raise EmptyOutfitError("AI did not select any valid items") - valid_ids = canonical_item_order(valid_ids, item_type_map) - + valid_ids = canonical_item_order(item_ids, item_type_map) reasoning = outfit_data.get("headline") or outfit_data.get("reasoning") style_notes = outfit_data.get("styling_tip") or outfit_data.get("style_notes") @@ -859,9 +883,20 @@ async def generate_recommendations( "Please add more items or adjust filters." ) + mandatory_ids = set(include_items) if include_items else None + item_type_map = {item.id: (item.type or "").lower() for item in candidates} + if include_items: + requested = deduplicate_by_body_slot( + list(include_items), item_type_map, mandatory_item_ids=mandatory_ids + ) + if not can_complete_outfit(requested, item_type_map): + raise ValueError( + "The selected items cannot make a complete outfit with the rest of your " + "wardrobe." + ) + # Check cache for pre-generated suggestions (used when count is 1) if use_cache and count == 1: - candidate_ids = {item.id for item in candidates} while True: cached = await pop_suggestion(user.id, occasion, include_items=include_items) if not cached: @@ -874,36 +909,23 @@ async def generate_recommendations( cached_number_map = cached.pop("_number_map", {}) number_map = {int(k): UUID(v) for k, v in cached_number_map.items()} - cached_item_ids = set() - for num in cached.get("items", []): - try: - str_num = str(int(num)) - if str_num in cached_number_map: - cached_item_ids.add(UUID(cached_number_map[str_num])) - except (ValueError, TypeError): - pass - # Rejected items are covered too, because they were merged into exclude_items. - if not cached_item_ids <= candidate_ids: + if not set(picked_item_ids(cached, number_map)) <= item_type_map.keys(): logger.info("Cached suggestion uses items no longer available, skipping") continue - logger.info(f"Using cached suggestion for user {user.id}, occasion: {occasion}") - try: - outfit = await self._materialize_outfit( - cached, - user, - weather, - occasion, - source, - number_map, - scheduled_date=scheduled_date, - mandatory_item_ids=set(include_items) if include_items else None, - drop_legwear=drop_legwear, - ) - except EmptyOutfitError: - logger.info("Cached suggestion has nothing left to wear, skipping") + item_ids = resolve_outfit_items( + cached, number_map, item_type_map, mandatory_ids, drop_legwear + ) + if not has_wearable_piece(item_ids, item_type_map) or missing_regions( + item_ids, item_type_map + ): + logger.info("Cached suggestion is empty or incomplete now, skipping") continue + logger.info(f"Using cached suggestion for user {user.id}, occasion: {occasion}") + outfit = await self._save_outfit( + cached, item_ids, item_type_map, user, weather, occasion, source, scheduled_date + ) return [outfit] # Fetch scoring context @@ -993,58 +1015,82 @@ async def generate_recommendations( raise ValueError(f"Expected dict, got {type(outfit_data)}") outfit_data["_ai_model"] = result.model outfit_data["_ai_endpoint"] = result.endpoint - outfit = await self._materialize_outfit( + item_ids = resolve_outfit_items( + outfit_data, number_map, item_type_map, mandatory_ids, drop_legwear + ) + # An answer with nothing usable fails rather than being built from scratch + # under a headline the AI wrote for other pieces. + if not has_wearable_piece(item_ids, item_type_map): + raise AIRecommendationError("AI did not select any valid items") + if missing_regions(item_ids, item_type_map): + ranking = rank_by_fit( + candidates, weather, occasion, current_season, preferences, learned_prefs + ) + completed = fill_best_partial([(outfit_data, item_ids)], ranking, item_type_map) + if completed is None: + raise AIRecommendationError(INCOMPLETE_OUTFIT_ERROR) + outfit_data, item_ids = completed + outfit = await self._save_outfit( outfit_data, + item_ids, + item_type_map, user, weather, occasion, source, - number_map, - scheduled_date=scheduled_date, - mandatory_item_ids=set(include_items) if include_items else None, - drop_legwear=drop_legwear, + scheduled_date, ) return [outfit] # Multi-outfit parse outfit_list = self._parse_multi_outfit_response(result.content) - outfits = [] - consumed = 0 + complete = [] + partials = [] for od in outfit_list: - if len(outfits) == count: - break - consumed += 1 od["_ai_model"] = result.model od["_ai_endpoint"] = result.endpoint - try: - outfit = await self._materialize_outfit( - od, - user, - weather, - occasion, - source, - number_map, - scheduled_date=scheduled_date, - mandatory_item_ids=set(include_items) if include_items else None, - drop_legwear=drop_legwear, - ) - except EmptyOutfitError: + item_ids = resolve_outfit_items( + od, number_map, item_type_map, mandatory_ids, drop_legwear + ) + missing = missing_regions(item_ids, item_type_map) + if not has_wearable_piece(item_ids, item_type_map): logger.warning("AI outfit option has nothing left to wear, skipping") - continue - outfits.append(outfit) - if not outfits: + elif missing: + logger.warning(f"AI outfit option has no {', '.join(missing)} piece") + partials.append((od, item_ids)) + else: + complete.append((od, item_ids)) + + chosen = complete[:count] + # Filling in a piece the AI did not choose is a last resort, because the AI's + # own complete options are better matched than anything added here. + if not chosen and partials: + ranking = rank_by_fit( + candidates, weather, occasion, current_season, preferences, learned_prefs + ) + completed = fill_best_partial(partials, ranking, item_type_map) + if completed is None: + raise AIRecommendationError(INCOMPLETE_OUTFIT_ERROR) + chosen = [completed] + if not chosen: raise AIRecommendationError("AI did not select any valid items") - # Cache remaining outfits for "Try Another" - if use_cache and len(outfit_list) > consumed: + outfits = [ + await self._save_outfit( + od, item_ids, item_type_map, user, weather, occasion, source, scheduled_date + ) + for od, item_ids in chosen + ] + + # Cache remaining complete outfits for "Try Another" + leftovers = [od for od, _ in complete[count:]] + if use_cache and leftovers: serializable_map = {str(k): str(v) for k, v in number_map.items()} to_cache = [] - for od in outfit_list[consumed:]: + for od in leftovers: od["_number_map"] = serializable_map od["_context"] = context - od["_ai_model"] = result.model - od["_ai_endpoint"] = result.endpoint to_cache.append(od) await push_suggestions(user.id, occasion, to_cache, include_items=include_items) logger.info(f"Cached {len(to_cache)} additional suggestions for user {user.id}") @@ -1098,9 +1144,3 @@ class InsufficientWardrobeError(Exception): class AIRecommendationError(Exception): pass - - -# An outfit with nothing left to wear after cleanup; one bad option among several, or a -# stale cached one, is skipped rather than failing the whole request. -class EmptyOutfitError(AIRecommendationError): - pass diff --git a/backend/app/utils/clothing.py b/backend/app/utils/clothing.py index f37f3dadd..f7cc68eb2 100644 --- a/backend/app/utils/clothing.py +++ b/backend/app/utils/clothing.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from uuid import UUID -from app.utils.garment_vocabulary import ITEM_ROLE, ROLES +from app.utils.garment_vocabulary import ITEM_ROLE, ROLES, WORN_ALONE_TYPES logger = logging.getLogger(__name__) @@ -12,9 +12,6 @@ "suit": frozenset({"bottom", "suit"}), } -# The slots an outfit must cover; a suit's own slot is optional like an outer layer. -BODY_SLOTS = frozenset({"base_top", "bottom"}) - @dataclass(frozen=True) class WardrobeComposition: @@ -46,15 +43,6 @@ def _slots_for_type(item_type: str) -> frozenset[str]: return ROLE_SLOTS.get(role, frozenset({role})) -def covered_body_slots(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> set[str]: - return { - slot - for iid in item_ids - for slot in _slots_for_type(item_type_map.get(iid, "")) - if slot in BODY_SLOTS - } - - def deduplicate_by_body_slot( item_ids: list[UUID], item_type_map: dict[UUID, str], @@ -91,6 +79,155 @@ def deduplicate_by_body_slot( return [iid for iid in item_ids if iid in kept] +# Each region is dressed by whatever takes its slot, so a one-piece covers upper and lower +# while a suit covers only lower and still needs a top. A coat or blazer over nothing does +# not dress the upper body; only the types the vocabulary marks worn_alone (a hoodie) stand +# in for a top. +REGION_SLOTS: dict[str, str] = {"upper": "base_top", "lower": "bottom", "feet": "footwear"} + + +def _regions(item_type: str) -> set[str]: + slots = _slots_for_type(item_type) + covered = {region for region, slot in REGION_SLOTS.items() if slot in slots} + if item_type in WORN_ALONE_TYPES: + covered.add("upper") + return covered + + +def covered_regions(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> set[str]: + return set().union(*(_regions(item_type_map.get(iid, "")) for iid in item_ids)) + + +LAYER_ROLES = frozenset({"mid_layer", "outer_layer"}) + + +def has_wearable_piece(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> bool: + # A wardrobe with nothing that dresses any region still gets outfits from its layers, + # but a bag or tights alone is never an outfit. + if not covered_regions(list(item_type_map), item_type_map): + return any(ITEM_ROLE.get(item_type_map.get(iid, "")) in LAYER_ROLES for iid in item_ids) + return bool(covered_regions(item_ids, item_type_map)) + + +def missing_regions(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> list[str]: + """`item_type_map` must cover every candidate, because it decides which regions the + wardrobe can fill at all.""" + worn = covered_regions(item_ids, item_type_map) + available = covered_regions(list(item_type_map), item_type_map) + return [region for region in REGION_SLOTS if region in available and region not in worn] + + +def _fill_options( + region: str, + remaining: frozenset[str], + rank: dict[UUID, int], + item_type_map: dict[UUID, str], + taken: frozenset[UUID], + claimed: frozenset[str], +) -> list[tuple[UUID, int]]: + # A piece may only cover regions that are still missing and take free slots, so that + # a dress never lands on trousers and a hoodie never goes under a coat. + fits = [ + iid + for iid in rank + if iid not in taken + and region in _regions(item_type_map.get(iid, "")) + and _regions(item_type_map.get(iid, "")) <= remaining + and not _slots_for_type(item_type_map.get(iid, "")) & claimed + ] + best: dict[frozenset[str], tuple[UUID, int]] = {} + for iid in fits: + best.setdefault(frozenset(_regions(item_type_map[iid])), (iid, rank[iid])) + single = best.get(frozenset({region})) + # A real top goes before a better-ranked layer worn alone, because the AI was asked + # for a top. It competes at the layer's rank, so that swapping in a weaker top never + # hands the win to a one-piece the layer would have beaten. + if single is not None and item_type_map[single[0]] in WORN_ALONE_TYPES: + top = next( + ( + iid + for iid in fits + if _regions(item_type_map[iid]) == {region} + and item_type_map[iid] not in WORN_ALONE_TYPES + ), + None, + ) + if top is not None: + best[frozenset({region})] = (top, single[1]) + return list(best.values()) + + +def _best_fill( + remaining: frozenset[str], + rank: dict[UUID, int], + item_type_map: dict[UUID, str], + taken: frozenset[UUID], + claimed: frozenset[str], +) -> list[tuple[UUID, int]]: + if not remaining: + return [] + region = next(r for r in REGION_SLOTS if r in remaining) + plans = [_best_fill(remaining - {region}, rank, item_type_map, taken, claimed)] + for pick, pick_rank in _fill_options(region, remaining, rank, item_type_map, taken, claimed): + pick_type = item_type_map[pick] + rest = _best_fill( + remaining - _regions(pick_type), + rank, + item_type_map, + taken | {pick}, + claimed | _slots_for_type(pick_type), + ) + plans.append([(pick, pick_rank), *rest]) + # The plan that dresses the most regions wins, then the one whose picks rank best, + # compared in region order (upper first). + return min( + plans, + key=lambda plan: ( + len(remaining - covered_regions([iid for iid, _ in plan], item_type_map)), + [pick_rank for _, pick_rank in plan], + ), + ) + + +def can_complete_outfit(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> bool: + # Looser than the fill, because an AI answer may add a piece that also covers a region + # already dressed (a hoodie over a dress); only free slots limit it. Items of one type + # share slots and regions, so searching types keeps this cheap on a large wardrobe. + others = {t for iid, t in item_type_map.items() if iid not in item_ids} + + def reachable(remaining: frozenset[str], claimed: frozenset[str]) -> bool: + if not remaining: + return True + region = next(r for r in REGION_SLOTS if r in remaining) + return any( + reachable(remaining - _regions(t), claimed | _slots_for_type(t)) + for t in others + if region in _regions(t) and not _slots_for_type(t) & claimed + ) + + claimed = frozenset().union(*(_slots_for_type(item_type_map.get(iid, "")) for iid in item_ids)) + return reachable(frozenset(missing_regions(item_ids, item_type_map)), claimed) + + +def fill_missing_regions( + item_ids: list[UUID], ranked_candidate_ids: list[UUID], item_type_map: dict[UUID, str] +) -> list[UUID]: + claimed = frozenset().union(*(_slots_for_type(item_type_map.get(iid, "")) for iid in item_ids)) + plan = _best_fill( + frozenset(missing_regions(item_ids, item_type_map)), + {iid: n for n, iid in enumerate(ranked_candidate_ids)}, + item_type_map, + frozenset(item_ids), + claimed, + ) + added = [iid for iid, _ in plan] + for iid in added: + covered = _regions(item_type_map[iid]) + regions = ", ".join(r for r in REGION_SLOTS if r in covered) + logger.info(f"Adding {item_type_map[iid]} item {iid}: outfit had no {regions} piece") + return [*item_ids, *added] + + _ROLE_SORT_INDEX: dict[str, int] = {role: idx for idx, role in enumerate(ROLES)} diff --git a/backend/app/utils/garment_vocabulary.py b/backend/app/utils/garment_vocabulary.py index a5fdc02e1..219691647 100644 --- a/backend/app/utils/garment_vocabulary.py +++ b/backend/app/utils/garment_vocabulary.py @@ -9,6 +9,10 @@ ROLES: tuple[str, ...] = tuple(_DATA["roles"]) TYPES: tuple[str, ...] = tuple(entry["value"] for entry in _DATA["types"]) ITEM_ROLE: dict[str, str] = {entry["value"]: entry["role"] for entry in _DATA["types"]} +# Layers that are commonly worn with nothing under them, so they dress the upper body alone. +WORN_ALONE_TYPES: frozenset[str] = frozenset( + entry["value"] for entry in _DATA["types"] if entry.get("worn_alone") +) DEFAULT_WASH_INTERVALS: dict[str, int] = { entry["value"]: entry["wash_interval"] for entry in _DATA["types"] } diff --git a/backend/tests/test_clothing_utils.py b/backend/tests/test_clothing_utils.py index 27f31fa49..c9bd2c053 100644 --- a/backend/tests/test_clothing_utils.py +++ b/backend/tests/test_clothing_utils.py @@ -1,11 +1,17 @@ from uuid import uuid4 +import pytest + from app.utils.clothing import ( ITEM_ROLE, WardrobeComposition, + can_complete_outfit, canonical_item_order, count_composition, deduplicate_by_body_slot, + fill_missing_regions, + has_wearable_piece, + missing_regions, remove_legwear, ) from app.utils.garment_vocabulary import ROLES @@ -401,3 +407,210 @@ def test_remove_legwear_keeps_requested_tights_and_everything_else(): [top, skirt, tights, requested_tights, shoes], item_type_map, {requested_tights} ) assert kept == [top, skirt, requested_tights, shoes] + + +def _typed(*types): + ids = _ids(len(types)) + return ids, dict(zip(ids, types, strict=True)) + + +@pytest.mark.parametrize( + ("outfit", "wardrobe", "expected"), + [ + (("jeans", "sneakers"), ("shirt",), ["upper"]), + (("shirt", "sneakers"), ("jeans",), ["lower"]), + (("shirt", "jeans"), ("boots",), ["feet"]), + (("shirt", "jeans", "sneakers"), ("dress", "sandals"), []), + (("dress", "sandals"), ("shirt", "jeans"), []), + (("suit", "shoes"), ("shirt", "pants"), ["upper"]), + (("hoodie", "jeans", "sneakers"), ("shirt",), []), + (("cardigan", "skirt", "tights", "boots"), ("blouse",), ["upper"]), + (("coat", "jeans", "boots"), ("shirt",), ["upper"]), + (("coat", "jeans", "boots"), ("blazer",), []), + (("shirt", "sneakers"), ("sweater",), []), + (("jeans", "sneakers"), ("dress",), ["upper"]), + (("bag",), ("shirt", "jeans", "shoes"), ["upper", "lower", "feet"]), + (("tights", "skirt", "blouse"), ("hat",), []), + ], + ids=[ + "no-top", + "no-bottom", + "no-shoes", + "complete-separates", + "dress", + "suit", + "hoodie-as-top", + "cardigan-alone-is-not-a-top", + "coat-alone-is-not-a-top", + "wardrobe-has-only-layers-for-the-top", + "wardrobe-has-no-bottoms", + "only-a-dress-could-cover-the-top", + "accessory-only", + "wardrobe-has-no-shoes", + ], +) +def test_missing_regions_only_counts_what_the_wardrobe_can_fill(outfit, wardrobe, expected): + outfit_ids, outfit_types = _typed(*outfit) + _, wardrobe_types = _typed(*wardrobe) + assert missing_regions(outfit_ids, outfit_types | wardrobe_types) == expected + + +def test_fill_adds_the_best_ranked_top_before_a_layer(): + (jeans, sneakers), outfit_types = _typed("jeans", "sneakers") + ranked, ranked_types = _typed("jacket", "t-shirt", "shirt") + filled = fill_missing_regions([jeans, sneakers], ranked, outfit_types | ranked_types) + assert filled == [jeans, sneakers, ranked[1]] + + +def test_fill_uses_a_hoodie_but_never_a_coat_when_there_is_no_top(): + (jeans, sneakers), outfit_types = _typed("jeans", "sneakers") + ranked, ranked_types = _typed("coat", "hoodie") + filled = fill_missing_regions([jeans, sneakers], ranked, outfit_types | ranked_types) + assert filled == [jeans, sneakers, ranked[1]] + + +@pytest.mark.parametrize( + ("outfit", "wardrobe", "expected"), + [ + (("bag",), ("shirt",), False), + (("tights", "hat"), ("jeans",), False), + (("coat",), ("shirt",), False), + (("hoodie",), ("shirt",), True), + (("jacket",), ("cardigan",), True), + ((), ("cardigan",), False), + (("bag",), ("hat",), False), + (("tights", "hat"), ("cardigan",), False), + ], + ids=[ + "bag", + "tights-and-hat", + "coat", + "hoodie", + "layers-only-wardrobe", + "empty", + "accessories-only-wardrobe", + "no-layer-in-a-layers-only-wardrobe", + ], +) +def test_has_wearable_piece(outfit, wardrobe, expected): + ids, types = _typed(*outfit) + _, wardrobe_types = _typed(*wardrobe) + assert has_wearable_piece(ids, types | wardrobe_types) is expected + + +@pytest.mark.parametrize( + ("requested", "wardrobe", "expected"), + [ + (("skirt",), ("t-shirt", "sneakers"), True), + (("skirt",), ("dress", "sneakers"), False), + (("coat",), ("hoodie", "jeans", "boots"), False), + (("suit",), ("dress", "shoes"), False), + (("bag",), ("dress", "shoes"), True), + (("hoodie",), ("dress", "sneakers"), True), + (("sneakers",), ("shirt", "dress"), True), + ((), ("jeans",), True), + ], + ids=[ + "top-available", + "only-a-dress", + "hoodie-under-coat", + "suit-only-a-dress", + "bag", + "hoodie-over-a-dress", + "shirt-would-block-the-dress", + "none", + ], +) +def test_can_complete_outfit(requested, wardrobe, expected): + ids, types = _typed(*requested) + _, wardrobe_types = _typed(*wardrobe) + assert can_complete_outfit(ids, types | wardrobe_types) is expected + + +def test_fill_adds_every_missing_region(): + (sneakers,), outfit_types = _typed("sneakers") + ranked, ranked_types = _typed("boots", "skirt", "blouse", "dress", "pants") + filled = fill_missing_regions([sneakers], ranked, outfit_types | ranked_types) + assert filled == [sneakers, ranked[2], ranked[1]] + + +def test_fill_never_uses_a_dress_or_suit_as_the_missing_top(): + (jeans, sneakers), outfit_types = _typed("jeans", "sneakers") + ranked, ranked_types = _typed("dress", "suit") + filled = fill_missing_regions([jeans, sneakers], ranked, outfit_types | ranked_types) + assert filled == [jeans, sneakers] + + +def test_fill_leaves_a_complete_outfit_alone(): + outfit, types = _typed("shirt", "jeans", "sneakers") + ranked, ranked_types = _typed("t-shirt", "pants", "boots") + assert fill_missing_regions(outfit, ranked, types | ranked_types) == outfit + + +def test_fill_prefers_a_top_to_a_better_ranked_hoodie(): + (jeans, sneakers), outfit_types = _typed("jeans", "sneakers") + ranked, ranked_types = _typed("hoodie", "t-shirt") + filled = fill_missing_regions([jeans, sneakers], ranked, outfit_types | ranked_types) + assert filled == [jeans, sneakers, ranked[1]] + + +def test_fill_never_puts_a_hoodie_under_a_coat(): + (coat, jeans, boots), outfit_types = _typed("coat", "jeans", "boots") + ranked, ranked_types = _typed("hoodie", "blazer") + filled = fill_missing_regions([coat, jeans, boots], ranked, outfit_types | ranked_types) + assert filled == [coat, jeans, boots] + + +def test_fill_uses_a_dress_when_top_and_bottom_are_both_missing(): + (coat, sneakers), outfit_types = _typed("coat", "sneakers") + ranked, ranked_types = _typed("dress", "jumpsuit") + filled = fill_missing_regions([coat, sneakers], ranked, outfit_types | ranked_types) + assert filled == [coat, sneakers, ranked[0]] + + +def test_a_suit_fills_only_the_bottom_and_still_takes_a_top(): + (shoes,), outfit_types = _typed("shoes") + ranked, ranked_types = _typed("suit", "shirt", "jeans") + filled = fill_missing_regions([shoes], ranked, outfit_types | ranked_types) + assert filled == [shoes, ranked[1], ranked[0]] + + +def test_fill_takes_better_ranked_jeans_over_a_suit_for_the_bottom(): + (sneakers,), outfit_types = _typed("sneakers") + ranked, ranked_types = _typed("hoodie", "jeans", "suit") + filled = fill_missing_regions([sneakers], ranked, outfit_types | ranked_types) + assert filled == [sneakers, ranked[0], ranked[1]] + + +@pytest.mark.parametrize("one_piece", ["dress", "jumpsuit"]) +def test_a_one_piece_that_outranks_the_separates_fills_top_and_bottom(one_piece): + (shoes,), outfit_types = _typed("shoes") + ranked, ranked_types = _typed(one_piece, "blouse", "skirt") + filled = fill_missing_regions([shoes], ranked, outfit_types | ranked_types) + assert filled == [shoes, ranked[0]] + + +# With a hoodie ahead of it, the shirt competes at the hoodie's rank and still outranks the +# one-piece, so only the uncovered-region count keeps the one-piece winning. +@pytest.mark.parametrize("one_piece", ["dress", "jumpsuit"]) +@pytest.mark.parametrize("before", [("shirt",), ("hoodie",)], ids=["shirt", "hoodie-then-shirt"]) +def test_fill_takes_a_one_piece_over_a_better_top_when_no_bottom_exists(before, one_piece): + (sneakers,), outfit_types = _typed("sneakers") + ranked, ranked_types = _typed(*before, one_piece, "shirt") + filled = fill_missing_regions([sneakers], ranked, outfit_types | ranked_types) + assert filled == [sneakers, ranked[1]] + + +@pytest.mark.parametrize("one_piece", ["dress", "jumpsuit"]) +def test_a_weaker_top_swapped_for_a_hoodie_still_beats_a_one_piece(one_piece): + (sneakers,), outfit_types = _typed("sneakers") + ranked, ranked_types = _typed("hoodie", one_piece, "jeans", "shirt") + filled = fill_missing_regions([sneakers], ranked, outfit_types | ranked_types) + assert filled == [sneakers, ranked[3], ranked[2]] + + +def test_fill_dresses_all_three_regions(): + (bag,), outfit_types = _typed("bag") + ranked, ranked_types = _typed("dress", "shirt", "jeans", "boots") + filled = fill_missing_regions([bag], ranked, outfit_types | ranked_types) + assert filled == [bag, ranked[0], ranked[3]] diff --git a/backend/tests/test_item_scorer.py b/backend/tests/test_item_scorer.py index 52e32cca0..360f6603a 100644 --- a/backend/tests/test_item_scorer.py +++ b/backend/tests/test_item_scorer.py @@ -14,6 +14,7 @@ _season_score, _weather_score, get_season, + rank_by_fit, score_items, scoring_temp_range, stays_above_hot_threshold, @@ -677,3 +678,64 @@ def test_promotion_never_evicts_a_mandatory_item(self): ) def test_stays_above_hot_threshold_needs_the_coolest_part_to_be_hot(weather, prefs, expected): assert stays_above_hot_threshold(weather, prefs) is expected + + +def test_rank_by_fit_puts_weather_fit_ahead_of_wardrobe_order(): + sweater = _item(type="sweater", material="wool") + linen = _item(type="shirt", material="linen") + assert rank_by_fit([sweater, linen], _weather(temp=35), "casual", "summer", None, None) == [ + linen.id, + sweater.id, + ] + assert rank_by_fit([linen, sweater], _weather(temp=2), "casual", "winter", None, None) == [ + sweater.id, + linen.id, + ] + + +def test_rank_by_fit_puts_the_occasion_ahead_of_wardrobe_order(): + items = [_item(type="t-shirt", formality="casual"), _item(type="shirt", formality="formal")] + assert rank_by_fit(items, _weather(), "formal", "spring", None, None) == [ + items[1].id, + items[0].id, + ] + + +def test_rank_by_fit_puts_the_season_ahead_of_wardrobe_order(): + winter = _item(type="shirt", season=["winter"]) + summer = _item(type="shirt", season=["summer"]) + assert rank_by_fit([winter, summer], _weather(), "casual", "summer", None, None) == [ + summer.id, + winter.id, + ] + + +def test_rank_by_fit_breaks_ties_by_least_recently_worn(): + worn = _item(type="t-shirt", last_worn_at=date(2026, 3, 7)) + older = _item(type="shirt", last_worn_at=date(2026, 1, 2)) + never = _item(type="polo") + assert rank_by_fit([worn, older, never], _weather(), "casual", "spring", None, None) == [ + never.id, + older.id, + worn.id, + ] + + +def test_rank_by_fit_ranks_an_avoided_colour_last(): + red = _item(type="t-shirt", primary_color="red") + blue = _item(type="shirt", primary_color="blue", last_worn_at=date(2026, 3, 7)) + prefs = _prefs(color_avoid=["red"]) + assert rank_by_fit([red, blue], _weather(), "casual", "spring", prefs, None) == [ + blue.id, + red.id, + ] + + +def test_rank_by_fit_ranks_a_learned_avoided_colour_last(): + orange = _item(type="t-shirt", primary_color="orange") + grey = _item(type="shirt", primary_color="grey", last_worn_at=date(2026, 3, 7)) + learned = {"learned_avoid_colors": ["orange"]} + assert rank_by_fit([orange, grey], _weather(), "casual", "spring", None, learned) == [ + grey.id, + orange.id, + ] diff --git a/backend/tests/test_recommendation_service.py b/backend/tests/test_recommendation_service.py index 860e32582..9c48180ff 100644 --- a/backend/tests/test_recommendation_service.py +++ b/backend/tests/test_recommendation_service.py @@ -4,6 +4,7 @@ from uuid import uuid4 import pytest +from sqlalchemy import func, select from app.models.item import ClothingItem, ItemStatus from app.models.outfit import Outfit, OutfitItem, OutfitSource, OutfitStatus, TimeOfDay @@ -15,8 +16,10 @@ AIRecommendationError, RecommendationService, cached_suggestion_fits, + fill_best_partial, format_temp_range_text, get_time_of_day, + resolve_outfit_items, suggestion_context, ) from app.services.weather_service import WeatherData @@ -326,6 +329,28 @@ def test_a_cached_suggestion_without_context_does_not_fit(): assert cached_suggestion_fits(older, _ctx(), _FIT_PREFS) is False +def test_fill_best_partial_prefers_an_option_the_fill_can_complete(): + coat, jeans, boots, hoodie = uuid4(), uuid4(), uuid4(), uuid4() + types = {coat: "coat", jeans: "jeans", boots: "boots", hoodie: "hoodie"} + unfillable = ({"headline": "coat"}, [coat, jeans, boots]) + fillable = ({"headline": "jeans"}, [jeans]) + outfit_data, item_ids = fill_best_partial( + [unfillable, fillable], [hoodie, boots, coat, jeans], types + ) + assert outfit_data["headline"] == "jeans" + assert set(item_ids) == {jeans, hoodie, boots} + assert outfit_data["_filled_item_ids"] == [str(hoodie), str(boots)] + assert "_filled_item_ids" not in unfillable[0] + + +def test_fill_best_partial_gives_up_when_no_option_can_be_completed(): + coat, jeans, boots, hoodie = uuid4(), uuid4(), uuid4(), uuid4() + types = {coat: "coat", jeans: "jeans", boots: "boots", hoodie: "hoodie"} + outfit_data = {"headline": "coat"} + assert fill_best_partial([(outfit_data, [coat, jeans, boots])], [hoodie], types) is None + assert "_filled_item_ids" not in outfit_data + + class TestSuggestRequestTimeOfDay: @pytest.mark.asyncio async def test_suggest_accepts_time_of_day(self, client, test_user, auth_headers, db_session): @@ -577,58 +602,16 @@ def test_format_mandatory_items_section(self): assert "MANDATORY ITEMS" in section assert "MUST appear in all outfit suggestions" in section - @pytest.mark.asyncio - async def test_materialize_outfit_enforces_mandatory_item_even_if_omitted_by_ai( - self, db_session, test_user - ): - mand_item = ClothingItem( - user_id=test_user.id, - type="shirt", - image_path=f"test/{uuid4()}.jpg", - status=ItemStatus.ready, - primary_color="blue", - ) - ai_item = ClothingItem( - user_id=test_user.id, - type="pants", - image_path=f"test/{uuid4()}.jpg", - status=ItemStatus.ready, - primary_color="black", - ) - db_session.add_all([mand_item, ai_item]) - await db_session.commit() - - service = RecommendationService(db_session) - weather = WeatherData( - temperature=20, - feels_like=20, - humidity=50, - precipitation_chance=0, - precipitation_mm=0, - wind_speed=0, - condition="clear", - condition_code=0, - is_day=True, - uv_index=0, - timestamp=datetime.utcnow(), - ) - number_map = {1: mand_item.id, 2: ai_item.id} - # AI returned only item 2, omitting item 1 - outfit_data = {"items": [2], "headline": "Casual day"} - - outfit = await service._materialize_outfit( - outfit_data, - test_user, - weather, - occasion="casual", - source=OutfitSource.on_demand, - number_map=number_map, - mandatory_item_ids={mand_item.id}, + def test_resolve_outfit_items_enforces_mandatory_item_even_if_omitted_by_ai(self): + shirt, pants = uuid4(), uuid4() + resolved = resolve_outfit_items( + {"items": [2], "headline": "Casual day"}, + {1: shirt, 2: pants}, + {shirt: "shirt", pants: "pants"}, + {shirt}, + drop_legwear=False, ) - - outfit_item_ids = {oi.item_id for oi in outfit.items} - assert mand_item.id in outfit_item_ids - assert ai_item.id in outfit_item_ids + assert set(resolved) == {shirt, pants} @pytest.mark.asyncio async def test_suggest_accepts_include_items(self, client, test_user, auth_headers, db_session): @@ -744,71 +727,35 @@ async def test_suggest_options_endpoint_returns_multiple(self, client, auth_head class TestHotDayLegwear: - async def _materialize(self, db_session, user, kinds, picked, drop_legwear, mandatory=()): - items = [ - ClothingItem( - user_id=user.id, - type=kind, - image_path=f"test/{uuid4()}.jpg", - status=ItemStatus.ready, - primary_color="black", - ) - for kind in kinds - ] - db_session.add_all(items) - await db_session.commit() - weather = WeatherData( - temperature=30, - feels_like=30, - humidity=50, - precipitation_chance=0, - precipitation_mm=0, - wind_speed=0, - condition="clear", - condition_code=0, - is_day=True, - uv_index=0, - timestamp=datetime.utcnow(), - ) - number_map = {n: item.id for n, item in enumerate(items, start=1)} - outfit = await RecommendationService(db_session)._materialize_outfit( - {"items": picked, "headline": "Hot day"}, - user, - weather, - occasion="casual", - source=OutfitSource.on_demand, - number_map=number_map, - mandatory_item_ids={items[n - 1].id for n in mandatory} or None, + def _resolve(self, kinds, picked, drop_legwear, mandatory=()): + ids = [uuid4() for _ in kinds] + resolved = resolve_outfit_items( + {"items": picked}, + dict(enumerate(ids, start=1)), + dict(zip(ids, kinds, strict=True)), + {ids[n - 1] for n in mandatory} or None, drop_legwear=drop_legwear, ) - return items, {oi.item_id for oi in outfit.items} + return ids, set(resolved) - @pytest.mark.asyncio @pytest.mark.parametrize( ("drop_legwear", "mandatory", "tights_kept"), [(True, (), False), (False, (), True), (True, (3,), True)], ids=["hot", "not-hot", "hot-but-requested"], ) - async def test_drops_only_unrequested_tights_on_a_hot_day( - self, db_session, test_user, drop_legwear, mandatory, tights_kept - ): - items, kept = await self._materialize( - db_session, - test_user, - ("t-shirt", "skirt", "tights", "sneakers"), - [1, 2, 3, 4], - drop_legwear, - mandatory, + def test_drops_only_unrequested_tights_on_a_hot_day(self, drop_legwear, mandatory, tights_kept): + ids, kept = self._resolve( + ("t-shirt", "skirt", "tights", "sneakers"), [1, 2, 3, 4], drop_legwear, mandatory ) - assert (items[2].id in kept) is tights_kept - assert {items[0].id, items[1].id, items[3].id} <= kept + assert (ids[2] in kept) is tights_kept + assert {ids[0], ids[1], ids[3]} <= kept - @pytest.mark.asyncio - async def test_an_answer_of_only_tights_is_not_saved_as_an_empty_outfit( - self, db_session, test_user - ): - with pytest.raises(AIRecommendationError): - await self._materialize(db_session, test_user, ("tights",), [1], True) + def test_an_answer_of_only_tights_resolves_to_nothing(self): + assert self._resolve(("tights",), [1], True)[1] == set() + + def test_unknown_and_non_numeric_picks_are_ignored(self): + ids, kept = self._resolve(("shirt", "jeans"), [1, "two", 9, 1], False) + assert kept == {ids[0]} async def _hot_day_outfits( self, @@ -820,8 +767,24 @@ async def _hot_day_outfits( cached_weather=None, unavailable=None, include=(), + extras=(), + single_outfit=False, + kinds=("tights", "skirt", "t-shirt", "sneakers"), ): - kinds = ("tights", "skirt", "t-shirt", "sneakers") + # Extras come first among the candidates and are numbered last, so that neither + # candidate order nor prompt order can stand in for the fill ranking. + extra_items = [ + ClothingItem( + user_id=user.id, + type=kind, + image_path=f"test/{uuid4()}.jpg", + status=ItemStatus.ready, + primary_color="black", + ) + for kind in extras + ] + db_session.add_all(extra_items) + await db_session.commit() items = [ ClothingItem( user_id=user.id, @@ -838,19 +801,23 @@ async def _hot_day_outfits( items[3].needs_wash = True db_session.add_all(items) await db_session.commit() + items += extra_items number_map = {n: item.id for n, item in enumerate(items, start=1)} user.preferences = None + options = [{"items": o, "headline": f"AI {n}"} for n, o in enumerate(ai_outfits, start=1)] ai = MagicMock() ai.generate_text = AsyncMock( return_value=MagicMock( - content=json.dumps({"outfits": [{"items": o} for o in ai_outfits]}), + content=json.dumps(options[0] if single_outfit else {"outfits": options}), model="m", endpoint="e", ) ) + self.ai = ai weather = _weather(temp=35) if cached is not None: cached = { + "headline": "Cached", **cached, "_number_map": {str(k): str(v) for k, v in number_map.items()}, "_context": suggestion_context( @@ -860,6 +827,12 @@ async def _hot_day_outfits( cached = json.loads(json.dumps(cached)) rejected = {items[3].id} if unavailable == "rejected" else set() service = RecommendationService(db_session) + unpatched = service.get_candidate_items + extra_ids = {item.id for item in extra_items} + + async def extras_first(**kwargs): + return sorted(await unpatched(**kwargs), key=lambda item: item.id not in extra_ids) + with ( patch("app.services.recommendation_service.AIService", return_value=ai), patch( @@ -869,6 +842,7 @@ async def _hot_day_outfits( patch("app.services.recommendation_service.push_suggestions", AsyncMock()) as push, patch.object(service, "_format_items_for_prompt", return_value=("", number_map)), patch.object(service, "_get_today_rejected_item_ids", AsyncMock(return_value=rejected)), + patch.object(service, "get_candidate_items", extras_first), ): outfits = await service.generate_recommendations( user=user, @@ -877,8 +851,9 @@ async def _hot_day_outfits( include_items=[items[n - 1].id for n in include], time_of_day=TimeOfDay.afternoon, count=count, + single_outfit=single_outfit, ) - self.ai = ai + self.reasonings = [o.reasoning for o in outfits] self.pop = pop self.raw_responses = [o.ai_raw_response for o in outfits] self.pushed_contexts = ( @@ -890,10 +865,11 @@ async def _hot_day_outfits( @pytest.mark.asyncio async def test_a_tights_only_option_is_skipped_not_fatal(self, db_session, test_user): items, outfits = await self._hot_day_outfits( - db_session, test_user, [[3, 2], [1], [2, 4], [3]], count=2 + db_session, test_user, [[3, 2, 4], [1], [2, 3, 4], [3, 2, 4]], count=2 ) - assert outfits == [{items[2].id, items[1].id}, {items[1].id, items[3].id}] - assert self.pushed == [[3]] + assert outfits == [{items[1].id, items[2].id, items[3].id}] * 2 + assert self.reasonings == ["AI 1", "AI 3"] + assert self.pushed == [[3, 2, 4]] @pytest.mark.asyncio async def test_a_tights_only_cached_suggestion_falls_through_to_a_fresh_one( @@ -903,13 +879,15 @@ async def test_a_tights_only_cached_suggestion_falls_through_to_a_fresh_one( db_session, test_user, [[3, 2, 4]], cached={"items": [1]} ) assert outfits == [{items[1].id, items[2].id, items[3].id}] + assert self.reasonings == ["AI 1"] @pytest.mark.asyncio async def test_a_cached_suggestion_for_the_same_weather_is_served(self, db_session, test_user): items, outfits = await self._hot_day_outfits( - db_session, test_user, [[3, 2, 4]], cached={"items": [2, 4]} + db_session, test_user, [[3, 2, 4]], cached={"items": [2, 3, 4]} ) - assert outfits == [{items[1].id, items[3].id}] + assert outfits == [{items[1].id, items[2].id, items[3].id}] + assert self.reasonings == ["Cached"] self.ai.generate_text.assert_not_called() assert not {"_context", "_number_map"} & set(self.raw_responses[0]) @@ -921,10 +899,10 @@ async def test_a_cached_suggestion_from_a_cold_day_is_not_served_on_a_hot_day( db_session, test_user, [[3, 2, 4]], - cached={"items": [2, 4]}, + cached={"items": [2, 3, 4]}, cached_weather=_weather(temp=2), ) - assert outfits == [{items[1].id, items[2].id, items[3].id}] + assert self.reasonings == ["AI 1"] @pytest.mark.asyncio @pytest.mark.parametrize("unavailable", ["archived", "needs_wash", "rejected"]) @@ -932,9 +910,10 @@ async def test_a_cached_suggestion_with_an_unavailable_item_is_not_served( self, db_session, test_user, unavailable ): items, outfits = await self._hot_day_outfits( - db_session, test_user, [[3, 2]], cached={"items": [2, 4]}, unavailable=unavailable + db_session, test_user, [[3, 2]], cached={"items": [2, 3, 4]}, unavailable=unavailable ) assert outfits == [{items[1].id, items[2].id}] + assert self.reasonings == ["AI 1"] assert self.pop.await_count == 2 @pytest.mark.asyncio @@ -945,18 +924,19 @@ async def test_a_cached_suggestion_with_a_requested_item_is_served_even_if_it_ne db_session, test_user, [[3, 2]], - cached={"items": [2, 4]}, + cached={"items": [2, 3, 4]}, unavailable="needs_wash", include=(4,), ) - assert outfits == [{items[1].id, items[3].id}] + assert outfits == [{items[1].id, items[2].id, items[3].id}] + assert self.reasonings == ["Cached"] self.ai.generate_text.assert_not_called() @pytest.mark.asyncio async def test_cached_options_record_the_weather_they_were_made_for( self, db_session, test_user ): - await self._hot_day_outfits(db_session, test_user, [[3, 2, 4], [2, 4]]) + await self._hot_day_outfits(db_session, test_user, [[3, 2, 4], [2, 3, 4]]) assert self.pushed_contexts == [ suggestion_context( _weather(temp=35), TimeOfDay.afternoon, None, test_user.body_measurements @@ -968,7 +948,125 @@ async def test_a_tights_only_first_option_falls_through_to_the_next( self, db_session, test_user ): items, outfits = await self._hot_day_outfits( - db_session, test_user, [[1], [3, 2, 4], [2, 3]] + db_session, test_user, [[1], [3, 2, 4], [2, 3, 4]] + ) + assert outfits == [{items[1].id, items[2].id, items[3].id}] + assert self.reasonings == ["AI 2"] + assert self.pushed == [[2, 3, 4]] + + @pytest.mark.asyncio + async def test_an_option_without_shoes_is_skipped_for_a_complete_one( + self, db_session, test_user + ): + await self._hot_day_outfits(db_session, test_user, [[3, 2], [3, 2, 4]]) + assert self.reasonings == ["AI 2"] + assert "_filled_item_ids" not in self.raw_responses[0] + + @pytest.mark.asyncio + async def test_incomplete_leftovers_are_not_cached(self, db_session, test_user): + await self._hot_day_outfits(db_session, test_user, [[3, 2, 4], [3, 2], [2, 3, 4]]) + assert self.pushed == [[2, 3, 4]] + + @pytest.mark.asyncio + async def test_when_no_option_is_complete_the_least_changed_one_is_filled_with_the_best_fit( + self, db_session, test_user + ): + items, outfits = await self._hot_day_outfits( + db_session, test_user, [[3], [2, 4]], extras=("sweater",) ) assert outfits == [{items[1].id, items[2].id, items[3].id}] - assert self.pushed == [[2, 3]] + assert self.reasonings == ["AI 2"] + assert self.raw_responses[0]["_filled_item_ids"] == [str(items[2].id)] + + @pytest.mark.asyncio + async def test_a_single_outfit_missing_a_top_is_filled_with_the_best_fit( + self, db_session, test_user + ): + items, outfits = await self._hot_day_outfits( + db_session, test_user, [[2, 4]], extras=("sweater",), single_outfit=True + ) + assert outfits == [{items[1].id, items[2].id, items[3].id}] + assert self.raw_responses[0]["_filled_item_ids"] == [str(items[2].id)] + + # The only piece for the top is a dress, which cannot go over the skirt the AI chose. + @pytest.mark.asyncio + @pytest.mark.parametrize("single_outfit", [True, False], ids=["single", "options"]) + async def test_an_outfit_the_wardrobe_cannot_complete_is_not_saved( + self, db_session, test_user, single_outfit + ): + with pytest.raises(AIRecommendationError, match="cannot be completed"): + await self._hot_day_outfits( + db_session, + test_user, + [[2, 4]], + single_outfit=single_outfit, + kinds=("tights", "skirt", "dress", "sneakers"), + ) + saved = await db_session.scalar( + select(func.count()).select_from(Outfit).where(Outfit.user_id == test_user.id) + ) + assert saved == 0 + + @pytest.mark.asyncio + async def test_a_requested_item_the_wardrobe_cannot_complete_is_refused_before_the_ai( + self, db_session, test_user + ): + with pytest.raises(ValueError, match="selected items cannot make a complete outfit"): + await self._hot_day_outfits( + db_session, + test_user, + [[2, 4]], + include=(2,), + kinds=("tights", "skirt", "dress", "sneakers"), + ) + self.ai.generate_text.assert_not_called() + + @pytest.mark.asyncio + async def test_a_single_outfit_with_nothing_valid_is_not_built_from_scratch( + self, db_session, test_user + ): + with pytest.raises(AIRecommendationError, match="did not select"): + await self._hot_day_outfits(db_session, test_user, [[99]], single_outfit=True) + + @pytest.mark.asyncio + async def test_options_with_nothing_valid_are_not_built_from_scratch( + self, db_session, test_user + ): + with pytest.raises(AIRecommendationError, match="did not select"): + await self._hot_day_outfits(db_session, test_user, [[99], [1]]) + + @pytest.mark.asyncio + async def test_an_accessory_only_single_outfit_is_not_built_from_scratch( + self, db_session, test_user + ): + with pytest.raises(AIRecommendationError, match="did not select"): + await self._hot_day_outfits( + db_session, test_user, [[5]], extras=("bag",), single_outfit=True + ) + + @pytest.mark.asyncio + async def test_the_fallback_fills_the_option_needing_the_fewest_additions( + self, db_session, test_user + ): + await self._hot_day_outfits(db_session, test_user, [[5], [4], [3, 4]], extras=("bag",)) + assert self.reasonings == ["AI 3"] + + @pytest.mark.asyncio + async def test_fewer_complete_options_than_asked_for_are_returned_as_they_are( + self, db_session, test_user + ): + await self._hot_day_outfits(db_session, test_user, [[3, 2], [3, 2, 4], [2, 4]], count=3) + assert self.reasonings == ["AI 2"] + + @pytest.mark.asyncio + async def test_a_requested_item_counts_toward_completeness(self, db_session, test_user): + await self._hot_day_outfits(db_session, test_user, [[3, 4], [3, 2, 4]], include=(2,)) + assert self.reasonings == ["AI 1"] + + @pytest.mark.asyncio + async def test_an_incomplete_cached_suggestion_falls_through_to_a_fresh_one( + self, db_session, test_user + ): + await self._hot_day_outfits(db_session, test_user, [[3, 2, 4]], cached={"items": [2, 4]}) + assert self.reasonings == ["AI 1"] + self.ai.generate_text.assert_awaited_once()