refactor(backend): consolidate product LLM function tools
This commit is contained in:
parent
b99b9ed68e
commit
40d26514a1
6 changed files with 172 additions and 380 deletions
133
backend/innercontext/api/product_llm_tools.py
Normal file
133
backend/innercontext/api/product_llm_tools.py
Normal file
|
|
@ -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"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -13,6 +13,11 @@ from sqlmodel import Field, Session, SQLModel, col, select
|
||||||
from db import get_session
|
from db import get_session
|
||||||
from innercontext.api.utils import get_or_404
|
from innercontext.api.utils import get_or_404
|
||||||
from innercontext.api.llm_context import build_user_profile_context
|
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 (
|
from innercontext.llm import (
|
||||||
call_gemini,
|
call_gemini,
|
||||||
call_gemini_with_function_tools,
|
call_gemini_with_function_tools,
|
||||||
|
|
@ -887,164 +892,8 @@ def _extract_active_names(product: Product) -> list[str]:
|
||||||
def _extract_requested_product_ids(
|
def _extract_requested_product_ids(
|
||||||
args: dict[str, object], max_ids: int = 8
|
args: dict[str, object], max_ids: int = 8
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
raw_ids = args.get("product_ids")
|
return _shared_extract_requested_product_ids(args, max_ids=max_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_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.
|
_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,
|
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"mogłyby uzupełnić rutynę pielęgnacyjną użytkownika.\n\n"
|
||||||
f"{context}\n\n"
|
f"{context}\n\n"
|
||||||
"NARZEDZIA:\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"
|
"- 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"
|
"- Grupuj UUID: staraj sie pobierac dane dla wielu produktow jednym wywolaniem.\n"
|
||||||
f"Zwróć wyłącznie JSON zgodny ze schematem."
|
f"Zwróć wyłącznie JSON zgodny ze schematem."
|
||||||
|
|
@ -1094,9 +943,7 @@ def suggest_shopping(session: Session = Depends(get_session)):
|
||||||
"tools": [
|
"tools": [
|
||||||
genai_types.Tool(
|
genai_types.Tool(
|
||||||
function_declarations=[
|
function_declarations=[
|
||||||
_INCI_FUNCTION_DECLARATION,
|
PRODUCT_DETAILS_FUNCTION_DECLARATION,
|
||||||
_SAFETY_RULES_FUNCTION_DECLARATION,
|
|
||||||
_ACTIVES_FUNCTION_DECLARATION,
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|
@ -1109,9 +956,7 @@ def suggest_shopping(session: Session = Depends(get_session)):
|
||||||
)
|
)
|
||||||
|
|
||||||
function_handlers = {
|
function_handlers = {
|
||||||
"get_product_inci": _build_inci_tool_handler(shopping_products),
|
"get_product_details": build_product_details_tool_handler(shopping_products),
|
||||||
"get_product_safety_rules": _build_safety_rules_tool_handler(shopping_products),
|
|
||||||
"get_product_actives": _build_actives_tool_handler(shopping_products),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ from sqlmodel import Field, Session, SQLModel, col, select
|
||||||
from db import get_session
|
from db import get_session
|
||||||
from innercontext.api.utils import get_or_404
|
from innercontext.api.utils import get_or_404
|
||||||
from innercontext.api.llm_context import build_user_profile_context
|
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 (
|
from innercontext.llm import (
|
||||||
call_gemini,
|
call_gemini,
|
||||||
call_gemini_with_function_tools,
|
call_gemini_with_function_tools,
|
||||||
|
|
@ -407,125 +412,6 @@ def _get_available_products(
|
||||||
return result
|
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]:
|
def _extract_active_names(product: Product) -> list[str]:
|
||||||
names: list[str] = []
|
names: list[str] = []
|
||||||
for a in product.actives or []:
|
for a in product.actives or []:
|
||||||
|
|
@ -543,66 +429,10 @@ def _extract_active_names(product: Product) -> list[str]:
|
||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
_INCI_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
|
def _extract_requested_product_ids(
|
||||||
name="get_product_inci",
|
args: dict[str, object], max_ids: int = 8
|
||||||
description=(
|
) -> list[str]:
|
||||||
"Return exact INCI ingredient lists for products identified by UUID from "
|
return _shared_extract_requested_product_ids(args, max_ids=max_ids)
|
||||||
"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 _build_objectives_context(include_minoxidil_beard: bool) -> str:
|
def _build_objectives_context(include_minoxidil_beard: bool) -> str:
|
||||||
|
|
@ -785,7 +615,7 @@ def suggest_routine(
|
||||||
"INPUT DATA:\n"
|
"INPUT DATA:\n"
|
||||||
f"{profile_ctx}\n{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{day_ctx}\n{products_ctx}\n{objectives_ctx}"
|
f"{profile_ctx}\n{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{day_ctx}\n{products_ctx}\n{objectives_ctx}"
|
||||||
"\nNARZEDZIA:\n"
|
"\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"
|
"- Wywoluj narzedzia tylko, gdy potrzebujesz detali do decyzji klinicznej/bezpieczenstwa.\n"
|
||||||
"- Staraj sie grupowac zapytania: podawaj wszystkie potrzebne UUID w jednym wywolaniu narzedzia.\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"
|
"- Nie zgaduj detali skladu i zasad bezpieczenstwa; jesli potrzebujesz szczegolow, wywolaj odpowiednie narzedzie.\n"
|
||||||
|
|
@ -803,9 +633,7 @@ def suggest_routine(
|
||||||
"tools": [
|
"tools": [
|
||||||
genai_types.Tool(
|
genai_types.Tool(
|
||||||
function_declarations=[
|
function_declarations=[
|
||||||
_INCI_FUNCTION_DECLARATION,
|
PRODUCT_DETAILS_FUNCTION_DECLARATION,
|
||||||
_SAFETY_RULES_FUNCTION_DECLARATION,
|
|
||||||
_ACTIVES_FUNCTION_DECLARATION,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|
@ -818,11 +646,7 @@ def suggest_routine(
|
||||||
)
|
)
|
||||||
|
|
||||||
function_handlers = {
|
function_handlers = {
|
||||||
"get_product_inci": _build_inci_tool_handler(available_products),
|
"get_product_details": build_product_details_tool_handler(available_products),
|
||||||
"get_product_safety_rules": _build_safety_rules_tool_handler(
|
|
||||||
available_products
|
|
||||||
),
|
|
||||||
"get_product_actives": _build_actives_tool_handler(available_products),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,9 @@ from unittest.mock import patch
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from innercontext.api.products import (
|
from innercontext.api.products import (
|
||||||
_build_actives_tool_handler,
|
|
||||||
_build_inci_tool_handler,
|
|
||||||
_build_safety_rules_tool_handler,
|
|
||||||
_build_shopping_context,
|
_build_shopping_context,
|
||||||
_extract_requested_product_ids,
|
_extract_requested_product_ids,
|
||||||
|
build_product_details_tool_handler,
|
||||||
)
|
)
|
||||||
from innercontext.models import (
|
from innercontext.models import (
|
||||||
Product,
|
Product,
|
||||||
|
|
@ -107,9 +105,7 @@ def test_suggest_shopping(client, session):
|
||||||
kwargs = mock_gemini.call_args.kwargs
|
kwargs = mock_gemini.call_args.kwargs
|
||||||
assert "USER PROFILE:" in kwargs["contents"]
|
assert "USER PROFILE:" in kwargs["contents"]
|
||||||
assert "function_handlers" in kwargs
|
assert "function_handlers" in kwargs
|
||||||
assert "get_product_inci" in kwargs["function_handlers"]
|
assert "get_product_details" in kwargs["function_handlers"]
|
||||||
assert "get_product_safety_rules" in kwargs["function_handlers"]
|
|
||||||
assert "get_product_actives" in kwargs["function_handlers"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_shopping_context_medication_skip(session: Session):
|
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)]}
|
payload = {"product_ids": [str(product.id)]}
|
||||||
|
|
||||||
inci_data = _build_inci_tool_handler([product])(payload)
|
details = build_product_details_tool_handler([product])(payload)
|
||||||
assert inci_data["products"][0]["inci"] == ["Water", "Niacinamide"]
|
assert details["products"][0]["inci"] == ["Water", "Niacinamide"]
|
||||||
|
assert details["products"][0]["actives"][0]["name"] == "Niacinamide"
|
||||||
actives_data = _build_actives_tool_handler([product])(payload)
|
assert "context_rules" in details["products"][0]
|
||||||
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]
|
|
||||||
|
|
|
||||||
|
|
@ -250,9 +250,7 @@ def test_suggest_routine(client, session):
|
||||||
kwargs = mock_gemini.call_args.kwargs
|
kwargs = mock_gemini.call_args.kwargs
|
||||||
assert "USER PROFILE:" in kwargs["contents"]
|
assert "USER PROFILE:" in kwargs["contents"]
|
||||||
assert "function_handlers" in kwargs
|
assert "function_handlers" in kwargs
|
||||||
assert "get_product_inci" in kwargs["function_handlers"]
|
assert "get_product_details" in kwargs["function_handlers"]
|
||||||
assert "get_product_safety_rules" in kwargs["function_handlers"]
|
|
||||||
assert "get_product_actives" in kwargs["function_handlers"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_batch(client, session):
|
def test_suggest_batch(client, session):
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,13 @@ from datetime import date, timedelta
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from innercontext.api.routines import (
|
from innercontext.api.routines import (
|
||||||
_build_actives_tool_handler,
|
|
||||||
_build_day_context,
|
_build_day_context,
|
||||||
_build_grooming_context,
|
_build_grooming_context,
|
||||||
_build_inci_tool_handler,
|
|
||||||
_build_objectives_context,
|
_build_objectives_context,
|
||||||
_build_products_context,
|
_build_products_context,
|
||||||
_build_recent_history,
|
_build_recent_history,
|
||||||
_build_safety_rules_tool_handler,
|
|
||||||
_build_skin_context,
|
_build_skin_context,
|
||||||
|
build_product_details_tool_handler,
|
||||||
_contains_minoxidil_text,
|
_contains_minoxidil_text,
|
||||||
_ev,
|
_ev,
|
||||||
_extract_active_names,
|
_extract_active_names,
|
||||||
|
|
@ -294,7 +292,9 @@ def test_get_available_products_respects_filters(session: Session):
|
||||||
assert "PM Cream" not in am_names
|
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(
|
available = Product(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
name="Available",
|
name="Available",
|
||||||
|
|
@ -316,7 +316,7 @@ def test_build_inci_tool_handler_returns_only_available_ids(session: Session):
|
||||||
product_effect_profile={},
|
product_effect_profile={},
|
||||||
)
|
)
|
||||||
|
|
||||||
handler = _build_inci_tool_handler([available])
|
handler = build_product_details_tool_handler([available])
|
||||||
payload = handler(
|
payload = handler(
|
||||||
{
|
{
|
||||||
"product_ids": [
|
"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]["id"] == str(available.id)
|
||||||
assert products[0]["name"] == "Available"
|
assert products[0]["name"] == "Available"
|
||||||
assert products[0]["inci"] == ["Water", "Niacinamide"]
|
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():
|
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"]
|
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(
|
p = Product(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
name="Detail Product",
|
name="Detail Product",
|
||||||
|
|
@ -388,8 +390,6 @@ def test_additional_tool_handlers_return_product_payloads(session: Session):
|
||||||
|
|
||||||
ids_payload = {"product_ids": [str(p.id)]}
|
ids_payload = {"product_ids": [str(p.id)]}
|
||||||
|
|
||||||
actives_out = _build_actives_tool_handler([p])(ids_payload)
|
details_out = build_product_details_tool_handler([p])(ids_payload)
|
||||||
assert actives_out["products"][0]["actives"][0]["name"] == "Niacinamide"
|
assert details_out["products"][0]["actives"][0]["name"] == "Niacinamide"
|
||||||
|
assert "context_rules" in details_out["products"][0]
|
||||||
safety_out = _build_safety_rules_tool_handler([p])(ids_payload)
|
|
||||||
assert "context_rules" in safety_out["products"][0]
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue