630 lines
18 KiB
Python
630 lines
18 KiB
Python
import uuid
|
|
from datetime import date, timedelta
|
|
|
|
from sqlmodel import Session
|
|
|
|
from innercontext.api.llm_context import build_products_context_summary_list
|
|
from innercontext.api.routines import (
|
|
_build_day_context,
|
|
_build_grooming_context,
|
|
_build_objectives_context,
|
|
_build_recent_history,
|
|
_build_skin_context,
|
|
_build_upcoming_grooming_context,
|
|
_contains_minoxidil_text,
|
|
_ev,
|
|
_extract_active_names,
|
|
_extract_requested_product_ids,
|
|
_filter_products_by_interval,
|
|
_get_available_products,
|
|
_get_latest_skin_snapshot_within_days,
|
|
_get_recent_skin_snapshot,
|
|
_is_minoxidil_product,
|
|
build_product_details_tool_handler,
|
|
)
|
|
from innercontext.models import (
|
|
GroomingSchedule,
|
|
Product,
|
|
Routine,
|
|
RoutineStep,
|
|
SkinConditionSnapshot,
|
|
)
|
|
from innercontext.models.enums import BarrierState, OverallSkinState, SkinConcern
|
|
|
|
|
|
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
|
|
reference_date = date(2026, 3, 10)
|
|
assert (
|
|
_build_skin_context(session, reference_date=reference_date)
|
|
== "SKIN CONDITION: no data\n"
|
|
)
|
|
|
|
# With data
|
|
snap = SkinConditionSnapshot(
|
|
id=uuid.uuid4(),
|
|
snapshot_date=reference_date,
|
|
overall_state=OverallSkinState.GOOD,
|
|
hydration_level=4,
|
|
barrier_state=BarrierState.INTACT,
|
|
active_concerns=[SkinConcern.ACNE, SkinConcern.DEHYDRATION],
|
|
priorities=["hydration"],
|
|
notes="Feeling good",
|
|
)
|
|
session.add(snap)
|
|
session.commit()
|
|
|
|
ctx = _build_skin_context(session, reference_date=reference_date)
|
|
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, dehydration" in ctx
|
|
assert "Priorities: hydration" in ctx
|
|
assert "Notes: Feeling good" in ctx
|
|
|
|
|
|
def test_build_skin_context_falls_back_to_recent_snapshot_within_14_days(
|
|
session: Session,
|
|
):
|
|
reference_date = date(2026, 3, 20)
|
|
snap = SkinConditionSnapshot(
|
|
id=uuid.uuid4(),
|
|
snapshot_date=reference_date - timedelta(days=10),
|
|
overall_state=OverallSkinState.FAIR,
|
|
hydration_level=3,
|
|
barrier_state=BarrierState.COMPROMISED,
|
|
active_concerns=[SkinConcern.REDNESS],
|
|
priorities=["barrier"],
|
|
)
|
|
session.add(snap)
|
|
session.commit()
|
|
|
|
ctx = _build_skin_context(session, reference_date=reference_date)
|
|
|
|
assert f"snapshot from {reference_date - timedelta(days=10)}" in ctx
|
|
assert "Barrier: compromised" in ctx
|
|
|
|
|
|
def test_build_skin_context_ignores_snapshot_older_than_14_days(session: Session):
|
|
reference_date = date(2026, 3, 20)
|
|
snap = SkinConditionSnapshot(
|
|
id=uuid.uuid4(),
|
|
snapshot_date=reference_date - timedelta(days=15),
|
|
overall_state=OverallSkinState.FAIR,
|
|
hydration_level=3,
|
|
barrier_state=BarrierState.INTACT,
|
|
)
|
|
session.add(snap)
|
|
session.commit()
|
|
|
|
assert (
|
|
_build_skin_context(session, reference_date=reference_date)
|
|
== "SKIN CONDITION: no data\n"
|
|
)
|
|
|
|
|
|
def test_get_recent_skin_snapshot_prefers_window_match(session: Session):
|
|
reference_date = date(2026, 3, 20)
|
|
older = SkinConditionSnapshot(
|
|
id=uuid.uuid4(),
|
|
snapshot_date=reference_date - timedelta(days=10),
|
|
overall_state=OverallSkinState.POOR,
|
|
hydration_level=2,
|
|
barrier_state=BarrierState.COMPROMISED,
|
|
)
|
|
newer = SkinConditionSnapshot(
|
|
id=uuid.uuid4(),
|
|
snapshot_date=reference_date - timedelta(days=2),
|
|
overall_state=OverallSkinState.GOOD,
|
|
hydration_level=4,
|
|
barrier_state=BarrierState.INTACT,
|
|
)
|
|
session.add_all([older, newer])
|
|
session.commit()
|
|
|
|
snapshot = _get_recent_skin_snapshot(session, reference_date=reference_date)
|
|
|
|
assert snapshot is not None
|
|
assert snapshot.id == newer.id
|
|
|
|
|
|
def test_get_latest_skin_snapshot_within_days_uses_latest_within_14_days(
|
|
session: Session,
|
|
):
|
|
reference_date = date(2026, 3, 20)
|
|
older = SkinConditionSnapshot(
|
|
id=uuid.uuid4(),
|
|
snapshot_date=reference_date - timedelta(days=10),
|
|
overall_state=OverallSkinState.POOR,
|
|
hydration_level=2,
|
|
barrier_state=BarrierState.COMPROMISED,
|
|
)
|
|
newer = SkinConditionSnapshot(
|
|
id=uuid.uuid4(),
|
|
snapshot_date=reference_date - timedelta(days=2),
|
|
overall_state=OverallSkinState.GOOD,
|
|
hydration_level=4,
|
|
barrier_state=BarrierState.INTACT,
|
|
)
|
|
session.add_all([older, newer])
|
|
session.commit()
|
|
|
|
snapshot = _get_latest_skin_snapshot_within_days(
|
|
session,
|
|
reference_date=reference_date,
|
|
)
|
|
|
|
assert snapshot is not None
|
|
assert snapshot.id == newer.id
|
|
|
|
|
|
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_upcoming_grooming_context(session: Session):
|
|
assert (
|
|
_build_upcoming_grooming_context(session, start_date=date(2026, 3, 2), days=7)
|
|
== "UPCOMING GROOMING (next 7 days): none\n"
|
|
)
|
|
|
|
monday = GroomingSchedule(
|
|
id=uuid.uuid4(), day_of_week=0, action="shaving_oneblade", notes="Morning"
|
|
)
|
|
wednesday = GroomingSchedule(id=uuid.uuid4(), day_of_week=2, action="dermarolling")
|
|
session.add_all([monday, wednesday])
|
|
session.commit()
|
|
|
|
ctx = _build_upcoming_grooming_context(
|
|
session,
|
|
start_date=date(2026, 3, 2),
|
|
days=7,
|
|
)
|
|
assert "UPCOMING GROOMING (next 7 days):" in ctx
|
|
assert "dzisiaj (2026-03-02, poniedziałek): shaving_oneblade (Morning)" in ctx
|
|
assert "za 2 dni (2026-03-04, środa): dermarolling" in ctx
|
|
|
|
|
|
def test_build_recent_history(session: Session):
|
|
reference_date = date(2026, 3, 10)
|
|
assert (
|
|
_build_recent_history(session, reference_date=reference_date)
|
|
== "RECENT ROUTINES: none\n"
|
|
)
|
|
|
|
r = Routine(id=uuid.uuid4(), routine_date=reference_date, part_of_day="am")
|
|
session.add(r)
|
|
p = Product(
|
|
id=uuid.uuid4(),
|
|
short_id=str(uuid.uuid4())[:8],
|
|
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, reference_date=reference_date)
|
|
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_recent_history_uses_reference_window(session: Session):
|
|
reference_date = date(2026, 3, 10)
|
|
recent = Routine(
|
|
id=uuid.uuid4(),
|
|
routine_date=reference_date - timedelta(days=3),
|
|
part_of_day="pm",
|
|
)
|
|
old = Routine(
|
|
id=uuid.uuid4(),
|
|
routine_date=reference_date - timedelta(days=6),
|
|
part_of_day="am",
|
|
)
|
|
session.add_all([recent, old])
|
|
session.commit()
|
|
|
|
ctx = _build_recent_history(session, reference_date=reference_date)
|
|
|
|
assert str(recent.routine_date) in ctx
|
|
assert str(old.routine_date) not in ctx
|
|
|
|
|
|
def test_build_recent_history_excludes_future_routines(session: Session):
|
|
reference_date = date(2026, 3, 10)
|
|
future = Routine(
|
|
id=uuid.uuid4(),
|
|
routine_date=reference_date + timedelta(days=1),
|
|
part_of_day="am",
|
|
)
|
|
session.add(future)
|
|
session.commit()
|
|
|
|
assert (
|
|
_build_recent_history(session, reference_date=reference_date)
|
|
== "RECENT ROUTINES: none\n"
|
|
)
|
|
|
|
|
|
def test_build_products_context_summary_list(session: Session):
|
|
p1 = Product(
|
|
id=uuid.uuid4(),
|
|
short_id=str(uuid.uuid4())[:8],
|
|
name="Regaine Minoxidil",
|
|
category="serum",
|
|
is_medication=True,
|
|
brand="J&J",
|
|
recommended_time="both",
|
|
leave_on=True,
|
|
product_effect_profile={},
|
|
)
|
|
p2 = Product(
|
|
id=uuid.uuid4(),
|
|
short_id=str(uuid.uuid4())[:8],
|
|
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()
|
|
|
|
products_am = _get_available_products(session, time_filter="am")
|
|
ctx = build_products_context_summary_list(products_am, {p2.id})
|
|
|
|
assert "Regaine Minoxidil" in ctx
|
|
assert "Sunscreen" in ctx
|
|
assert "[✓]" in ctx
|
|
assert "hydration=2" in ctx
|
|
assert "!post_shave" 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
|