feat(api): add INCI tool-calling with normalized tool traces
Enable on-demand INCI retrieval in /routines/suggest through Gemini function calling so detailed ingredient data is fetched only when needed. Persist and normalize tool_trace data in AI logs to make function-call behavior directly inspectable via /ai-logs endpoints.
This commit is contained in:
parent
c0eeb0425d
commit
cfd2485b7e
8 changed files with 455 additions and 29 deletions
44
backend/tests/test_ai_logs.py
Normal file
44
backend/tests/test_ai_logs.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import uuid
|
||||
from typing import Any, cast
|
||||
|
||||
from innercontext.models.ai_log import AICallLog
|
||||
|
||||
|
||||
def test_list_ai_logs_normalizes_tool_trace_string(client, session):
|
||||
log = AICallLog(
|
||||
id=uuid.uuid4(),
|
||||
endpoint="routines/suggest",
|
||||
model="gemini-3-flash-preview",
|
||||
success=True,
|
||||
)
|
||||
log.tool_trace = cast(
|
||||
Any,
|
||||
'{"mode":"function_tools","events":[{"function":"get_product_inci"}]}',
|
||||
)
|
||||
session.add(log)
|
||||
session.commit()
|
||||
|
||||
response = client.get("/ai-logs")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["tool_trace"]["mode"] == "function_tools"
|
||||
assert data[0]["tool_trace"]["events"][0]["function"] == "get_product_inci"
|
||||
|
||||
|
||||
def test_get_ai_log_normalizes_tool_trace_string(client, session):
|
||||
log = AICallLog(
|
||||
id=uuid.uuid4(),
|
||||
endpoint="routines/suggest",
|
||||
model="gemini-3-flash-preview",
|
||||
success=True,
|
||||
)
|
||||
log.tool_trace = cast(Any, '{"mode":"function_tools","round":1}')
|
||||
session.add(log)
|
||||
session.commit()
|
||||
|
||||
response = client.get(f"/ai-logs/{log.id}")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["tool_trace"]["mode"] == "function_tools"
|
||||
assert payload["tool_trace"]["round"] == 1
|
||||
|
|
@ -220,7 +220,9 @@ def test_delete_grooming_schedule_not_found(client):
|
|||
|
||||
|
||||
def test_suggest_routine(client, session):
|
||||
with patch("innercontext.api.routines.call_gemini") as mock_gemini:
|
||||
with patch(
|
||||
"innercontext.api.routines.call_gemini_with_function_tools"
|
||||
) as mock_gemini:
|
||||
# Mock the Gemini response
|
||||
mock_response = type(
|
||||
"Response",
|
||||
|
|
@ -245,6 +247,9 @@ def test_suggest_routine(client, session):
|
|||
assert len(data["steps"]) == 1
|
||||
assert data["steps"][0]["action_type"] == "shaving_razor"
|
||||
assert data["reasoning"] == "because"
|
||||
kwargs = mock_gemini.call_args.kwargs
|
||||
assert "function_handlers" in kwargs
|
||||
assert "get_product_inci" in kwargs["function_handlers"]
|
||||
|
||||
|
||||
def test_suggest_batch(client, session):
|
||||
|
|
|
|||
|
|
@ -1,26 +1,28 @@
|
|||
from datetime import date, timedelta
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from innercontext.api.routines import (
|
||||
_contains_minoxidil_text,
|
||||
_is_minoxidil_product,
|
||||
_ev,
|
||||
_build_skin_context,
|
||||
_build_grooming_context,
|
||||
_build_recent_history,
|
||||
_build_products_context,
|
||||
_build_objectives_context,
|
||||
_build_day_context,
|
||||
_build_grooming_context,
|
||||
_build_inci_tool_handler,
|
||||
_build_objectives_context,
|
||||
_build_products_context,
|
||||
_build_recent_history,
|
||||
_build_skin_context,
|
||||
_contains_minoxidil_text,
|
||||
_ev,
|
||||
_get_available_products,
|
||||
_is_minoxidil_product,
|
||||
)
|
||||
from innercontext.models import (
|
||||
Product,
|
||||
SkinConditionSnapshot,
|
||||
GroomingSchedule,
|
||||
Product,
|
||||
ProductInventory,
|
||||
Routine,
|
||||
RoutineStep,
|
||||
ProductInventory,
|
||||
SkinConditionSnapshot,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -242,3 +244,95 @@ def test_build_day_context():
|
|||
assert _build_day_context(None) == ""
|
||||
assert "Leaving home: yes" in _build_day_context(True)
|
||||
assert "Leaving home: no" in _build_day_context(False)
|
||||
|
||||
|
||||
def test_get_available_products_respects_filters(session: Session):
|
||||
regular_med = Product(
|
||||
id=uuid.uuid4(),
|
||||
name="Tretinoin",
|
||||
category="serum",
|
||||
is_medication=True,
|
||||
brand="Test",
|
||||
recommended_time="pm",
|
||||
leave_on=True,
|
||||
product_effect_profile={},
|
||||
)
|
||||
minoxidil_med = Product(
|
||||
id=uuid.uuid4(),
|
||||
name="Minoxidil 5%",
|
||||
category="serum",
|
||||
is_medication=True,
|
||||
brand="Test",
|
||||
recommended_time="both",
|
||||
leave_on=True,
|
||||
product_effect_profile={},
|
||||
)
|
||||
am_product = Product(
|
||||
id=uuid.uuid4(),
|
||||
name="AM SPF",
|
||||
category="spf",
|
||||
brand="Test",
|
||||
recommended_time="am",
|
||||
leave_on=True,
|
||||
product_effect_profile={},
|
||||
)
|
||||
pm_product = Product(
|
||||
id=uuid.uuid4(),
|
||||
name="PM Cream",
|
||||
category="moisturizer",
|
||||
brand="Test",
|
||||
recommended_time="pm",
|
||||
leave_on=True,
|
||||
product_effect_profile={},
|
||||
)
|
||||
session.add_all([regular_med, minoxidil_med, am_product, pm_product])
|
||||
session.commit()
|
||||
|
||||
am_available = _get_available_products(session, time_filter="am")
|
||||
am_names = {p.name for p in am_available}
|
||||
assert "Tretinoin" not in am_names
|
||||
assert "Minoxidil 5%" in am_names
|
||||
assert "AM SPF" in am_names
|
||||
assert "PM Cream" not in am_names
|
||||
|
||||
|
||||
def test_build_inci_tool_handler_returns_only_available_ids(session: Session):
|
||||
available = Product(
|
||||
id=uuid.uuid4(),
|
||||
name="Available",
|
||||
category="serum",
|
||||
brand="Test",
|
||||
recommended_time="both",
|
||||
leave_on=True,
|
||||
inci=["Water", "Niacinamide"],
|
||||
product_effect_profile={},
|
||||
)
|
||||
unavailable = Product(
|
||||
id=uuid.uuid4(),
|
||||
name="Unavailable",
|
||||
category="serum",
|
||||
brand="Test",
|
||||
recommended_time="both",
|
||||
leave_on=True,
|
||||
inci=["Water", "Retinol"],
|
||||
product_effect_profile={},
|
||||
)
|
||||
|
||||
handler = _build_inci_tool_handler([available])
|
||||
payload = handler(
|
||||
{
|
||||
"product_ids": [
|
||||
str(available.id),
|
||||
str(unavailable.id),
|
||||
str(available.id),
|
||||
123,
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert "products" in payload
|
||||
products = payload["products"]
|
||||
assert len(products) == 1
|
||||
assert products[0]["id"] == str(available.id)
|
||||
assert products[0]["name"] == "Available"
|
||||
assert products[0]["inci"] == ["Water", "Niacinamide"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue