fix(routines): enforce min_interval_hours and minoxidil flag server-side
Two bugs in /routines/suggest where the LLM could override hard constraints: 1. Products with min_interval_hours (e.g. retinol at 72h) were passed to the LLM even if used too recently. The LLM reasoned away the constraint in at least one observed case. Fix: added _filter_products_by_interval() which removes ineligible products before the prompt is built, so they don't appear in AVAILABLE PRODUCTS at all. 2. Minoxidil was included in the available products list regardless of the include_minoxidil_beard flag. Only the objectives context was gated, leaving the product visible to the LLM which would include it based on recent usage history. Fix: added include_minoxidil param to _get_available_products() and threaded it through suggest_routine and suggest_batch. Also refactored _build_products_context() to accept a pre-supplied products list instead of calling _get_available_products() internally, ensuring the tool handler and context text always use the same filtered set. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7a66a7911d
commit
e3ed0dd3a3
2 changed files with 144 additions and 12 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
@ -306,10 +307,9 @@ def _build_recent_history(session: Session) -> str:
|
||||||
|
|
||||||
def _build_products_context(
|
def _build_products_context(
|
||||||
session: Session,
|
session: Session,
|
||||||
time_filter: Optional[str] = None,
|
products: list[Product],
|
||||||
reference_date: Optional[date] = None,
|
reference_date: Optional[date] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
products = _get_available_products(session, time_filter=time_filter)
|
|
||||||
product_ids = [p.id for p in products]
|
product_ids = [p.id for p in products]
|
||||||
inventory_rows = (
|
inventory_rows = (
|
||||||
session.exec(
|
session.exec(
|
||||||
|
|
@ -400,6 +400,7 @@ def _build_products_context(
|
||||||
def _get_available_products(
|
def _get_available_products(
|
||||||
session: Session,
|
session: Session,
|
||||||
time_filter: Optional[str] = None,
|
time_filter: Optional[str] = None,
|
||||||
|
include_minoxidil: bool = True,
|
||||||
) -> list[Product]:
|
) -> list[Product]:
|
||||||
stmt = select(Product).where(col(Product.is_tool).is_(False))
|
stmt = select(Product).where(col(Product.is_tool).is_(False))
|
||||||
products = session.exec(stmt).all()
|
products = session.exec(stmt).all()
|
||||||
|
|
@ -407,12 +408,32 @@ def _get_available_products(
|
||||||
for p in products:
|
for p in products:
|
||||||
if p.is_medication and not _is_minoxidil_product(p):
|
if p.is_medication and not _is_minoxidil_product(p):
|
||||||
continue
|
continue
|
||||||
|
if not include_minoxidil and _is_minoxidil_product(p):
|
||||||
|
continue
|
||||||
if time_filter and _ev(p.recommended_time) not in (time_filter, "both"):
|
if time_filter and _ev(p.recommended_time) not in (time_filter, "both"):
|
||||||
continue
|
continue
|
||||||
result.append(p)
|
result.append(p)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_products_by_interval(
|
||||||
|
products: list[Product],
|
||||||
|
routine_date: date,
|
||||||
|
last_used_on_by_product: dict[str, date],
|
||||||
|
) -> list[Product]:
|
||||||
|
"""Remove products that haven't yet reached their min_interval_hours since last use."""
|
||||||
|
result = []
|
||||||
|
for p in products:
|
||||||
|
if p.min_interval_hours:
|
||||||
|
last_used = last_used_on_by_product.get(str(p.id))
|
||||||
|
if last_used is not None:
|
||||||
|
days_needed = math.ceil(p.min_interval_hours / 24)
|
||||||
|
if routine_date < last_used + timedelta(days=days_needed):
|
||||||
|
continue
|
||||||
|
result.append(p)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _extract_active_names(product: Product) -> list[str]:
|
def _extract_active_names(product: Product) -> list[str]:
|
||||||
names: list[str] = []
|
names: list[str] = []
|
||||||
for a in product.actives or []:
|
for a in product.actives or []:
|
||||||
|
|
@ -596,17 +617,23 @@ def suggest_routine(
|
||||||
grooming_ctx = _build_grooming_context(session, weekdays=[weekday])
|
grooming_ctx = _build_grooming_context(session, weekdays=[weekday])
|
||||||
history_ctx = _build_recent_history(session)
|
history_ctx = _build_recent_history(session)
|
||||||
day_ctx = _build_day_context(data.leaving_home)
|
day_ctx = _build_day_context(data.leaving_home)
|
||||||
products_ctx = _build_products_context(
|
|
||||||
session, time_filter=data.part_of_day.value, reference_date=data.routine_date
|
|
||||||
)
|
|
||||||
available_products = _get_available_products(
|
available_products = _get_available_products(
|
||||||
session,
|
session,
|
||||||
time_filter=data.part_of_day.value,
|
time_filter=data.part_of_day.value,
|
||||||
|
include_minoxidil=data.include_minoxidil_beard,
|
||||||
)
|
)
|
||||||
last_used_on_by_product = build_last_used_on_by_product(
|
last_used_on_by_product = build_last_used_on_by_product(
|
||||||
session,
|
session,
|
||||||
product_ids=[p.id for p in available_products],
|
product_ids=[p.id for p in available_products],
|
||||||
)
|
)
|
||||||
|
available_products = _filter_products_by_interval(
|
||||||
|
available_products,
|
||||||
|
data.routine_date,
|
||||||
|
last_used_on_by_product,
|
||||||
|
)
|
||||||
|
products_ctx = _build_products_context(
|
||||||
|
session, available_products, reference_date=data.routine_date
|
||||||
|
)
|
||||||
objectives_ctx = _build_objectives_context(data.include_minoxidil_beard)
|
objectives_ctx = _build_objectives_context(data.include_minoxidil_beard)
|
||||||
|
|
||||||
mode_line = "MODE: standard"
|
mode_line = "MODE: standard"
|
||||||
|
|
@ -762,7 +789,13 @@ def suggest_batch(
|
||||||
skin_ctx = _build_skin_context(session)
|
skin_ctx = _build_skin_context(session)
|
||||||
grooming_ctx = _build_grooming_context(session, weekdays=weekdays)
|
grooming_ctx = _build_grooming_context(session, weekdays=weekdays)
|
||||||
history_ctx = _build_recent_history(session)
|
history_ctx = _build_recent_history(session)
|
||||||
products_ctx = _build_products_context(session, reference_date=data.from_date)
|
batch_products = _get_available_products(
|
||||||
|
session,
|
||||||
|
include_minoxidil=data.include_minoxidil_beard,
|
||||||
|
)
|
||||||
|
products_ctx = _build_products_context(
|
||||||
|
session, batch_products, reference_date=data.from_date
|
||||||
|
)
|
||||||
objectives_ctx = _build_objectives_context(data.include_minoxidil_beard)
|
objectives_ctx = _build_objectives_context(data.include_minoxidil_beard)
|
||||||
|
|
||||||
date_range_lines = []
|
date_range_lines = []
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from innercontext.api.routines import (
|
||||||
_ev,
|
_ev,
|
||||||
_extract_active_names,
|
_extract_active_names,
|
||||||
_extract_requested_product_ids,
|
_extract_requested_product_ids,
|
||||||
|
_filter_products_by_interval,
|
||||||
_get_available_products,
|
_get_available_products,
|
||||||
_is_minoxidil_product,
|
_is_minoxidil_product,
|
||||||
)
|
)
|
||||||
|
|
@ -204,9 +205,8 @@ def test_build_products_context(session: Session):
|
||||||
session.add(s)
|
session.add(s)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
ctx = _build_products_context(
|
products_am = _get_available_products(session, time_filter="am")
|
||||||
session, time_filter="am", reference_date=date.today()
|
ctx = _build_products_context(session, products_am, reference_date=date.today())
|
||||||
)
|
|
||||||
# p1 is medication but not minoxidil (wait, Regaine name doesn't contain minoxidil!) -> skipped
|
# p1 is medication but not minoxidil (wait, Regaine name doesn't contain minoxidil!) -> skipped
|
||||||
assert "Regaine" not in ctx
|
assert "Regaine" not in ctx
|
||||||
|
|
||||||
|
|
@ -215,9 +215,8 @@ def test_build_products_context(session: Session):
|
||||||
session.add(p1)
|
session.add(p1)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
ctx = _build_products_context(
|
products_am = _get_available_products(session, time_filter="am")
|
||||||
session, time_filter="am", reference_date=date.today()
|
ctx = _build_products_context(session, products_am, reference_date=date.today())
|
||||||
)
|
|
||||||
assert "Regaine Minoxidil" in ctx
|
assert "Regaine Minoxidil" in ctx
|
||||||
assert "Sunscreen" in ctx
|
assert "Sunscreen" in ctx
|
||||||
assert "inventory_status={active:2,opened:1,sealed:1}" in ctx
|
assert "inventory_status={active:2,opened:1,sealed:1}" in ctx
|
||||||
|
|
@ -375,6 +374,106 @@ def test_extract_active_names_uses_compact_distinct_names(session: Session):
|
||||||
assert names == ["Niacinamide", "Zinc PCA"]
|
assert names == ["Niacinamide", "Zinc PCA"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_available_products_excludes_minoxidil_when_flag_false(session: Session):
|
||||||
|
minoxidil = Product(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="Minoxidil 5%",
|
||||||
|
category="hair_treatment",
|
||||||
|
is_medication=True,
|
||||||
|
brand="Test",
|
||||||
|
recommended_time="both",
|
||||||
|
leave_on=True,
|
||||||
|
product_effect_profile={},
|
||||||
|
)
|
||||||
|
regular = Product(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="Cleanser",
|
||||||
|
category="cleanser",
|
||||||
|
brand="Test",
|
||||||
|
recommended_time="both",
|
||||||
|
leave_on=False,
|
||||||
|
product_effect_profile={},
|
||||||
|
)
|
||||||
|
session.add_all([minoxidil, regular])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# With flag True (default) - minoxidil included
|
||||||
|
products = _get_available_products(session, include_minoxidil=True)
|
||||||
|
names = {p.name for p in products}
|
||||||
|
assert "Minoxidil 5%" in names
|
||||||
|
assert "Cleanser" in names
|
||||||
|
|
||||||
|
# With flag False - minoxidil excluded
|
||||||
|
products = _get_available_products(session, include_minoxidil=False)
|
||||||
|
names = {p.name for p in products}
|
||||||
|
assert "Minoxidil 5%" not in names
|
||||||
|
assert "Cleanser" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_products_by_interval():
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
p_no_interval = Product(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="No Interval",
|
||||||
|
category="serum",
|
||||||
|
brand="Test",
|
||||||
|
recommended_time="both",
|
||||||
|
leave_on=True,
|
||||||
|
product_effect_profile={},
|
||||||
|
)
|
||||||
|
p_interval_72 = Product(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="Retinol",
|
||||||
|
category="serum",
|
||||||
|
brand="Test",
|
||||||
|
recommended_time="pm",
|
||||||
|
leave_on=True,
|
||||||
|
min_interval_hours=72,
|
||||||
|
product_effect_profile={},
|
||||||
|
)
|
||||||
|
p_interval_48 = Product(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="AHA",
|
||||||
|
category="exfoliant",
|
||||||
|
brand="Test",
|
||||||
|
recommended_time="pm",
|
||||||
|
leave_on=True,
|
||||||
|
min_interval_hours=48,
|
||||||
|
product_effect_profile={},
|
||||||
|
)
|
||||||
|
|
||||||
|
last_used = {
|
||||||
|
str(p_interval_72.id): today - timedelta(days=1), # used yesterday -> need 3 days
|
||||||
|
str(p_interval_48.id): today - timedelta(days=3), # used 3 days ago -> 48h ok
|
||||||
|
}
|
||||||
|
|
||||||
|
products = [p_no_interval, p_interval_72, p_interval_48]
|
||||||
|
result = _filter_products_by_interval(products, today, last_used)
|
||||||
|
result_names = {p.name for p in result}
|
||||||
|
|
||||||
|
assert "No Interval" in result_names # always included
|
||||||
|
assert "Retinol" not in result_names # used 1 day ago, needs 3 -> blocked
|
||||||
|
assert "AHA" in result_names # used 3 days ago, needs 2 -> ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_products_by_interval_never_used_passes():
|
||||||
|
today = date.today()
|
||||||
|
p = Product(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="Retinol",
|
||||||
|
category="serum",
|
||||||
|
brand="Test",
|
||||||
|
recommended_time="pm",
|
||||||
|
leave_on=True,
|
||||||
|
min_interval_hours=72,
|
||||||
|
product_effect_profile={},
|
||||||
|
)
|
||||||
|
# no last_used entry -> should pass
|
||||||
|
result = _filter_products_by_interval([p], today, {})
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_product_details_tool_handler_returns_product_payloads(session: Session):
|
def test_product_details_tool_handler_returns_product_payloads(session: Session):
|
||||||
p = Product(
|
p = Product(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue