From 558708653c29d1f8dbbe8bd080ab418f2f6ada94 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Wed, 4 Mar 2026 11:52:07 +0100 Subject: [PATCH] feat(api): expand routines tool-calling to reduce prompt load Keep the /routines/suggest base context lean by sending only active names and fetching detailed safety, actives, usage notes, and INCI on demand. Add a conservative fallback when tool roundtrip limits are hit to preserve safe outputs instead of failing the request. --- backend/innercontext/api/routines.py | 303 +++++++++++++++++++++---- backend/tests/test_routines.py | 3 + backend/tests/test_routines_helpers.py | 70 ++++++ 3 files changed, 329 insertions(+), 47 deletions(-) diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 3f682d5..ae15f9f 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -345,8 +345,9 @@ def _build_products_context( f" leave_on={ctx.get('leave_on', '')}" f" targets={ctx.get('targets', [])}" ) - if "actives" in ctx: - entry += f" actives={ctx['actives']}" + active_names = _extract_active_names(p) + if active_names: + entry += f" actives={active_names}" active_inventory = [inv for inv in p.inventory if inv.finished_at is None] open_inventory = [inv for inv in active_inventory if inv.is_opened] @@ -394,12 +395,6 @@ def _build_products_context( entry += f" max_frequency_per_week={ctx['max_frequency_per_week']}" usage_count = recent_usage_counts.get(p.id, 0) entry += f" used_in_last_7_days={usage_count}" - usage_notes = ctx.get("usage_notes") - if usage_notes: - compact_notes = " ".join(str(usage_notes).split()) - if len(compact_notes) > 260: - compact_notes = compact_notes[:257] + "..." - entry += f' usage_notes="{compact_notes}"' lines.append(entry) return "\n".join(lines) + "\n" @@ -422,45 +417,158 @@ def _get_available_products( 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_usage_notes_tool_handler( + products: list[Product], +): + def _mapper(product: Product, pid: str) -> dict[str, object]: + notes = " ".join(str(product.usage_notes or "").split()) + if len(notes) > 500: + notes = notes[:497] + "..." + return { + "id": pid, + "name": product.name, + "usage_notes": notes, + } + + 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, + "incompatible_with": (ctx.get("incompatible_with") or [])[:24], + "contraindications": (ctx.get("contraindications") or [])[:24], + "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]: - raw_ids = args.get("product_ids") - if not isinstance(raw_ids, list): - return {"products": []} - - 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) >= 8: - break - + 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 - inci = product.inci or [] - compact_inci = [str(i)[:120] for i in inci[:128]] - products_payload.append( - { - "id": pid, - "name": product.name, - "inci": compact_inci, - } - ) + 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 []: + if isinstance(a, dict): + name = str(a.get("name") or "").strip() + else: + name = str(getattr(a, "name", "") or "").strip() + if not name: + continue + if name in names: + continue + names.append(name) + if len(names) >= 12: + break + return names + + _INCI_FUNCTION_DECLARATION = genai_types.FunctionDeclaration( name="get_product_inci", description=( @@ -480,6 +588,63 @@ _INCI_FUNCTION_DECLARATION = genai_types.FunctionDeclaration( ), ) +_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"], + ), +) + +_USAGE_NOTES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration( + name="get_product_usage_notes", + description=( + "Return compact usage notes for selected product UUIDs (application method, " + "timing, and cautions)." + ), + 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 safety and compatibility rules for selected product UUIDs: " + "incompatible_with, contraindications, context_rules and safety flags." + ), + 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 _build_objectives_context(include_minoxidil_beard: bool) -> str: if include_minoxidil_beard: @@ -668,9 +833,10 @@ def suggest_routine( "INPUT DATA:\n" f"{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(product_ids).\n" - "- Uzyj jej tylko, gdy potrzebujesz dokladnego skladu INCI do oceny bezpieczenstwa, kompatybilnosci lub redundancji aktywnych.\n" - "- Nie zgaduj INCI; jesli potrzebujesz skladu, wywolaj funkcje dla konkretnych UUID.\n" + "- Masz dostep do funkcji: get_product_inci, get_product_safety_rules, get_product_actives, get_product_usage_notes.\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" f"{notes_line}" f"{_ROUTINES_SINGLE_EXTRA}\n" "Zwróć JSON zgodny ze schematem." @@ -684,7 +850,12 @@ def suggest_routine( update={ "tools": [ genai_types.Tool( - function_declarations=[_INCI_FUNCTION_DECLARATION], + function_declarations=[ + _INCI_FUNCTION_DECLARATION, + _SAFETY_RULES_FUNCTION_DECLARATION, + _ACTIVES_FUNCTION_DECLARATION, + _USAGE_NOTES_FUNCTION_DECLARATION, + ], ) ], "tool_config": genai_types.ToolConfig( @@ -695,16 +866,54 @@ def suggest_routine( } ) - response = call_gemini_with_function_tools( - endpoint="routines/suggest", - contents=prompt, - config=config, - function_handlers={ - "get_product_inci": _build_inci_tool_handler(available_products) - }, - user_input=prompt, - max_tool_roundtrips=2, - ) + 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_usage_notes": _build_usage_notes_tool_handler(available_products), + } + + try: + response = call_gemini_with_function_tools( + endpoint="routines/suggest", + contents=prompt, + config=config, + function_handlers=function_handlers, + user_input=prompt, + max_tool_roundtrips=3, + ) + except HTTPException as exc: + if ( + exc.status_code != 502 + or str(exc.detail) != "Gemini requested too many function calls" + ): + raise + + conservative_prompt = ( + f"{prompt}\n\n" + "TRYB AWARYJNY (KONSERWATYWNY):\n" + "- Osiagnieto limit wywolan narzedzi.\n" + "- Nie wywoluj narzedzi ponownie.\n" + "- Zaproponuj maksymalnie konserwatywna, bezpieczna rutyne na podstawie dostepnych juz danych," + " preferujac lagodne produkty wspierajace bariere i fotoprotekcje.\n" + "- Gdy masz watpliwosci, pomijaj ryzykowne aktywne kroki.\n" + ) + response = call_gemini( + endpoint="routines/suggest", + contents=conservative_prompt, + config=get_creative_config( + system_instruction=_ROUTINES_SYSTEM_PROMPT, + response_schema=_SuggestionOut, + max_output_tokens=4096, + ), + user_input=conservative_prompt, + tool_trace={ + "mode": "fallback_conservative", + "reason": "max_tool_roundtrips_exceeded", + }, + ) raw = response.text if not raw: diff --git a/backend/tests/test_routines.py b/backend/tests/test_routines.py index 9001c6c..8f04045 100644 --- a/backend/tests/test_routines.py +++ b/backend/tests/test_routines.py @@ -250,6 +250,9 @@ def test_suggest_routine(client, session): kwargs = mock_gemini.call_args.kwargs 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_usage_notes" 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 29dfc4e..5d78872 100644 --- a/backend/tests/test_routines_helpers.py +++ b/backend/tests/test_routines_helpers.py @@ -4,15 +4,20 @@ 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_usage_notes_tool_handler, _contains_minoxidil_text, _ev, + _extract_active_names, + _extract_requested_product_ids, _get_available_products, _is_minoxidil_product, ) @@ -336,3 +341,68 @@ 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"] + + +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_additional_tool_handlers_return_product_payloads(session: Session): + p = Product( + id=uuid.uuid4(), + name="Detail Product", + category="serum", + brand="Test", + recommended_time="both", + leave_on=True, + usage_notes="Apply morning and evening.", + actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}], + incompatible_with=[{"target": "Retinol", "scope": "same_step"}], + context_rules={"safe_after_shaving": True}, + product_effect_profile={}, + ) + + 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" + + notes_out = _build_usage_notes_tool_handler([p])(ids_payload) + assert notes_out["products"][0]["usage_notes"] == "Apply morning and evening." + + safety_out = _build_safety_rules_tool_handler([p])(ids_payload) + assert "incompatible_with" in safety_out["products"][0] + assert "context_rules" in safety_out["products"][0]