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}