wardrobe-us / src /catalog.py
Ox1's picture
fix(catalog): ensure data persists across app restarts
913537b
Raw
History Blame Contribute Delete
5.74 kB
"""Catalog module: JSON-based wardrobe storage with search.
The catalog is a simple JSON file that stores garment entries. Each
garment has a unique ID and structured attributes extracted by the
vision pipeline.
No vector DB, no embeddings. In-memory filtering over a list of dicts.
A wardrobe will never exceed a few hundred items.
"""
import json
import logging
from pathlib import Path
from .storage import save_image, get_image_path, delete_image
logger = logging.getLogger(__name__)
CATALOG_PATH = Path(__file__).resolve().parent.parent / "data" / "catalog.json"
def load_catalog() -> list[dict]:
"""Load the catalog from disk. Returns empty list if file doesn't exist."""
if not CATALOG_PATH.exists():
logger.debug("Catalog file not found: %s", CATALOG_PATH)
return []
try:
with open(CATALOG_PATH, "r", encoding="utf-8") as f:
catalog = json.load(f)
logger.debug("Loaded catalog: %d garments from %s", len(catalog), CATALOG_PATH)
return catalog
except (json.JSONDecodeError, IOError) as e:
logger.error("Failed to load catalog: %s", e)
return []
def save_catalog(garments: list[dict]) -> None:
"""Write the full catalog to disk."""
CATALOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(CATALOG_PATH, "w", encoding="utf-8") as f:
json.dump(garments, f, indent=2, ensure_ascii=False)
logger.info("Catalog saved: %d garments", len(garments))
def _next_id(catalog: list[dict]) -> int:
"""Get the next available garment ID number."""
if not catalog:
return 1
existing_ids = []
for g in catalog:
gid = g.get("id", "")
if isinstance(gid, str) and gid.startswith("garment_"):
try:
existing_ids.append(int(gid.split("_")[1]))
except (ValueError, IndexError):
pass
return max(existing_ids, default=0) + 1
def add_garments(
garments_with_images: list[tuple[dict, bytes | None]],
) -> list[dict]:
"""Add new garments to the catalog. Assigns IDs and persists images.
Accepts a list of (garment_dict, image_bytes) tuples. Each garment
gets its own individual image saved.
Returns the garments with their assigned IDs.
"""
catalog = load_catalog()
next_num = _next_id(catalog)
added = []
for i, (garment, img_bytes) in enumerate(garments_with_images):
garment_id = f"garment_{next_num + i:03d}"
garment["id"] = garment_id
if img_bytes:
filename = save_image(garment_id, img_bytes)
garment["image_ref"] = filename
catalog.append(garment)
added.append(garment)
save_catalog(catalog)
return added
def search(
garment_type: str | None = None,
color: str | None = None,
season: str | None = None,
formality: str | None = None,
) -> list[dict]:
"""Filter catalog by attributes. All filters are AND-combined."""
catalog = load_catalog()
results = catalog
if garment_type:
garment_type = garment_type.lower().strip()
results = [g for g in results if garment_type in g.get("type", "")]
if color:
color = color.lower().strip()
results = [g for g in results if color in g.get("color", "")]
if season:
season = season.lower().strip()
results = [g for g in results if g.get("season", "") in (season, "all")]
if formality:
formality = formality.lower().strip()
results = [g for g in results if formality in g.get("formality", "")]
return results
def get_catalog_summary() -> str:
"""Format the catalog as a text block for LLM context injection."""
catalog = load_catalog()
if not catalog:
return "The wardrobe is empty. No garments have been cataloged yet."
lines = [f"Wardrobe catalog ({len(catalog)} garments):\n"]
for g in catalog:
gid = g.get("id", "?")
gtype = g.get("type", "?")
color = g.get("color", "?")
material = g.get("material", "?")
pattern = g.get("pattern", "?")
season = g.get("season", "?")
formality = g.get("formality", "?")
description = g.get("description", "")
line = (
f"- [{gid}] {color} {gtype} ({material}, {pattern}) "
f"| season: {season} | style: {formality}"
)
if description:
line += f"\n → {description}"
lines.append(line)
return "\n".join(lines)
def get_catalog_stats() -> dict:
"""Return aggregate statistics about the catalog."""
catalog = load_catalog()
if not catalog:
return {"total": 0}
types: dict[str, int] = {}
colors: dict[str, int] = {}
seasons: dict[str, int] = {}
for g in catalog:
t = g.get("type", "unknown")
types[t] = types.get(t, 0) + 1
c = g.get("color", "unknown")
colors[c] = colors.get(c, 0) + 1
s = g.get("season", "unknown")
seasons[s] = seasons.get(s, 0) + 1
return {
"total": len(catalog),
"by_type": dict(sorted(types.items(), key=lambda x: -x[1])),
"by_color": dict(sorted(colors.items(), key=lambda x: -x[1])),
"by_season": dict(sorted(seasons.items(), key=lambda x: -x[1])),
}
def get_garment_image_path(garment_id: str) -> str | None:
"""Resolve the local filesystem path for a garment's image."""
return get_image_path(garment_id)
def clear_catalog() -> None:
"""Remove all garments from the catalog and their images."""
catalog = load_catalog()
for g in catalog:
gid = g.get("id", "")
if gid:
delete_image(gid)
save_catalog([])
logger.info("Catalog cleared")