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.
This commit is contained in:
parent
bb5d402c15
commit
d91d06455b
18 changed files with 587 additions and 210 deletions
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue