From cc41f8bd6ec5a024f4d232630d7505a112839f9e Mon Sep 17 00:00:00 2001 From: Sarah Withee Date: Tue, 13 Oct 2026 01:34:29 -0400 Subject: [PATCH 1/6] Refactor prompts and template rendering to align outfit definitions with garment vocabulary, ensuring consistency and avoiding manual updates. Added tests for vocabulary-based role types and prompt rendering. --- backend/app/prompts/item_pairing.txt | 8 +++--- backend/app/prompts/recommendation.txt | 9 ++++--- backend/app/services/pairing_service.py | 3 ++- .../app/services/recommendation_service.py | 3 ++- backend/app/utils/garment_vocabulary.py | 15 +++++++++++ backend/tests/test_garment_vocabulary.py | 26 +++++++++++++++++++ 6 files changed, 55 insertions(+), 9 deletions(-) diff --git a/backend/app/prompts/item_pairing.txt b/backend/app/prompts/item_pairing.txt index f34e450a5..ca3ff7fa1 100644 --- a/backend/app/prompts/item_pairing.txt +++ b/backend/app/prompts/item_pairing.txt @@ -8,10 +8,12 @@ AVAILABLE ITEMS: Create {num_pairings} DIFFERENT complete outfits. Each MUST: - Include the source item [{source_number}] -- Have: exactly ONE top + ONE bottom + ONE shoes (or ONE dress/jumpsuit + shoes) -- NEVER combine two items from the same slot. Bottoms (pants, jeans, shorts, skirt) are the SAME slot. Tops (shirt, t-shirt, blouse, sweater, hoodie, polo, tank-top, cardigan, vest) are the SAME slot. +- Complete it with ONE of these silhouettes. They are equally valid — pick whichever fits the source item, do not assume separates: + - Separates: exactly ONE top (<>) + ONE bottom (<>) + ONE footwear (<>) + - One-piece: exactly ONE full-body garment (<>) + ONE footwear (<>) +- NEVER combine two items from the same slot. Bottoms (<>) are the SAME slot, tops (<>) are the SAME slot, and a full-body garment already fills both the top and bottom slots. - Optionally add accessories (belt, bag, jewelry, scarf) if they complement -- Optionally add tights under a skirt, dress or shorts; tights do not replace the bottom or shoes. When the source item is tights, build every outfit around a skirt, dress or shorts +- Optionally add legwear (<>) under a skirt, dress or shorts; legwear does not replace the bottom or footwear. When the source item is legwear, build every outfit around a skirt, dress or shorts For each outfit, explain WHY the items complement the source item (color harmony, style match, formality level). diff --git a/backend/app/prompts/recommendation.txt b/backend/app/prompts/recommendation.txt index 8a74181e8..a1d69d7ac 100644 --- a/backend/app/prompts/recommendation.txt +++ b/backend/app/prompts/recommendation.txt @@ -25,7 +25,7 @@ Texture and fabric: Proportion and silhouette: - Balance volume: if the top is relaxed or oversized, the bottom should be more fitted, and vice versa - Do not go oversized on both top and bottom simultaneously -- Consider the visual line — where the top breaks (waist vs hip) affects the overall silhouette +- Consider the visual line: with separates, where the top breaks (waist vs hip) sets the silhouette; with a one-piece garment, its own waist placement and fit define the line - If user body measurements are provided, consider proportions: taller frames carry volume better, broader builds benefit from structured pieces, and item sizes should be compatible with the user's measurements Layering (when weather calls for it): @@ -46,9 +46,10 @@ AVAILABLE ITEMS: {mandatory_items_section} RULES: -- Pick exactly ONE top (shirt/blouse/sweater/t-shirt) + ONE bottom (pants/jeans/skirt/shorts) + ONE shoes -- OR pick ONE dress + ONE shoes -- OPTIONAL: tights under a skirt, dress or shorts in cool or cold weather; leave them out when it is warm, and put only thermal tights under trousers, only when it is very cold. Tights do not replace the bottom or shoes +- Build the look around ONE of these silhouettes. They are equally valid — choose whichever suits the wardrobe and the occasion, do not assume separates: + - Separates: ONE top (<>) + ONE bottom (<>) + ONE footwear (<>) + - One-piece: ONE full-body garment (<>) + ONE footwear (<>) +- OPTIONAL: legwear (<>) under any silhouette — under a skirt, dress or shorts in cool or cold weather; leave it out when it is warm, and put only thermal legwear under trousers, only when it is very cold. Legwear does not replace the bottom, the one-piece garment or footwear - OPTIONAL: ONE bag if appropriate for the occasion - OPTIONAL: accessories (belt, scarf, hat, jewelry) that genuinely complement the outfit - For cold or rainy weather, add outerwear (jacket/coat) if available diff --git a/backend/app/services/pairing_service.py b/backend/app/services/pairing_service.py index 5c5cf50d0..e24951ee8 100644 --- a/backend/app/services/pairing_service.py +++ b/backend/app/services/pairing_service.py @@ -12,12 +12,13 @@ from app.models.user import User from app.services.ai_service import AIResponseTruncatedError, AIService, require_internal_ai from app.utils.clothing import 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 logger = logging.getLogger(__name__) -PAIRING_PROMPT_TEMPLATE = load_prompt("item_pairing") +PAIRING_PROMPT_TEMPLATE = render_outfit_prompt(load_prompt("item_pairing")) # A pairing is an internally-generated row, or an externally-authored one carrying the # server-set "pairing" occasion (external rows with any other occasion are suggestions). diff --git a/backend/app/services/recommendation_service.py b/backend/app/services/recommendation_service.py index 35f483a8e..a54678983 100644 --- a/backend/app/services/recommendation_service.py +++ b/backend/app/services/recommendation_service.py @@ -40,6 +40,7 @@ WeatherServiceError, ) from app.utils.clothing import canonical_item_order, deduplicate_by_body_slot, remove_legwear +from app.utils.garment_vocabulary import render_outfit_prompt from app.utils.preference_defaults import ( DEFAULT_LAYERING_PREFERENCE, DEFAULT_TEMPERATURE_SENSITIVITY, @@ -134,7 +135,7 @@ class RecommendationContext: include_items: list[UUID] -RECOMMENDATION_PROMPT = load_prompt("recommendation") +RECOMMENDATION_PROMPT = render_outfit_prompt(load_prompt("recommendation")) class RecommendationService: diff --git a/backend/app/utils/garment_vocabulary.py b/backend/app/utils/garment_vocabulary.py index bd623e5c6..a5fdc02e1 100644 --- a/backend/app/utils/garment_vocabulary.py +++ b/backend/app/utils/garment_vocabulary.py @@ -65,6 +65,13 @@ def canonical_colors(names: list[str]) -> list[str]: return list(dict.fromkeys(c for c in map(canonical_color, names) if c)) +def types_for_role(role: str) -> list[str]: + # Derived from the vocabulary rather than restated, so an outfit prompt cannot drift from the + # taxonomy the tagger validates against. For example, base_top is everything a separates top + # may be (shirt, sweater, tank-top, ...), not a hand-kept subset. + return [entry["value"] for entry in _DATA["types"] if entry["role"] == role] + + def render_tagging_prompt(template: str) -> str: return ( template.replace("<>", ", ".join(TYPES)) @@ -72,3 +79,11 @@ def render_tagging_prompt(template: str) -> str: .replace("<>", ", ".join(FORMALITY)) .replace("<>", ", ".join(COLORS)) ) + + +def render_outfit_prompt(template: str) -> str: + # Outfit prompts name the garments that fill each role; <> tokens are filled from + # ITEM_ROLE so separates, one-piece and legwear lists stay in step with the vocabulary. + for role in ROLES: + template = template.replace(f"<<{role.upper()}_TYPES>>", ", ".join(types_for_role(role))) + return template diff --git a/backend/tests/test_garment_vocabulary.py b/backend/tests/test_garment_vocabulary.py index c3ad6c825..1b131cf25 100644 --- a/backend/tests/test_garment_vocabulary.py +++ b/backend/tests/test_garment_vocabulary.py @@ -33,7 +33,9 @@ canonical_color, canonical_colors, normalize_color, + render_outfit_prompt, render_tagging_prompt, + types_for_role, ) from app.utils.prompts import load_prompt @@ -74,6 +76,30 @@ def test_type_lists_agree(): assert prompt_types == set(DEFAULT_WASH_INTERVALS) +def test_types_for_role_is_derived_from_the_vocabulary(): + assert types_for_role("base_top") == [ + "shirt", "t-shirt", "top", "sweater", "polo", "blouse", "tank-top", + ] # fmt: skip + assert types_for_role("full_body") == ["dress", "jumpsuit"] + assert set(types_for_role("bottom")) == {"pants", "jeans", "shorts", "skirt"} + assert set(types_for_role("footwear")) == {"shoes", "sneakers", "boots", "sandals"} + assert types_for_role("legwear") == ["tights"] + assert all(ITEM_ROLE[garment] == "base_top" for garment in types_for_role("base_top")) + + +def test_outfit_prompt_template_tokens_are_all_rendered(): + for name in ("recommendation", "item_pairing"): + assert "<<" not in render_outfit_prompt(load_prompt(name)) + + +@pytest.mark.parametrize("prompt_name", ["recommendation", "item_pairing"]) +def test_outfit_prompts_list_the_vocabulary_for_each_silhouette_role(prompt_name): + prompt = render_outfit_prompt(load_prompt(prompt_name)) + for role in ("base_top", "bottom", "full_body", "footwear", "legwear"): + for garment in types_for_role(role): + assert garment in prompt, f"{garment} ({role}) missing from {prompt_name}" + + @pytest.mark.parametrize( ("heading", "vocabulary"), [ From d0409db2d72652833488f586dc33479895fa85f7 Mon Sep 17 00:00:00 2001 From: Sarah Withee Date: Tue, 13 Oct 2026 03:19:46 -0400 Subject: [PATCH 2/6] Fix pairings dropping their source item on a body-slot conflict --- backend/app/services/pairing_service.py | 9 ++++- backend/tests/test_pairings.py | 51 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/backend/app/services/pairing_service.py b/backend/app/services/pairing_service.py index e24951ee8..759bf5752 100644 --- a/backend/app/services/pairing_service.py +++ b/backend/app/services/pairing_service.py @@ -266,8 +266,13 @@ async def generate_pairings( if source_item.id not in valid_ids: valid_ids.insert(0, source_item.id) - # Deduplicate by body slot (e.g. prevent shorts + pants) - valid_ids = deduplicate_by_body_slot(valid_ids, item_type_map) + # Deduplicate by body slot (e.g. prevent shorts + pants). The source item is + # 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. + valid_ids = deduplicate_by_body_slot( + valid_ids, item_type_map, mandatory_item_ids={source_item.id} + ) if len(valid_ids) < 2: logger.warning("Pairing has too few valid items, skipping") diff --git a/backend/tests/test_pairings.py b/backend/tests/test_pairings.py index 6b5264bcf..bbd8215bd 100644 --- a/backend/tests/test_pairings.py +++ b/backend/tests/test_pairings.py @@ -1,4 +1,6 @@ +import json from datetime import date +from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 import pytest @@ -394,3 +396,52 @@ async def test_cannot_delete_other_users_pairing( response = await client.delete(f"/api/v1/pairings/{pairing.id}", headers=auth_headers) assert response.status_code == 404 + + +class TestGeneratePairingsPreservesSource: + @pytest.mark.asyncio + @pytest.mark.parametrize("source_type", ["shirt", "pants"]) + async def test_source_survives_a_full_body_garment( + self, db_session: AsyncSession, test_user, source_type + ): + from app.services.pairing_service import PairingService + + source = _make_item(test_user.id, source_type) + other_bottom = _make_item(test_user.id, "pants" if source_type == "shirt" else "jeans") + dress = _make_item(test_user.id, "dress") + shoes = _make_item(test_user.id, "sneakers") + db_session.add_all([source, other_bottom, dress, shoes]) + await db_session.commit() + + # The model answers with only the one-piece silhouette, omitting the source. + number_map = {1: source.id, 2: other_bottom.id, 3: dress.id, 4: shoes.id} + ai = MagicMock() + ai.generate_text = AsyncMock( + return_value=MagicMock( + content=json.dumps( + [{"items": [3, 4], "headline": "Dress", "highlights": [], "styling_tip": ""}] + ), + model="m", + endpoint="e", + ) + ) + + service = PairingService(db_session) + test_user.preferences = None + with ( + patch("app.services.pairing_service.AIService", return_value=ai), + patch.object( + service, + "_format_items_for_prompt", + return_value=("source", "", 1, number_map), + ), + ): + outfits = await service.generate_pairings( + user=test_user, source_item_id=source.id, num_pairings=1 + ) + + assert outfits + for outfit in outfits: + item_ids = {oi.item_id for oi in outfit.items} + assert source.id in item_ids + assert dress.id not in item_ids From 97ce26a70a3ceca9f8bc244e75256ea5eb22768d Mon Sep 17 00:00:00 2001 From: Sarah Withee Date: Tue, 13 Oct 2026 03:30:06 -0400 Subject: [PATCH 3/6] test: derive types_for_role expectations from the garment vocabulary test_types_for_role_is_derived_from_the_vocabulary pinned fixed per-role lists, so a hard-coded types_for_role would still pass after a garment is added to a role. Compare each role against the entries derived from ITEM_ROLE and assert the per-role lists cover the taxonomy. --- backend/tests/test_garment_vocabulary.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/tests/test_garment_vocabulary.py b/backend/tests/test_garment_vocabulary.py index 1b131cf25..1fd690c45 100644 --- a/backend/tests/test_garment_vocabulary.py +++ b/backend/tests/test_garment_vocabulary.py @@ -77,14 +77,14 @@ def test_type_lists_agree(): def test_types_for_role_is_derived_from_the_vocabulary(): - assert types_for_role("base_top") == [ - "shirt", "t-shirt", "top", "sweater", "polo", "blouse", "tank-top", - ] # fmt: skip - assert types_for_role("full_body") == ["dress", "jumpsuit"] - assert set(types_for_role("bottom")) == {"pants", "jeans", "shorts", "skirt"} - assert set(types_for_role("footwear")) == {"shoes", "sneakers", "boots", "sandals"} - assert types_for_role("legwear") == ["tights"] - assert all(ITEM_ROLE[garment] == "base_top" for garment in types_for_role("base_top")) + # Compared against ITEM_ROLE rather than fixed lists, so adding a garment to a role fails this + # test unless types_for_role picks it up. + for role in ROLES: + assert types_for_role(role) == [t for t in TYPES if ITEM_ROLE[t] == role] + + # Every type belongs to exactly one role, so the per-role lists together cover the taxonomy. + covered = [t for role in ROLES for t in types_for_role(role)] + assert sorted(covered) == sorted(TYPES) def test_outfit_prompt_template_tokens_are_all_rendered(): From 5905508849d76f86460d0e791e957e448a9544aa Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:52:18 +1100 Subject: [PATCH 4/6] fix: skip pairings whose silhouette does not fit the source item --- backend/app/services/pairing_service.py | 8 +++- backend/app/utils/clothing.py | 4 ++ backend/tests/test_pairings.py | 54 ++++++++++++++++--------- 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/backend/app/services/pairing_service.py b/backend/app/services/pairing_service.py index 759bf5752..5074a7812 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 deduplicate_by_body_slot +from app.utils.clothing import covered_slots, 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,9 +270,15 @@ 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_slots(valid_ids, item_type_map) valid_ids = deduplicate_by_body_slot( valid_ids, item_type_map, mandatory_item_ids={source_item.id} ) + # Keeping the source must not open a hole the model had filled: a dress dropped + # for a shirt source would leave the outfit with no bottom. + if covered_slots(valid_ids, item_type_map) != picked_slots: + logger.warning("Pairing silhouette does not fit the source item, skipping") + continue if len(valid_ids) < 2: logger.warning("Pairing has too few valid items, skipping") diff --git a/backend/app/utils/clothing.py b/backend/app/utils/clothing.py index 5af5ae87f..6b06436eb 100644 --- a/backend/app/utils/clothing.py +++ b/backend/app/utils/clothing.py @@ -43,6 +43,10 @@ def _slots_for_type(item_type: str) -> frozenset[str]: return ROLE_SLOTS.get(role, frozenset({role})) +def covered_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, ""))} + + def deduplicate_by_body_slot( item_ids: list[UUID], item_type_map: dict[UUID, str], diff --git a/backend/tests/test_pairings.py b/backend/tests/test_pairings.py index bbd8215bd..95ed19256 100644 --- a/backend/tests/test_pairings.py +++ b/backend/tests/test_pairings.py @@ -18,6 +18,7 @@ OutfitStatus, ) from app.models.user import User +from app.services.pairing_service import PairingService from tests.identity_helpers import add_users_with_identities @@ -400,26 +401,41 @@ async def test_cannot_delete_other_users_pairing( class TestGeneratePairingsPreservesSource: @pytest.mark.asyncio - @pytest.mark.parametrize("source_type", ["shirt", "pants"]) - async def test_source_survives_a_full_body_garment( - self, db_session: AsyncSession, test_user, source_type + @pytest.mark.parametrize( + ("source_type", "answer", "expected"), + [ + # The model swaps the source for a one-piece look: dropping the dress keeps the + # source but would leave no bottom, so the answer is skipped instead. + ("shirt", ["dress", "sneakers"], None), + ("pants", ["dress", "sneakers"], None), + ("shirt", ["source", "pants", "dress", "sneakers"], ["source", "pants", "sneakers"]), + ("shirt", ["pants", "sneakers"], ["source", "pants", "sneakers"]), + ("dress", ["shirt", "pants", "sneakers"], ["source", "sneakers"]), + ], + ) + async def test_source_survives_a_conflicting_answer( + self, db_session: AsyncSession, test_user, source_type, answer, expected ): - from app.services.pairing_service import PairingService - source = _make_item(test_user.id, source_type) - other_bottom = _make_item(test_user.id, "pants" if source_type == "shirt" else "jeans") - dress = _make_item(test_user.id, "dress") - shoes = _make_item(test_user.id, "sneakers") - db_session.add_all([source, other_bottom, dress, shoes]) + others = {t: _make_item(test_user.id, t) for t in ("shirt", "pants", "dress", "sneakers")} + items = {"source": source, **others} + db_session.add_all(items.values()) await db_session.commit() - # The model answers with only the one-piece silhouette, omitting the source. - number_map = {1: source.id, 2: other_bottom.id, 3: dress.id, 4: shoes.id} + number_map = {n: item.id for n, item in enumerate(items.values(), start=1)} + number_of = {item_id: n for n, item_id in number_map.items()} ai = MagicMock() ai.generate_text = AsyncMock( return_value=MagicMock( content=json.dumps( - [{"items": [3, 4], "headline": "Dress", "highlights": [], "styling_tip": ""}] + [ + { + "items": [number_of[items[name].id] for name in answer], + "headline": "Look", + "highlights": [], + "styling_tip": "", + } + ] ), model="m", endpoint="e", @@ -433,15 +449,17 @@ async def test_source_survives_a_full_body_garment( patch.object( service, "_format_items_for_prompt", - return_value=("source", "", 1, number_map), + return_value=("source", "", number_of[source.id], number_map), ), ): outfits = await service.generate_pairings( user=test_user, source_item_id=source.id, num_pairings=1 ) - assert outfits - for outfit in outfits: - item_ids = {oi.item_id for oi in outfit.items} - assert source.id in item_ids - assert dress.id not in item_ids + if expected is None: + assert outfits == [] + else: + assert len(outfits) == 1 + assert [oi.item_id for oi in sorted(outfits[0].items, key=lambda oi: oi.position)] == [ + items[name].id for name in expected + ] From f8abf28b71ebc250429067c32051ac21d723bd77 Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:52:19 +1100 Subject: [PATCH 5/6] fix: add the suit silhouette and tie each pairing silhouette to its source --- backend/app/prompts/item_pairing.txt | 6 ++++-- backend/app/prompts/recommendation.txt | 7 ++++--- backend/tests/test_garment_vocabulary.py | 7 ++++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/backend/app/prompts/item_pairing.txt b/backend/app/prompts/item_pairing.txt index ca3ff7fa1..a57486e02 100644 --- a/backend/app/prompts/item_pairing.txt +++ b/backend/app/prompts/item_pairing.txt @@ -8,10 +8,12 @@ AVAILABLE ITEMS: Create {num_pairings} DIFFERENT complete outfits. Each MUST: - Include the source item [{source_number}] -- Complete it with ONE of these silhouettes. They are equally valid — pick whichever fits the source item, do not assume separates: +- Complete it with ONE of these silhouettes. They are equally valid, do not assume separates: - Separates: exactly ONE top (<>) + ONE bottom (<>) + ONE footwear (<>) - One-piece: exactly ONE full-body garment (<>) + ONE footwear (<>) -- NEVER combine two items from the same slot. Bottoms (<>) are the SAME slot, tops (<>) are the SAME slot, and a full-body garment already fills both the top and bottom slots. + - Suit: exactly ONE top + ONE suit (<>) + ONE footwear +- The silhouette must contain the source item: a top or bottom source is worn as separates, a full-body source as a one-piece and a suit source as a suit. Any other source item can go with whichever silhouette suits it +- NEVER combine two items from the same slot. Bottoms (<>) are the SAME slot, tops (<>) are the SAME slot, a full-body garment already fills both the top and bottom slots, and a suit already fills the bottom slot. - Optionally add accessories (belt, bag, jewelry, scarf) if they complement - Optionally add legwear (<>) under a skirt, dress or shorts; legwear does not replace the bottom or footwear. When the source item is legwear, build every outfit around a skirt, dress or shorts diff --git a/backend/app/prompts/recommendation.txt b/backend/app/prompts/recommendation.txt index a1d69d7ac..fa4101558 100644 --- a/backend/app/prompts/recommendation.txt +++ b/backend/app/prompts/recommendation.txt @@ -46,10 +46,11 @@ AVAILABLE ITEMS: {mandatory_items_section} RULES: -- Build the look around ONE of these silhouettes. They are equally valid — choose whichever suits the wardrobe and the occasion, do not assume separates: +- Build the look around ONE of these silhouettes. They are equally valid; choose whichever suits the wardrobe and the occasion, do not assume separates: - Separates: ONE top (<>) + ONE bottom (<>) + ONE footwear (<>) - One-piece: ONE full-body garment (<>) + ONE footwear (<>) -- OPTIONAL: legwear (<>) under any silhouette — under a skirt, dress or shorts in cool or cold weather; leave it out when it is warm, and put only thermal legwear under trousers, only when it is very cold. Legwear does not replace the bottom, the one-piece garment or footwear + - Suit: ONE top + ONE suit (<>) + ONE footwear; the suit replaces the bottom +- OPTIONAL: legwear (<>) under a skirt, dress or shorts in cool or cold weather; leave it out when it is warm, and put only thermal legwear under trousers or a jumpsuit, only when it is very cold. Legwear does not replace the bottom, the one-piece garment or footwear - OPTIONAL: ONE bag if appropriate for the occasion - OPTIONAL: accessories (belt, scarf, hat, jewelry) that genuinely complement the outfit - For cold or rainy weather, add outerwear (jacket/coat) if available @@ -60,4 +61,4 @@ RULES: Respond with valid JSON containing exactly 3 distinct outfit options. Each must be a complete look. {{"outfits": [{{"items": [item numbers], "headline": "Short catchy outfit title (max 5 words)", "highlights": ["One short sentence each — vary your reasoning across color, texture, proportion, occasion, weather, or time of day"], "styling_tip": "One specific, actionable styling detail — do not suggest rolling up sleeves every time, vary your advice"}}, ...]}} -Each outfit MUST use different key pieces (different tops and bottoms). Vary color palettes and energy levels. First outfit = strongest recommendation. \ No newline at end of file +Each outfit MUST use different key pieces (different tops, bottoms, one-piece garments or suits). Vary color palettes and energy levels. First outfit = strongest recommendation. \ No newline at end of file diff --git a/backend/tests/test_garment_vocabulary.py b/backend/tests/test_garment_vocabulary.py index 1fd690c45..a14b2d651 100644 --- a/backend/tests/test_garment_vocabulary.py +++ b/backend/tests/test_garment_vocabulary.py @@ -95,9 +95,10 @@ def test_outfit_prompt_template_tokens_are_all_rendered(): @pytest.mark.parametrize("prompt_name", ["recommendation", "item_pairing"]) def test_outfit_prompts_list_the_vocabulary_for_each_silhouette_role(prompt_name): prompt = render_outfit_prompt(load_prompt(prompt_name)) - for role in ("base_top", "bottom", "full_body", "footwear", "legwear"): - for garment in types_for_role(role): - assert garment in prompt, f"{garment} ({role}) missing from {prompt_name}" + for role in ("base_top", "bottom", "full_body", "suit", "footwear", "legwear"): + assert f"({', '.join(types_for_role(role))})" in prompt, ( + f"{role} missing from {prompt_name}" + ) @pytest.mark.parametrize( From b53de5008de23f1f324a41f163bb46062429de44 Mon Sep 17 00:00:00 2001 From: Anish Shrestha <26220465+Anyesh@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:55:27 +1100 Subject: [PATCH 6/6] fix: only skip a pairing when dedup leaves its top or bottom bare --- backend/app/prompts/item_pairing.txt | 2 +- backend/app/services/pairing_service.py | 10 +++++----- backend/app/utils/clothing.py | 12 ++++++++++-- backend/tests/test_pairings.py | 6 +++++- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/backend/app/prompts/item_pairing.txt b/backend/app/prompts/item_pairing.txt index a57486e02..d4d4a734e 100644 --- a/backend/app/prompts/item_pairing.txt +++ b/backend/app/prompts/item_pairing.txt @@ -12,7 +12,7 @@ Create {num_pairings} DIFFERENT complete outfits. Each MUST: - Separates: exactly ONE top (<>) + ONE bottom (<>) + ONE footwear (<>) - One-piece: exactly ONE full-body garment (<>) + ONE footwear (<>) - Suit: exactly ONE top + ONE suit (<>) + ONE footwear -- The silhouette must contain the source item: a top or bottom source is worn as separates, a full-body source as a one-piece and a suit source as a suit. Any other source item can go with whichever silhouette suits it +- The silhouette must contain the source item: a top or bottom source is worn as separates, a full-body source as a one-piece and a suit source as a suit. Any other source item, except legwear (below), can go with whichever silhouette suits it - NEVER combine two items from the same slot. Bottoms (<>) are the SAME slot, tops (<>) are the SAME slot, a full-body garment already fills both the top and bottom slots, and a suit already fills the bottom slot. - Optionally add accessories (belt, bag, jewelry, scarf) if they complement - Optionally add legwear (<>) under a skirt, dress or shorts; legwear does not replace the bottom or footwear. When the source item is legwear, build every outfit around a skirt, dress or shorts diff --git a/backend/app/services/pairing_service.py b/backend/app/services/pairing_service.py index 5074a7812..0c3055130 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_slots, deduplicate_by_body_slot +from app.utils.clothing import covered_body_slots, 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_slots(valid_ids, item_type_map) + picked_slots = covered_body_slots(valid_ids, item_type_map) valid_ids = deduplicate_by_body_slot( valid_ids, item_type_map, mandatory_item_ids={source_item.id} ) - # Keeping the source must not open a hole the model had filled: a dress dropped - # for a shirt source would leave the outfit with no bottom. - if covered_slots(valid_ids, item_type_map) != picked_slots: + # 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: 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 6b06436eb..f37f3dadd 100644 --- a/backend/app/utils/clothing.py +++ b/backend/app/utils/clothing.py @@ -12,6 +12,9 @@ "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: @@ -43,8 +46,13 @@ def _slots_for_type(item_type: str) -> frozenset[str]: return ROLE_SLOTS.get(role, frozenset({role})) -def covered_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, ""))} +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( diff --git a/backend/tests/test_pairings.py b/backend/tests/test_pairings.py index 95ed19256..f83752d7c 100644 --- a/backend/tests/test_pairings.py +++ b/backend/tests/test_pairings.py @@ -411,13 +411,17 @@ class TestGeneratePairingsPreservesSource: ("shirt", ["source", "pants", "dress", "sneakers"], ["source", "pants", "sneakers"]), ("shirt", ["pants", "sneakers"], ["source", "pants", "sneakers"]), ("dress", ["shirt", "pants", "sneakers"], ["source", "sneakers"]), + ("pants", ["shirt", "suit", "sneakers"], ["source", "shirt", "sneakers"]), + ("sneakers", ["dress", "suit"], ["source", "dress"]), ], ) async def test_source_survives_a_conflicting_answer( self, db_session: AsyncSession, test_user, source_type, answer, expected ): source = _make_item(test_user.id, source_type) - others = {t: _make_item(test_user.id, t) for t in ("shirt", "pants", "dress", "sneakers")} + others = { + t: _make_item(test_user.id, t) for t in ("shirt", "pants", "dress", "suit", "sneakers") + } items = {"source": source, **others} db_session.add_all(items.values()) await db_session.commit()