diff --git a/backend/innercontext/api/product_llm_tools.py b/backend/innercontext/api/product_llm_tools.py new file mode 100644 index 0000000..cac1f30 --- /dev/null +++ b/backend/innercontext/api/product_llm_tools.py @@ -0,0 +1,133 @@ +from typing import Any + +from google.genai import types as genai_types + +from innercontext.models import Product + + +def _ev(v: object) -> str: + if v is None: + return "" + value = getattr(v, "value", None) + if isinstance(value, str): + return value + return str(v) + + +def _extract_requested_product_ids( + args: dict[str, object], max_ids: int = 8 +) -> list[str]: + raw_ids = args.get("product_ids") + if not isinstance(raw_ids, list): + return [] + + requested_ids: list[str] = [] + seen: set[str] = set() + for raw_id in raw_ids: + if not isinstance(raw_id, str): + continue + if raw_id in seen: + continue + seen.add(raw_id) + requested_ids.append(raw_id) + if len(requested_ids) >= max_ids: + break + return requested_ids + + +def _build_compact_actives_payload(product: Product) -> list[dict[str, object]]: + payload: list[dict[str, object]] = [] + for active in product.actives or []: + if isinstance(active, dict): + name = str(active.get("name") or "").strip() + if not name: + continue + item: dict[str, object] = {"name": name} + percent = active.get("percent") + if percent is not None: + item["percent"] = percent + functions = active.get("functions") + if isinstance(functions, list): + item["functions"] = [str(f) for f in functions[:4]] + strength_level = active.get("strength_level") + if strength_level is not None: + item["strength_level"] = str(strength_level) + payload.append(item) + continue + + name = str(getattr(active, "name", "") or "").strip() + if not name: + continue + item = {"name": name} + percent = getattr(active, "percent", None) + if percent is not None: + item["percent"] = percent + functions = getattr(active, "functions", None) + if isinstance(functions, list): + item["functions"] = [_ev(f) for f in functions[:4]] + strength_level = getattr(active, "strength_level", None) + if strength_level is not None: + item["strength_level"] = _ev(strength_level) + payload.append(item) + return payload[:24] + + +def _map_product_details(product: Product, pid: str) -> dict[str, object]: + ctx = product.to_llm_context() + inci = product.inci or [] + + return { + "id": pid, + "name": product.name, + "brand": product.brand, + "category": ctx.get("category"), + "recommended_time": ctx.get("recommended_time"), + "leave_on": product.leave_on, + "targets": ctx.get("targets") or [], + "effect_profile": ctx.get("effect_profile") or {}, + "inci": [str(i)[:120] for i in inci[:128]], + "actives": _build_compact_actives_payload(product), + "context_rules": ctx.get("context_rules") or {}, + "safety": ctx.get("safety") or {}, + "min_interval_hours": ctx.get("min_interval_hours"), + "max_frequency_per_week": ctx.get("max_frequency_per_week"), + } + + +def build_product_details_tool_handler(products: list[Product]): + available_by_id = {str(p.id): p for p in products} + + def _handler(args: dict[str, Any]) -> dict[str, object]: + requested_ids = _extract_requested_product_ids(args) + products_payload = [] + for pid in requested_ids: + product = available_by_id.get(pid) + if product is None: + continue + products_payload.append(_map_product_details(product, pid)) + return {"products": products_payload} + + return _handler + + +PRODUCT_DETAILS_FUNCTION_DECLARATION = genai_types.FunctionDeclaration( + name="get_product_details", + description=( + "Use this to fetch canonical product data before making clinical/safety decisions. " + "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." + ), + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, + properties={ + "product_ids": genai_types.Schema( + type=genai_types.Type.ARRAY, + items=genai_types.Schema(type=genai_types.Type.STRING), + description="Product UUIDs from the provided product list.", + ) + }, + required=["product_ids"], + ), +) diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index 656e2da..4bfeeb1 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -13,6 +13,11 @@ from sqlmodel import Field, Session, SQLModel, col, select from db import get_session from innercontext.api.utils import get_or_404 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_product_details_tool_handler, +) from innercontext.llm import ( call_gemini, call_gemini_with_function_tools, @@ -887,164 +892,8 @@ def _extract_active_names(product: Product) -> list[str]: def _extract_requested_product_ids( args: dict[str, object], max_ids: int = 8 ) -> list[str]: - raw_ids = args.get("product_ids") - if not isinstance(raw_ids, list): - return [] + return _shared_extract_requested_product_ids(args, max_ids=max_ids) - requested_ids: list[str] = [] - seen: set[str] = set() - for raw_id in raw_ids: - if not isinstance(raw_id, str): - continue - if raw_id in seen: - continue - seen.add(raw_id) - requested_ids.append(raw_id) - if len(requested_ids) >= max_ids: - break - return requested_ids - - -def _build_product_details_tool_handler(products: list[Product], mapper): - available_by_id = {str(p.id): p for p in products} - - def _handler(args: dict[str, object]) -> dict[str, object]: - requested_ids = _extract_requested_product_ids(args) - products_payload = [] - for pid in requested_ids: - product = available_by_id.get(pid) - if product is None: - continue - products_payload.append(mapper(product, pid)) - return {"products": products_payload} - - return _handler - - -def _build_inci_tool_handler(products: list[Product]): - def _mapper(product: Product, pid: str) -> dict[str, object]: - inci = product.inci or [] - compact_inci = [str(i)[:120] for i in inci[:128]] - return {"id": pid, "name": product.name, "inci": compact_inci} - - return _build_product_details_tool_handler(products, mapper=_mapper) - - -def _build_actives_tool_handler(products: list[Product]): - def _mapper(product: Product, pid: str) -> dict[str, object]: - payload = [] - for active in product.actives or []: - if isinstance(active, dict): - name = str(active.get("name") or "").strip() - if not name: - continue - item = {"name": name} - percent = active.get("percent") - if percent is not None: - item["percent"] = percent - functions = active.get("functions") - if isinstance(functions, list): - item["functions"] = [str(f) for f in functions[:4]] - strength_level = active.get("strength_level") - if strength_level is not None: - item["strength_level"] = str(strength_level) - payload.append(item) - continue - - name = str(getattr(active, "name", "") or "").strip() - if not name: - continue - item = {"name": name} - percent = getattr(active, "percent", None) - if percent is not None: - item["percent"] = percent - functions = getattr(active, "functions", None) - if isinstance(functions, list): - item["functions"] = [_ev(f) for f in functions[:4]] - strength_level = getattr(active, "strength_level", None) - if strength_level is not None: - item["strength_level"] = _ev(strength_level) - payload.append(item) - return {"id": pid, "name": product.name, "actives": payload[:24]} - - return _build_product_details_tool_handler(products, mapper=_mapper) - - -def _build_safety_rules_tool_handler(products: list[Product]): - def _mapper(product: Product, pid: str) -> dict[str, object]: - ctx = product.to_llm_context() - return { - "id": pid, - "name": product.name, - "context_rules": ctx.get("context_rules") or {}, - "safety": ctx.get("safety") or {}, - "min_interval_hours": ctx.get("min_interval_hours"), - "max_frequency_per_week": ctx.get("max_frequency_per_week"), - } - - return _build_product_details_tool_handler(products, mapper=_mapper) - - -_INCI_FUNCTION_DECLARATION = genai_types.FunctionDeclaration( - name="get_product_inci", - description=( - "Return exact INCI ingredient lists for selected product UUIDs from " - "POSIADANE PRODUKTY." - ), - parameters=genai_types.Schema( - type=genai_types.Type.OBJECT, - properties={ - "product_ids": genai_types.Schema( - type=genai_types.Type.ARRAY, - items=genai_types.Schema(type=genai_types.Type.STRING), - description="Product UUIDs from POSIADANE PRODUKTY.", - ) - }, - required=["product_ids"], - ), -) - -_SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration( - name="get_product_safety_rules", - description=( - "Return structured safety metadata for selected product UUIDs. " - "context_rules explain when a product should be avoided in a routine " - "(for example after acids, after retinoids, or with a compromised barrier). " - "safety flags describe formulation-level constraints " - "(for example whether the formula is fragrance-free, whether it contains denatured alcohol, and whether it is pregnancy-safe). " - "Also includes min_interval_hours and max_frequency_per_week." - ), - parameters=genai_types.Schema( - type=genai_types.Type.OBJECT, - properties={ - "product_ids": genai_types.Schema( - type=genai_types.Type.ARRAY, - items=genai_types.Schema(type=genai_types.Type.STRING), - description="Product UUIDs from POSIADANE PRODUKTY.", - ) - }, - required=["product_ids"], - ), -) - -_ACTIVES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration( - name="get_product_actives", - description=( - "Return detailed active ingredients for selected product UUIDs, " - "including concentration and functions when available." - ), - parameters=genai_types.Schema( - type=genai_types.Type.OBJECT, - properties={ - "product_ids": genai_types.Schema( - type=genai_types.Type.ARRAY, - items=genai_types.Schema(type=genai_types.Type.STRING), - description="Product UUIDs from POSIADANE PRODUKTY.", - ) - }, - required=["product_ids"], - ), -) _SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry. Twoim zadaniem jest przeanalizować stan skóry użytkownika oraz produkty, które już posiada, @@ -1079,7 +928,7 @@ def suggest_shopping(session: Session = Depends(get_session)): f"mogłyby uzupełnić rutynę pielęgnacyjną użytkownika.\n\n" f"{context}\n\n" "NARZEDZIA:\n" - "- Masz dostep do funkcji: get_product_inci, get_product_safety_rules, get_product_actives.\n" + "- Masz dostep do funkcji: get_product_details.\n" "- Wywoluj narzedzia tylko, gdy potrzebujesz detali do oceny konfliktow skladnikow lub ryzyka podraznien.\n" "- Grupuj UUID: staraj sie pobierac dane dla wielu produktow jednym wywolaniem.\n" f"Zwróć wyłącznie JSON zgodny ze schematem." @@ -1094,9 +943,7 @@ def suggest_shopping(session: Session = Depends(get_session)): "tools": [ genai_types.Tool( function_declarations=[ - _INCI_FUNCTION_DECLARATION, - _SAFETY_RULES_FUNCTION_DECLARATION, - _ACTIVES_FUNCTION_DECLARATION, + PRODUCT_DETAILS_FUNCTION_DECLARATION, ] ) ], @@ -1109,9 +956,7 @@ def suggest_shopping(session: Session = Depends(get_session)): ) function_handlers = { - "get_product_inci": _build_inci_tool_handler(shopping_products), - "get_product_safety_rules": _build_safety_rules_tool_handler(shopping_products), - "get_product_actives": _build_actives_tool_handler(shopping_products), + "get_product_details": build_product_details_tool_handler(shopping_products), } try: diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 0f79e14..a05aa75 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -11,6 +11,11 @@ from sqlmodel import Field, Session, SQLModel, col, select from db import get_session from innercontext.api.utils import get_or_404 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_product_details_tool_handler, +) from innercontext.llm import ( call_gemini, call_gemini_with_function_tools, @@ -407,125 +412,6 @@ def _get_available_products( return result -def _build_inci_tool_handler( - products: list[Product], -): - def _mapper(product: Product, pid: str) -> dict[str, object]: - inci = product.inci or [] - compact_inci = [str(i)[:120] for i in inci[:128]] - return { - "id": pid, - "name": product.name, - "inci": compact_inci, - } - - return _build_product_details_tool_handler(products, mapper=_mapper) - - -def _build_actives_tool_handler( - products: list[Product], -): - def _mapper(product: Product, pid: str) -> dict[str, object]: - actives_payload = [] - for a in product.actives or []: - if isinstance(a, dict): - active_name = str(a.get("name") or "").strip() - if not active_name: - continue - item = {"name": active_name} - percent = a.get("percent") - if percent is not None: - item["percent"] = percent - functions = a.get("functions") - if isinstance(functions, list): - item["functions"] = [str(f) for f in functions[:4]] - strength_level = a.get("strength_level") - if strength_level is not None: - item["strength_level"] = str(strength_level) - actives_payload.append(item) - continue - - active_name = str(getattr(a, "name", "") or "").strip() - if not active_name: - continue - item = {"name": active_name} - percent = getattr(a, "percent", None) - if percent is not None: - item["percent"] = percent - functions = getattr(a, "functions", None) - if isinstance(functions, list): - item["functions"] = [_ev(f) for f in functions[:4]] - strength_level = getattr(a, "strength_level", None) - if strength_level is not None: - item["strength_level"] = _ev(strength_level) - actives_payload.append(item) - - return { - "id": pid, - "name": product.name, - "actives": actives_payload[:24], - } - - return _build_product_details_tool_handler(products, mapper=_mapper) - - -def _build_safety_rules_tool_handler( - products: list[Product], -): - def _mapper(product: Product, pid: str) -> dict[str, object]: - ctx = product.to_llm_context() - return { - "id": pid, - "name": product.name, - "context_rules": ctx.get("context_rules") or {}, - "safety": ctx.get("safety") or {}, - "min_interval_hours": ctx.get("min_interval_hours"), - "max_frequency_per_week": ctx.get("max_frequency_per_week"), - } - - return _build_product_details_tool_handler(products, mapper=_mapper) - - -def _build_product_details_tool_handler( - products: list[Product], - mapper, -): - available_by_id = {str(p.id): p for p in products} - - def _handler(args: dict[str, object]) -> dict[str, object]: - requested_ids = _extract_requested_product_ids(args) - products_payload = [] - for pid in requested_ids: - product = available_by_id.get(pid) - if product is None: - continue - products_payload.append(mapper(product, pid)) - return {"products": products_payload} - - return _handler - - -def _extract_requested_product_ids( - args: dict[str, object], max_ids: int = 8 -) -> list[str]: - raw_ids = args.get("product_ids") - if not isinstance(raw_ids, list): - return [] - - requested_ids: list[str] = [] - seen: set[str] = set() - for raw_id in raw_ids: - if not isinstance(raw_id, str): - continue - if raw_id in seen: - continue - seen.add(raw_id) - requested_ids.append(raw_id) - if len(requested_ids) >= max_ids: - break - return requested_ids - - def _extract_active_names(product: Product) -> list[str]: names: list[str] = [] for a in product.actives or []: @@ -543,66 +429,10 @@ def _extract_active_names(product: Product) -> list[str]: return names -_INCI_FUNCTION_DECLARATION = genai_types.FunctionDeclaration( - name="get_product_inci", - description=( - "Return exact INCI ingredient lists for products identified by UUID from " - "the AVAILABLE PRODUCTS list." - ), - parameters=genai_types.Schema( - type=genai_types.Type.OBJECT, - properties={ - "product_ids": genai_types.Schema( - type=genai_types.Type.ARRAY, - items=genai_types.Schema(type=genai_types.Type.STRING), - description="Product UUIDs from AVAILABLE PRODUCTS.", - ) - }, - required=["product_ids"], - ), -) - -_ACTIVES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration( - name="get_product_actives", - description=( - "Return detailed active ingredients (name, strength, concentration, functions) " - "for selected product UUIDs." - ), - parameters=genai_types.Schema( - type=genai_types.Type.OBJECT, - properties={ - "product_ids": genai_types.Schema( - type=genai_types.Type.ARRAY, - items=genai_types.Schema(type=genai_types.Type.STRING), - description="Product UUIDs from AVAILABLE PRODUCTS.", - ) - }, - required=["product_ids"], - ), -) - -_SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration( - name="get_product_safety_rules", - description=( - "Return structured safety metadata for selected product UUIDs. " - "context_rules explain when a product should be avoided in a routine " - "(for example after acids, after retinoids, or with a compromised barrier). " - "safety flags describe formulation-level constraints " - "(for example whether the formula is fragrance-free, whether it contains denatured alcohol, and whether it is pregnancy-safe). " - "Also includes min_interval_hours and max_frequency_per_week." - ), - parameters=genai_types.Schema( - type=genai_types.Type.OBJECT, - properties={ - "product_ids": genai_types.Schema( - type=genai_types.Type.ARRAY, - items=genai_types.Schema(type=genai_types.Type.STRING), - description="Product UUIDs from AVAILABLE PRODUCTS.", - ) - }, - required=["product_ids"], - ), -) +def _extract_requested_product_ids( + args: dict[str, object], max_ids: int = 8 +) -> list[str]: + return _shared_extract_requested_product_ids(args, max_ids=max_ids) def _build_objectives_context(include_minoxidil_beard: bool) -> str: @@ -785,7 +615,7 @@ def suggest_routine( "INPUT DATA:\n" f"{profile_ctx}\n{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{day_ctx}\n{products_ctx}\n{objectives_ctx}" "\nNARZEDZIA:\n" - "- Masz dostep do funkcji: get_product_inci, get_product_safety_rules, get_product_actives.\n" + "- Masz dostep do funkcji: get_product_details.\n" "- Wywoluj narzedzia tylko, gdy potrzebujesz detali do decyzji klinicznej/bezpieczenstwa.\n" "- Staraj sie grupowac zapytania: podawaj wszystkie potrzebne UUID w jednym wywolaniu narzedzia.\n" "- Nie zgaduj detali skladu i zasad bezpieczenstwa; jesli potrzebujesz szczegolow, wywolaj odpowiednie narzedzie.\n" @@ -803,9 +633,7 @@ def suggest_routine( "tools": [ genai_types.Tool( function_declarations=[ - _INCI_FUNCTION_DECLARATION, - _SAFETY_RULES_FUNCTION_DECLARATION, - _ACTIVES_FUNCTION_DECLARATION, + PRODUCT_DETAILS_FUNCTION_DECLARATION, ], ) ], @@ -818,11 +646,7 @@ def suggest_routine( ) function_handlers = { - "get_product_inci": _build_inci_tool_handler(available_products), - "get_product_safety_rules": _build_safety_rules_tool_handler( - available_products - ), - "get_product_actives": _build_actives_tool_handler(available_products), + "get_product_details": build_product_details_tool_handler(available_products), } try: diff --git a/backend/tests/test_products_helpers.py b/backend/tests/test_products_helpers.py index 941d441..1e9b598 100644 --- a/backend/tests/test_products_helpers.py +++ b/backend/tests/test_products_helpers.py @@ -5,11 +5,9 @@ from unittest.mock import patch from sqlmodel import Session from innercontext.api.products import ( - _build_actives_tool_handler, - _build_inci_tool_handler, - _build_safety_rules_tool_handler, _build_shopping_context, _extract_requested_product_ids, + build_product_details_tool_handler, ) from innercontext.models import ( Product, @@ -107,9 +105,7 @@ def test_suggest_shopping(client, session): kwargs = mock_gemini.call_args.kwargs assert "USER PROFILE:" in kwargs["contents"] assert "function_handlers" in kwargs - assert "get_product_inci" in kwargs["function_handlers"] - assert "get_product_safety_rules" in kwargs["function_handlers"] - assert "get_product_actives" in kwargs["function_handlers"] + assert "get_product_details" in kwargs["function_handlers"] def test_shopping_context_medication_skip(session: Session): @@ -154,11 +150,7 @@ def test_shopping_tool_handlers_return_payloads(session: Session): payload = {"product_ids": [str(product.id)]} - inci_data = _build_inci_tool_handler([product])(payload) - assert inci_data["products"][0]["inci"] == ["Water", "Niacinamide"] - - actives_data = _build_actives_tool_handler([product])(payload) - assert actives_data["products"][0]["actives"][0]["name"] == "Niacinamide" - - safety_data = _build_safety_rules_tool_handler([product])(payload) - assert "context_rules" in safety_data["products"][0] + details = build_product_details_tool_handler([product])(payload) + assert details["products"][0]["inci"] == ["Water", "Niacinamide"] + assert details["products"][0]["actives"][0]["name"] == "Niacinamide" + assert "context_rules" in details["products"][0] diff --git a/backend/tests/test_routines.py b/backend/tests/test_routines.py index e59e81c..b993eeb 100644 --- a/backend/tests/test_routines.py +++ b/backend/tests/test_routines.py @@ -250,9 +250,7 @@ def test_suggest_routine(client, session): kwargs = mock_gemini.call_args.kwargs assert "USER PROFILE:" in kwargs["contents"] assert "function_handlers" in kwargs - assert "get_product_inci" in kwargs["function_handlers"] - assert "get_product_safety_rules" in kwargs["function_handlers"] - assert "get_product_actives" in kwargs["function_handlers"] + assert "get_product_details" in kwargs["function_handlers"] def test_suggest_batch(client, session): diff --git a/backend/tests/test_routines_helpers.py b/backend/tests/test_routines_helpers.py index 5e2a0b1..5a2fe11 100644 --- a/backend/tests/test_routines_helpers.py +++ b/backend/tests/test_routines_helpers.py @@ -4,15 +4,13 @@ from datetime import date, timedelta from sqlmodel import Session from innercontext.api.routines import ( - _build_actives_tool_handler, _build_day_context, _build_grooming_context, - _build_inci_tool_handler, _build_objectives_context, _build_products_context, _build_recent_history, - _build_safety_rules_tool_handler, _build_skin_context, + build_product_details_tool_handler, _contains_minoxidil_text, _ev, _extract_active_names, @@ -294,7 +292,9 @@ def test_get_available_products_respects_filters(session: Session): assert "PM Cream" not in am_names -def test_build_inci_tool_handler_returns_only_available_ids(session: Session): +def test_build_product_details_tool_handler_returns_only_available_ids( + session: Session, +): available = Product( id=uuid.uuid4(), name="Available", @@ -316,7 +316,7 @@ def test_build_inci_tool_handler_returns_only_available_ids(session: Session): product_effect_profile={}, ) - handler = _build_inci_tool_handler([available]) + handler = build_product_details_tool_handler([available]) payload = handler( { "product_ids": [ @@ -334,6 +334,8 @@ def test_build_inci_tool_handler_returns_only_available_ids(session: Session): 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(): @@ -373,7 +375,7 @@ def test_extract_active_names_uses_compact_distinct_names(session: Session): assert names == ["Niacinamide", "Zinc PCA"] -def test_additional_tool_handlers_return_product_payloads(session: Session): +def test_product_details_tool_handler_returns_product_payloads(session: Session): p = Product( id=uuid.uuid4(), name="Detail Product", @@ -388,8 +390,6 @@ def test_additional_tool_handlers_return_product_payloads(session: Session): ids_payload = {"product_ids": [str(p.id)]} - actives_out = _build_actives_tool_handler([p])(ids_payload) - assert actives_out["products"][0]["actives"][0]["name"] == "Niacinamide" - - safety_out = _build_safety_rules_tool_handler([p])(ids_payload) - assert "context_rules" in safety_out["products"][0] + 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]