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>
495 lines
14 KiB
Python
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
|