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
2 changes: 2 additions & 0 deletions backend/app/prompts/recommendation.txt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ Time of day:
AVAILABLE ITEMS:
{items_text}

{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
Expand Down
15 changes: 14 additions & 1 deletion backend/app/services/item_scorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,16 @@ def _usage_score(item: ClothingItem, median_wear: float) -> float:
return 0.9


def _sort_mandatory_first(
scored: list[ScoredItem],
mandatory_item_ids: set[UUID] | None,
) -> list[ScoredItem]:
if not mandatory_item_ids:
return scored

return sorted(scored, key=lambda s: s.item.id not in mandatory_item_ids)


def _pair_bonus(
item: ClothingItem,
top_items: list[ClothingItem],
Expand Down Expand Up @@ -269,9 +279,11 @@ def score_items(
learned_prefs: dict | None,
good_pairs: dict[UUID, list[UUID]],
recently_worn_dates: dict[UUID, date],
mandatory_item_ids: set[UUID] | None = None,
) -> list[ScoredItem]:
if len(items) < MIN_ITEMS_FOR_SCORING:
return [ScoredItem(item=item) for item in items]
scored = [ScoredItem(item=item) for item in items]
return _sort_mandatory_first(scored, mandatory_item_ids)

avoid_days = 7
if preferences and preferences.avoid_repeat_days is not None:
Expand Down Expand Up @@ -319,4 +331,5 @@ def score_items(
si.score += si.pair_bonus

scored.sort(key=lambda s: s.score, reverse=True)
scored = _sort_mandatory_first(scored, mandatory_item_ids)
return scored[:TOP_N]
28 changes: 28 additions & 0 deletions backend/app/services/recommendation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,31 @@ def _format_items_for_prompt(

return "\n".join(lines), number_map

def _format_mandatory_items_section(
self,
mandatory_item_ids: list[UUID] | None,
id_to_number: dict[int, UUID],
) -> str:
if not mandatory_item_ids:
return ""

uuid_to_number = {v: k for k, v in id_to_number.items()}
mandatory_numbers = []
for item_id in mandatory_item_ids:
if item_id in uuid_to_number:
mandatory_numbers.append(uuid_to_number[item_id])

if not mandatory_numbers:
return ""

mandatory_numbers_sorted = sorted(mandatory_numbers)
mandatory_refs = ", ".join(f"[{n}]" for n in mandatory_numbers_sorted)
return (
f"MANDATORY ITEMS (MUST include in every outfit):\n"
f"The following items are already selected and MUST appear in all 3 outfit suggestions: {mandatory_refs}\n\n"
f"Complete each outfit with complementary pieces from the available items above."
)

def _format_preferences_for_prompt(
self,
preferences: UserPreference | None,
Expand Down Expand Up @@ -738,10 +763,12 @@ async def generate_recommendation(
learned_prefs=learned_prefs,
good_pairs=good_pairs,
recently_worn_dates=recently_worn_dates,
mandatory_item_ids=set(include_items) if include_items else None,
)

# Format enriched prompt
items_text, number_map = self._format_items_for_prompt(scored, good_pairs, user_today)
mandatory_items_section = self._format_mandatory_items_section(include_items, number_map)

worn_combinations = await self._get_recently_worn_outfit_combinations(user, days=7)

Expand All @@ -763,6 +790,7 @@ async def generate_recommendation(
precipitation_chance=weather.precipitation_chance,
preferences_text=preferences_text,
items_text=items_text,
mandatory_items_section=mandatory_items_section,
)

# For single_outfit mode (notifications), replace multi-outfit format
Expand Down
21 changes: 21 additions & 0 deletions backend/tests/test_item_scorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,27 @@ def test_returns_top_n(self):
)
assert len(result) <= 70

def test_prioritizes_mandatory_items_into_top_n(self):
items = [_item(type="shirt") for _ in range(100)]
mandatory_item = _item(type="shorts")
items.append(mandatory_item)

result = score_items(
items=items,
weather=_weather(temp=5),
occasion="casual",
preferences=None,
user_today=date(2026, 3, 8),
current_season="spring",
learned_prefs=None,
good_pairs={},
recently_worn_dates={},
mandatory_item_ids={mandatory_item.id},
)

assert result[0].item.id == mandatory_item.id
assert mandatory_item.id in {s.item.id for s in result}

def test_small_wardrobe_skips_scoring(self):
items = [_item() for _ in range(10)]
result = score_items(
Expand Down
2 changes: 2 additions & 0 deletions backend/tests/test_recommendation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def test_prompt_format_accepts_time_of_day(self):
precipitation_chance=10,
preferences_text="",
items_text="[1] shirt | blue | cotton",
mandatory_items_section="",
)
assert "evening" in formatted
assert "casual" in formatted
Expand All @@ -129,6 +130,7 @@ def test_prompt_format_all_time_of_day_values(self):
precipitation_chance=30,
preferences_text="",
items_text="[1] shirt",
mandatory_items_section="",
)
assert tod in formatted

Expand Down
32 changes: 31 additions & 1 deletion frontend/app/dashboard/outfits/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export default function OutfitDetailPage() {
}
};

const title = outfit.name || `${outfit.occasion} outfit`;
const title = outfit.name || outfit.reasoning || `${outfit.occasion} outfit`;

return (
<div className="space-y-6 max-w-4xl mx-auto">
Expand Down Expand Up @@ -108,6 +108,36 @@ export default function OutfitDetailPage() {
: 'Lookbook template'}
</span>
</div>

{/* AI reasoning */}
{((outfit.name && outfit.reasoning) ||
(outfit.highlights && outfit.highlights.length > 0)) && (
<div className="mt-2 space-y-1.5 text-xs flex-1">
{outfit.name && outfit.reasoning && (
<p className="font-medium text-foreground break-words">{outfit.reasoning}</p>
)}
{outfit.highlights && outfit.highlights.length > 0 && (
<ul className="space-y-0.5">
{outfit.highlights.slice(0, 3).map((highlight, index) => (
<li key={index} className="flex items-start gap-1.5 text-muted-foreground">
<span className="text-primary">•</span>
<span>{highlight}</span>
</li>
))}
</ul>
)}
</div>
)}

{/* Styling tip */}
{outfit.style_notes && (
<div className="mt-2 p-2 bg-muted rounded border text-xs">
<p className="text-muted-foreground">
<span className="font-medium text-foreground">Tip:</span> {outfit.style_notes}
</p>
</div>
)}

</div>

<LineageCard outfit={outfit} />
Expand Down
1 change: 1 addition & 0 deletions frontend/components/outfits/outfit-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ function getSourceBadge(outfit: Outfit): {

function getCardTitle(outfit: Outfit): string {
if (outfit.name) return outfit.name;
if (outfit.reasoning) return outfit.reasoning;
if (outfit.highlights && outfit.highlights.length > 0) {
return outfit.highlights[0];
}
Expand Down
Loading