feat(api): align routine context windows with recent skin history
This commit is contained in:
parent
1c457d62a3
commit
fecfa0b9e4
3 changed files with 273 additions and 71 deletions
|
|
@ -49,6 +49,9 @@ from innercontext.validators.routine_validator import RoutineValidationContext
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
HISTORY_WINDOW_DAYS = 5
|
||||||
|
SNAPSHOT_FALLBACK_DAYS = 14
|
||||||
|
|
||||||
|
|
||||||
def _build_response_metadata(session: Session, log_id: Any) -> ResponseMetadata | None:
|
def _build_response_metadata(session: Session, log_id: Any) -> ResponseMetadata | None:
|
||||||
"""Build ResponseMetadata from AICallLog for Phase 3 observability."""
|
"""Build ResponseMetadata from AICallLog for Phase 3 observability."""
|
||||||
|
|
@ -284,12 +287,58 @@ def _ev(v: object) -> str:
|
||||||
return str(v)
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
def _build_skin_context(session: Session) -> str:
|
def _get_recent_skin_snapshot(
|
||||||
|
session: Session,
|
||||||
|
reference_date: date,
|
||||||
|
window_days: int = HISTORY_WINDOW_DAYS,
|
||||||
|
fallback_days: int = SNAPSHOT_FALLBACK_DAYS,
|
||||||
|
) -> SkinConditionSnapshot | None:
|
||||||
|
window_cutoff = reference_date - timedelta(days=window_days)
|
||||||
|
fallback_cutoff = reference_date - timedelta(days=fallback_days)
|
||||||
|
|
||||||
snapshot = session.exec(
|
snapshot = session.exec(
|
||||||
select(SkinConditionSnapshot).order_by(
|
select(SkinConditionSnapshot)
|
||||||
col(SkinConditionSnapshot.snapshot_date).desc()
|
.where(SkinConditionSnapshot.snapshot_date <= reference_date)
|
||||||
)
|
.where(SkinConditionSnapshot.snapshot_date >= window_cutoff)
|
||||||
|
.order_by(col(SkinConditionSnapshot.snapshot_date).desc())
|
||||||
).first()
|
).first()
|
||||||
|
if snapshot is not None:
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
return session.exec(
|
||||||
|
select(SkinConditionSnapshot)
|
||||||
|
.where(SkinConditionSnapshot.snapshot_date <= reference_date)
|
||||||
|
.where(SkinConditionSnapshot.snapshot_date >= fallback_cutoff)
|
||||||
|
.order_by(col(SkinConditionSnapshot.snapshot_date).desc())
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_latest_skin_snapshot_within_days(
|
||||||
|
session: Session,
|
||||||
|
reference_date: date,
|
||||||
|
max_age_days: int = SNAPSHOT_FALLBACK_DAYS,
|
||||||
|
) -> SkinConditionSnapshot | None:
|
||||||
|
cutoff = reference_date - timedelta(days=max_age_days)
|
||||||
|
return session.exec(
|
||||||
|
select(SkinConditionSnapshot)
|
||||||
|
.where(SkinConditionSnapshot.snapshot_date <= reference_date)
|
||||||
|
.where(SkinConditionSnapshot.snapshot_date >= cutoff)
|
||||||
|
.order_by(col(SkinConditionSnapshot.snapshot_date).desc())
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_skin_context(
|
||||||
|
session: Session,
|
||||||
|
reference_date: date,
|
||||||
|
window_days: int = HISTORY_WINDOW_DAYS,
|
||||||
|
fallback_days: int = SNAPSHOT_FALLBACK_DAYS,
|
||||||
|
) -> str:
|
||||||
|
snapshot = _get_recent_skin_snapshot(
|
||||||
|
session,
|
||||||
|
reference_date=reference_date,
|
||||||
|
window_days=window_days,
|
||||||
|
fallback_days=fallback_days,
|
||||||
|
)
|
||||||
if snapshot is None:
|
if snapshot is None:
|
||||||
return "SKIN CONDITION: no data\n"
|
return "SKIN CONDITION: no data\n"
|
||||||
ev = _ev
|
ev = _ev
|
||||||
|
|
@ -369,10 +418,15 @@ def _build_upcoming_grooming_context(
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def _build_recent_history(session: Session) -> str:
|
def _build_recent_history(
|
||||||
cutoff = date.today() - timedelta(days=7)
|
session: Session,
|
||||||
|
reference_date: date,
|
||||||
|
window_days: int = HISTORY_WINDOW_DAYS,
|
||||||
|
) -> str:
|
||||||
|
cutoff = reference_date - timedelta(days=window_days)
|
||||||
routines = session.exec(
|
routines = session.exec(
|
||||||
select(Routine)
|
select(Routine)
|
||||||
|
.where(Routine.routine_date <= reference_date)
|
||||||
.where(Routine.routine_date >= cutoff)
|
.where(Routine.routine_date >= cutoff)
|
||||||
.order_by(col(Routine.routine_date).desc())
|
.order_by(col(Routine.routine_date).desc())
|
||||||
).all()
|
).all()
|
||||||
|
|
@ -672,14 +726,14 @@ def suggest_routine(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
weekday = data.routine_date.weekday()
|
weekday = data.routine_date.weekday()
|
||||||
skin_ctx = _build_skin_context(session)
|
skin_ctx = _build_skin_context(session, reference_date=data.routine_date)
|
||||||
profile_ctx = build_user_profile_context(session, reference_date=data.routine_date)
|
profile_ctx = build_user_profile_context(session, reference_date=data.routine_date)
|
||||||
upcoming_grooming_ctx = _build_upcoming_grooming_context(
|
upcoming_grooming_ctx = _build_upcoming_grooming_context(
|
||||||
session,
|
session,
|
||||||
start_date=data.routine_date,
|
start_date=data.routine_date,
|
||||||
days=7,
|
days=7,
|
||||||
)
|
)
|
||||||
history_ctx = _build_recent_history(session)
|
history_ctx = _build_recent_history(session, reference_date=data.routine_date)
|
||||||
day_ctx = _build_day_context(data.leaving_home)
|
day_ctx = _build_day_context(data.leaving_home)
|
||||||
available_products = _get_available_products(
|
available_products = _get_available_products(
|
||||||
session,
|
session,
|
||||||
|
|
@ -848,10 +902,10 @@ def suggest_routine(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get skin snapshot for barrier state
|
# Get skin snapshot for barrier state
|
||||||
stmt = select(SkinConditionSnapshot).order_by(
|
skin_snapshot = _get_latest_skin_snapshot_within_days(
|
||||||
col(SkinConditionSnapshot.snapshot_date).desc()
|
session,
|
||||||
|
reference_date=data.routine_date,
|
||||||
)
|
)
|
||||||
skin_snapshot = session.exec(stmt).first()
|
|
||||||
|
|
||||||
# Build validation context
|
# Build validation context
|
||||||
products_by_id = {p.id: p for p in available_products}
|
products_by_id = {p.id: p for p in available_products}
|
||||||
|
|
@ -923,9 +977,9 @@ def suggest_batch(
|
||||||
{(data.from_date + timedelta(days=i)).weekday() for i in range(delta)}
|
{(data.from_date + timedelta(days=i)).weekday() for i in range(delta)}
|
||||||
)
|
)
|
||||||
profile_ctx = build_user_profile_context(session, reference_date=data.from_date)
|
profile_ctx = build_user_profile_context(session, reference_date=data.from_date)
|
||||||
skin_ctx = _build_skin_context(session)
|
skin_ctx = _build_skin_context(session, reference_date=data.from_date)
|
||||||
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, reference_date=data.from_date)
|
||||||
batch_products = _get_available_products(
|
batch_products = _get_available_products(
|
||||||
session,
|
session,
|
||||||
include_minoxidil=data.include_minoxidil_beard,
|
include_minoxidil=data.include_minoxidil_beard,
|
||||||
|
|
@ -1030,10 +1084,10 @@ def suggest_batch(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get skin snapshot for barrier state
|
# Get skin snapshot for barrier state
|
||||||
stmt = select(SkinConditionSnapshot).order_by(
|
skin_snapshot = _get_latest_skin_snapshot_within_days(
|
||||||
col(SkinConditionSnapshot.snapshot_date).desc()
|
session,
|
||||||
|
reference_date=data.from_date,
|
||||||
)
|
)
|
||||||
skin_snapshot = session.exec(stmt).first()
|
|
||||||
|
|
||||||
# Build validation context
|
# Build validation context
|
||||||
products_by_id = {p.id: p for p in batch_products}
|
products_by_id = {p.id: p for p in batch_products}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import date
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from innercontext.models import Routine, SkinConditionSnapshot
|
||||||
|
from innercontext.models.enums import BarrierState, OverallSkinState
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routines
|
# Routines
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -223,6 +227,18 @@ def test_suggest_routine(client, session):
|
||||||
with patch(
|
with patch(
|
||||||
"innercontext.api.routines.call_gemini_with_function_tools"
|
"innercontext.api.routines.call_gemini_with_function_tools"
|
||||||
) as mock_gemini:
|
) as mock_gemini:
|
||||||
|
session.add(
|
||||||
|
SkinConditionSnapshot(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
snapshot_date=date(2026, 2, 22),
|
||||||
|
overall_state=OverallSkinState.GOOD,
|
||||||
|
hydration_level=4,
|
||||||
|
barrier_state=BarrierState.INTACT,
|
||||||
|
priorities=["hydration"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
# Mock the Gemini response
|
# Mock the Gemini response
|
||||||
mock_response = type(
|
mock_response = type(
|
||||||
"Response",
|
"Response",
|
||||||
|
|
@ -231,7 +247,7 @@ def test_suggest_routine(client, session):
|
||||||
"text": '{"steps": [{"product_id": null, "action_type": "shaving_razor"}], "reasoning": "because"}'
|
"text": '{"steps": [{"product_id": null, "action_type": "shaving_razor"}], "reasoning": "because"}'
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
mock_gemini.return_value = mock_response
|
mock_gemini.return_value = (mock_response, None)
|
||||||
|
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/routines/suggest",
|
"/routines/suggest",
|
||||||
|
|
@ -250,12 +266,32 @@ def test_suggest_routine(client, session):
|
||||||
kwargs = mock_gemini.call_args.kwargs
|
kwargs = mock_gemini.call_args.kwargs
|
||||||
assert "USER PROFILE:" in kwargs["contents"]
|
assert "USER PROFILE:" in kwargs["contents"]
|
||||||
assert "UPCOMING GROOMING (next 7 days):" in kwargs["contents"]
|
assert "UPCOMING GROOMING (next 7 days):" in kwargs["contents"]
|
||||||
|
assert "snapshot from 2026-02-22" in kwargs["contents"]
|
||||||
|
assert "RECENT ROUTINES: none" in kwargs["contents"]
|
||||||
assert "function_handlers" in kwargs
|
assert "function_handlers" in kwargs
|
||||||
assert "get_product_details" in kwargs["function_handlers"]
|
assert "get_product_details" in kwargs["function_handlers"]
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_batch(client, session):
|
def test_suggest_batch(client, session):
|
||||||
with patch("innercontext.api.routines.call_gemini") as mock_gemini:
|
with patch("innercontext.api.routines.call_gemini") as mock_gemini:
|
||||||
|
session.add(
|
||||||
|
Routine(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
routine_date=date(2026, 2, 27),
|
||||||
|
part_of_day="pm",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
SkinConditionSnapshot(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
snapshot_date=date(2026, 2, 20),
|
||||||
|
overall_state=OverallSkinState.GOOD,
|
||||||
|
hydration_level=4,
|
||||||
|
barrier_state=BarrierState.INTACT,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
# Mock the Gemini response
|
# Mock the Gemini response
|
||||||
mock_response = type(
|
mock_response = type(
|
||||||
"Response",
|
"Response",
|
||||||
|
|
@ -264,7 +300,7 @@ def test_suggest_batch(client, session):
|
||||||
"text": '{"days": [{"date": "2026-03-03", "am_steps": [], "pm_steps": [], "reasoning": "none"}], "overall_reasoning": "batch test"}'
|
"text": '{"days": [{"date": "2026-03-03", "am_steps": [], "pm_steps": [], "reasoning": "none"}], "overall_reasoning": "batch test"}'
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
mock_gemini.return_value = mock_response
|
mock_gemini.return_value = (mock_response, None)
|
||||||
|
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/routines/suggest-batch",
|
"/routines/suggest-batch",
|
||||||
|
|
@ -281,6 +317,8 @@ def test_suggest_batch(client, session):
|
||||||
assert data["overall_reasoning"] == "batch test"
|
assert data["overall_reasoning"] == "batch test"
|
||||||
kwargs = mock_gemini.call_args.kwargs
|
kwargs = mock_gemini.call_args.kwargs
|
||||||
assert "USER PROFILE:" in kwargs["contents"]
|
assert "USER PROFILE:" in kwargs["contents"]
|
||||||
|
assert "2026-02-27 PM:" in kwargs["contents"]
|
||||||
|
assert "snapshot from 2026-02-20" in kwargs["contents"]
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_batch_invalid_date_range(client):
|
def test_suggest_batch_invalid_date_range(client):
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@ from datetime import date, timedelta
|
||||||
|
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from innercontext.api.llm_context import build_products_context_summary_list
|
||||||
from innercontext.api.routines import (
|
from innercontext.api.routines import (
|
||||||
_build_day_context,
|
_build_day_context,
|
||||||
_build_grooming_context,
|
_build_grooming_context,
|
||||||
_build_objectives_context,
|
_build_objectives_context,
|
||||||
_build_products_context,
|
|
||||||
_build_recent_history,
|
_build_recent_history,
|
||||||
_build_skin_context,
|
_build_skin_context,
|
||||||
_build_upcoming_grooming_context,
|
_build_upcoming_grooming_context,
|
||||||
|
|
@ -17,17 +17,19 @@ from innercontext.api.routines import (
|
||||||
_extract_requested_product_ids,
|
_extract_requested_product_ids,
|
||||||
_filter_products_by_interval,
|
_filter_products_by_interval,
|
||||||
_get_available_products,
|
_get_available_products,
|
||||||
|
_get_latest_skin_snapshot_within_days,
|
||||||
|
_get_recent_skin_snapshot,
|
||||||
_is_minoxidil_product,
|
_is_minoxidil_product,
|
||||||
build_product_details_tool_handler,
|
build_product_details_tool_handler,
|
||||||
)
|
)
|
||||||
from innercontext.models import (
|
from innercontext.models import (
|
||||||
GroomingSchedule,
|
GroomingSchedule,
|
||||||
Product,
|
Product,
|
||||||
ProductInventory,
|
|
||||||
Routine,
|
Routine,
|
||||||
RoutineStep,
|
RoutineStep,
|
||||||
SkinConditionSnapshot,
|
SkinConditionSnapshot,
|
||||||
)
|
)
|
||||||
|
from innercontext.models.enums import BarrierState, OverallSkinState, SkinConcern
|
||||||
|
|
||||||
|
|
||||||
def test_contains_minoxidil_text():
|
def test_contains_minoxidil_text():
|
||||||
|
|
@ -78,32 +80,131 @@ def test_ev():
|
||||||
|
|
||||||
def test_build_skin_context(session: Session):
|
def test_build_skin_context(session: Session):
|
||||||
# Empty
|
# Empty
|
||||||
assert _build_skin_context(session) == "SKIN CONDITION: no data\n"
|
reference_date = date(2026, 3, 10)
|
||||||
|
assert (
|
||||||
|
_build_skin_context(session, reference_date=reference_date)
|
||||||
|
== "SKIN CONDITION: no data\n"
|
||||||
|
)
|
||||||
|
|
||||||
# With data
|
# With data
|
||||||
snap = SkinConditionSnapshot(
|
snap = SkinConditionSnapshot(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
snapshot_date=date.today(),
|
snapshot_date=reference_date,
|
||||||
overall_state="good",
|
overall_state=OverallSkinState.GOOD,
|
||||||
hydration_level=4,
|
hydration_level=4,
|
||||||
barrier_state="intact",
|
barrier_state=BarrierState.INTACT,
|
||||||
active_concerns=["acne", "dryness"],
|
active_concerns=[SkinConcern.ACNE, SkinConcern.DEHYDRATION],
|
||||||
priorities=["hydration"],
|
priorities=["hydration"],
|
||||||
notes="Feeling good",
|
notes="Feeling good",
|
||||||
)
|
)
|
||||||
session.add(snap)
|
session.add(snap)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
ctx = _build_skin_context(session)
|
ctx = _build_skin_context(session, reference_date=reference_date)
|
||||||
assert "SKIN CONDITION (snapshot from" in ctx
|
assert "SKIN CONDITION (snapshot from" in ctx
|
||||||
assert "Overall state: good" in ctx
|
assert "Overall state: good" in ctx
|
||||||
assert "Hydration: 4/5" in ctx
|
assert "Hydration: 4/5" in ctx
|
||||||
assert "Barrier: intact" in ctx
|
assert "Barrier: intact" in ctx
|
||||||
assert "Active concerns: acne, dryness" in ctx
|
assert "Active concerns: acne, dehydration" in ctx
|
||||||
assert "Priorities: hydration" in ctx
|
assert "Priorities: hydration" in ctx
|
||||||
assert "Notes: Feeling good" 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):
|
def test_build_grooming_context(session: Session):
|
||||||
assert _build_grooming_context(session) == "GROOMING SCHEDULE: none\n"
|
assert _build_grooming_context(session) == "GROOMING SCHEDULE: none\n"
|
||||||
|
|
||||||
|
|
@ -146,12 +247,17 @@ def test_build_upcoming_grooming_context(session: Session):
|
||||||
|
|
||||||
|
|
||||||
def test_build_recent_history(session: Session):
|
def test_build_recent_history(session: Session):
|
||||||
assert _build_recent_history(session) == "RECENT ROUTINES: none\n"
|
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=date.today(), part_of_day="am")
|
r = Routine(id=uuid.uuid4(), routine_date=reference_date, part_of_day="am")
|
||||||
session.add(r)
|
session.add(r)
|
||||||
p = Product(
|
p = Product(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
|
short_id=str(uuid.uuid4())[:8],
|
||||||
name="Cleanser",
|
name="Cleanser",
|
||||||
category="cleanser",
|
category="cleanser",
|
||||||
brand="Test",
|
brand="Test",
|
||||||
|
|
@ -174,7 +280,7 @@ def test_build_recent_history(session: Session):
|
||||||
session.add_all([s1, s2, s3])
|
session.add_all([s1, s2, s3])
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
ctx = _build_recent_history(session)
|
ctx = _build_recent_history(session, reference_date=reference_date)
|
||||||
assert "RECENT ROUTINES:" in ctx
|
assert "RECENT ROUTINES:" in ctx
|
||||||
assert "AM:" in ctx
|
assert "AM:" in ctx
|
||||||
assert "cleanser [" in ctx
|
assert "cleanser [" in ctx
|
||||||
|
|
@ -182,10 +288,48 @@ def test_build_recent_history(session: Session):
|
||||||
assert "unknown [" in ctx
|
assert "unknown [" in ctx
|
||||||
|
|
||||||
|
|
||||||
def test_build_products_context(session: Session):
|
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(
|
p1 = Product(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
name="Regaine",
|
short_id=str(uuid.uuid4())[:8],
|
||||||
|
name="Regaine Minoxidil",
|
||||||
category="serum",
|
category="serum",
|
||||||
is_medication=True,
|
is_medication=True,
|
||||||
brand="J&J",
|
brand="J&J",
|
||||||
|
|
@ -195,6 +339,7 @@ def test_build_products_context(session: Session):
|
||||||
)
|
)
|
||||||
p2 = Product(
|
p2 = Product(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
|
short_id=str(uuid.uuid4())[:8],
|
||||||
name="Sunscreen",
|
name="Sunscreen",
|
||||||
category="spf",
|
category="spf",
|
||||||
brand="Test",
|
brand="Test",
|
||||||
|
|
@ -209,49 +354,14 @@ def test_build_products_context(session: Session):
|
||||||
session.add_all([p1, p2])
|
session.add_all([p1, p2])
|
||||||
session.commit()
|
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")
|
products_am = _get_available_products(session, time_filter="am")
|
||||||
ctx = _build_products_context(session, products_am, reference_date=date.today())
|
ctx = build_products_context_summary_list(products_am, {p2.id})
|
||||||
# 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 "Regaine Minoxidil" in ctx
|
||||||
assert "Sunscreen" in ctx
|
assert "Sunscreen" in ctx
|
||||||
assert "inventory_status={active:2,opened:1,sealed:1}" in ctx
|
assert "[✓]" in ctx
|
||||||
assert "nearest_open_expiry=" in ctx
|
assert "hydration=2" in ctx
|
||||||
assert "nearest_open_pao_deadline=" in ctx
|
assert "!post_shave" 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():
|
def test_build_objectives_context():
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue