Spaces:
Runtime error
Runtime error
File size: 11,386 Bytes
727dd4f b269444 727dd4f b269444 727dd4f 913537b 727dd4f d81d91f 63a6b7d d81d91f 63a6b7d d81d91f 63a6b7d d81d91f 63a6b7d 727dd4f b269444 d81d91f 63a6b7d 727dd4f 63a6b7d 727dd4f 63a6b7d d81d91f 727dd4f 63a6b7d d81d91f 63a6b7d 727dd4f d81d91f 63a6b7d d81d91f 727dd4f 63a6b7d 727dd4f 63a6b7d b269444 63a6b7d b269444 63a6b7d b269444 63a6b7d b269444 63a6b7d d81d91f 63a6b7d b269444 63a6b7d b269444 63a6b7d b269444 63a6b7d b269444 727dd4f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 | """Outfit combination engine: rules + LLM ranking.
Generates top+bottom outfit combinations from the catalog, filters by
compatibility rules (season, formality), and optionally ranks them
using the LLM. Persists user preferences (like/dislike) for future
reference.
"""
import json
import logging
import re
from datetime import datetime, timezone
from itertools import product
from pathlib import Path
from .catalog import load_catalog, get_garment_image_path
from .model_loader import model_manager
logger = logging.getLogger(__name__)
OUTFITS_PATH = Path(__file__).resolve().parent.parent / "data" / "outfits.json"
TOPS = frozenset({
"shirt", "blouse", "t-shirt", "top", "tank-top",
"sweater", "cardigan", "hoodie", "sweatshirt",
})
BOTTOMS = frozenset({
"jeans", "pants", "trousers", "shorts", "skirt",
})
FORMALITY_COMPAT = {
"casual": {"casual", "smart-casual"},
"smart-casual": {"casual", "smart-casual", "formal"},
"formal": {"smart-casual", "formal"},
}
RANKING_SYSTEM_PROMPT = (
"You are a personal stylist. Rank outfit combinations (top + bottom) best-to-worst "
"for the given occasion. Prioritise: occasion fit, color harmony, formality match, "
"season, pattern balance. Return ONLY a JSON array of outfit IDs, best first."
)
# Max combos sent to the LLM — must fit in n_ctx=4096 alongside the response.
MAX_RANKING_ITEMS = 20
def _format_garment_line(garment: dict) -> str:
"""Compact one-line garment summary for the ranking prompt."""
return (
f"{garment.get('color', '?')} {garment.get('type', '?')}"
f" ({garment.get('pattern', 'solid')}, {garment.get('season', 'all')},"
f" {garment.get('formality', 'casual')})"
)
def _select_combos_for_ranking(combinations: list[dict], max_items: int = MAX_RANKING_ITEMS) -> list[dict]:
"""Pick a diverse subset when the list is too large for the LLM context."""
if len(combinations) <= max_items:
return combinations
# Spread selections across different tops so the LLM sees variety
by_top: dict[str, list[dict]] = {}
for combo in combinations:
top_id = combo["top"]["id"]
by_top.setdefault(top_id, []).append(combo)
selected: list[dict] = []
top_ids = list(by_top.keys())
idx = 0
while len(selected) < max_items and any(by_top.values()):
top_id = top_ids[idx % len(top_ids)]
bucket = by_top.get(top_id, [])
if bucket:
selected.append(bucket.pop(0))
idx += 1
return selected
def _format_liked_hint() -> str:
"""Build a short hint from previously liked outfits."""
liked = get_liked_outfits()
if not liked:
return ""
lines = ["\nUser liked (style signal):"]
for outfit in liked[:3]:
top = outfit["top"]
bottom = outfit["bottom"]
lines.append(
f"- {_format_garment_line(top)} + {_format_garment_line(bottom)}"
)
return "\n".join(lines)
def _is_season_compatible(season_a: str, season_b: str) -> bool:
if season_a == "all" or season_b == "all":
return True
return season_a == season_b
def _is_formality_compatible(formality_a: str, formality_b: str) -> bool:
allowed = FORMALITY_COMPAT.get(formality_a, {formality_a})
return formality_b in allowed
def _classify_garment(garment: dict) -> str | None:
"""Classify a garment as 'top', 'bottom', or None."""
gtype = garment.get("type", "").lower().strip()
if gtype in TOPS:
return "top"
for top_type in TOPS:
if top_type in gtype or gtype in top_type:
return "top"
if gtype in BOTTOMS:
return "bottom"
for bottom_type in BOTTOMS:
if bottom_type in gtype or gtype in bottom_type:
return "bottom"
return None
def get_tops_and_bottoms() -> tuple[list[dict], list[dict]]:
"""Split catalog into tops and bottoms."""
catalog = load_catalog()
tops = []
bottoms = []
for g in catalog:
category = _classify_garment(g)
if category == "top":
tops.append(g)
elif category == "bottom":
bottoms.append(g)
return tops, bottoms
def generate_combinations(
season: str | None = None,
exclude_disliked: bool = True,
) -> list[dict]:
"""Generate compatible top+bottom combinations.
Applies rules-based filtering: season compatibility and formality
compatibility. Optionally excludes previously disliked outfits.
Returns a list of combination dicts:
{"top": garment_dict, "bottom": garment_dict, "id": "outfit_NNN"}
"""
tops, bottoms = get_tops_and_bottoms()
if not tops or not bottoms:
return []
disliked_pairs = set()
if exclude_disliked:
disliked_pairs = _get_disliked_pairs()
combinations = []
combo_id = 1
for top, bottom in product(tops, bottoms):
pair_key = (top["id"], bottom["id"])
if pair_key in disliked_pairs:
continue
top_season = top.get("season", "all")
bottom_season = bottom.get("season", "all")
if season:
if not _is_season_compatible(top_season, season):
continue
if not _is_season_compatible(bottom_season, season):
continue
elif not _is_season_compatible(top_season, bottom_season):
continue
top_formality = top.get("formality", "casual")
bottom_formality = bottom.get("formality", "casual")
if not _is_formality_compatible(top_formality, bottom_formality):
continue
combinations.append({
"id": f"outfit_{combo_id:03d}",
"top": top,
"bottom": bottom,
})
combo_id += 1
return combinations
def rank_combinations_prompt(
combinations: list[dict],
context: str = "",
max_items: int = MAX_RANKING_ITEMS,
) -> tuple[str, str]:
"""Build system + user prompts for the LLM to rank outfit combinations.
Returns (system_prompt, user_prompt).
"""
if not combinations:
return "", ""
subset = _select_combos_for_ranking(combinations, max_items=max_items)
occasion = context.strip() if context and context.strip() else "everyday casual wear"
user_lines = [
f"Occasion: {occasion}",
f"Rank these {len(subset)} outfits best-to-worst. Return JSON array of IDs only.",
"",
]
for combo in subset:
top = _format_garment_line(combo["top"])
bottom = _format_garment_line(combo["bottom"])
user_lines.append(f"- {combo['id']}: {top} + {bottom}")
liked_hint = _format_liked_hint()
if liked_hint:
user_lines.append(liked_hint)
user_lines.append(f'Return: ["outfit_XXX", ...] with all {len(subset)} IDs reordered.')
return RANKING_SYSTEM_PROMPT, "\n".join(user_lines)
def rank_with_llm(combinations: list[dict], context: str = "") -> list[dict]:
"""Rank combinations using the LLM.
Always ranks — uses a default occasion when no context is provided.
Falls back to the original order if parsing fails.
"""
if not combinations:
return combinations
system_prompt, user_prompt = rank_combinations_prompt(combinations, context=context)
if not user_prompt:
return combinations
llm = model_manager.get_text_model()
logger.info(
"Ranking %d combinations (context: %s)",
len(combinations),
(context[:80] if context else "default"),
)
response = llm.create_chat_completion(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
max_tokens=512,
temperature=0.2,
)
raw_text = response["choices"][0]["message"]["content"]
logger.debug("LLM ranking response: %s", raw_text[:300])
ranked_ids = _parse_ranking_response(raw_text)
if not ranked_ids:
logger.warning("Could not parse ranking, returning original order")
return combinations
combo_map = {c["id"]: c for c in combinations}
ranked = [combo_map[oid] for oid in ranked_ids if oid in combo_map]
seen = set(ranked_ids)
for combo in combinations:
if combo["id"] not in seen:
ranked.append(combo)
logger.info("Ranked %d/%d combinations successfully", len(ranked_ids), len(combinations))
return ranked
def _parse_ranking_response(text: str) -> list[str]:
"""Extract a list of outfit IDs from the LLM ranking response."""
cleaned = text.strip()
fence_match = re.search(r"```(?:json)?\s*\n?(.*?)```", cleaned, re.DOTALL)
if fence_match:
cleaned = fence_match.group(1).strip()
start = cleaned.find("[")
end = cleaned.rfind("]")
if start != -1 and end != -1 and end > start:
try:
parsed = json.loads(cleaned[start:end + 1])
if isinstance(parsed, list):
return [str(item) for item in parsed]
except json.JSONDecodeError:
pass
ids = re.findall(r"outfit_\d+", cleaned)
return ids
def load_outfits() -> list[dict]:
"""Load saved outfit preferences from disk."""
if not OUTFITS_PATH.exists():
return []
try:
with open(OUTFITS_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
logger.error("Failed to load outfits: %s", e)
return []
def save_outfits(outfits: list[dict]) -> None:
"""Write outfit preferences to disk."""
OUTFITS_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(OUTFITS_PATH, "w", encoding="utf-8") as f:
json.dump(outfits, f, indent=2, ensure_ascii=False)
def save_preference(top_id: str, bottom_id: str, liked: bool) -> dict:
"""Record a user preference for an outfit combination."""
outfits = load_outfits()
for outfit in outfits:
if outfit.get("top") == top_id and outfit.get("bottom") == bottom_id:
outfit["liked"] = liked
outfit["timestamp"] = datetime.now(timezone.utc).isoformat()
save_outfits(outfits)
return outfit
next_num = len(outfits) + 1
entry = {
"id": f"outfit_{next_num:03d}",
"top": top_id,
"bottom": bottom_id,
"liked": liked,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
outfits.append(entry)
save_outfits(outfits)
return entry
def get_liked_outfits() -> list[dict]:
"""Return only the outfits the user liked, with full garment data."""
outfits = load_outfits()
catalog = load_catalog()
catalog_map = {g["id"]: g for g in catalog}
liked = []
for outfit in outfits:
if not outfit.get("liked"):
continue
top = catalog_map.get(outfit["top"])
bottom = catalog_map.get(outfit["bottom"])
if top and bottom:
liked.append({
"id": outfit["id"],
"top": top,
"bottom": bottom,
"timestamp": outfit.get("timestamp"),
})
return liked
def _get_disliked_pairs() -> set[tuple[str, str]]:
"""Return set of (top_id, bottom_id) pairs the user disliked."""
outfits = load_outfits()
return {
(o["top"], o["bottom"])
for o in outfits
if not o.get("liked")
}
|