From 0a1a169c708e64ed125dc3d01b2b22c7a509935f Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:59:08 +1100 Subject: [PATCH 01/16] fix: skip outfit options missing a top, bottom or shoes and fill them as a last resort --- backend/app/services/item_scorer.py | 18 ++ .../app/services/recommendation_service.py | 214 ++++++++-------- backend/app/utils/clothing.py | 49 ++++ backend/tests/test_clothing_utils.py | 81 ++++++ backend/tests/test_item_scorer.py | 20 ++ backend/tests/test_recommendation_service.py | 240 +++++++++--------- 6 files changed, 396 insertions(+), 226 deletions(-) diff --git a/backend/app/services/item_scorer.py b/backend/app/services/item_scorer.py index 7c012c1a9..b147c79a4 100644 --- a/backend/app/services/item_scorer.py +++ b/backend/app/services/item_scorer.py @@ -264,6 +264,24 @@ def _formality_score(item: ClothingItem, occasion: str) -> float: return 0.15 +def rank_by_fit( + items: list[ClothingItem], + weather: WeatherData, + occasion: str, + preferences: UserPreference | 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. + ranked = sorted( + items, + key=lambda item: ( + _weather_score(item, weather, preferences) * _formality_score(item, occasion) + ), + reverse=True, + ) + return [item.id for item in ranked] + + def _season_score( item: ClothingItem, current_season: str, diff --git a/backend/app/services/recommendation_service.py b/backend/app/services/recommendation_service.py index a54678983..654759c04 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,13 @@ WeatherService, WeatherServiceError, ) -from app.utils.clothing import canonical_item_order, deduplicate_by_body_slot, remove_legwear +from app.utils.clothing import ( + canonical_item_order, + deduplicate_by_body_slot, + fill_missing_regions, + missing_regions, + remove_legwear, +) from app.utils.garment_vocabulary import render_outfit_prompt from app.utils.preference_defaults import ( DEFAULT_LAYERING_PREFERENCE, @@ -125,6 +132,41 @@ def cached_suggestion_fits( ) +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: 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 valid_ids: + valid_ids.append(number_map[num_int]) + + 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 + + @dataclass class RecommendationContext: user: User @@ -640,65 +682,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 +854,12 @@ 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} + candidate_ids = list(item_type_map) + # 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: @@ -884,26 +882,20 @@ async def generate_recommendations( pass # Rejected items are covered too, because they were merged into exclude_items. - if not cached_item_ids <= candidate_ids: + if not cached_item_ids <= 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 item_ids or missing_regions(item_ids, candidate_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 +985,74 @@ 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 + ) + # Nothing usable is left to the worker's retry, which asks the AI again, + # rather than built from scratch under the AI's headline. + if not item_ids: + raise AIRecommendationError("AI did not select any valid items") + item_ids = fill_missing_regions( + item_ids, rank_by_fit(candidates, weather, occasion, preferences), item_type_map + ) + 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 = [] + first_partial = None 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, candidate_ids, item_type_map) + if not item_ids: 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, skipping") + if first_partial is None: + first_partial = (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 first_partial is not None: + od, item_ids = first_partial + ranking = rank_by_fit(candidates, weather, occasion, preferences) + chosen = [(od, fill_missing_regions(item_ids, ranking, item_type_map))] + 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 +1106,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..d19153ea1 100644 --- a/backend/app/utils/clothing.py +++ b/backend/app/utils/clothing.py @@ -91,6 +91,55 @@ def deduplicate_by_body_slot( return [iid for iid in item_ids if iid in kept] +# A hoodie or jacket over nothing still dresses the upper body, so any upper role counts. +REGION_ROLES: dict[str, frozenset[str]] = { + "upper": frozenset({"base_top", "mid_layer", "outer_layer", "suit", "full_body"}), + "lower": frozenset({"bottom", "suit", "full_body"}), + "feet": frozenset({"footwear"}), +} + +# Separates only, because a dress or suit would clash with the pieces already chosen. +REGION_FILL_ROLES: dict[str, tuple[str, ...]] = { + "upper": ("base_top", "mid_layer", "outer_layer"), + "lower": ("bottom",), + "feet": ("footwear",), +} + + +def _roles(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> set[str | None]: + return {ITEM_ROLE.get(item_type_map.get(iid, "")) for iid in item_ids} + + +def missing_regions( + item_ids: list[UUID], candidate_ids: list[UUID], item_type_map: dict[UUID, str] +) -> list[str]: + worn = _roles(item_ids, item_type_map) + available = _roles(candidate_ids, item_type_map) + return [ + region for region, roles in REGION_ROLES.items() if roles & available and not roles & worn + ] + + +def fill_missing_regions( + item_ids: list[UUID], ranked_candidate_ids: list[UUID], item_type_map: dict[UUID, str] +) -> list[UUID]: + filled = list(item_ids) + for region in missing_regions(item_ids, ranked_candidate_ids, item_type_map): + pick = next( + ( + iid + for role in REGION_FILL_ROLES[region] + for iid in ranked_candidate_ids + if iid not in filled and ITEM_ROLE.get(item_type_map.get(iid, "")) == role + ), + None, + ) + if pick is not None: + logger.info(f"Adding {item_type_map[pick]} item {pick}: outfit had no {region} piece") + filled.append(pick) + return filled + + _ROLE_SORT_INDEX: dict[str, int] = {role: idx for idx, role in enumerate(ROLES)} diff --git a/backend/tests/test_clothing_utils.py b/backend/tests/test_clothing_utils.py index 27f31fa49..ea3190b71 100644 --- a/backend/tests/test_clothing_utils.py +++ b/backend/tests/test_clothing_utils.py @@ -1,11 +1,15 @@ from uuid import uuid4 +import pytest + from app.utils.clothing import ( ITEM_ROLE, WardrobeComposition, canonical_item_order, count_composition, deduplicate_by_body_slot, + fill_missing_regions, + missing_regions, remove_legwear, ) from app.utils.garment_vocabulary import ROLES @@ -401,3 +405,80 @@ 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"), []), + (("hoodie", "jeans", "sneakers"), ("shirt",), []), + (("cardigan", "skirt", "tights", "boots"), ("blouse",), []), + (("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-as-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_ids, wardrobe_types = _typed(*wardrobe) + candidates = outfit_ids + wardrobe_ids + assert missing_regions(outfit_ids, candidates, 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_falls_back_to_a_layer_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[0]] + + +def test_fill_adds_every_missing_region(): + (sneakers,), outfit_types = _typed("sneakers") + ranked, ranked_types = _typed("boots", "skirt", "dress", "blouse", "pants") + filled = fill_missing_regions([sneakers], ranked, outfit_types | ranked_types) + assert filled == [sneakers, ranked[3], ranked[1]] + + +def test_fill_never_adds_a_dress_or_suit_to_existing_pieces(): + (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 diff --git a/backend/tests/test_item_scorer.py b/backend/tests/test_item_scorer.py index 52e32cca0..2179861d7 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,22 @@ 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", None) == [ + linen.id, + sweater.id, + ] + assert rank_by_fit([linen, sweater], _weather(temp=2), "casual", None) == [ + sweater.id, + linen.id, + ] + + +def test_rank_by_fit_ranks_below_the_scoring_minimum_too(): + items = [_item(type="t-shirt", formality="casual"), _item(type="shirt", formality="formal")] + assert len(items) < MIN_ITEMS_FOR_SCORING + assert rank_by_fit(items, _weather(), "formal", None) == [items[1].id, items[0].id] diff --git a/backend/tests/test_recommendation_service.py b/backend/tests/test_recommendation_service.py index 860e32582..da1122f93 100644 --- a/backend/tests/test_recommendation_service.py +++ b/backend/tests/test_recommendation_service.py @@ -17,6 +17,7 @@ cached_suggestion_fits, format_temp_range_text, get_time_of_day, + resolve_outfit_items, suggestion_context, ) from app.services.weather_service import WeatherData @@ -577,58 +578,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(), + 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, ) - 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}, - ) - - 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 +703,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 +743,24 @@ async def _hot_day_outfits( cached_weather=None, unavailable=None, include=(), + extras=(), + single_outfit=False, ): kinds = ("tights", "skirt", "t-shirt", "sneakers") + # Extras are stored first and numbered last, so that neither database 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,12 +777,14 @@ 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", ) @@ -851,6 +792,7 @@ async def _hot_day_outfits( 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( @@ -877,8 +819,10 @@ 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 +834,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 +848,14 @@ 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 self.reasonings == ["Cached"] self.ai.generate_text.assert_not_called() assert not {"_context", "_number_map"} & set(self.raw_responses[0]) @@ -921,10 +867,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 +878,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 +892,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 +916,59 @@ 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.pushed == [[2, 3]] + 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"] + + @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_first_is_filled_with_the_best_fit( + self, db_session, test_user + ): + items, outfits = await self._hot_day_outfits( + db_session, test_user, [[2, 4], [3]], extras=("sweater",) + ) + assert outfits == [{items[1].id, items[2].id, items[3].id}] + assert self.reasonings == ["AI 1"] + + @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}] + + @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): + await self._hot_day_outfits(db_session, test_user, [[99]], single_outfit=True) + + @pytest.mark.asyncio + async def test_a_requested_item_counts_toward_completeness(self, db_session, test_user): + items, outfits = await self._hot_day_outfits(db_session, test_user, [[4]], include=(2,)) + assert outfits == [{items[1].id, items[2].id, items[3].id}] + + @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() From 6335e25ef6e5fdbba95855b6f7c4050d3dad3c5e Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:20:04 +1100 Subject: [PATCH 02/16] fix: rank fill-ins by fit and wear, and drop picks that are no longer candidates --- backend/app/services/item_scorer.py | 20 +++--- .../app/services/recommendation_service.py | 63 ++++++++++--------- backend/app/utils/clothing.py | 10 +-- backend/tests/test_clothing_utils.py | 5 +- backend/tests/test_item_scorer.py | 21 ++++++- backend/tests/test_recommendation_service.py | 28 ++++++++- 6 files changed, 96 insertions(+), 51 deletions(-) diff --git a/backend/app/services/item_scorer.py b/backend/app/services/item_scorer.py index b147c79a4..602e6893e 100644 --- a/backend/app/services/item_scorer.py +++ b/backend/app/services/item_scorer.py @@ -271,15 +271,17 @@ def rank_by_fit( preferences: UserPreference | 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. - ranked = sorted( - items, - key=lambda item: ( - _weather_score(item, weather, preferences) * _formality_score(item, occasion) - ), - reverse=True, - ) - return [item.id for item in ranked] + # 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 = ( + _weather_score(item, weather, preferences) + * _formality_score(item, occasion) + * _preference_score(item, preferences, None) + ) + return -fit, item.last_worn_at or date.min + + return [item.id for item in sorted(items, key=key)] def _season_score( diff --git a/backend/app/services/recommendation_service.py b/backend/app/services/recommendation_service.py index 654759c04..cd7b4eed0 100644 --- a/backend/app/services/recommendation_service.py +++ b/backend/app/services/recommendation_service.py @@ -132,14 +132,8 @@ def cached_suggestion_fits( ) -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: list[UUID] = [] +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) @@ -148,8 +142,26 @@ def resolve_outfit_items( continue if num_int not in number_map: logger.warning(f"AI selected invalid item number: {num}") - elif number_map[num_int] not in valid_ids: - valid_ids.append(number_map[num_int]) + 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]: + """`item_type_map` holds the current candidates; picks outside it are dropped, because a + numbered item may have been archived, washed or rejected since the prompt was built.""" + valid_ids = [] + for item_id in picked_item_ids(outfit_data, number_map): + if item_id in item_type_map: + valid_ids.append(item_id) + else: + logger.warning(f"AI selected item {item_id} that is no longer a candidate") numbered = set(number_map.values()) for mid in mandatory_item_ids or (): @@ -856,7 +868,6 @@ async def generate_recommendations( mandatory_ids = set(include_items) if include_items else None item_type_map = {item.id: (item.type or "").lower() for item in candidates} - candidate_ids = list(item_type_map) # Check cache for pre-generated suggestions (used when count is 1) if use_cache and count == 1: @@ -872,24 +883,15 @@ 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 <= item_type_map.keys(): + if not set(picked_item_ids(cached, number_map)) <= item_type_map.keys(): logger.info("Cached suggestion uses items no longer available, skipping") continue item_ids = resolve_outfit_items( cached, number_map, item_type_map, mandatory_ids, drop_legwear ) - if not item_ids or missing_regions(item_ids, candidate_ids, item_type_map): + if not item_ids 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}") @@ -988,13 +990,16 @@ async def generate_recommendations( item_ids = resolve_outfit_items( outfit_data, number_map, item_type_map, mandatory_ids, drop_legwear ) - # Nothing usable is left to the worker's retry, which asks the AI again, - # rather than built from scratch under the AI's headline. + # An answer with nothing usable fails rather than being built from scratch + # under a headline the AI wrote for other pieces. if not item_ids: raise AIRecommendationError("AI did not select any valid items") - item_ids = fill_missing_regions( - item_ids, rank_by_fit(candidates, weather, occasion, preferences), item_type_map - ) + if missing_regions(item_ids, item_type_map): + item_ids = fill_missing_regions( + item_ids, + rank_by_fit(candidates, weather, occasion, preferences), + item_type_map, + ) outfit = await self._save_outfit( outfit_data, item_ids, @@ -1018,11 +1023,11 @@ async def generate_recommendations( item_ids = resolve_outfit_items( od, number_map, item_type_map, mandatory_ids, drop_legwear ) - missing = missing_regions(item_ids, candidate_ids, item_type_map) + missing = missing_regions(item_ids, item_type_map) if not item_ids: logger.warning("AI outfit option has nothing left to wear, skipping") elif missing: - logger.warning(f"AI outfit option has no {', '.join(missing)} piece, skipping") + logger.warning(f"AI outfit option has no {', '.join(missing)} piece") if first_partial is None: first_partial = (od, item_ids) else: diff --git a/backend/app/utils/clothing.py b/backend/app/utils/clothing.py index d19153ea1..1558eff5a 100644 --- a/backend/app/utils/clothing.py +++ b/backend/app/utils/clothing.py @@ -110,11 +110,11 @@ def _roles(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> set[str | No return {ITEM_ROLE.get(item_type_map.get(iid, "")) for iid in item_ids} -def missing_regions( - item_ids: list[UUID], candidate_ids: list[UUID], item_type_map: dict[UUID, str] -) -> list[str]: +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 = _roles(item_ids, item_type_map) - available = _roles(candidate_ids, item_type_map) + available = _roles(list(item_type_map), item_type_map) return [ region for region, roles in REGION_ROLES.items() if roles & available and not roles & worn ] @@ -124,7 +124,7 @@ def fill_missing_regions( item_ids: list[UUID], ranked_candidate_ids: list[UUID], item_type_map: dict[UUID, str] ) -> list[UUID]: filled = list(item_ids) - for region in missing_regions(item_ids, ranked_candidate_ids, item_type_map): + for region in missing_regions(item_ids, item_type_map): pick = next( ( iid diff --git a/backend/tests/test_clothing_utils.py b/backend/tests/test_clothing_utils.py index ea3190b71..869b323c4 100644 --- a/backend/tests/test_clothing_utils.py +++ b/backend/tests/test_clothing_utils.py @@ -445,9 +445,8 @@ def _typed(*types): ) def test_missing_regions_only_counts_what_the_wardrobe_can_fill(outfit, wardrobe, expected): outfit_ids, outfit_types = _typed(*outfit) - wardrobe_ids, wardrobe_types = _typed(*wardrobe) - candidates = outfit_ids + wardrobe_ids - assert missing_regions(outfit_ids, candidates, outfit_types | wardrobe_types) == expected + _, 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(): diff --git a/backend/tests/test_item_scorer.py b/backend/tests/test_item_scorer.py index 2179861d7..ed6a8c04e 100644 --- a/backend/tests/test_item_scorer.py +++ b/backend/tests/test_item_scorer.py @@ -693,7 +693,24 @@ def test_rank_by_fit_puts_weather_fit_ahead_of_wardrobe_order(): ] -def test_rank_by_fit_ranks_below_the_scoring_minimum_too(): +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 len(items) < MIN_ITEMS_FOR_SCORING assert rank_by_fit(items, _weather(), "formal", None) == [items[1].id, items[0].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", 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", prefs) == [blue.id, red.id] diff --git a/backend/tests/test_recommendation_service.py b/backend/tests/test_recommendation_service.py index da1122f93..4db30b118 100644 --- a/backend/tests/test_recommendation_service.py +++ b/backend/tests/test_recommendation_service.py @@ -733,6 +733,13 @@ 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]} + def test_a_pick_that_is_no_longer_a_candidate_is_dropped(self): + shirt, archived = uuid4(), uuid4() + resolved = resolve_outfit_items( + {"items": [1, 2]}, {1: shirt, 2: archived}, {shirt: "shirt"}, None, False + ) + assert resolved == [shirt] + async def _hot_day_outfits( self, db_session, @@ -855,6 +862,7 @@ async def test_a_cached_suggestion_for_the_same_weather_is_served(self, db_sessi items, outfits = await self._hot_day_outfits( db_session, test_user, [[3, 2, 4]], cached={"items": [2, 3, 4]} ) + 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]) @@ -957,13 +965,27 @@ async def test_a_single_outfit_missing_a_top_is_filled_with_the_best_fit( async def test_a_single_outfit_with_nothing_valid_is_not_built_from_scratch( self, db_session, test_user ): - with pytest.raises(AIRecommendationError): + 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_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): - items, outfits = await self._hot_day_outfits(db_session, test_user, [[4]], include=(2,)) - assert outfits == [{items[1].id, items[2].id, items[3].id}] + 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( From 5692d34ca1f295fa39636929e9aaff0565f7dc85 Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:31:08 +1100 Subject: [PATCH 03/16] fix: require a real top, not a coat alone, and never fill an answer that covers nothing --- backend/app/data/garment_vocabulary.json | 2 +- backend/app/services/item_scorer.py | 3 +- .../app/services/recommendation_service.py | 27 +++++------- backend/app/utils/clothing.py | 41 +++++++++++-------- backend/app/utils/garment_vocabulary.py | 4 ++ backend/tests/test_clothing_utils.py | 22 ++++++++-- backend/tests/test_item_scorer.py | 20 ++++++--- backend/tests/test_recommendation_service.py | 34 +++++++++++---- 8 files changed, 98 insertions(+), 55 deletions(-) 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 602e6893e..d393193a0 100644 --- a/backend/app/services/item_scorer.py +++ b/backend/app/services/item_scorer.py @@ -269,6 +269,7 @@ def rank_by_fit( weather: WeatherData, occasion: 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 @@ -277,7 +278,7 @@ def key(item: ClothingItem) -> tuple[float, date]: fit = ( _weather_score(item, weather, preferences) * _formality_score(item, occasion) - * _preference_score(item, preferences, None) + * _preference_score(item, preferences, learned_prefs) ) return -fit, item.last_worn_at or date.min diff --git a/backend/app/services/recommendation_service.py b/backend/app/services/recommendation_service.py index cd7b4eed0..407665d44 100644 --- a/backend/app/services/recommendation_service.py +++ b/backend/app/services/recommendation_service.py @@ -42,6 +42,7 @@ ) from app.utils.clothing import ( canonical_item_order, + covers_any_region, deduplicate_by_body_slot, fill_missing_regions, missing_regions, @@ -154,14 +155,7 @@ def resolve_outfit_items( mandatory_item_ids: set[UUID] | None, drop_legwear: bool, ) -> list[UUID]: - """`item_type_map` holds the current candidates; picks outside it are dropped, because a - numbered item may have been archived, washed or rejected since the prompt was built.""" - valid_ids = [] - for item_id in picked_item_ids(outfit_data, number_map): - if item_id in item_type_map: - valid_ids.append(item_id) - else: - logger.warning(f"AI selected item {item_id} that is no longer a candidate") + valid_ids = picked_item_ids(outfit_data, number_map) numbered = set(number_map.values()) for mid in mandatory_item_ids or (): @@ -992,12 +986,12 @@ async def generate_recommendations( ) # An answer with nothing usable fails rather than being built from scratch # under a headline the AI wrote for other pieces. - if not item_ids: + if not covers_any_region(item_ids, item_type_map): raise AIRecommendationError("AI did not select any valid items") if missing_regions(item_ids, item_type_map): item_ids = fill_missing_regions( item_ids, - rank_by_fit(candidates, weather, occasion, preferences), + rank_by_fit(candidates, weather, occasion, preferences, learned_prefs), item_type_map, ) outfit = await self._save_outfit( @@ -1016,7 +1010,7 @@ async def generate_recommendations( outfit_list = self._parse_multi_outfit_response(result.content) complete = [] - first_partial = None + partials = [] for od in outfit_list: od["_ai_model"] = result.model od["_ai_endpoint"] = result.endpoint @@ -1024,21 +1018,20 @@ async def generate_recommendations( od, number_map, item_type_map, mandatory_ids, drop_legwear ) missing = missing_regions(item_ids, item_type_map) - if not item_ids: + if not covers_any_region(item_ids, item_type_map): logger.warning("AI outfit option has nothing left to wear, skipping") elif missing: logger.warning(f"AI outfit option has no {', '.join(missing)} piece") - if first_partial is None: - first_partial = (od, item_ids) + partials.append((len(missing), 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 first_partial is not None: - od, item_ids = first_partial - ranking = rank_by_fit(candidates, weather, occasion, preferences) + if not chosen and partials: + _, od, item_ids = min(partials, key=lambda partial: partial[0]) + ranking = rank_by_fit(candidates, weather, occasion, preferences, learned_prefs) chosen = [(od, fill_missing_regions(item_ids, ranking, item_type_map))] if not chosen: raise AIRecommendationError("AI did not select any valid items") diff --git a/backend/app/utils/clothing.py b/backend/app/utils/clothing.py index 1558eff5a..c5feb7b08 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__) @@ -91,33 +91,37 @@ def deduplicate_by_body_slot( return [iid for iid in item_ids if iid in kept] -# A hoodie or jacket over nothing still dresses the upper body, so any upper role counts. +# 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_ROLES: dict[str, frozenset[str]] = { - "upper": frozenset({"base_top", "mid_layer", "outer_layer", "suit", "full_body"}), + "upper": frozenset({"base_top", "suit", "full_body"}), "lower": frozenset({"bottom", "suit", "full_body"}), "feet": frozenset({"footwear"}), } -# Separates only, because a dress or suit would clash with the pieces already chosen. -REGION_FILL_ROLES: dict[str, tuple[str, ...]] = { - "upper": ("base_top", "mid_layer", "outer_layer"), - "lower": ("bottom",), - "feet": ("footwear",), -} + +def _regions(item_type: str) -> set[str]: + role = ITEM_ROLE.get(item_type) + covered = {region for region, roles in REGION_ROLES.items() if role in roles} + if item_type in WORN_ALONE_TYPES: + covered.add("upper") + return covered + + +def _covered(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)) -def _roles(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> set[str | None]: - return {ITEM_ROLE.get(item_type_map.get(iid, "")) for iid in item_ids} +def covers_any_region(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> bool: + return bool(_covered(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 = _roles(item_ids, item_type_map) - available = _roles(list(item_type_map), item_type_map) - return [ - region for region, roles in REGION_ROLES.items() if roles & available and not roles & worn - ] + worn = _covered(item_ids, item_type_map) + available = _covered(list(item_type_map), item_type_map) + return [region for region in REGION_ROLES if region in available and region not in worn] def fill_missing_regions( @@ -125,12 +129,13 @@ def fill_missing_regions( ) -> list[UUID]: filled = list(item_ids) for region in missing_regions(item_ids, item_type_map): + # Only a piece covering just this region, because a dress or suit would clash + # with the pieces already chosen. pick = next( ( iid - for role in REGION_FILL_ROLES[region] for iid in ranked_candidate_ids - if iid not in filled and ITEM_ROLE.get(item_type_map.get(iid, "")) == role + if iid not in filled and _regions(item_type_map.get(iid, "")) == {region} ), None, ) 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 869b323c4..e100ed819 100644 --- a/backend/tests/test_clothing_utils.py +++ b/backend/tests/test_clothing_utils.py @@ -7,6 +7,7 @@ WardrobeComposition, canonical_item_order, count_composition, + covers_any_region, deduplicate_by_body_slot, fill_missing_regions, missing_regions, @@ -422,7 +423,9 @@ def _typed(*types): (("dress", "sandals"), ("shirt", "jeans"), []), (("suit", "shoes"), ("shirt", "pants"), []), (("hoodie", "jeans", "sneakers"), ("shirt",), []), - (("cardigan", "skirt", "tights", "boots"), ("blouse",), []), + (("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"]), @@ -436,7 +439,9 @@ def _typed(*types): "dress", "suit", "hoodie-as-top", - "cardigan-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", @@ -456,11 +461,20 @@ def test_fill_adds_the_best_ranked_top_before_a_layer(): assert filled == [jeans, sneakers, ranked[1]] -def test_fill_falls_back_to_a_layer_when_there_is_no_top(): +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[0]] + assert filled == [jeans, sneakers, ranked[1]] + + +@pytest.mark.parametrize( + ("outfit", "expected"), + [(("bag",), False), (("tights", "hat"), False), (("coat",), False), (("hoodie",), True)], +) +def test_covers_any_region(outfit, expected): + ids, types = _typed(*outfit) + assert covers_any_region(ids, types) is expected def test_fill_adds_every_missing_region(): diff --git a/backend/tests/test_item_scorer.py b/backend/tests/test_item_scorer.py index ed6a8c04e..8f181c842 100644 --- a/backend/tests/test_item_scorer.py +++ b/backend/tests/test_item_scorer.py @@ -683,11 +683,11 @@ def test_stays_above_hot_threshold_needs_the_coolest_part_to_be_hot(weather, pre 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", None) == [ + assert rank_by_fit([sweater, linen], _weather(temp=35), "casual", None, None) == [ linen.id, sweater.id, ] - assert rank_by_fit([linen, sweater], _weather(temp=2), "casual", None) == [ + assert rank_by_fit([linen, sweater], _weather(temp=2), "casual", None, None) == [ sweater.id, linen.id, ] @@ -695,14 +695,14 @@ def test_rank_by_fit_puts_weather_fit_ahead_of_wardrobe_order(): 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", None) == [items[1].id, items[0].id] + assert rank_by_fit(items, _weather(), "formal", None, None) == [items[1].id, items[0].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", None) == [ + assert rank_by_fit([worn, older, never], _weather(), "casual", None, None) == [ never.id, older.id, worn.id, @@ -713,4 +713,14 @@ 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", prefs) == [blue.id, red.id] + assert rank_by_fit([red, blue], _weather(), "casual", 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", None, learned) == [ + grey.id, + orange.id, + ] diff --git a/backend/tests/test_recommendation_service.py b/backend/tests/test_recommendation_service.py index 4db30b118..add88e513 100644 --- a/backend/tests/test_recommendation_service.py +++ b/backend/tests/test_recommendation_service.py @@ -733,13 +733,6 @@ 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]} - def test_a_pick_that_is_no_longer_a_candidate_is_dropped(self): - shirt, archived = uuid4(), uuid4() - resolved = resolve_outfit_items( - {"items": [1, 2]}, {1: shirt, 2: archived}, {shirt: "shirt"}, None, False - ) - assert resolved == [shirt] - async def _hot_day_outfits( self, db_session, @@ -754,8 +747,8 @@ async def _hot_day_outfits( single_outfit=False, ): kinds = ("tights", "skirt", "t-shirt", "sneakers") - # Extras are stored first and numbered last, so that neither database order nor - # prompt order can stand in for the fill ranking. + # 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, @@ -809,6 +802,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( @@ -818,6 +817,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, @@ -975,6 +975,22 @@ async def test_options_with_nothing_valid_are_not_built_from_scratch( 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_missing_the_fewest_pieces( + 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 From 6ae58048f28800b65f76f0537361ed86623fbcb4 Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:44:00 +1100 Subject: [PATCH 04/16] fix: fill with a top before a hoodie, respect slots, and allow a dress for top and bottom --- .../app/services/recommendation_service.py | 27 +++++++++--- backend/app/utils/clothing.py | 36 ++++++++++----- backend/tests/test_clothing_utils.py | 44 ++++++++++++++++--- 3 files changed, 81 insertions(+), 26 deletions(-) diff --git a/backend/app/services/recommendation_service.py b/backend/app/services/recommendation_service.py index 407665d44..3446e4b4a 100644 --- a/backend/app/services/recommendation_service.py +++ b/backend/app/services/recommendation_service.py @@ -42,9 +42,9 @@ ) from app.utils.clothing import ( canonical_item_order, - covers_any_region, deduplicate_by_body_slot, fill_missing_regions, + has_wearable_piece, missing_regions, remove_legwear, ) @@ -885,7 +885,9 @@ async def generate_recommendations( item_ids = resolve_outfit_items( cached, number_map, item_type_map, mandatory_ids, drop_legwear ) - if not item_ids or missing_regions(item_ids, item_type_map): + 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}") @@ -986,7 +988,7 @@ async def generate_recommendations( ) # An answer with nothing usable fails rather than being built from scratch # under a headline the AI wrote for other pieces. - if not covers_any_region(item_ids, item_type_map): + 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): item_ids = fill_missing_regions( @@ -1018,11 +1020,11 @@ async def generate_recommendations( od, number_map, item_type_map, mandatory_ids, drop_legwear ) missing = missing_regions(item_ids, item_type_map) - if not covers_any_region(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") elif missing: logger.warning(f"AI outfit option has no {', '.join(missing)} piece") - partials.append((len(missing), od, item_ids)) + partials.append((od, item_ids)) else: complete.append((od, item_ids)) @@ -1030,9 +1032,20 @@ async def generate_recommendations( # 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: - _, od, item_ids = min(partials, key=lambda partial: partial[0]) ranking = rank_by_fit(candidates, weather, occasion, preferences, learned_prefs) - chosen = [(od, fill_missing_regions(item_ids, ranking, item_type_map))] + filled = [ + (od, item_ids, fill_missing_regions(item_ids, ranking, item_type_map)) + for od, item_ids in partials + ] + # Prefer the option the fill completes, then the one it changes least. + od, _, item_ids = min( + filled, + key=lambda f: ( + len(missing_regions(f[2], item_type_map)), + len(f[2]) - len(f[1]), + ), + ) + chosen = [(od, item_ids)] if not chosen: raise AIRecommendationError("AI did not select any valid items") diff --git a/backend/app/utils/clothing.py b/backend/app/utils/clothing.py index c5feb7b08..020e564b6 100644 --- a/backend/app/utils/clothing.py +++ b/backend/app/utils/clothing.py @@ -112,7 +112,10 @@ def _covered(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)) -def covers_any_region(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> bool: +def has_wearable_piece(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> bool: + # A wardrobe with nothing that dresses any region (only layers, say) still gets outfits. + if not _covered(list(item_type_map), item_type_map): + return bool(item_ids) return bool(_covered(item_ids, item_type_map)) @@ -128,20 +131,29 @@ def fill_missing_regions( item_ids: list[UUID], ranked_candidate_ids: list[UUID], item_type_map: dict[UUID, str] ) -> list[UUID]: filled = list(item_ids) - for region in missing_regions(item_ids, item_type_map): - # Only a piece covering just this region, because a dress or suit would clash - # with the pieces already chosen. - pick = next( - ( - iid - for iid in ranked_candidate_ids - if iid not in filled and _regions(item_type_map.get(iid, "")) == {region} - ), - None, - ) + still_missing = set(missing_regions(item_ids, item_type_map)) + claimed = set().union(*(_slots_for_type(item_type_map.get(iid, "")) for iid in filled)) + for region in REGION_ROLES: + if region not in still_missing: + continue + # 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 ranked_candidate_ids + if iid not in filled + and region in _regions(item_type_map.get(iid, "")) + and _regions(item_type_map.get(iid, "")) <= still_missing + and not _slots_for_type(item_type_map.get(iid, "")) & claimed + ] + # A real top goes before a layer worn alone, because the AI was asked for a top. + pick = next((iid for iid in fits if item_type_map[iid] not in WORN_ALONE_TYPES), None) + pick = pick or next(iter(fits), None) if pick is not None: logger.info(f"Adding {item_type_map[pick]} item {pick}: outfit had no {region} piece") filled.append(pick) + still_missing -= _regions(item_type_map[pick]) + claimed |= _slots_for_type(item_type_map[pick]) return filled diff --git a/backend/tests/test_clothing_utils.py b/backend/tests/test_clothing_utils.py index e100ed819..f31de2a7a 100644 --- a/backend/tests/test_clothing_utils.py +++ b/backend/tests/test_clothing_utils.py @@ -7,9 +7,9 @@ WardrobeComposition, canonical_item_order, count_composition, - covers_any_region, deduplicate_by_body_slot, fill_missing_regions, + has_wearable_piece, missing_regions, remove_legwear, ) @@ -469,19 +469,28 @@ def test_fill_uses_a_hoodie_but_never_a_coat_when_there_is_no_top(): @pytest.mark.parametrize( - ("outfit", "expected"), - [(("bag",), False), (("tights", "hat"), False), (("coat",), False), (("hoodie",), True)], + ("outfit", "wardrobe", "expected"), + [ + (("bag",), ("shirt",), False), + (("tights", "hat"), ("jeans",), False), + (("coat",), ("shirt",), False), + (("hoodie",), ("shirt",), True), + (("jacket",), ("cardigan",), True), + ((), ("cardigan",), False), + ], + ids=["bag", "tights-and-hat", "coat", "hoodie", "layers-only-wardrobe", "empty"], ) -def test_covers_any_region(outfit, expected): +def test_has_wearable_piece(outfit, wardrobe, expected): ids, types = _typed(*outfit) - assert covers_any_region(ids, types) is expected + _, wardrobe_types = _typed(*wardrobe) + assert has_wearable_piece(ids, types | wardrobe_types) is expected def test_fill_adds_every_missing_region(): (sneakers,), outfit_types = _typed("sneakers") - ranked, ranked_types = _typed("boots", "skirt", "dress", "blouse", "pants") + ranked, ranked_types = _typed("boots", "skirt", "blouse", "dress", "pants") filled = fill_missing_regions([sneakers], ranked, outfit_types | ranked_types) - assert filled == [sneakers, ranked[3], ranked[1]] + assert filled == [sneakers, ranked[2], ranked[1]] def test_fill_never_adds_a_dress_or_suit_to_existing_pieces(): @@ -495,3 +504,24 @@ 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]] From 74505640b3c690af9e463ac889f6e5c5d4dea046 Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:48:50 +1100 Subject: [PATCH 05/16] fix: only demote a hoodie for a real top and pick the partial the fill completes --- .../app/services/recommendation_service.py | 32 +++++++++++-------- backend/app/utils/clothing.py | 16 ++++++++-- backend/tests/test_clothing_utils.py | 15 +++++++++ backend/tests/test_recommendation_service.py | 17 ++++++++-- 4 files changed, 62 insertions(+), 18 deletions(-) diff --git a/backend/app/services/recommendation_service.py b/backend/app/services/recommendation_service.py index 3446e4b4a..7aa52f843 100644 --- a/backend/app/services/recommendation_service.py +++ b/backend/app/services/recommendation_service.py @@ -173,6 +173,24 @@ def resolve_outfit_items( 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]]: + filled = [ + (outfit_data, item_ids, fill_missing_regions(item_ids, ranked_ids, item_type_map)) + for outfit_data, item_ids in partials + ] + # Prefer the option the fill completes, then the one it changes least, because the + # AI's own picks are better matched than anything added here. + outfit_data, _, item_ids = min( + filled, + key=lambda f: (len(missing_regions(f[2], item_type_map)), len(f[2]) - len(f[1])), + ) + return outfit_data, item_ids + + @dataclass class RecommendationContext: user: User @@ -1033,19 +1051,7 @@ async def generate_recommendations( # own complete options are better matched than anything added here. if not chosen and partials: ranking = rank_by_fit(candidates, weather, occasion, preferences, learned_prefs) - filled = [ - (od, item_ids, fill_missing_regions(item_ids, ranking, item_type_map)) - for od, item_ids in partials - ] - # Prefer the option the fill completes, then the one it changes least. - od, _, item_ids = min( - filled, - key=lambda f: ( - len(missing_regions(f[2], item_type_map)), - len(f[2]) - len(f[1]), - ), - ) - chosen = [(od, item_ids)] + chosen = [fill_best_partial(partials, ranking, item_type_map)] if not chosen: raise AIRecommendationError("AI did not select any valid items") diff --git a/backend/app/utils/clothing.py b/backend/app/utils/clothing.py index 020e564b6..5664198b9 100644 --- a/backend/app/utils/clothing.py +++ b/backend/app/utils/clothing.py @@ -146,9 +146,19 @@ def fill_missing_regions( and _regions(item_type_map.get(iid, "")) <= still_missing and not _slots_for_type(item_type_map.get(iid, "")) & claimed ] - # A real top goes before a layer worn alone, because the AI was asked for a top. - pick = next((iid for iid in fits if item_type_map[iid] not in WORN_ALONE_TYPES), None) - pick = pick or next(iter(fits), None) + pick = next(iter(fits), None) + # A real top goes before a better-ranked layer worn alone, because the AI was asked + # for a top; anything else keeps its rank. + if pick is not None and item_type_map[pick] in WORN_ALONE_TYPES: + pick = next( + ( + iid + for iid in fits + if _regions(item_type_map[iid]) == {region} + and item_type_map[iid] not in WORN_ALONE_TYPES + ), + pick, + ) if pick is not None: logger.info(f"Adding {item_type_map[pick]} item {pick}: outfit had no {region} piece") filled.append(pick) diff --git a/backend/tests/test_clothing_utils.py b/backend/tests/test_clothing_utils.py index f31de2a7a..90a29e6f5 100644 --- a/backend/tests/test_clothing_utils.py +++ b/backend/tests/test_clothing_utils.py @@ -525,3 +525,18 @@ def test_fill_uses_a_dress_when_top_and_bottom_are_both_missing(): 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_fill_keeps_a_hoodie_over_a_worse_ranked_suit(): + (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", "suit"]) +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]] diff --git a/backend/tests/test_recommendation_service.py b/backend/tests/test_recommendation_service.py index add88e513..1f6f88b88 100644 --- a/backend/tests/test_recommendation_service.py +++ b/backend/tests/test_recommendation_service.py @@ -15,6 +15,7 @@ AIRecommendationError, RecommendationService, cached_suggestion_fits, + fill_best_partial, format_temp_range_text, get_time_of_day, resolve_outfit_items, @@ -327,6 +328,18 @@ 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} + + class TestSuggestRequestTimeOfDay: @pytest.mark.asyncio async def test_suggest_accepts_time_of_day(self, client, test_user, auth_headers, db_session): @@ -943,7 +956,7 @@ async def test_incomplete_leftovers_are_not_cached(self, db_session, test_user): assert self.pushed == [[2, 3, 4]] @pytest.mark.asyncio - async def test_when_no_option_is_complete_the_first_is_filled_with_the_best_fit( + 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( @@ -985,7 +998,7 @@ async def test_an_accessory_only_single_outfit_is_not_built_from_scratch( ) @pytest.mark.asyncio - async def test_the_fallback_fills_the_option_missing_the_fewest_pieces( + 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",)) From 5594bfe7ca2601b0d7deb456109a1c3535172f9f Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:51:57 +1100 Subject: [PATCH 06/16] fix: choose the fill plan that dresses the most regions instead of filling greedily --- backend/app/utils/clothing.py | 117 +++++++++++++------ backend/tests/test_clothing_utils.py | 8 ++ backend/tests/test_recommendation_service.py | 4 +- 3 files changed, 92 insertions(+), 37 deletions(-) diff --git a/backend/app/utils/clothing.py b/backend/app/utils/clothing.py index 5664198b9..621dfd6fb 100644 --- a/backend/app/utils/clothing.py +++ b/backend/app/utils/clothing.py @@ -127,44 +127,91 @@ def missing_regions(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> lis return [region for region in REGION_ROLES if region in available and region not in worn] +def _fill_options( + region: str, + remaining: frozenset[str], + ranked_ids: list[UUID], + item_type_map: dict[UUID, str], + taken: frozenset[UUID], + claimed: frozenset[str], +) -> list[UUID]: + # 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 ranked_ids + 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], UUID] = {} + for iid in fits: + best.setdefault(frozenset(_regions(item_type_map[iid])), 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. + if single is not None and item_type_map[single] in WORN_ALONE_TYPES: + best[frozenset({region})] = next( + ( + iid + for iid in fits + if _regions(item_type_map[iid]) == {region} + and item_type_map[iid] not in WORN_ALONE_TYPES + ), + single, + ) + return list(best.values()) + + +def _best_fill( + remaining: frozenset[str], + ranked_ids: list[UUID], + item_type_map: dict[UUID, str], + taken: frozenset[UUID], + claimed: frozenset[str], +) -> list[UUID]: + if not remaining: + return [] + region = next(r for r in REGION_ROLES if r in remaining) + plans = [_best_fill(remaining - {region}, ranked_ids, item_type_map, taken, claimed)] + for pick in _fill_options(region, remaining, ranked_ids, item_type_map, taken, claimed): + pick_type = item_type_map[pick] + plans.append( + [pick] + + _best_fill( + remaining - _regions(pick_type), + ranked_ids, + item_type_map, + taken | {pick}, + claimed | _slots_for_type(pick_type), + ) + ) + rank = {iid: n for n, iid in enumerate(ranked_ids)} + # The plan that dresses the most regions wins, then the one whose picks rank best. + return min( + plans, + key=lambda plan: ( + len(remaining - _covered(plan, item_type_map)), + [rank[iid] for iid in plan], + ), + ) + + def fill_missing_regions( item_ids: list[UUID], ranked_candidate_ids: list[UUID], item_type_map: dict[UUID, str] ) -> list[UUID]: - filled = list(item_ids) - still_missing = set(missing_regions(item_ids, item_type_map)) - claimed = set().union(*(_slots_for_type(item_type_map.get(iid, "")) for iid in filled)) - for region in REGION_ROLES: - if region not in still_missing: - continue - # 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 ranked_candidate_ids - if iid not in filled - and region in _regions(item_type_map.get(iid, "")) - and _regions(item_type_map.get(iid, "")) <= still_missing - and not _slots_for_type(item_type_map.get(iid, "")) & claimed - ] - pick = next(iter(fits), None) - # A real top goes before a better-ranked layer worn alone, because the AI was asked - # for a top; anything else keeps its rank. - if pick is not None and item_type_map[pick] in WORN_ALONE_TYPES: - pick = next( - ( - iid - for iid in fits - if _regions(item_type_map[iid]) == {region} - and item_type_map[iid] not in WORN_ALONE_TYPES - ), - pick, - ) - if pick is not None: - logger.info(f"Adding {item_type_map[pick]} item {pick}: outfit had no {region} piece") - filled.append(pick) - still_missing -= _regions(item_type_map[pick]) - claimed |= _slots_for_type(item_type_map[pick]) - return filled + claimed = frozenset().union(*(_slots_for_type(item_type_map.get(iid, "")) for iid in item_ids)) + added = _best_fill( + frozenset(missing_regions(item_ids, item_type_map)), + ranked_candidate_ids, + item_type_map, + frozenset(item_ids), + claimed, + ) + for iid in added: + logger.info(f"Adding {item_type_map[iid]} item {iid} to fill a missing region") + return [*item_ids, *added] _ROLE_SORT_INDEX: dict[str, int] = {role: idx for idx, role in enumerate(ROLES)} diff --git a/backend/tests/test_clothing_utils.py b/backend/tests/test_clothing_utils.py index 90a29e6f5..143bb3e0f 100644 --- a/backend/tests/test_clothing_utils.py +++ b/backend/tests/test_clothing_utils.py @@ -540,3 +540,11 @@ def test_a_one_piece_that_outranks_the_separates_fills_top_and_bottom(one_piece) ranked, ranked_types = _typed(one_piece, "blouse", "skirt") filled = fill_missing_regions([shoes], ranked, outfit_types | ranked_types) assert filled == [shoes, ranked[0]] + + +@pytest.mark.parametrize("one_piece", ["dress", "suit"]) +def test_fill_takes_a_one_piece_over_a_top_when_no_bottom_exists(one_piece): + (sneakers,), outfit_types = _typed("sneakers") + ranked, ranked_types = _typed("hoodie", one_piece, "shirt") + filled = fill_missing_regions([sneakers], ranked, outfit_types | ranked_types) + assert filled == [sneakers, ranked[1]] diff --git a/backend/tests/test_recommendation_service.py b/backend/tests/test_recommendation_service.py index 1f6f88b88..a62c038e6 100644 --- a/backend/tests/test_recommendation_service.py +++ b/backend/tests/test_recommendation_service.py @@ -960,10 +960,10 @@ async def test_when_no_option_is_complete_the_least_changed_one_is_filled_with_t self, db_session, test_user ): items, outfits = await self._hot_day_outfits( - db_session, test_user, [[2, 4], [3]], extras=("sweater",) + db_session, test_user, [[3], [2, 4]], extras=("sweater",) ) assert outfits == [{items[1].id, items[2].id, items[3].id}] - assert self.reasonings == ["AI 1"] + assert self.reasonings == ["AI 2"] @pytest.mark.asyncio async def test_a_single_outfit_missing_a_top_is_filled_with_the_best_fit( From 79e89f1458955ef9290240b8e2d52745fa62f166 Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:00:09 +1100 Subject: [PATCH 07/16] fix: rank a top swapped in for a hoodie at the hoodie's rank --- backend/app/utils/clothing.py | 61 +++++++++++++++------------- backend/tests/test_clothing_utils.py | 19 ++++++++- 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/backend/app/utils/clothing.py b/backend/app/utils/clothing.py index 621dfd6fb..1e5655aa0 100644 --- a/backend/app/utils/clothing.py +++ b/backend/app/utils/clothing.py @@ -130,70 +130,71 @@ def missing_regions(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> lis def _fill_options( region: str, remaining: frozenset[str], - ranked_ids: list[UUID], + rank: dict[UUID, int], item_type_map: dict[UUID, str], taken: frozenset[UUID], claimed: frozenset[str], -) -> list[UUID]: +) -> 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 ranked_ids + 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], UUID] = {} + best: dict[frozenset[str], tuple[UUID, int]] = {} for iid in fits: - best.setdefault(frozenset(_regions(item_type_map[iid])), iid) + 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. - if single is not None and item_type_map[single] in WORN_ALONE_TYPES: - best[frozenset({region})] = next( + # for a top. It competes at the layer's rank, so that swapping in a weaker top never + # hands the win to a dress or suit 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 ), - single, + None, ) + if top is not None: + best[frozenset({region})] = (top, single[1]) return list(best.values()) def _best_fill( remaining: frozenset[str], - ranked_ids: list[UUID], + rank: dict[UUID, int], item_type_map: dict[UUID, str], taken: frozenset[UUID], claimed: frozenset[str], -) -> list[UUID]: +) -> list[tuple[UUID, int]]: if not remaining: return [] region = next(r for r in REGION_ROLES if r in remaining) - plans = [_best_fill(remaining - {region}, ranked_ids, item_type_map, taken, claimed)] - for pick in _fill_options(region, remaining, ranked_ids, item_type_map, taken, claimed): + 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] - plans.append( - [pick] - + _best_fill( - remaining - _regions(pick_type), - ranked_ids, - item_type_map, - taken | {pick}, - claimed | _slots_for_type(pick_type), - ) + rest = _best_fill( + remaining - _regions(pick_type), + rank, + item_type_map, + taken | {pick}, + claimed | _slots_for_type(pick_type), ) - rank = {iid: n for n, iid in enumerate(ranked_ids)} - # The plan that dresses the most regions wins, then the one whose picks rank best. + 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(plan, item_type_map)), - [rank[iid] for iid in plan], + len(remaining - _covered([iid for iid, _ in plan], item_type_map)), + [pick_rank for _, pick_rank in plan], ), ) @@ -202,15 +203,17 @@ 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)) - added = _best_fill( + plan = _best_fill( frozenset(missing_regions(item_ids, item_type_map)), - ranked_candidate_ids, + {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: - logger.info(f"Adding {item_type_map[iid]} item {iid} to fill a missing region") + regions = ", ".join(sorted(_regions(item_type_map[iid]))) + logger.info(f"Adding {item_type_map[iid]} item {iid}: outfit had no {regions} piece") return [*item_ids, *added] diff --git a/backend/tests/test_clothing_utils.py b/backend/tests/test_clothing_utils.py index 143bb3e0f..d9e77b5cb 100644 --- a/backend/tests/test_clothing_utils.py +++ b/backend/tests/test_clothing_utils.py @@ -543,8 +543,23 @@ def test_a_one_piece_that_outranks_the_separates_fills_top_and_bottom(one_piece) @pytest.mark.parametrize("one_piece", ["dress", "suit"]) -def test_fill_takes_a_one_piece_over_a_top_when_no_bottom_exists(one_piece): +def test_fill_takes_a_one_piece_over_a_better_top_when_no_bottom_exists(one_piece): (sneakers,), outfit_types = _typed("sneakers") - ranked, ranked_types = _typed("hoodie", one_piece, "shirt") + ranked, ranked_types = _typed("shirt", one_piece) filled = fill_missing_regions([sneakers], ranked, outfit_types | ranked_types) assert filled == [sneakers, ranked[1]] + + +@pytest.mark.parametrize("one_piece", ["dress", "suit"]) +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]] From 9b890b4696834e2cf6b0722f5e341c3d139c6db3 Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:36:34 +1100 Subject: [PATCH 08/16] test: keep the hoodie-then-shirt case where only region count saves the one-piece --- backend/tests/test_clothing_utils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_clothing_utils.py b/backend/tests/test_clothing_utils.py index d9e77b5cb..1dcfff0b7 100644 --- a/backend/tests/test_clothing_utils.py +++ b/backend/tests/test_clothing_utils.py @@ -542,10 +542,13 @@ def test_a_one_piece_that_outranks_the_separates_fills_top_and_bottom(one_piece) 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", "suit"]) -def test_fill_takes_a_one_piece_over_a_better_top_when_no_bottom_exists(one_piece): +@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("shirt", one_piece) + ranked, ranked_types = _typed(*before, one_piece, "shirt") filled = fill_missing_regions([sneakers], ranked, outfit_types | ranked_types) assert filled == [sneakers, ranked[1]] From abcedf4f92bd892d7d9e7f87beddddce462ab4f0 Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:36:35 +1100 Subject: [PATCH 09/16] fix: log filled regions in body order --- backend/app/utils/clothing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/app/utils/clothing.py b/backend/app/utils/clothing.py index 1e5655aa0..3cd538708 100644 --- a/backend/app/utils/clothing.py +++ b/backend/app/utils/clothing.py @@ -212,7 +212,8 @@ def fill_missing_regions( ) added = [iid for iid, _ in plan] for iid in added: - regions = ", ".join(sorted(_regions(item_type_map[iid]))) + covered = _regions(item_type_map[iid]) + regions = ", ".join(r for r in REGION_ROLES if r in covered) logger.info(f"Adding {item_type_map[iid]} item {iid}: outfit had no {regions} piece") return [*item_ids, *added] From 02025abf22061c8cc855edfa8fbc3aa135225dd2 Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:10:52 +1100 Subject: [PATCH 10/16] fix: count a suit as a bottom so it still needs a top, and share region coverage with pairings --- backend/app/services/pairing_service.py | 6 ++-- backend/app/utils/clothing.py | 46 +++++++++---------------- backend/tests/test_clothing_utils.py | 15 +++++--- 3 files changed, 30 insertions(+), 37 deletions(-) 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/utils/clothing.py b/backend/app/utils/clothing.py index 3cd538708..312df3e18 100644 --- a/backend/app/utils/clothing.py +++ b/backend/app/utils/clothing.py @@ -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,40 +79,38 @@ def deduplicate_by_body_slot( return [iid for iid in item_ids if iid in kept] -# 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_ROLES: dict[str, frozenset[str]] = { - "upper": frozenset({"base_top", "suit", "full_body"}), - "lower": frozenset({"bottom", "suit", "full_body"}), - "feet": frozenset({"footwear"}), -} +# 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]: - role = ITEM_ROLE.get(item_type) - covered = {region for region, roles in REGION_ROLES.items() if role in roles} + 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(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> set[str]: +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)) def has_wearable_piece(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> bool: # A wardrobe with nothing that dresses any region (only layers, say) still gets outfits. - if not _covered(list(item_type_map), item_type_map): + if not covered_regions(list(item_type_map), item_type_map): return bool(item_ids) - return bool(_covered(item_ids, item_type_map)) + 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(item_ids, item_type_map) - available = _covered(list(item_type_map), item_type_map) - return [region for region in REGION_ROLES if region in available and region not in worn] + 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( @@ -176,7 +162,7 @@ def _best_fill( ) -> list[tuple[UUID, int]]: if not remaining: return [] - region = next(r for r in REGION_ROLES if r in remaining) + 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] @@ -193,7 +179,7 @@ def _best_fill( return min( plans, key=lambda plan: ( - len(remaining - _covered([iid for iid, _ in plan], item_type_map)), + len(remaining - covered_regions([iid for iid, _ in plan], item_type_map)), [pick_rank for _, pick_rank in plan], ), ) @@ -213,7 +199,7 @@ def fill_missing_regions( added = [iid for iid, _ in plan] for iid in added: covered = _regions(item_type_map[iid]) - regions = ", ".join(r for r in REGION_ROLES if r in covered) + 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] diff --git a/backend/tests/test_clothing_utils.py b/backend/tests/test_clothing_utils.py index 1dcfff0b7..83c2f0c0e 100644 --- a/backend/tests/test_clothing_utils.py +++ b/backend/tests/test_clothing_utils.py @@ -421,7 +421,7 @@ def _typed(*types): (("shirt", "jeans"), ("boots",), ["feet"]), (("shirt", "jeans", "sneakers"), ("dress", "sandals"), []), (("dress", "sandals"), ("shirt", "jeans"), []), - (("suit", "shoes"), ("shirt", "pants"), []), + (("suit", "shoes"), ("shirt", "pants"), ["upper"]), (("hoodie", "jeans", "sneakers"), ("shirt",), []), (("cardigan", "skirt", "tights", "boots"), ("blouse",), ["upper"]), (("coat", "jeans", "boots"), ("shirt",), ["upper"]), @@ -527,6 +527,13 @@ def test_fill_uses_a_dress_when_top_and_bottom_are_both_missing(): 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_keeps_a_hoodie_over_a_worse_ranked_suit(): (sneakers,), outfit_types = _typed("sneakers") ranked, ranked_types = _typed("hoodie", "jeans", "suit") @@ -534,7 +541,7 @@ def test_fill_keeps_a_hoodie_over_a_worse_ranked_suit(): assert filled == [sneakers, ranked[0], ranked[1]] -@pytest.mark.parametrize("one_piece", ["dress", "suit"]) +@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") @@ -544,7 +551,7 @@ def test_a_one_piece_that_outranks_the_separates_fills_top_and_bottom(one_piece) # 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", "suit"]) +@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") @@ -553,7 +560,7 @@ def test_fill_takes_a_one_piece_over_a_better_top_when_no_bottom_exists(before, assert filled == [sneakers, ranked[1]] -@pytest.mark.parametrize("one_piece", ["dress", "suit"]) +@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") From e66e73d6658470ac13ad2439bb5052724a2c5255 Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:23:05 +1100 Subject: [PATCH 11/16] test: name the suit fill cases after the bottom-only suit rule --- backend/app/utils/clothing.py | 2 +- backend/tests/test_clothing_utils.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/utils/clothing.py b/backend/app/utils/clothing.py index 312df3e18..f3168c889 100644 --- a/backend/app/utils/clothing.py +++ b/backend/app/utils/clothing.py @@ -137,7 +137,7 @@ def _fill_options( 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 dress or suit the layer would have beaten. + # 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( ( diff --git a/backend/tests/test_clothing_utils.py b/backend/tests/test_clothing_utils.py index 83c2f0c0e..e9c84f9db 100644 --- a/backend/tests/test_clothing_utils.py +++ b/backend/tests/test_clothing_utils.py @@ -493,7 +493,7 @@ def test_fill_adds_every_missing_region(): assert filled == [sneakers, ranked[2], ranked[1]] -def test_fill_never_adds_a_dress_or_suit_to_existing_pieces(): +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) @@ -534,7 +534,7 @@ def test_a_suit_fills_only_the_bottom_and_still_takes_a_top(): assert filled == [shoes, ranked[1], ranked[0]] -def test_fill_keeps_a_hoodie_over_a_worse_ranked_suit(): +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) From c743a7f4297c64bbe565093af7ca8bfe88e76e48 Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:05:06 +1100 Subject: [PATCH 12/16] fix: rank fill-ins by season too, sharing the fit factors with score_items --- backend/app/services/item_scorer.py | 31 ++++++++++++++----- .../app/services/recommendation_service.py | 13 ++++++-- backend/tests/test_item_scorer.py | 27 ++++++++++++---- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/backend/app/services/item_scorer.py b/backend/app/services/item_scorer.py index d393193a0..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,10 +265,27 @@ 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]: @@ -275,10 +293,8 @@ def rank_by_fit( # 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 = ( - _weather_score(item, weather, preferences) - * _formality_score(item, occasion) - * _preference_score(item, preferences, learned_prefs) + fit = prod( + _fit_factors(item, weather, occasion, current_season, preferences, learned_prefs) ) return -fit, item.last_worn_at or date.min @@ -477,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/recommendation_service.py b/backend/app/services/recommendation_service.py index 7aa52f843..3ffd36f52 100644 --- a/backend/app/services/recommendation_service.py +++ b/backend/app/services/recommendation_service.py @@ -1011,7 +1011,14 @@ async def generate_recommendations( if missing_regions(item_ids, item_type_map): item_ids = fill_missing_regions( item_ids, - rank_by_fit(candidates, weather, occasion, preferences, learned_prefs), + rank_by_fit( + candidates, + weather, + occasion, + current_season, + preferences, + learned_prefs, + ), item_type_map, ) outfit = await self._save_outfit( @@ -1050,7 +1057,9 @@ async def generate_recommendations( # 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, preferences, learned_prefs) + ranking = rank_by_fit( + candidates, weather, occasion, current_season, preferences, learned_prefs + ) chosen = [fill_best_partial(partials, ranking, item_type_map)] if not chosen: raise AIRecommendationError("AI did not select any valid items") diff --git a/backend/tests/test_item_scorer.py b/backend/tests/test_item_scorer.py index 8f181c842..360f6603a 100644 --- a/backend/tests/test_item_scorer.py +++ b/backend/tests/test_item_scorer.py @@ -683,11 +683,11 @@ def test_stays_above_hot_threshold_needs_the_coolest_part_to_be_hot(weather, pre 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", None, None) == [ + 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", None, None) == [ + assert rank_by_fit([linen, sweater], _weather(temp=2), "casual", "winter", None, None) == [ sweater.id, linen.id, ] @@ -695,14 +695,26 @@ def test_rank_by_fit_puts_weather_fit_ahead_of_wardrobe_order(): 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", None, None) == [items[1].id, items[0].id] + 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", None, None) == [ + assert rank_by_fit([worn, older, never], _weather(), "casual", "spring", None, None) == [ never.id, older.id, worn.id, @@ -713,14 +725,17 @@ 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", prefs, None) == [blue.id, red.id] + 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", None, learned) == [ + assert rank_by_fit([orange, grey], _weather(), "casual", "spring", None, learned) == [ grey.id, orange.id, ] From 3848e5edc53487a4b20cdb2222aac2e1dfebafb3 Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:05:23 +1100 Subject: [PATCH 13/16] fix: count only a layer as wearable in a wardrobe that dresses no region --- backend/app/utils/clothing.py | 8 ++++++-- backend/tests/test_clothing_utils.py | 13 ++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/backend/app/utils/clothing.py b/backend/app/utils/clothing.py index f3168c889..de3009b6e 100644 --- a/backend/app/utils/clothing.py +++ b/backend/app/utils/clothing.py @@ -98,10 +98,14 @@ def covered_regions(item_ids: list[UUID], item_type_map: dict[UUID, str]) -> set 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 (only layers, say) still gets outfits. + # 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 bool(item_ids) + 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)) diff --git a/backend/tests/test_clothing_utils.py b/backend/tests/test_clothing_utils.py index e9c84f9db..e7f7e4117 100644 --- a/backend/tests/test_clothing_utils.py +++ b/backend/tests/test_clothing_utils.py @@ -477,8 +477,19 @@ def test_fill_uses_a_hoodie_but_never_a_coat_when_there_is_no_top(): (("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", ], - ids=["bag", "tights-and-hat", "coat", "hoodie", "layers-only-wardrobe", "empty"], ) def test_has_wearable_piece(outfit, wardrobe, expected): ids, types = _typed(*outfit) From 77ec4e6597d3b6df5e1118635b9302992e23aa69 Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:05:40 +1100 Subject: [PATCH 14/16] fix: refuse an outfit the fill cannot complete instead of saving it --- .../app/services/recommendation_service.py | 37 +++++++++---------- backend/tests/test_recommendation_service.py | 30 ++++++++++++--- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/backend/app/services/recommendation_service.py b/backend/app/services/recommendation_service.py index 3ffd36f52..1838e4df3 100644 --- a/backend/app/services/recommendation_service.py +++ b/backend/app/services/recommendation_service.py @@ -61,6 +61,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" @@ -177,17 +178,17 @@ def fill_best_partial( partials: list[tuple[dict, list[UUID]]], ranked_ids: list[UUID], item_type_map: dict[UUID, str], -) -> tuple[dict, list[UUID]]: +) -> 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 ] - # Prefer the option the fill completes, then the one it changes least, because the - # AI's own picks are better matched than anything added here. - outfit_data, _, item_ids = min( - filled, - key=lambda f: (len(missing_regions(f[2], item_type_map)), len(f[2]) - len(f[1])), - ) + 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, _, item_ids = min(completed, key=lambda f: len(f[2]) - len(f[1])) return outfit_data, item_ids @@ -1009,18 +1010,13 @@ async def generate_recommendations( 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): - item_ids = fill_missing_regions( - item_ids, - rank_by_fit( - candidates, - weather, - occasion, - current_season, - preferences, - learned_prefs, - ), - 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, @@ -1060,7 +1056,10 @@ async def generate_recommendations( ranking = rank_by_fit( candidates, weather, occasion, current_season, preferences, learned_prefs ) - chosen = [fill_best_partial(partials, ranking, item_type_map)] + 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") diff --git a/backend/tests/test_recommendation_service.py b/backend/tests/test_recommendation_service.py index a62c038e6..fb52dbf82 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 @@ -340,6 +341,13 @@ def test_fill_best_partial_prefers_an_option_the_fill_can_complete(): assert set(item_ids) == {jeans, hoodie, boots} +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 + + class TestSuggestRequestTimeOfDay: @pytest.mark.asyncio async def test_suggest_accepts_time_of_day(self, client, test_user, auth_headers, db_session): @@ -758,8 +766,8 @@ async def _hot_day_outfits( 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 = [ @@ -974,12 +982,24 @@ async def test_a_single_outfit_missing_a_top_is_filled_with_the_best_fit( ) assert outfits == [{items[1].id, items[2].id, items[3].id}] + # The only piece for the top is a dress, which cannot go over the skirt the AI chose. @pytest.mark.asyncio - async def test_a_single_outfit_with_nothing_valid_is_not_built_from_scratch( - self, db_session, test_user + @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="did not select"): - await self._hot_day_outfits(db_session, test_user, [[99]], single_outfit=True) + 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_options_with_nothing_valid_are_not_built_from_scratch( From b6ffd3e5f5b0496825fc90bb09744d394c118313 Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:05:57 +1100 Subject: [PATCH 15/16] fix: record which pieces the fill added in the saved AI response --- backend/app/services/recommendation_service.py | 5 ++++- backend/tests/test_recommendation_service.py | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/app/services/recommendation_service.py b/backend/app/services/recommendation_service.py index 1838e4df3..5eeb72904 100644 --- a/backend/app/services/recommendation_service.py +++ b/backend/app/services/recommendation_service.py @@ -188,7 +188,10 @@ def fill_best_partial( return None # Prefer the option the fill changes least, because the AI's own picks are better # matched than anything added here. - outfit_data, _, item_ids = min(completed, key=lambda f: len(f[2]) - len(f[1])) + 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 diff --git a/backend/tests/test_recommendation_service.py b/backend/tests/test_recommendation_service.py index fb52dbf82..29bfc2856 100644 --- a/backend/tests/test_recommendation_service.py +++ b/backend/tests/test_recommendation_service.py @@ -339,6 +339,8 @@ def test_fill_best_partial_prefers_an_option_the_fill_can_complete(): ) 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(): @@ -346,6 +348,7 @@ def test_fill_best_partial_gives_up_when_no_option_can_be_completed(): 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: @@ -957,6 +960,7 @@ async def test_an_option_without_shoes_is_skipped_for_a_complete_one( ): 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): @@ -972,6 +976,7 @@ async def test_when_no_option_is_complete_the_least_changed_one_is_filled_with_t ) assert outfits == [{items[1].id, items[2].id, items[3].id}] 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( @@ -981,6 +986,7 @@ async def test_a_single_outfit_missing_a_top_is_filled_with_the_best_fit( 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 From a8f701701e0a389b4ae0cedb82385de0514d09c3 Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:06:12 +1100 Subject: [PATCH 16/16] fix: refuse requested items the wardrobe cannot complete before calling the AI --- .../app/services/recommendation_service.py | 10 +++++++ backend/app/utils/clothing.py | 20 +++++++++++++ backend/tests/test_clothing_utils.py | 30 +++++++++++++++++++ backend/tests/test_recommendation_service.py | 23 +++++++++++++- 4 files changed, 82 insertions(+), 1 deletion(-) diff --git a/backend/app/services/recommendation_service.py b/backend/app/services/recommendation_service.py index 5eeb72904..72500b62e 100644 --- a/backend/app/services/recommendation_service.py +++ b/backend/app/services/recommendation_service.py @@ -41,6 +41,7 @@ WeatherServiceError, ) from app.utils.clothing import ( + can_complete_outfit, canonical_item_order, deduplicate_by_body_slot, fill_missing_regions, @@ -884,6 +885,15 @@ async def generate_recommendations( 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: diff --git a/backend/app/utils/clothing.py b/backend/app/utils/clothing.py index de3009b6e..f7cc68eb2 100644 --- a/backend/app/utils/clothing.py +++ b/backend/app/utils/clothing.py @@ -189,6 +189,26 @@ def _best_fill( ) +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]: diff --git a/backend/tests/test_clothing_utils.py b/backend/tests/test_clothing_utils.py index e7f7e4117..c9bd2c053 100644 --- a/backend/tests/test_clothing_utils.py +++ b/backend/tests/test_clothing_utils.py @@ -5,6 +5,7 @@ from app.utils.clothing import ( ITEM_ROLE, WardrobeComposition, + can_complete_outfit, canonical_item_order, count_composition, deduplicate_by_body_slot, @@ -497,6 +498,35 @@ def test_has_wearable_piece(outfit, wardrobe, expected): 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") diff --git a/backend/tests/test_recommendation_service.py b/backend/tests/test_recommendation_service.py index 29bfc2856..9c48180ff 100644 --- a/backend/tests/test_recommendation_service.py +++ b/backend/tests/test_recommendation_service.py @@ -813,6 +813,7 @@ async def _hot_day_outfits( endpoint="e", ) ) + self.ai = ai weather = _weather(temp=35) if cached is not None: cached = { @@ -852,7 +853,6 @@ async def extras_first(**kwargs): 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] @@ -1007,6 +1007,27 @@ async def test_an_outfit_the_wardrobe_cannot_complete_is_not_saved( ) 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