innercontext/backend/tests/test_routines_helpers.py
Piotr Oleszczyk e3ed0dd3a3 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>
2026-03-05 23:36:15 +01:00

495 lines
14 KiB
Python

import uuid
from datetime import date, timedelta
from sqlmodel import Session
from innercontext.api.routines import (
_build_day_context,
_build_grooming_context,
_build_objectives_context,
_build_products_context,
_build_recent_history,
_build_skin_context,
build_product_details_tool_handler,
_contains_minoxidil_text,
_ev,
_extract_active_names,
_extract_requested_product_ids,
_filter_products_by_interval,
_get_available_products,
_is_minoxidil_product,
)
from innercontext.models import (
GroomingSchedule,
Product,
ProductInventory,
Routine,
RoutineStep,
SkinConditionSnapshot,
)
def test_contains_minoxidil_text():
assert _contains_minoxidil_text(None) is False
assert _contains_minoxidil_text("") is False
assert _contains_minoxidil_text("some random text") is False
assert _contains_minoxidil_text("contains MINOXIDIL here") is True
assert _contains_minoxidil_text("minoksydyl 5%") is True
def test_is_minoxidil_product():
# Setup product
p = Product(id=uuid.uuid4(), name="Test", brand="Brand", is_medication=True)
assert _is_minoxidil_product(p) is False
p.name = "Minoxidil 5%"
assert _is_minoxidil_product(p) is True
p.name = "Test"
p.brand = "Brand with minoksydyl"
assert _is_minoxidil_product(p) is True
p.brand = "Brand"
p.line_name = "Minoxidil Line"
assert _is_minoxidil_product(p) is True
p.line_name = None
p.inci = ["water", "minoxidil"]
assert _is_minoxidil_product(p) is True
p.inci = None
p.actives = [{"name": "minoxidil", "strength": "5%"}]
assert _is_minoxidil_product(p) is True
# As Pydantic model representation isn't exactly a dict in db sometimes, we just test dict
p.actives = [{"name": "Retinol", "strength": "1%"}]
assert _is_minoxidil_product(p) is False
def test_ev():
class DummyEnum:
value = "dummy"
assert _ev(None) == ""
assert _ev(DummyEnum()) == "dummy"
assert _ev("string") == "string"
def test_build_skin_context(session: Session):
# Empty
assert _build_skin_context(session) == "SKIN CONDITION: no data\n"
# With data
snap = SkinConditionSnapshot(
id=uuid.uuid4(),
snapshot_date=date.today(),
overall_state="good",
hydration_level=4,
barrier_state="intact",
active_concerns=["acne", "dryness"],
priorities=["hydration"],
notes="Feeling good",
)
session.add(snap)
session.commit()
ctx = _build_skin_context(session)
assert "SKIN CONDITION (snapshot from" in ctx
assert "Overall state: good" in ctx
assert "Hydration: 4/5" in ctx
assert "Barrier: intact" in ctx
assert "Active concerns: acne, dryness" in ctx
assert "Priorities: hydration" in ctx
assert "Notes: Feeling good" in ctx
def test_build_grooming_context(session: Session):
assert _build_grooming_context(session) == "GROOMING SCHEDULE: none\n"
sch = GroomingSchedule(
id=uuid.uuid4(), day_of_week=0, action="shaving_oneblade", notes="Morning"
)
session.add(sch)
session.commit()
ctx = _build_grooming_context(session)
assert "GROOMING SCHEDULE:" in ctx
assert "poniedziałek: shaving_oneblade (Morning)" in ctx
# Test weekdays filter
ctx2 = _build_grooming_context(session, weekdays=[1]) # not monday
assert "(no entries for specified days)" in ctx2
def test_build_recent_history(session: Session):
assert _build_recent_history(session) == "RECENT ROUTINES: none\n"
r = Routine(id=uuid.uuid4(), routine_date=date.today(), part_of_day="am")
session.add(r)
p = Product(
id=uuid.uuid4(),
name="Cleanser",
category="cleanser",
brand="Test",
recommended_time="both",
leave_on=False,
product_effect_profile={},
)
session.add(p)
session.commit()
s1 = RoutineStep(id=uuid.uuid4(), routine_id=r.id, order_index=1, product_id=p.id)
s2 = RoutineStep(
id=uuid.uuid4(), routine_id=r.id, order_index=2, action_type="shaving_razor"
)
# Step with non-existent product
s3 = RoutineStep(
id=uuid.uuid4(), routine_id=r.id, order_index=3, product_id=uuid.uuid4()
)
session.add_all([s1, s2, s3])
session.commit()
ctx = _build_recent_history(session)
assert "RECENT ROUTINES:" in ctx
assert "AM:" in ctx
assert "cleanser [" in ctx
assert "action: shaving_razor" in ctx
assert "unknown [" in ctx
def test_build_products_context(session: Session):
p1 = Product(
id=uuid.uuid4(),
name="Regaine",
category="serum",
is_medication=True,
brand="J&J",
recommended_time="both",
leave_on=True,
product_effect_profile={},
)
p2 = Product(
id=uuid.uuid4(),
name="Sunscreen",
category="spf",
brand="Test",
leave_on=True,
recommended_time="am",
pao_months=6,
product_effect_profile={"hydration_immediate": 2, "exfoliation_strength": 0},
context_rules={"safe_after_shaving": False},
min_interval_hours=12,
max_frequency_per_week=7,
)
session.add_all([p1, p2])
session.commit()
# Inventory
inv1 = ProductInventory(
id=uuid.uuid4(),
product_id=p2.id,
is_opened=True,
opened_at=date.today() - timedelta(days=10),
expiry_date=date.today() + timedelta(days=365),
)
inv2 = ProductInventory(id=uuid.uuid4(), product_id=p2.id, is_opened=False)
session.add_all([inv1, inv2])
session.commit()
# Usage
r = Routine(id=uuid.uuid4(), routine_date=date.today(), part_of_day="am")
session.add(r)
session.commit()
s = RoutineStep(id=uuid.uuid4(), routine_id=r.id, order_index=1, product_id=p2.id)
session.add(s)
session.commit()
products_am = _get_available_products(session, time_filter="am")
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
assert "Regaine" not in ctx
# Let's fix p1 to be minoxidil
p1.name = "Regaine Minoxidil"
session.add(p1)
session.commit()
products_am = _get_available_products(session, time_filter="am")
ctx = _build_products_context(session, products_am, reference_date=date.today())
assert "Regaine Minoxidil" in ctx
assert "Sunscreen" in ctx
assert "inventory_status={active:2,opened:1,sealed:1}" in ctx
assert "nearest_open_expiry=" in ctx
assert "nearest_open_pao_deadline=" in ctx
assert "pao_months=6" in ctx
assert "effects={'hydration_immediate': 2}" in ctx
assert "context_rules={'safe_after_shaving': False}" in ctx
assert "min_interval_hours=12" in ctx
assert "max_frequency_per_week=7" in ctx
assert "used_in_last_7_days=1" in ctx
def test_build_objectives_context():
assert _build_objectives_context(False) == ""
assert "improve beard" in _build_objectives_context(True)
def test_build_day_context():
assert _build_day_context(None) == ""
assert "Leaving home: yes" in _build_day_context(True)
assert "Leaving home: no" in _build_day_context(False)
def test_get_available_products_respects_filters(session: Session):
regular_med = Product(
id=uuid.uuid4(),
name="Tretinoin",
category="serum",
is_medication=True,
brand="Test",
recommended_time="pm",
leave_on=True,
product_effect_profile={},
)
minoxidil_med = Product(
id=uuid.uuid4(),
name="Minoxidil 5%",
category="serum",
is_medication=True,
brand="Test",
recommended_time="both",
leave_on=True,
product_effect_profile={},
)
am_product = Product(
id=uuid.uuid4(),
name="AM SPF",
category="spf",
brand="Test",
recommended_time="am",
leave_on=True,
product_effect_profile={},
)
pm_product = Product(
id=uuid.uuid4(),
name="PM Cream",
category="moisturizer",
brand="Test",
recommended_time="pm",
leave_on=True,
product_effect_profile={},
)
session.add_all([regular_med, minoxidil_med, am_product, pm_product])
session.commit()
am_available = _get_available_products(session, time_filter="am")
am_names = {p.name for p in am_available}
assert "Tretinoin" not in am_names
assert "Minoxidil 5%" in am_names
assert "AM SPF" in am_names
assert "PM Cream" not in am_names
def test_build_product_details_tool_handler_returns_only_available_ids(
session: Session,
):
available = Product(
id=uuid.uuid4(),
name="Available",
category="serum",
brand="Test",
recommended_time="both",
leave_on=True,
inci=["Water", "Niacinamide"],
product_effect_profile={},
)
unavailable = Product(
id=uuid.uuid4(),
name="Unavailable",
category="serum",
brand="Test",
recommended_time="both",
leave_on=True,
inci=["Water", "Retinol"],
product_effect_profile={},
)
handler = build_product_details_tool_handler([available])
payload = handler(
{
"product_ids": [
str(available.id),
str(unavailable.id),
str(available.id),
123,
]
}
)
assert "products" in payload
products = payload["products"]
assert len(products) == 1
assert products[0]["id"] == str(available.id)
assert products[0]["name"] == "Available"
assert products[0]["inci"] == ["Water", "Niacinamide"]
assert "actives" in products[0]
assert "safety" in products[0]
def test_extract_requested_product_ids_dedupes_and_limits():
ids = _extract_requested_product_ids(
{
"product_ids": [
"id-1",
"id-2",
"id-1",
3,
"id-3",
"id-4",
]
},
max_ids=3,
)
assert ids == ["id-1", "id-2", "id-3"]
def test_extract_active_names_uses_compact_distinct_names(session: Session):
p = Product(
id=uuid.uuid4(),
name="Test",
category="serum",
brand="Test",
recommended_time="both",
leave_on=True,
actives=[
{"name": "Niacinamide", "percent": 10},
{"name": "Niacinamide", "percent": 5},
{"name": "Zinc PCA", "percent": 1},
],
product_effect_profile={},
)
names = _extract_active_names(p)
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):
p = Product(
id=uuid.uuid4(),
name="Detail Product",
category="serum",
brand="Test",
recommended_time="both",
leave_on=True,
actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}],
context_rules={"safe_after_shaving": True},
product_effect_profile={},
)
ids_payload = {"product_ids": [str(p.id)]}
details_out = build_product_details_tool_handler([p])(ids_payload)
assert details_out["products"][0]["actives"][0]["name"] == "Niacinamide"
assert "context_rules" in details_out["products"][0]
assert details_out["products"][0]["last_used_on"] is None