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:
Piotr Oleszczyk 2026-03-05 23:36:15 +01:00
parent 7a66a7911d
commit e3ed0dd3a3
2 changed files with 144 additions and 12 deletions

View file

@ -1,4 +1,5 @@
import json
import math
from datetime import date, timedelta
from typing import Optional
from uuid import UUID, uuid4
@ -306,10 +307,9 @@ def _build_recent_history(session: Session) -> str:
def _build_products_context(
session: Session,
time_filter: Optional[str] = None,
products: list[Product],
reference_date: Optional[date] = None,
) -> str:
products = _get_available_products(session, time_filter=time_filter)
product_ids = [p.id for p in products]
inventory_rows = (
session.exec(
@ -400,6 +400,7 @@ def _build_products_context(
def _get_available_products(
session: Session,
time_filter: Optional[str] = None,
include_minoxidil: bool = True,
) -> list[Product]:
stmt = select(Product).where(col(Product.is_tool).is_(False))
products = session.exec(stmt).all()
@ -407,12 +408,32 @@ def _get_available_products(
for p in products:
if p.is_medication and not _is_minoxidil_product(p):
continue
if not include_minoxidil and _is_minoxidil_product(p):
continue
if time_filter and _ev(p.recommended_time) not in (time_filter, "both"):
continue
result.append(p)
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]:
names: list[str] = []
for a in product.actives or []:
@ -596,17 +617,23 @@ def suggest_routine(
grooming_ctx = _build_grooming_context(session, weekdays=[weekday])
history_ctx = _build_recent_history(session)
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(
session,
time_filter=data.part_of_day.value,
include_minoxidil=data.include_minoxidil_beard,
)
last_used_on_by_product = build_last_used_on_by_product(
session,
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)
mode_line = "MODE: standard"
@ -762,7 +789,13 @@ def suggest_batch(
skin_ctx = _build_skin_context(session)
grooming_ctx = _build_grooming_context(session, weekdays=weekdays)
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)
date_range_lines = []