diff --git a/backend/innercontext/api/product_llm_tools.py b/backend/innercontext/api/product_llm_tools.py index cac1f30..e2a4fae 100644 --- a/backend/innercontext/api/product_llm_tools.py +++ b/backend/innercontext/api/product_llm_tools.py @@ -1,8 +1,11 @@ +from datetime import date from typing import Any +from uuid import UUID from google.genai import types as genai_types +from sqlmodel import Session, col, select -from innercontext.models import Product +from innercontext.models import Product, Routine, RoutineStep def _ev(v: object) -> str: @@ -72,7 +75,12 @@ def _build_compact_actives_payload(product: Product) -> list[dict[str, object]]: return payload[:24] -def _map_product_details(product: Product, pid: str) -> dict[str, object]: +def _map_product_details( + product: Product, + pid: str, + *, + last_used_on: date | None = None, +) -> dict[str, object]: ctx = product.to_llm_context() inci = product.inci or [] @@ -91,11 +99,43 @@ def _map_product_details(product: Product, pid: str) -> dict[str, object]: "safety": ctx.get("safety") or {}, "min_interval_hours": ctx.get("min_interval_hours"), "max_frequency_per_week": ctx.get("max_frequency_per_week"), + "last_used_on": last_used_on.isoformat() if last_used_on else None, } -def build_product_details_tool_handler(products: list[Product]): +def build_last_used_on_by_product( + session: Session, + product_ids: list[UUID], +) -> dict[str, date]: + if not product_ids: + return {} + + rows = session.exec( + select(RoutineStep, Routine) + .join(Routine) + .where(col(RoutineStep.product_id).in_(product_ids)) + .order_by(col(Routine.routine_date).desc()) + ).all() + + last_used: dict[str, date] = {} + for step, routine in rows: + product_id = step.product_id + if product_id is None: + continue + key = str(product_id) + if key in last_used: + continue + last_used[key] = routine.routine_date + return last_used + + +def build_product_details_tool_handler( + products: list[Product], + *, + last_used_on_by_product: dict[str, date] | None = None, +): available_by_id = {str(p.id): p for p in products} + last_used_on_by_product = last_used_on_by_product or {} def _handler(args: dict[str, Any]) -> dict[str, object]: requested_ids = _extract_requested_product_ids(args) @@ -104,7 +144,13 @@ def build_product_details_tool_handler(products: list[Product]): product = available_by_id.get(pid) if product is None: continue - products_payload.append(_map_product_details(product, pid)) + products_payload.append( + _map_product_details( + product, + pid, + last_used_on=last_used_on_by_product.get(pid), + ) + ) return {"products": products_payload} return _handler @@ -117,7 +163,7 @@ PRODUCT_DETAILS_FUNCTION_DECLARATION = genai_types.FunctionDeclaration( "Call it when you need to verify ingredient conflicts, irritation risk, barrier compatibility, " "or usage cadence. Returns per-product fields: id, name, brand, category, recommended_time, " "leave_on, targets, effect_profile, inci, actives, context_rules, safety, " - "min_interval_hours, and max_frequency_per_week." + "min_interval_hours, max_frequency_per_week, and last_used_on (ISO date or null)." ), parameters=genai_types.Schema( type=genai_types.Type.OBJECT, diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index 4bfeeb1..f60cbb8 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -16,6 +16,7 @@ from innercontext.api.llm_context import build_user_profile_context from innercontext.api.product_llm_tools import ( PRODUCT_DETAILS_FUNCTION_DECLARATION, _extract_requested_product_ids as _shared_extract_requested_product_ids, + build_last_used_on_by_product, build_product_details_tool_handler, ) from innercontext.llm import ( @@ -922,6 +923,10 @@ Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem.""" def suggest_shopping(session: Session = Depends(get_session)): context = _build_shopping_context(session, reference_date=date.today()) shopping_products = _get_shopping_products(session) + last_used_on_by_product = build_last_used_on_by_product( + session, + product_ids=[p.id for p in shopping_products], + ) prompt = ( f"Na podstawie poniższych danych przeanalizuj, jakie TYPY produktów " @@ -956,7 +961,10 @@ def suggest_shopping(session: Session = Depends(get_session)): ) function_handlers = { - "get_product_details": build_product_details_tool_handler(shopping_products), + "get_product_details": build_product_details_tool_handler( + shopping_products, + last_used_on_by_product=last_used_on_by_product, + ), } try: diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index a05aa75..4322132 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -14,6 +14,7 @@ from innercontext.api.llm_context import build_user_profile_context from innercontext.api.product_llm_tools import ( PRODUCT_DETAILS_FUNCTION_DECLARATION, _extract_requested_product_ids as _shared_extract_requested_product_ids, + build_last_used_on_by_product, build_product_details_tool_handler, ) from innercontext.llm import ( @@ -602,6 +603,10 @@ def suggest_routine( session, time_filter=data.part_of_day.value, ) + last_used_on_by_product = build_last_used_on_by_product( + session, + product_ids=[p.id for p in available_products], + ) objectives_ctx = _build_objectives_context(data.include_minoxidil_beard) mode_line = "MODE: standard" @@ -646,7 +651,10 @@ def suggest_routine( ) function_handlers = { - "get_product_details": build_product_details_tool_handler(available_products), + "get_product_details": build_product_details_tool_handler( + available_products, + last_used_on_by_product=last_used_on_by_product, + ), } try: diff --git a/backend/tests/test_products_helpers.py b/backend/tests/test_products_helpers.py index 1e9b598..283eb11 100644 --- a/backend/tests/test_products_helpers.py +++ b/backend/tests/test_products_helpers.py @@ -154,3 +154,24 @@ def test_shopping_tool_handlers_return_payloads(session: Session): assert details["products"][0]["inci"] == ["Water", "Niacinamide"] assert details["products"][0]["actives"][0]["name"] == "Niacinamide" assert "context_rules" in details["products"][0] + assert details["products"][0]["last_used_on"] is None + + +def test_shopping_tool_handler_includes_last_used_on_from_mapping(session: Session): + product = Product( + id=uuid.uuid4(), + name="Mapped Product", + brand="Brand", + category="serum", + recommended_time="both", + leave_on=True, + product_effect_profile={}, + ) + + payload = {"product_ids": [str(product.id)]} + details = build_product_details_tool_handler( + [product], + last_used_on_by_product={str(product.id): date(2026, 3, 1)}, + )(payload) + + assert details["products"][0]["last_used_on"] == "2026-03-01" diff --git a/backend/tests/test_routines_helpers.py b/backend/tests/test_routines_helpers.py index 5a2fe11..d37326a 100644 --- a/backend/tests/test_routines_helpers.py +++ b/backend/tests/test_routines_helpers.py @@ -393,3 +393,4 @@ def test_product_details_tool_handler_returns_product_payloads(session: Session) 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