From d91d06455bd963a0b591f160075ac5302cd2e63f Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Mon, 9 Mar 2026 13:37:40 +0100 Subject: [PATCH] feat(products): improve replenishment-aware shopping suggestions Replace product weight and repurchase intent fields with per-package remaining levels and inventory-first restock signals. Enrich shopping suggestions with usage-aware replenishment scoring so the frontend and LLM can prioritize real gaps and near-empty staples more reliably. --- ..._weights_with_inventory_remaining_level.py | 69 ++++ backend/innercontext/api/products.py | 363 +++++++++++++++--- backend/innercontext/models/__init__.py | 2 + backend/innercontext/models/enums.py | 7 + backend/innercontext/models/product.py | 9 +- backend/tests/test_inventory.py | 7 +- backend/tests/test_products.py | 17 +- backend/tests/test_products_helpers.py | 95 ++++- frontend/messages/en.json | 10 +- frontend/messages/pl.json | 10 +- frontend/src/lib/api.ts | 2 - .../src/lib/components/ProductForm.svelte | 15 - .../ProductFormDetailsSection.svelte | 14 - .../ProductFormNotesSection.svelte | 17 - frontend/src/lib/types.ts | 7 +- .../src/routes/products/[id]/+page.server.ts | 16 +- .../src/routes/products/[id]/+page.svelte | 129 ++++--- .../src/routes/products/new/+page.server.ts | 8 +- 18 files changed, 587 insertions(+), 210 deletions(-) create mode 100644 backend/alembic/versions/9f3a2c1b4d5e_replace_product_weights_with_inventory_remaining_level.py diff --git a/backend/alembic/versions/9f3a2c1b4d5e_replace_product_weights_with_inventory_remaining_level.py b/backend/alembic/versions/9f3a2c1b4d5e_replace_product_weights_with_inventory_remaining_level.py new file mode 100644 index 0000000..67f00d0 --- /dev/null +++ b/backend/alembic/versions/9f3a2c1b4d5e_replace_product_weights_with_inventory_remaining_level.py @@ -0,0 +1,69 @@ +"""replace product weights with inventory remaining level + +Revision ID: 9f3a2c1b4d5e +Revises: 7e6f73d1cc95 +Create Date: 2026-03-08 12:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "9f3a2c1b4d5e" +down_revision: Union[str, Sequence[str], None] = "7e6f73d1cc95" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + remaining_level_enum = sa.Enum( + "HIGH", + "MEDIUM", + "LOW", + "NEARLY_EMPTY", + name="remaininglevel", + ) + remaining_level_enum.create(bind, checkfirst=True) + + op.add_column( + "product_inventory", + sa.Column("remaining_level", remaining_level_enum, nullable=True), + ) + op.drop_column("product_inventory", "last_weighed_at") + op.drop_column("product_inventory", "current_weight_g") + op.drop_column("products", "personal_repurchase_intent") + op.drop_column("products", "empty_weight_g") + op.drop_column("products", "full_weight_g") + + +def downgrade() -> None: + bind = op.get_bind() + remaining_level_enum = sa.Enum( + "HIGH", + "MEDIUM", + "LOW", + "NEARLY_EMPTY", + name="remaininglevel", + ) + + op.add_column( + "products", + sa.Column("personal_repurchase_intent", sa.Boolean(), nullable=True), + ) + op.add_column( + "product_inventory", + sa.Column("current_weight_g", sa.Float(), nullable=True), + ) + op.add_column( + "product_inventory", + sa.Column("last_weighed_at", sa.Date(), nullable=True), + ) + op.add_column("products", sa.Column("full_weight_g", sa.Float(), nullable=True)) + op.add_column("products", sa.Column("empty_weight_g", sa.Float(), nullable=True)) + op.drop_column("product_inventory", "remaining_level") + remaining_level_enum.drop(bind, checkfirst=True) diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index 6c13ac9..eab246d 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -1,7 +1,7 @@ import json import logging from datetime import date -from typing import Any, Literal, Optional +from typing import Any, Literal, Optional, cast from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException, Query @@ -48,6 +48,7 @@ from innercontext.models.enums import ( AbsorptionSpeed, DayTime, PriceTier, + RemainingLevel, SkinType, TextureType, ) @@ -128,8 +129,6 @@ class ProductUpdate(SQLModel): price_amount: Optional[float] = None price_currency: Optional[str] = None size_ml: Optional[float] = None - full_weight_g: Optional[float] = None - empty_weight_g: Optional[float] = None pao_months: Optional[int] = None inci: Optional[list[str]] = None @@ -159,7 +158,6 @@ class ProductUpdate(SQLModel): needle_length_mm: Optional[float] = None personal_tolerance_notes: Optional[str] = None - personal_repurchase_intent: Optional[bool] = None class ProductParseRequest(SQLModel): @@ -181,8 +179,6 @@ class ProductParseResponse(SQLModel): price_amount: Optional[float] = None price_currency: Optional[str] = None size_ml: Optional[float] = None - full_weight_g: Optional[float] = None - empty_weight_g: Optional[float] = None pao_months: Optional[int] = None inci: Optional[list[str]] = None actives: Optional[list[ActiveIngredient]] = None @@ -234,8 +230,7 @@ class InventoryCreate(SQLModel): opened_at: Optional[date] = None finished_at: Optional[date] = None expiry_date: Optional[date] = None - current_weight_g: Optional[float] = None - last_weighed_at: Optional[date] = None + remaining_level: Optional[RemainingLevel] = None notes: Optional[str] = None @@ -244,11 +239,173 @@ class InventoryUpdate(SQLModel): opened_at: Optional[date] = None finished_at: Optional[date] = None expiry_date: Optional[date] = None - current_weight_g: Optional[float] = None - last_weighed_at: Optional[date] = None + remaining_level: Optional[RemainingLevel] = None notes: Optional[str] = None +def _remaining_level_rank(level: RemainingLevel | str | None) -> int: + if level in (RemainingLevel.NEARLY_EMPTY, "nearly_empty"): + return 0 + if level in (RemainingLevel.LOW, "low"): + return 1 + if level in (RemainingLevel.MEDIUM, "medium"): + return 2 + if level in (RemainingLevel.HIGH, "high"): + return 3 + return 99 + + +_STAPLE_CATEGORIES = {"cleanser", "moisturizer", "spf"} +_OCCASIONAL_CATEGORIES = {"exfoliant", "mask", "spot_treatment", "tool"} + + +def _compute_days_since_last_used( + last_used_on: date | None, reference_date: date +) -> int | None: + if last_used_on is None: + return None + return max((reference_date - last_used_on).days, 0) + + +def _compute_replenishment_score( + *, + has_stock: bool, + sealed_backup_count: int, + lowest_remaining_level: str | None, + days_since_last_used: int | None, + category: ProductCategory | str, +) -> dict[str, object]: + score = 0 + reason_codes: list[str] = [] + category_value = _ev(category) + + if not has_stock: + score = 90 + reason_codes.append("out_of_stock") + elif sealed_backup_count > 0: + score = 10 + reason_codes.append("has_sealed_backup") + elif lowest_remaining_level == "nearly_empty": + score = 80 + reason_codes.append("nearly_empty_opened") + elif lowest_remaining_level == "low": + score = 60 + reason_codes.append("low_opened") + elif lowest_remaining_level == "medium": + score = 25 + elif lowest_remaining_level == "high": + score = 5 + else: + reason_codes.append("insufficient_remaining_data") + + if days_since_last_used is not None: + if days_since_last_used <= 3: + score += 20 + reason_codes.append("recently_used") + elif days_since_last_used <= 7: + score += 12 + reason_codes.append("recently_used") + elif days_since_last_used <= 14: + score += 6 + elif days_since_last_used <= 30: + pass + elif days_since_last_used <= 60: + score -= 10 + reason_codes.append("stale_usage") + else: + score -= 20 + reason_codes.append("stale_usage") + + if category_value in _STAPLE_CATEGORIES: + score += 15 + reason_codes.append("staple_category") + elif category_value in _OCCASIONAL_CATEGORIES: + score -= 10 + reason_codes.append("occasional_category") + elif category_value == "serum": + score += 5 + + if sealed_backup_count > 0 and has_stock: + score = min(score, 15) + if ( + days_since_last_used is not None + and days_since_last_used > 60 + and category_value not in _STAPLE_CATEGORIES + ): + score = min(score, 25) + if ( + lowest_remaining_level is None + and has_stock + and (days_since_last_used is None or days_since_last_used > 14) + ): + score = min(score, 20) + + score = max(0, min(score, 100)) + if score >= 80: + priority_hint = "high" + elif score >= 50: + priority_hint = "medium" + elif score >= 25: + priority_hint = "low" + else: + priority_hint = "none" + + return { + "replenishment_score": score, + "replenishment_priority_hint": priority_hint, + "repurchase_candidate": priority_hint != "none", + "replenishment_reason_codes": reason_codes, + } + + +def _summarize_inventory_state(entries: list[ProductInventory]) -> dict[str, object]: + active_entries = [entry for entry in entries if entry.finished_at is None] + opened_entries = [entry for entry in active_entries if entry.is_opened] + sealed_entries = [entry for entry in active_entries if not entry.is_opened] + + opened_levels = [ + _ev(entry.remaining_level) + for entry in opened_entries + if entry.remaining_level is not None + ] + opened_levels_sorted = sorted( + opened_levels, + key=_remaining_level_rank, + ) + lowest_opened_level = opened_levels_sorted[0] if opened_levels_sorted else None + + stock_state = "healthy" + if not active_entries: + stock_state = "out_of_stock" + elif sealed_entries: + stock_state = "healthy" + elif lowest_opened_level == "nearly_empty": + stock_state = "urgent" + elif lowest_opened_level == "low": + stock_state = "low" + elif lowest_opened_level == "medium": + stock_state = "monitor" + + replenishment_signal = "none" + if stock_state == "out_of_stock": + replenishment_signal = "out_of_stock" + elif stock_state == "urgent": + replenishment_signal = "urgent" + elif stock_state == "low": + replenishment_signal = "soon" + + return { + "has_stock": bool(active_entries), + "active_count": len(active_entries), + "opened_count": len(opened_entries), + "sealed_backup_count": len(sealed_entries), + "opened_levels": opened_levels_sorted, + "lowest_opened_level": lowest_opened_level, + "stock_state": stock_state, + "replenishment_signal": replenishment_signal, + } + + # --------------------------------------------------------------------------- # Shopping suggestion schemas # --------------------------------------------------------------------------- @@ -324,15 +481,6 @@ def _estimated_amount_per_use(category: ProductCategory) -> float | None: return _ESTIMATED_AMOUNT_PER_USE.get(category) -def _net_weight_g(product: Product) -> float | None: - if product.full_weight_g is None or product.empty_weight_g is None: - return None - net = product.full_weight_g - product.empty_weight_g - if net <= 0: - return None - return net - - def _price_per_use_pln(product: Product) -> float | None: if product.price_amount is None or product.price_currency is None: return None @@ -342,8 +490,6 @@ def _price_per_use_pln(product: Product) -> float | None: return None pack_amount = product.size_ml - if pack_amount is None or pack_amount <= 0: - pack_amount = _net_weight_g(product) if pack_amount is None or pack_amount <= 0: return None @@ -518,8 +664,10 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session)) if payload.get("price_currency"): payload["price_currency"] = str(payload["price_currency"]).upper() + product_id = uuid4() product = Product( - id=uuid4(), + id=product_id, + short_id=str(product_id)[:8], **payload, ) session.add(product) @@ -599,8 +747,6 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine): "price_amount": number, "price_currency": string, "size_ml": number, - "full_weight_g": number, - "empty_weight_g": number, "pao_months": integer, "inci": [string, ...], "actives": [ @@ -869,7 +1015,13 @@ def _ev(v: object) -> str: return str(v) -def _build_shopping_context(session: Session, reference_date: date) -> str: +def _build_shopping_context( + session: Session, + reference_date: date, + *, + products: list[Product] | None = None, + last_used_on_by_product: dict[str, date] | None = None, +) -> str: profile_ctx = build_user_profile_context(session, reference_date=reference_date) snapshot = session.exec( select(SkinConditionSnapshot).order_by( @@ -892,9 +1044,14 @@ def _build_shopping_context(session: Session, reference_date: date) -> str: else: skin_lines.append(" (brak danych)") - products = _get_shopping_products(session) + if products is None: + products = _get_shopping_products(session) product_ids = [p.id for p in products] + last_used_on_by_product = last_used_on_by_product or build_last_used_on_by_product( + session, + product_ids=product_ids, + ) inventory_rows = ( session.exec( select(ProductInventory).where( @@ -910,15 +1067,31 @@ def _build_shopping_context(session: Session, reference_date: date) -> str: products_lines = ["POSIADANE PRODUKTY:"] products_lines.append( - " Legenda: [✓] = produkt dostępny (w magazynie), [✗] = brak w magazynie" + " Legenda: [✓] = aktywny zapas istnieje, [✗] = brak aktywnego zapasu" + ) + products_lines.append( + " Pola: stock_state, sealed_backup_count, lowest_remaining_level, days_since_last_used, replenishment_score, replenishment_priority_hint, repurchase_candidate" ) for p in products: - active_inv = [i for i in inv_by_product.get(p.id, []) if i.finished_at is None] - has_stock = len(active_inv) > 0 # any unfinished inventory = in stock - stock = "✓" if has_stock else "✗" + inventory_summary = _summarize_inventory_state(inv_by_product.get(p.id, [])) + stock = "✓" if inventory_summary["has_stock"] else "✗" + last_used_on = last_used_on_by_product.get(str(p.id)) + days_since_last_used = _compute_days_since_last_used( + last_used_on, reference_date + ) + replenishment = _compute_replenishment_score( + has_stock=bool(inventory_summary["has_stock"]), + sealed_backup_count=cast(int, inventory_summary["sealed_backup_count"]), + lowest_remaining_level=( + str(inventory_summary["lowest_opened_level"]) + if inventory_summary["lowest_opened_level"] is not None + else None + ), + days_since_last_used=days_since_last_used, + category=p.category, + ) actives = _extract_active_names(p) - actives_str = f", actives: {actives}" if actives else "" ep = p.product_effect_profile if isinstance(ep, dict): @@ -929,13 +1102,55 @@ def _build_shopping_context(session: Session, reference_date: date) -> str: for k, v in ep.model_dump().items() if v >= 3 } - effects_str = f", effects: {effects}" if effects else "" - targets = [_ev(t) for t in (p.targets or [])] - + product_header = f" [{stock}] id={p.id} {p.name}" + if p.brand: + product_header += f" ({p.brand})" + products_lines.append(product_header) + products_lines.append(f" category={_ev(p.category)}") + products_lines.append(f" targets={targets}") + if actives: + products_lines.append(f" actives={actives}") + if effects: + products_lines.append(f" effects={effects}") + products_lines.append(f" stock_state={inventory_summary['stock_state']}") + products_lines.append(f" active_count={inventory_summary['active_count']}") + products_lines.append(f" opened_count={inventory_summary['opened_count']}") products_lines.append( - f" [{stock}] id={p.id} {p.name} ({p.brand or ''}) - {_ev(p.category)}, " - f"targets: {targets}{actives_str}{effects_str}" + f" sealed_backup_count={inventory_summary['sealed_backup_count']}" + ) + products_lines.append( + " lowest_remaining_level=" + + ( + str(inventory_summary["lowest_opened_level"]) + if inventory_summary["lowest_opened_level"] is not None + else "null" + ) + ) + products_lines.append( + " last_used_on=" + (last_used_on.isoformat() if last_used_on else "null") + ) + products_lines.append( + " days_since_last_used=" + + ( + str(days_since_last_used) + if days_since_last_used is not None + else "null" + ) + ) + products_lines.append( + f" replenishment_score={replenishment['replenishment_score']}" + ) + products_lines.append( + " replenishment_priority_hint=" + + str(replenishment["replenishment_priority_hint"]) + ) + products_lines.append( + " repurchase_candidate=" + + ("true" if replenishment["repurchase_candidate"] else "false") + ) + products_lines.append( + " reason_codes=" + str(replenishment["replenishment_reason_codes"]) ) return ( @@ -972,33 +1187,47 @@ def _extract_requested_product_ids( return _shared_extract_requested_product_ids(args, max_ids=max_ids) -_SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry. -Twoim zadaniem jest przeanalizować stan skóry użytkownika oraz produkty, które już posiada, -a następnie zasugerować TYPY produktów (bez marek), które mogłyby uzupełnić ich rutynę. +_SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry. -LEGENDA: -- [✓] = produkt dostępny w magazynie (nawet jeśli jest zapieczętowany) -- [✗] = produkt niedostępny (brak w magazynie, wszystkie opakowania zużyte) +Twoim zadaniem jest ocenić dwie rzeczy: +1. czy w rutynie użytkownika istnieją realne luki produktowe, +2. czy któryś z już posiadanych typów produktów warto odkupić teraz z powodu kończącego się zapasu. -ZASADY: +Masz działać konserwatywnie: nie sugeruj zakupu tylko dlatego, że coś mogłoby się przydać. +Sugestia ma pojawić się tylko wtedy, gdy istnieje wyraźny powód praktyczny. + +WAŻNE POJĘCIA: +- `stock_state` opisuje stan zapasu: `out_of_stock`, `healthy`, `monitor`, `low`, `urgent` +- `sealed_backup_count` > 0 zwykle oznacza, że odkup nie jest pilny +- `lowest_remaining_level` dotyczy tylko otwartych opakowań i może mieć wartość: `high`, `medium`, `low`, `nearly_empty` +- `last_used_on` i `days_since_last_used` pomagają ocenić, czy produkt jest nadal faktycznie używany +- `replenishment_score`, `replenishment_priority_hint` i `repurchase_candidate` są backendowym sygnałem pilności odkupu; traktuj je jako mocną wskazówkę +- `reason_codes` tłumaczą, dlaczego backend uznał odkup za pilny albo niepilny + +JAK PODEJMOWAĆ DECYZJĘ: 0. Sugeruj tylko wtedy, gdy jest realna potrzeba - nie zwracaj stałej liczby produktów -1. Sugeruj TYLKO typy produktów, NIGDY konkretne marki (np. "Salicylic Acid 2% Masque", nie "La Roche-Posay") -2. Produkty oznaczone [✗] to te, których NIE MA w magazynie - możesz je zasugerować -3. Produkty oznaczone [✓] są już dostępne - nie sugeruj ich ponownie -4. Bierz pod uwagę aktywne problemy skóry (acne, hyperpigmentacja, aging, etc.) -5. Sugeruj realistyczną częstotliwość użycia (dzienna, 2-3x tygodniowo, etc.) -6. Zachowaj kolejność warstw: cleanse → toner → serum → moisturizer → SPF -7. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów -8. Zwracaj uwagę na ewentualne konflikty polecanych składników z tymi, które użytkownik już posiada (np. nie polecaj peptydów miedziowych jeśli użytkownik nadużywa kwasów) -9. Odpowiadaj w języku polskim -10. Używaj wyłącznie dozwolonych wartości enumów poniżej - nie twórz synonimów typu "night", "evening" ani "treatment" -11. Możesz zwrócić pustą listę suggestions, jeśli nie widzisz realnej potrzeby zakupowej -12. Każda sugestia ma mieć charakter decision-support: konkretnie wyjaśnij, dlaczego warto kupić ją teraz, jak wpisuje się w obecną rutynę i jakie są ograniczenia -13. `short_reason` ma być krótkim, 1-zdaniowym skrótem decyzji zakupowej -14. `reason_to_buy_now` ma być konkretne i praktyczne, bez lania wody -15. `reason_not_needed_if_budget_tight` jest opcjonalne - uzupełniaj tylko wtedy, gdy zakup nie jest pilny lub istnieje rozsądny kompromis -16. `usage_cautions` ma być krótką listą praktycznych uwag; gdy brak istotnych zastrzeżeń zwróć pustą listę -17. `priority` ustawiaj jako: high = wyraźna luka lub pilna potrzeba, medium = sensowne uzupełnienie, low = opcjonalny upgrade +1. Najpierw oceń, czy użytkownik ma lukę w rutynie +2. Potem oceń, czy któryś istniejący typ produktu jest wart odkupu teraz +3. Nie rekomenduj ponownie produktu, który ma zdrowy zapas +4. Nie rekomenduj odkupu tylko dlatego, że produkt jest niski, jeśli nie był używany od dawna i nie wygląda na nadal potrzebny +5. Dla kategorii podstawowych (`cleanser`, `moisturizer`, `spf`) niski stan i świeże użycie mają większe znaczenie niż dla produktów okazjonalnych +6. Dla produktów okazjonalnych (`exfoliant`, `mask`, `spot_treatment`) świeżość użycia jest ważniejsza niż sam niski stan +7. Jeśli `sealed_backup_count` > 0, zwykle nie rekomenduj odkupu +8. Sugeruj TYLKO typy produktów, NIGDY konkretne marki (np. "Salicylic Acid 2% Masque", nie "La Roche-Posay") +9. Bierz pod uwagę aktywne problemy skóry (acne, hyperpigmentacja, aging, etc.) +10. Sugeruj realistyczną częstotliwość użycia (dzienna, 2-3x tygodniowo, etc.) +11. Zachowaj kolejność warstw: cleanse → toner → serum → moisturizer → SPF +12. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów +13. Zwracaj uwagę na ewentualne konflikty polecanych składników z tymi, które użytkownik już posiada +14. Odpowiadaj w języku polskim +15. Używaj wyłącznie dozwolonych wartości enumów poniżej - nie twórz synonimów typu "night", "evening" ani "treatment" +16. Możesz zwrócić pustą listę suggestions, jeśli nie widzisz realnej potrzeby zakupowej +17. Każda sugestia ma mieć charakter decision-support: konkretnie wyjaśnij, dlaczego warto kupić ją teraz, jak wpisuje się w obecną rutynę i jakie są ograniczenia +18. `short_reason` ma być krótkim, 1-zdaniowym skrótem decyzji zakupowej +19. `reason_to_buy_now` ma być konkretne i praktyczne, bez lania wody +20. `reason_not_needed_if_budget_tight` jest opcjonalne - uzupełniaj tylko wtedy, gdy zakup nie jest pilny lub istnieje rozsądny kompromis +21. `usage_cautions` ma być krótką listą praktycznych uwag; gdy brak istotnych zastrzeżeń zwróć pustą listę +22. `priority` ustawiaj jako: high = wyraźna luka lub pilna potrzeba, medium = sensowne uzupełnienie, low = opcjonalny upgrade DOZWOLONE WARTOŚCI ENUMÓW: - category: "cleanser" | "toner" | "essence" | "serum" | "moisturizer" | "spf" | "mask" | "exfoliant" | "hair_treatment" | "tool" | "spot_treatment" | "oil" @@ -1009,16 +1238,24 @@ Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem.""" @router.post("/suggest", response_model=ShoppingSuggestionResponse) def suggest_shopping(session: Session = Depends(get_session)): - context = _build_shopping_context(session, reference_date=date.today()) + reference_date = date.today() shopping_products = _get_shopping_products(session) last_used_on_by_product = build_last_used_on_by_product( session, product_ids=[p.id for p in shopping_products], ) + context = _build_shopping_context( + session, + reference_date=reference_date, + products=shopping_products, + last_used_on_by_product=last_used_on_by_product, + ) prompt = ( - f"Na podstawie poniższych danych przeanalizuj, jakie TYPY produktów " - f"mogłyby uzupełnić rutynę pielęgnacyjną użytkownika.\n\n" + "Przeanalizuj dane użytkownika i zaproponuj tylko te zakupy, które mają realny sens teraz.\n\n" + "Najpierw rozważ luki w rutynie, potem ewentualny odkup kończących się produktów.\n" + "Jeśli produkt już istnieje, ale ma niski zapas i jest nadal realnie używany, możesz zasugerować odkup tego typu produktu.\n" + "Jeśli produkt ma sealed backup albo nie był używany od dawna, zwykle nie sugeruj odkupu.\n\n" f"{context}\n\n" "NARZEDZIA:\n" "- Masz dostep do funkcji: get_product_details.\n" diff --git a/backend/innercontext/models/__init__.py b/backend/innercontext/models/__init__.py index 4287754..a6d31e3 100644 --- a/backend/innercontext/models/__init__.py +++ b/backend/innercontext/models/__init__.py @@ -12,6 +12,7 @@ from .enums import ( PartOfDay, PriceTier, ProductCategory, + RemainingLevel, ResultFlag, RoutineRole, SexAtBirth, @@ -58,6 +59,7 @@ __all__ = [ "OverallSkinState", "PartOfDay", "PriceTier", + "RemainingLevel", "ProductCategory", "ResultFlag", "RoutineRole", diff --git a/backend/innercontext/models/enums.py b/backend/innercontext/models/enums.py index 96f1c6a..01adf39 100644 --- a/backend/innercontext/models/enums.py +++ b/backend/innercontext/models/enums.py @@ -124,6 +124,13 @@ class PriceTier(str, Enum): LUXURY = "luxury" +class RemainingLevel(str, Enum): + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + NEARLY_EMPTY = "nearly_empty" + + class EvidenceLevel(str, Enum): LOW = "low" MIXED = "mixed" diff --git a/backend/innercontext/models/product.py b/backend/innercontext/models/product.py index db4ed15..56f678b 100644 --- a/backend/innercontext/models/product.py +++ b/backend/innercontext/models/product.py @@ -14,6 +14,7 @@ from .enums import ( IngredientFunction, PriceTier, ProductCategory, + RemainingLevel, SkinConcern, SkinType, StrengthLevel, @@ -97,8 +98,6 @@ class ProductBase(SQLModel): price_amount: float | None = Field(default=None, gt=0) price_currency: str | None = Field(default=None, min_length=3, max_length=3) size_ml: float | None = Field(default=None, gt=0) - full_weight_g: float | None = Field(default=None, gt=0) - empty_weight_g: float | None = Field(default=None, gt=0) pao_months: int | None = Field(default=None, ge=1, le=60) inci: list[str] = Field(default_factory=list) @@ -129,7 +128,6 @@ class ProductBase(SQLModel): needle_length_mm: float | None = Field(default=None, gt=0) personal_tolerance_notes: str | None = None - personal_repurchase_intent: bool | None = None # --------------------------------------------------------------------------- @@ -338,8 +336,6 @@ class Product(ProductBase, table=True): ctx["needle_length_mm"] = self.needle_length_mm if self.personal_tolerance_notes: ctx["personal_tolerance_notes"] = self.personal_tolerance_notes - if self.personal_repurchase_intent is not None: - ctx["personal_repurchase_intent"] = self.personal_repurchase_intent try: opened_items = [ @@ -365,8 +361,7 @@ class ProductInventory(SQLModel, table=True): opened_at: date | None = Field(default=None) finished_at: date | None = Field(default=None) expiry_date: date | None = Field(default=None) - current_weight_g: float | None = Field(default=None, gt=0) - last_weighed_at: date | None = Field(default=None) + remaining_level: RemainingLevel | None = None notes: str | None = None created_at: datetime = Field(default_factory=utc_now, nullable=False) diff --git a/backend/tests/test_inventory.py b/backend/tests/test_inventory.py index bb5a017..a559bfb 100644 --- a/backend/tests/test_inventory.py +++ b/backend/tests/test_inventory.py @@ -24,12 +24,17 @@ def test_update_inventory_opened(client, created_product): r2 = client.patch( f"/inventory/{inv_id}", - json={"is_opened": True, "opened_at": "2026-01-15"}, + json={ + "is_opened": True, + "opened_at": "2026-01-15", + "remaining_level": "low", + }, ) assert r2.status_code == 200 data = r2.json() assert data["is_opened"] is True assert data["opened_at"] == "2026-01-15" + assert data["remaining_level"] == "low" def test_update_inventory_not_found(client): diff --git a/backend/tests/test_products.py b/backend/tests/test_products.py index e8d2655..df93c48 100644 --- a/backend/tests/test_products.py +++ b/backend/tests/test_products.py @@ -187,11 +187,15 @@ def test_list_inventory_product_not_found(client): def test_create_inventory(client, created_product): pid = created_product["id"] - r = client.post(f"/products/{pid}/inventory", json={"is_opened": False}) + r = client.post( + f"/products/{pid}/inventory", + json={"is_opened": True, "remaining_level": "medium"}, + ) assert r.status_code == 201 data = r.json() assert data["product_id"] == pid - assert data["is_opened"] is False + assert data["is_opened"] is True + assert data["remaining_level"] == "medium" def test_create_inventory_product_not_found(client): @@ -204,11 +208,16 @@ def test_parse_text_accepts_numeric_strength_levels(client, monkeypatch): class _FakeResponse: text = ( - '{"name":"Test Serum","actives":[{"name":"Niacinamide","percent":10,' + '{"name":"Test Serum","category":"serum","recommended_time":"both",' + '"leave_on":true,"actives":[{"name":"Niacinamide","percent":10,' '"functions":["niacinamide"],"strength_level":2,"irritation_potential":1}]}' ) - monkeypatch.setattr(products_api, "call_gemini", lambda **kwargs: _FakeResponse()) + monkeypatch.setattr( + products_api, + "call_gemini", + lambda **kwargs: (_FakeResponse(), None), + ) r = client.post("/products/parse-text", json={"text": "dummy input"}) assert r.status_code == 200 diff --git a/backend/tests/test_products_helpers.py b/backend/tests/test_products_helpers.py index 9ce3573..4693283 100644 --- a/backend/tests/test_products_helpers.py +++ b/backend/tests/test_products_helpers.py @@ -8,6 +8,8 @@ from innercontext.api.products import ( ProductSuggestion, ShoppingSuggestionResponse, _build_shopping_context, + _compute_days_since_last_used, + _compute_replenishment_score, _extract_requested_product_ids, build_product_details_tool_handler, ) @@ -68,7 +70,12 @@ def test_build_shopping_context(session: Session): session.commit() # Add inventory - inv = ProductInventory(id=uuid.uuid4(), product_id=p.id, is_opened=True) + inv = ProductInventory( + id=uuid.uuid4(), + product_id=p.id, + is_opened=True, + remaining_level="medium", + ) session.add(inv) session.commit() @@ -87,9 +94,89 @@ def test_build_shopping_context(session: Session): assert "Soothing Serum" in ctx assert f"id={p.id}" in ctx assert "BrandX" in ctx - assert "targets: ['redness']" in ctx - assert "actives: ['Centella']" in ctx - assert "effects: {'soothing': 4}" in ctx + assert "targets=['redness']" in ctx + assert "actives=['Centella']" in ctx + assert "effects={'soothing': 4}" in ctx + assert "stock_state=monitor" in ctx + assert "opened_count=1" in ctx + assert "sealed_backup_count=0" in ctx + assert "lowest_remaining_level=medium" in ctx + assert "replenishment_score=30" in ctx + assert "replenishment_priority_hint=low" in ctx + assert "repurchase_candidate=true" in ctx + + +def test_build_shopping_context_flags_replenishment_signal(session: Session): + product = Product( + id=uuid.uuid4(), + short_id=str(uuid.uuid4())[:8], + name="Barrier Cleanser", + brand="BrandY", + category="cleanser", + recommended_time="both", + leave_on=False, + product_effect_profile={}, + ) + session.add(product) + session.commit() + + session.add( + ProductInventory( + id=uuid.uuid4(), + product_id=product.id, + is_opened=True, + remaining_level="nearly_empty", + ) + ) + session.commit() + + ctx = _build_shopping_context(session, reference_date=date.today()) + assert "lowest_remaining_level=nearly_empty" in ctx + assert "stock_state=urgent" in ctx + assert "replenishment_priority_hint=high" in ctx + + +def test_compute_replenishment_score_prefers_recent_staples_without_backup(): + result = _compute_replenishment_score( + has_stock=True, + sealed_backup_count=0, + lowest_remaining_level="low", + days_since_last_used=2, + category=ProductCategory.CLEANSER, + ) + + assert result["replenishment_score"] == 95 + assert result["replenishment_priority_hint"] == "high" + assert result["repurchase_candidate"] is True + assert result["replenishment_reason_codes"] == [ + "low_opened", + "recently_used", + "staple_category", + ] + + +def test_compute_replenishment_score_downranks_sealed_backup_and_stale_usage(): + result = _compute_replenishment_score( + has_stock=True, + sealed_backup_count=1, + lowest_remaining_level="nearly_empty", + days_since_last_used=70, + category=ProductCategory.EXFOLIANT, + ) + + assert result["replenishment_score"] == 0 + assert result["replenishment_priority_hint"] == "none" + assert result["repurchase_candidate"] is False + assert result["replenishment_reason_codes"] == [ + "has_sealed_backup", + "stale_usage", + "occasional_category", + ] + + +def test_compute_days_since_last_used_returns_none_without_usage(): + assert _compute_days_since_last_used(None, date(2026, 3, 9)) is None + assert _compute_days_since_last_used(date(2026, 3, 7), date(2026, 3, 9)) == 2 def test_suggest_shopping(client, session): diff --git a/frontend/messages/en.json b/frontend/messages/en.json index df9d006..5efb7b6 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -95,8 +95,11 @@ "inventory_openedDate": "Opened date", "inventory_finishedDate": "Finished date", "inventory_expiryDate": "Expiry date", - "inventory_currentWeight": "Current weight (g)", - "inventory_lastWeighed": "Last weighed", + "inventory_remainingLevel": "Remaining product level", + "inventory_remainingHigh": "high", + "inventory_remainingMedium": "medium", + "inventory_remainingLow": "low", + "inventory_remainingNearlyEmpty": "nearly empty", "inventory_notes": "Notes", "inventory_badgeOpen": "Open", "inventory_badgeSealed": "Sealed", @@ -104,8 +107,6 @@ "inventory_exp": "Exp:", "inventory_opened": "Opened:", "inventory_finished": "Finished:", - "inventory_remaining": "g remaining", - "inventory_weighed": "Weighed:", "inventory_confirmDelete": "Delete this package?", "routines_title": "Routines", @@ -514,7 +515,6 @@ "productForm_isTool": "Is tool (e.g. dermaroller)", "productForm_needleLengthMm": "Needle length (mm, tools only)", "productForm_personalNotes": "Personal notes", - "productForm_repurchaseIntent": "Repurchase intent", "productForm_toleranceNotes": "Tolerance notes", "productForm_toleranceNotesPlaceholder": "e.g. Causes mild stinging, fine after 2 weeks", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index c1c8d56..cfd8308 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -97,8 +97,11 @@ "inventory_openedDate": "Data otwarcia", "inventory_finishedDate": "Data skończenia", "inventory_expiryDate": "Data ważności", - "inventory_currentWeight": "Aktualna waga (g)", - "inventory_lastWeighed": "Ostatnie ważenie", + "inventory_remainingLevel": "Poziom pozostałego produktu:", + "inventory_remainingHigh": "dużo", + "inventory_remainingMedium": "średnio", + "inventory_remainingLow": "mało", + "inventory_remainingNearlyEmpty": "prawie puste", "inventory_notes": "Notatki", "inventory_badgeOpen": "Otwarte", "inventory_badgeSealed": "Zamknięte", @@ -106,8 +109,6 @@ "inventory_exp": "Wazność:", "inventory_opened": "Otwarto:", "inventory_finished": "Skończono:", - "inventory_remaining": "g pozostało", - "inventory_weighed": "Ważono:", "inventory_confirmDelete": "Usunąć to opakowanie?", "routines_title": "Rutyny", @@ -528,7 +529,6 @@ "productForm_isTool": "To narzędzie (np. dermaroller)", "productForm_needleLengthMm": "Długość igły (mm, tylko narzędzia)", "productForm_personalNotes": "Notatki osobiste", - "productForm_repurchaseIntent": "Zamiar ponownego zakupu", "productForm_toleranceNotes": "Notatki o tolerancji", "productForm_toleranceNotesPlaceholder": "np. Lekkie pieczenie, ustępuje po 2 tygodniach", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 49bd01c..f03a41e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -135,8 +135,6 @@ export interface ProductParseResponse { price_amount?: number; price_currency?: string; size_ml?: number; - full_weight_g?: number; - empty_weight_g?: number; pao_months?: number; inci?: string[]; actives?: ActiveIngredient[]; diff --git a/frontend/src/lib/components/ProductForm.svelte b/frontend/src/lib/components/ProductForm.svelte index 3c4314c..80a0b73 100644 --- a/frontend/src/lib/components/ProductForm.svelte +++ b/frontend/src/lib/components/ProductForm.svelte @@ -165,8 +165,6 @@ let sku = $state(untrack(() => product?.sku ?? '')); let barcode = $state(untrack(() => product?.barcode ?? '')); let sizeMl = $state(untrack(() => (product?.size_ml != null ? String(product.size_ml) : ''))); - let fullWeightG = $state(untrack(() => (product?.full_weight_g != null ? String(product.full_weight_g) : ''))); - let emptyWeightG = $state(untrack(() => (product?.empty_weight_g != null ? String(product.empty_weight_g) : ''))); let paoMonths = $state(untrack(() => (product?.pao_months != null ? String(product.pao_months) : ''))); let phMin = $state(untrack(() => (product?.ph_min != null ? String(product.ph_min) : ''))); let phMax = $state(untrack(() => (product?.ph_max != null ? String(product.ph_max) : ''))); @@ -221,8 +219,6 @@ if (r.price_currency) priceCurrency = r.price_currency; if (r.leave_on != null) leaveOn = String(r.leave_on); if (r.size_ml != null) sizeMl = String(r.size_ml); - if (r.full_weight_g != null) fullWeightG = String(r.full_weight_g); - if (r.empty_weight_g != null) emptyWeightG = String(r.empty_weight_g); if (r.pao_months != null) paoMonths = String(r.pao_months); if (r.ph_min != null) phMin = String(r.ph_min); if (r.ph_max != null) phMax = String(r.ph_max); @@ -281,9 +277,6 @@ let pregnancySafe = $state( untrack(() => (product?.pregnancy_safe != null ? String(product.pregnancy_safe) : '')) ); - let personalRepurchaseIntent = $state( - untrack(() => (product?.personal_repurchase_intent != null ? String(product.personal_repurchase_intent) : '')) - ); // context rules tristate const cr = untrack(() => product?.context_rules); @@ -403,8 +396,6 @@ sku, barcode, sizeMl, - fullWeightG, - emptyWeightG, paoMonths, phMin, phMax, @@ -428,7 +419,6 @@ essentialOilsFree, alcoholDenatFree, pregnancySafe, - personalRepurchaseIntent, ctxAfterShaving, ctxAfterAcids, ctxAfterRetinoids, @@ -723,8 +713,6 @@ bind:priceAmount bind:priceCurrency bind:sizeMl - bind:fullWeightG - bind:emptyWeightG bind:paoMonths bind:phMin bind:phMax @@ -743,10 +731,7 @@ {@const NotesSection = mod.default} {/await} diff --git a/frontend/src/lib/components/product-form/ProductFormDetailsSection.svelte b/frontend/src/lib/components/product-form/ProductFormDetailsSection.svelte index 6ac503e..1eda6eb 100644 --- a/frontend/src/lib/components/product-form/ProductFormDetailsSection.svelte +++ b/frontend/src/lib/components/product-form/ProductFormDetailsSection.svelte @@ -9,8 +9,6 @@ priceAmount = $bindable(''), priceCurrency = $bindable('PLN'), sizeMl = $bindable(''), - fullWeightG = $bindable(''), - emptyWeightG = $bindable(''), paoMonths = $bindable(''), phMin = $bindable(''), phMax = $bindable(''), @@ -27,8 +25,6 @@ priceAmount?: string; priceCurrency?: string; sizeMl?: string; - fullWeightG?: string; - emptyWeightG?: string; paoMonths?: string; phMin?: string; phMax?: string; @@ -63,16 +59,6 @@ -
- - -
- -
- - -
-
diff --git a/frontend/src/lib/components/product-form/ProductFormNotesSection.svelte b/frontend/src/lib/components/product-form/ProductFormNotesSection.svelte index 272508a..34637ea 100644 --- a/frontend/src/lib/components/product-form/ProductFormNotesSection.svelte +++ b/frontend/src/lib/components/product-form/ProductFormNotesSection.svelte @@ -3,21 +3,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Label } from '$lib/components/ui/label'; - type TriOption = { value: string; label: string }; - let { visible = false, - selectClass, textareaClass, - tristate, - personalRepurchaseIntent = $bindable(''), personalToleranceNotes = $bindable('') }: { visible?: boolean; - selectClass: string; textareaClass: string; - tristate: TriOption[]; - personalRepurchaseIntent?: string; personalToleranceNotes?: string; } = $props(); @@ -25,15 +17,6 @@ {m["productForm_personalNotes"]()} -
- - -
-