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
|
|
@ -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