Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions backend/app/prompts/item_pairing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<<BASE_TOP_TYPES>>) + ONE bottom (<<BOTTOM_TYPES>>) + ONE footwear (<<FOOTWEAR_TYPES>>)
- One-piece: exactly ONE full-body garment (<<FULL_BODY_TYPES>>) + ONE footwear (<<FOOTWEAR_TYPES>>)
- Suit: exactly ONE top + ONE suit (<<SUIT_TYPES>>) + 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 (<<BOTTOM_TYPES>>) are the SAME slot, tops (<<BASE_TOP_TYPES>>) 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 (<<LEGWEAR_TYPES>>) 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).

Expand Down
12 changes: 7 additions & 5 deletions backend/app/prompts/recommendation.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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 (<<BASE_TOP_TYPES>>) + ONE bottom (<<BOTTOM_TYPES>>) + ONE footwear (<<FOOTWEAR_TYPES>>)
- One-piece: ONE full-body garment (<<FULL_BODY_TYPES>>) + ONE footwear (<<FOOTWEAR_TYPES>>)
- Suit: ONE top + ONE suit (<<SUIT_TYPES>>) + ONE footwear; the suit replaces the bottom
- OPTIONAL: legwear (<<LEGWEAR_TYPES>>) 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
Expand All @@ -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.
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.
20 changes: 16 additions & 4 deletions backend/app/services/pairing_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

# 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).
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion backend/app/services/recommendation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions backend/app/utils/clothing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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],
Expand Down
15 changes: 15 additions & 0 deletions backend/app/utils/garment_vocabulary.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,25 @@ 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("<<TYPES>>", ", ".join(TYPES))
.replace("<<MATERIALS>>", ", ".join(MATERIALS))
.replace("<<FORMALITY>>", ", ".join(FORMALITY))
.replace("<<COLORS>>", ", ".join(COLORS))
)


def render_outfit_prompt(template: str) -> str:
# Outfit prompts name the garments that fill each role; <<ROLE_TYPES>> 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
27 changes: 27 additions & 0 deletions backend/tests/test_garment_vocabulary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"),
[
Expand Down
73 changes: 73 additions & 0 deletions backend/tests/test_pairings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import json
from datetime import date
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4

import pytest
Expand All @@ -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


Expand Down Expand Up @@ -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
]
Loading