feat(api): add tool-calling flow for shopping suggestions
Keep /products/suggest lean by exposing product UUIDs and fetching INCI, safety rules, actives, and usage notes on demand through Gemini function tools. Add conservative fallback behavior for tool roundtrip limits and expand helper tests to cover tool wiring and payload handlers.
This commit is contained in:
parent
558708653c
commit
b58fcb1440
2 changed files with 370 additions and 30 deletions
|
|
@ -4,8 +4,16 @@ from unittest.mock import patch
|
|||
|
||||
from sqlmodel import Session
|
||||
|
||||
from innercontext.api.products import _build_shopping_context
|
||||
from innercontext.models import Product, SkinConditionSnapshot, ProductInventory
|
||||
from innercontext.api.products import (
|
||||
_build_actives_tool_handler,
|
||||
_build_inci_tool_handler,
|
||||
_build_safety_rules_tool_handler,
|
||||
_build_shopping_context,
|
||||
_build_usage_notes_tool_handler,
|
||||
_extract_requested_product_ids,
|
||||
)
|
||||
from innercontext.models import Product, ProductInventory, SkinConditionSnapshot
|
||||
|
||||
|
||||
def test_build_shopping_context(session: Session):
|
||||
# Empty context
|
||||
|
|
@ -23,7 +31,7 @@ def test_build_shopping_context(session: Session):
|
|||
sensitivity_level=4,
|
||||
barrier_state="mildly_compromised",
|
||||
active_concerns=["redness"],
|
||||
priorities=["soothing"]
|
||||
priorities=["soothing"],
|
||||
)
|
||||
session.add(snap)
|
||||
|
||||
|
|
@ -37,7 +45,7 @@ def test_build_shopping_context(session: Session):
|
|||
leave_on=True,
|
||||
targets=["redness"],
|
||||
product_effect_profile={"soothing_strength": 4, "hydration_immediate": 1},
|
||||
actives=[{"name": "Centella"}]
|
||||
actives=[{"name": "Centella"}],
|
||||
)
|
||||
session.add(p)
|
||||
session.commit()
|
||||
|
|
@ -53,9 +61,11 @@ def test_build_shopping_context(session: Session):
|
|||
assert "Wrażliwość: 4/5" in ctx
|
||||
assert "Aktywne problemy: redness" in ctx
|
||||
assert "Priorytety: soothing" in ctx
|
||||
|
||||
|
||||
# Check product
|
||||
assert "[✓] Soothing Serum" in ctx
|
||||
assert "[✓] id=" in ctx
|
||||
assert "Soothing Serum" in ctx
|
||||
assert f"id={p.id}" in ctx
|
||||
assert "BrandX" in ctx
|
||||
assert "targets: ['redness']" in ctx
|
||||
assert "actives: ['Centella']" in ctx
|
||||
|
|
@ -63,8 +73,16 @@ def test_build_shopping_context(session: Session):
|
|||
|
||||
|
||||
def test_suggest_shopping(client, session):
|
||||
with patch("innercontext.api.products.call_gemini") as mock_gemini:
|
||||
mock_response = type("Response", (), {"text": '{"suggestions": [{"category": "cleanser", "product_type": "cleanser", "priority": "high", "key_ingredients": [], "target_concerns": [], "why_needed": "reason", "recommended_time": "am", "frequency": "daily"}], "reasoning": "Test shopping"}'})
|
||||
with patch(
|
||||
"innercontext.api.products.call_gemini_with_function_tools"
|
||||
) as mock_gemini:
|
||||
mock_response = type(
|
||||
"Response",
|
||||
(),
|
||||
{
|
||||
"text": '{"suggestions": [{"category": "cleanser", "product_type": "cleanser", "priority": "high", "key_ingredients": [], "target_concerns": [], "why_needed": "reason", "recommended_time": "am", "frequency": "daily"}], "reasoning": "Test shopping"}'
|
||||
},
|
||||
)
|
||||
mock_gemini.return_value = mock_response
|
||||
|
||||
r = client.post("/products/suggest")
|
||||
|
|
@ -73,6 +91,13 @@ def test_suggest_shopping(client, session):
|
|||
assert len(data["suggestions"]) == 1
|
||||
assert data["suggestions"][0]["product_type"] == "cleanser"
|
||||
assert data["reasoning"] == "Test shopping"
|
||||
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_shopping_context_medication_skip(session: Session):
|
||||
p = Product(
|
||||
|
|
@ -83,11 +108,50 @@ def test_shopping_context_medication_skip(session: Session):
|
|||
recommended_time="pm",
|
||||
leave_on=True,
|
||||
is_medication=True,
|
||||
product_effect_profile={}
|
||||
product_effect_profile={},
|
||||
)
|
||||
session.add(p)
|
||||
session.commit()
|
||||
|
||||
|
||||
ctx = _build_shopping_context(session)
|
||||
assert "Epiduo" not in ctx
|
||||
|
||||
|
||||
def test_extract_requested_product_ids_dedupes_and_limits():
|
||||
ids = _extract_requested_product_ids(
|
||||
{"product_ids": ["a", "b", "a", 1, "c", "d"]},
|
||||
max_ids=3,
|
||||
)
|
||||
assert ids == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_shopping_tool_handlers_return_payloads(session: Session):
|
||||
product = Product(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Product",
|
||||
brand="Brand",
|
||||
category="serum",
|
||||
recommended_time="both",
|
||||
leave_on=True,
|
||||
usage_notes="Use AM and PM on clean skin.",
|
||||
inci=["Water", "Niacinamide"],
|
||||
actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}],
|
||||
incompatible_with=[{"target": "Vitamin C", "scope": "same_step"}],
|
||||
context_rules={"safe_after_shaving": True},
|
||||
product_effect_profile={},
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
notes_data = _build_usage_notes_tool_handler([product])(payload)
|
||||
assert notes_data["products"][0]["usage_notes"] == "Use AM and PM on clean skin."
|
||||
|
||||
safety_data = _build_safety_rules_tool_handler([product])(payload)
|
||||
assert "incompatible_with" in safety_data["products"][0]
|
||||
assert "context_rules" in safety_data["products"][0]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue