diff --git a/backend/app/prompts/item_pairing.txt b/backend/app/prompts/item_pairing.txt index f34e450a5..d4d4a734e 100644 --- a/backend/app/prompts/item_pairing.txt +++ b/backend/app/prompts/item_pairing.txt @@ -8,10 +8,14 @@ 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, do not assume separates: + - 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, 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 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..fa4101558 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,11 @@ 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 (<>) + - 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 @@ -59,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/app/services/pairing_service.py b/backend/app/services/pairing_service.py index 5c5cf50d0..0c3055130 100644 --- a/backend/app/services/pairing_service.py +++ b/backend/app/services/pairing_service.py @@ -11,13 +11,14 @@ 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_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 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). @@ -265,8 +266,19 @@ 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. + 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} + ) + # 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 if len(valid_ids) < 2: logger.warning("Pairing has too few valid items, skipping") 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/clothing.py b/backend/app/utils/clothing.py index 5af5ae87f..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,6 +46,15 @@ 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], 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..a14b2d651 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,31 @@ def test_type_lists_agree(): assert prompt_types == set(DEFAULT_WASH_INTERVALS) +def test_types_for_role_is_derived_from_the_vocabulary(): + # 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(): + 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", "suit", "footwear", "legwear"): + assert f"({', '.join(types_for_role(role))})" in prompt, ( + f"{role} missing from {prompt_name}" + ) + + @pytest.mark.parametrize( ("heading", "vocabulary"), [ diff --git a/backend/tests/test_pairings.py b/backend/tests/test_pairings.py index 6b5264bcf..f83752d7c 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 @@ -16,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 @@ -394,3 +397,73 @@ 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", "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"]), + ("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", "suit", "sneakers") + } + items = {"source": source, **others} + db_session.add_all(items.values()) + await db_session.commit() + + 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": [number_of[items[name].id] for name in answer], + "headline": "Look", + "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", "", number_of[source.id], number_map), + ), + ): + outfits = await service.generate_pairings( + user=test_user, source_item_id=source.id, num_pairings=1 + ) + + 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 + ]