innercontext/backend/innercontext/api/products.py

1457 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# pyright: reportImportCycles=false, reportIncompatibleVariableOverride=false
import json
import logging
from datetime import date
from typing import Any, Literal, Optional, cast
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query
from google.genai import types as genai_types
from pydantic import BaseModel as PydanticBase
from pydantic import ValidationError
from sqlalchemy import inspect
from sqlalchemy import select as sa_select
from sqlmodel import Field, Session, SQLModel, col, select
from db import get_session
from innercontext.api.auth_deps import get_current_user
from innercontext.api.authz import is_product_visible
from innercontext.api.llm_context import build_user_profile_context
from innercontext.api.product_llm_tools import (
PRODUCT_DETAILS_FUNCTION_DECLARATION,
)
from innercontext.api.product_llm_tools import (
_extract_requested_product_ids as _shared_extract_requested_product_ids,
)
from innercontext.api.product_llm_tools import (
build_last_used_on_by_product,
build_product_details_tool_handler,
)
from innercontext.api.utils import get_or_404, get_owned_or_404_admin_override
from innercontext.auth import CurrentUser
from innercontext.llm import (
call_gemini,
call_gemini_with_function_tools,
get_creative_config,
get_extraction_config,
)
from innercontext.llm_safety import sanitize_user_input
from innercontext.models import (
Product,
ProductBase,
ProductCategory,
ProductInventory,
ProductPublic,
ProductWithInventory,
SkinConcern,
SkinConditionSnapshot,
)
from innercontext.models import Role
from innercontext.models.ai_log import AICallLog
from innercontext.models.api_metadata import ResponseMetadata, TokenMetrics
from innercontext.models.enums import (
AbsorptionSpeed,
DayTime,
PriceTier,
RemainingLevel,
SkinType,
TextureType,
)
from innercontext.models.product import (
ActiveIngredient,
ProductContext,
ProductEffectProfile,
)
from innercontext.services.fx import convert_to_pln
from innercontext.services.pricing_jobs import enqueue_pricing_recalc
from innercontext.validators import ProductParseValidator, ShoppingValidator
from innercontext.validators.shopping_validator import ShoppingValidationContext
logger = logging.getLogger(__name__)
router = APIRouter()
def _is_inventory_visible_to_user(
inventory: ProductInventory,
session: Session,
current_user: CurrentUser,
) -> bool:
if current_user.role is Role.ADMIN:
return True
if inventory.user_id == current_user.user_id:
return True
if not inventory.is_household_shared:
return False
if inventory.user_id is None:
return False
return is_product_visible(session, inventory.product_id, current_user)
def _visible_inventory_for_product(
inventories: list[ProductInventory],
session: Session,
current_user: CurrentUser,
) -> list[ProductInventory]:
return [
inventory
for inventory in inventories
if _is_inventory_visible_to_user(inventory, session, current_user)
]
def _build_response_metadata(session: Session, log_id: Any) -> ResponseMetadata | None:
"""Build ResponseMetadata from AICallLog for Phase 3 observability."""
if not log_id:
return None
log = session.get(AICallLog, log_id)
if not log:
return None
token_metrics = None
if (
log.prompt_tokens is not None
and log.completion_tokens is not None
and log.total_tokens is not None
):
token_metrics = TokenMetrics(
prompt_tokens=log.prompt_tokens,
completion_tokens=log.completion_tokens,
thoughts_tokens=log.thoughts_tokens,
total_tokens=log.total_tokens,
)
return ResponseMetadata(
model_used=log.model,
duration_ms=log.duration_ms or 0,
reasoning_chain=log.reasoning_chain,
token_metrics=token_metrics,
)
PricingSource = Literal["category", "fallback", "insufficient_data"]
PricingOutput = tuple[PriceTier | None, float | None, PricingSource | None]
PricingOutputs = dict[UUID, PricingOutput]
# ---------------------------------------------------------------------------
# Request / response schemas
# ---------------------------------------------------------------------------
class ProductCreate(ProductBase):
pass
class ProductUpdate(SQLModel):
name: Optional[str] = None
brand: Optional[str] = None
line_name: Optional[str] = None
sku: Optional[str] = None
url: Optional[str] = None
barcode: Optional[str] = None
category: Optional[ProductCategory] = None
recommended_time: Optional[DayTime] = None
texture: Optional[TextureType] = None
absorption_speed: Optional[AbsorptionSpeed] = None
leave_on: Optional[bool] = None
price_amount: Optional[float] = None
price_currency: Optional[str] = None
size_ml: Optional[float] = None
pao_months: Optional[int] = None
inci: Optional[list[str]] = None
actives: Optional[list[ActiveIngredient]] = None
recommended_for: Optional[list[SkinType]] = None
targets: Optional[list[SkinConcern]] = None
fragrance_free: Optional[bool] = None
essential_oils_free: Optional[bool] = None
alcohol_denat_free: Optional[bool] = None
pregnancy_safe: Optional[bool] = None
product_effect_profile: Optional[ProductEffectProfile] = None
ph_min: Optional[float] = None
ph_max: Optional[float] = None
context_rules: Optional[ProductContext] = None
min_interval_hours: Optional[int] = None
max_frequency_per_week: Optional[int] = None
is_medication: Optional[bool] = None
is_tool: Optional[bool] = None
needle_length_mm: Optional[float] = None
personal_tolerance_notes: Optional[str] = None
class ProductParseRequest(SQLModel):
text: str
class ProductParseResponse(SQLModel):
name: Optional[str] = None
brand: Optional[str] = None
line_name: Optional[str] = None
sku: Optional[str] = None
url: Optional[str] = None
barcode: Optional[str] = None
category: Optional[ProductCategory] = None
recommended_time: Optional[DayTime] = None
texture: Optional[TextureType] = None
absorption_speed: Optional[AbsorptionSpeed] = None
leave_on: Optional[bool] = None
price_amount: Optional[float] = None
price_currency: Optional[str] = None
size_ml: Optional[float] = None
pao_months: Optional[int] = None
inci: Optional[list[str]] = None
actives: Optional[list[ActiveIngredient]] = None
recommended_for: Optional[list[SkinType]] = None
targets: Optional[list[SkinConcern]] = None
fragrance_free: Optional[bool] = None
essential_oils_free: Optional[bool] = None
alcohol_denat_free: Optional[bool] = None
pregnancy_safe: Optional[bool] = None
product_effect_profile: Optional[ProductEffectProfile] = None
ph_min: Optional[float] = None
ph_max: Optional[float] = None
context_rules: Optional[ProductContext] = None
min_interval_hours: Optional[int] = None
max_frequency_per_week: Optional[int] = None
is_medication: Optional[bool] = None
is_tool: Optional[bool] = None
needle_length_mm: Optional[float] = None
class ProductListItem(SQLModel):
id: UUID
name: str
brand: str
category: ProductCategory
recommended_time: DayTime
targets: list[SkinConcern] = Field(default_factory=list)
is_owned: bool
price_tier: PriceTier | None = None
price_per_use_pln: float | None = None
price_tier_source: PricingSource | None = None
class AIActiveIngredient(ActiveIngredient):
# Gemini API rejects int-enum values in response_schema; override with plain int.
strength_level: Optional[int] = None # pyright: ignore[reportIncompatibleVariableOverride]
irritation_potential: Optional[int] = None # pyright: ignore[reportIncompatibleVariableOverride]
class ProductParseLLMResponse(ProductParseResponse):
# Gemini response schema currently requires enum values to be strings.
# Strength fields are numeric in our domain (1-3), so keep them as ints here
# and convert via ProductParseResponse validation afterward.
actives: Optional[list[AIActiveIngredient]] = None # pyright: ignore[reportIncompatibleVariableOverride]
class InventoryCreate(SQLModel):
is_opened: bool = False
opened_at: Optional[date] = None
finished_at: Optional[date] = None
expiry_date: Optional[date] = None
remaining_level: Optional[RemainingLevel] = None
notes: Optional[str] = None
class InventoryUpdate(SQLModel):
is_opened: Optional[bool] = None
opened_at: Optional[date] = None
finished_at: Optional[date] = None
expiry_date: Optional[date] = None
remaining_level: Optional[RemainingLevel] = None
notes: Optional[str] = None
def _remaining_level_rank(level: RemainingLevel | str | None) -> int:
if level in (RemainingLevel.NEARLY_EMPTY, "nearly_empty"):
return 0
if level in (RemainingLevel.LOW, "low"):
return 1
if level in (RemainingLevel.MEDIUM, "medium"):
return 2
if level in (RemainingLevel.HIGH, "high"):
return 3
return 99
_STAPLE_CATEGORIES = {"cleanser", "moisturizer", "spf"}
_OCCASIONAL_CATEGORIES = {"exfoliant", "mask", "spot_treatment", "tool"}
def _compute_days_since_last_used(
last_used_on: date | None, reference_date: date
) -> int | None:
if last_used_on is None:
return None
return max((reference_date - last_used_on).days, 0)
def _compute_replenishment_score(
*,
has_stock: bool,
sealed_backup_count: int,
lowest_remaining_level: str | None,
days_since_last_used: int | None,
category: ProductCategory | str,
) -> dict[str, object]:
score = 0
reason_codes: list[str] = []
category_value = _ev(category)
if not has_stock:
score = 90
reason_codes.append("out_of_stock")
elif sealed_backup_count > 0:
score = 10
reason_codes.append("has_sealed_backup")
elif lowest_remaining_level == "nearly_empty":
score = 80
reason_codes.append("nearly_empty_opened")
elif lowest_remaining_level == "low":
score = 60
reason_codes.append("low_opened")
elif lowest_remaining_level == "medium":
score = 25
elif lowest_remaining_level == "high":
score = 5
else:
reason_codes.append("insufficient_remaining_data")
if days_since_last_used is not None:
if days_since_last_used <= 3:
score += 20
reason_codes.append("recently_used")
elif days_since_last_used <= 7:
score += 12
reason_codes.append("recently_used")
elif days_since_last_used <= 14:
score += 6
elif days_since_last_used <= 30:
pass
elif days_since_last_used <= 60:
score -= 10
reason_codes.append("stale_usage")
else:
score -= 20
reason_codes.append("stale_usage")
if category_value in _STAPLE_CATEGORIES:
score += 15
reason_codes.append("staple_category")
elif category_value in _OCCASIONAL_CATEGORIES:
score -= 10
reason_codes.append("occasional_category")
elif category_value == "serum":
score += 5
if sealed_backup_count > 0 and has_stock:
score = min(score, 15)
if (
days_since_last_used is not None
and days_since_last_used > 60
and category_value not in _STAPLE_CATEGORIES
):
score = min(score, 25)
if (
lowest_remaining_level is None
and has_stock
and (days_since_last_used is None or days_since_last_used > 14)
):
score = min(score, 20)
score = max(0, min(score, 100))
if score >= 80:
priority_hint = "high"
elif score >= 50:
priority_hint = "medium"
elif score >= 25:
priority_hint = "low"
else:
priority_hint = "none"
return {
"replenishment_score": score,
"replenishment_priority_hint": priority_hint,
"repurchase_candidate": priority_hint != "none",
"replenishment_reason_codes": reason_codes,
}
def _summarize_inventory_state(entries: list[ProductInventory]) -> dict[str, object]:
active_entries = [entry for entry in entries if entry.finished_at is None]
opened_entries = [entry for entry in active_entries if entry.is_opened]
sealed_entries = [entry for entry in active_entries if not entry.is_opened]
opened_levels = [
_ev(entry.remaining_level)
for entry in opened_entries
if entry.remaining_level is not None
]
opened_levels_sorted = sorted(
opened_levels,
key=_remaining_level_rank,
)
lowest_opened_level = opened_levels_sorted[0] if opened_levels_sorted else None
stock_state = "healthy"
if not active_entries:
stock_state = "out_of_stock"
elif sealed_entries:
stock_state = "healthy"
elif lowest_opened_level == "nearly_empty":
stock_state = "urgent"
elif lowest_opened_level == "low":
stock_state = "low"
elif lowest_opened_level == "medium":
stock_state = "monitor"
replenishment_signal = "none"
if stock_state == "out_of_stock":
replenishment_signal = "out_of_stock"
elif stock_state == "urgent":
replenishment_signal = "urgent"
elif stock_state == "low":
replenishment_signal = "soon"
return {
"has_stock": bool(active_entries),
"active_count": len(active_entries),
"opened_count": len(opened_entries),
"sealed_backup_count": len(sealed_entries),
"opened_levels": opened_levels_sorted,
"lowest_opened_level": lowest_opened_level,
"stock_state": stock_state,
"replenishment_signal": replenishment_signal,
}
# ---------------------------------------------------------------------------
# Shopping suggestion schemas
# ---------------------------------------------------------------------------
class ProductSuggestion(PydanticBase):
category: ProductCategory
product_type: str
priority: Literal["high", "medium", "low"]
key_ingredients: list[str]
target_concerns: list[SkinConcern]
recommended_time: DayTime
frequency: str
short_reason: str
reason_to_buy_now: str
reason_not_needed_if_budget_tight: str | None = None
fit_with_current_routine: str
usage_cautions: list[str]
class ShoppingSuggestionResponse(PydanticBase):
suggestions: list[ProductSuggestion]
reasoning: str
# Phase 3: Observability fields
validation_warnings: list[str] | None = None
auto_fixes_applied: list[str] | None = None
response_metadata: "ResponseMetadata | None" = None
class _ProductSuggestionOut(PydanticBase):
category: ProductCategory
product_type: str
priority: Literal["high", "medium", "low"]
key_ingredients: list[str]
target_concerns: list[SkinConcern]
recommended_time: DayTime
frequency: str
short_reason: str
reason_to_buy_now: str
reason_not_needed_if_budget_tight: str | None = None
fit_with_current_routine: str
usage_cautions: list[str]
class _ShoppingSuggestionsOut(PydanticBase):
suggestions: list[_ProductSuggestionOut]
reasoning: str
# ---------------------------------------------------------------------------
# Pricing helpers
# ---------------------------------------------------------------------------
_MIN_PRODUCTS_FOR_PRICE_TIER = 8
_MIN_CATEGORY_SIZE_FOR_FALLBACK = 4
_ESTIMATED_AMOUNT_PER_USE: dict[ProductCategory, float] = {
ProductCategory.CLEANSER: 1.5,
ProductCategory.TONER: 1.5,
ProductCategory.ESSENCE: 1.0,
ProductCategory.SERUM: 0.35,
ProductCategory.MOISTURIZER: 0.8,
ProductCategory.SPF: 1.2,
ProductCategory.MASK: 2.5,
ProductCategory.EXFOLIANT: 0.7,
ProductCategory.HAIR_TREATMENT: 1.0,
ProductCategory.SPOT_TREATMENT: 0.1,
ProductCategory.OIL: 0.35,
}
def _estimated_amount_per_use(category: ProductCategory) -> float | None:
return _ESTIMATED_AMOUNT_PER_USE.get(category)
def _price_per_use_pln(product: Product) -> float | None:
if product.price_amount is None or product.price_currency is None:
return None
amount_per_use = _estimated_amount_per_use(product.category)
if amount_per_use is None or amount_per_use <= 0:
return None
pack_amount = product.size_ml
if pack_amount is None or pack_amount <= 0:
return None
uses_per_pack = pack_amount / amount_per_use
if uses_per_pack <= 0:
return None
price_pln = convert_to_pln(product.price_amount, product.price_currency.upper())
if price_pln is None:
return None
return price_pln / uses_per_pack
def _percentile(sorted_values: list[float], fraction: float) -> float:
if not sorted_values:
raise ValueError("sorted_values cannot be empty")
if len(sorted_values) == 1:
return sorted_values[0]
position = (len(sorted_values) - 1) * fraction
low = int(position)
high = min(low + 1, len(sorted_values) - 1)
weight = position - low
return sorted_values[low] * (1 - weight) + sorted_values[high] * weight
def _tier_from_thresholds(
value: float,
*,
p25: float,
p50: float,
p75: float,
) -> PriceTier:
if value <= p25:
return PriceTier.BUDGET
if value <= p50:
return PriceTier.MID
if value <= p75:
return PriceTier.PREMIUM
return PriceTier.LUXURY
def _thresholds(values: list[float]) -> tuple[float, float, float]:
sorted_vals = sorted(values)
return (
_percentile(sorted_vals, 0.25),
_percentile(sorted_vals, 0.50),
_percentile(sorted_vals, 0.75),
)
def _compute_pricing_outputs(
products: list[Product],
) -> PricingOutputs:
price_per_use_by_id: dict[UUID, float] = {}
grouped: dict[ProductCategory, list[tuple[UUID, float]]] = {}
for product in products:
ppu = _price_per_use_pln(product)
if ppu is None:
continue
price_per_use_by_id[product.id] = ppu
grouped.setdefault(product.category, []).append((product.id, ppu))
outputs: PricingOutputs = {
p.id: (
None,
price_per_use_by_id.get(p.id),
"insufficient_data" if p.id in price_per_use_by_id else None,
)
for p in products
}
fallback_rows: list[tuple[UUID, float]] = []
for product in products:
ppu = price_per_use_by_id.get(product.id)
if ppu is None:
continue
if product.is_tool or product.is_medication or not product.leave_on:
continue
fallback_rows.append((product.id, ppu))
fallback_thresholds: tuple[float, float, float] | None = None
if len(fallback_rows) >= _MIN_PRODUCTS_FOR_PRICE_TIER:
fallback_thresholds = _thresholds([ppu for _, ppu in fallback_rows])
for category_rows in grouped.values():
if len(category_rows) < _MIN_PRODUCTS_FOR_PRICE_TIER:
if (
len(category_rows) >= _MIN_CATEGORY_SIZE_FOR_FALLBACK
and fallback_thresholds is not None
):
p25, p50, p75 = fallback_thresholds
for product_id, ppu in category_rows:
tier = _tier_from_thresholds(ppu, p25=p25, p50=p50, p75=p75)
outputs[product_id] = (tier, ppu, "fallback")
continue
p25, p50, p75 = _thresholds([ppu for _, ppu in category_rows])
for product_id, ppu in category_rows:
tier = _tier_from_thresholds(ppu, p25=p25, p50=p50, p75=p75)
outputs[product_id] = (tier, ppu, "category")
return outputs
# ---------------------------------------------------------------------------
# Product routes
# ---------------------------------------------------------------------------
@router.get("", response_model=list[ProductWithInventory])
def list_products(
category: Optional[ProductCategory] = None,
brand: Optional[str] = None,
targets: Optional[list[SkinConcern]] = Query(default=None),
is_medication: Optional[bool] = None,
is_tool: Optional[bool] = None,
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
stmt = select(Product)
if category is not None:
stmt = stmt.where(Product.category == category)
if brand is not None:
stmt = stmt.where(Product.brand == brand)
if is_medication is not None:
stmt = stmt.where(Product.is_medication == is_medication)
if is_tool is not None:
stmt = stmt.where(Product.is_tool == is_tool)
products = list(session.exec(stmt).all())
if current_user.role is not Role.ADMIN:
products = [
product
for product in products
if is_product_visible(session, product.id, current_user)
]
# Filter by targets (JSON column — done in Python)
if targets:
target_values = {t.value for t in targets}
products = [
p
for p in products
if any(
(t.value if hasattr(t, "value") else t) in target_values
for t in (p.targets or [])
)
]
# Bulk-load inventory for all products in one query
product_ids = [p.id for p in products]
inventory_rows = (
session.exec(
select(ProductInventory).where(
col(ProductInventory.product_id).in_(product_ids)
)
).all()
if product_ids
else []
)
inv_by_product: dict[UUID, list[ProductInventory]] = {}
for inv in inventory_rows:
inv_by_product.setdefault(inv.product_id, []).append(inv)
results = []
for p in products:
r = ProductWithInventory.model_validate(p, from_attributes=True)
r.inventory = _visible_inventory_for_product(
inv_by_product.get(p.id, []),
session,
current_user,
)
results.append(r)
return results
@router.post("", response_model=ProductPublic, status_code=201)
def create_product(
data: ProductCreate,
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
payload = data.model_dump()
if payload.get("price_currency"):
payload["price_currency"] = str(payload["price_currency"]).upper()
product_id = uuid4()
product = Product(
id=product_id,
user_id=current_user.user_id,
short_id=str(product_id)[:8],
**payload,
)
session.add(product)
enqueue_pricing_recalc(session)
session.commit()
session.refresh(product)
return product
def _product_parse_system_prompt() -> str:
return """\
You are a skincare and cosmetics product data extraction expert. \
Given raw text (product page copy, ingredient list, label scan, etc.), \
extract structured product data and return it as a single JSON object.
RULES:
- Return ONLY raw JSON — no markdown code fences, no explanation, no preamble, no indentation or extra whitespace (minified).
- Omit any field you cannot confidently determine from the text. Do not guess.
- All enum values must exactly match the allowed strings listed below.
- For INCI lists: return each ingredient as a separate string in the array, \
preserving standard INCI names exactly as they appear.
- For actives: extract name, concentration (numeric, 0100), functions \
(use the allowed strings), and strength/irritation level if inferable.
- For effect_profile scores (05 int): ALWAYS return the full product_effect_profile \
object with all 13 fields. Infer each score from ingredient activity and product claims. \
Use 0 only when you truly have no basis for an estimate.
- For pH: extract from explicit mention (e.g. "pH 5.5", "pH range 4.05.0"). \
Do not infer from ingredients alone.
- For context_rules: infer from usage instructions and ingredient interactions \
(e.g. "do not use with AHAs" → safe_after_acids: false).
- fragrance_free / essential_oils_free / alcohol_denat_free: infer from INCI \
or explicit claims. Fragrance = "Parfum" or "Fragrance" in INCI → fragrance_free: false.
- For leave_on: true = leave-on treatment, false = rinse-off (cleanser, mask to rinse).
- recommended_time: "am" if contains SPF or vitamin C; "pm" if retinoid/retinol; \
"both" otherwise (when unclear, use "both").
ENUM ALLOWED VALUES (use ONLY these exact strings):
category: "cleanser" | "toner" | "essence" | "serum" | "moisturizer" | "spf" | \
"mask" | "exfoliant" | "hair_treatment" | "tool" | "spot_treatment" | "oil"
recommended_time: "am" | "pm" | "both"
texture: "watery" | "gel" | "emulsion" | "cream" | "oil" | "balm" | "foam" | "fluid"
absorption_speed: "very_fast" | "fast" | "moderate" | "slow" | "very_slow"
recommended_for (array, pick applicable):
"dry" | "oily" | "combination" | "sensitive" | "normal" | "acne_prone"
targets (array, pick applicable):
"acne" | "rosacea" | "hyperpigmentation" | "aging" | "dehydration" | "redness" | \
"damaged_barrier" | "pore_visibility" | "uneven_texture" | "hair_growth" | "sebum_excess"
actives[].functions (array, pick applicable):
"humectant" | "emollient" | "occlusive" | "exfoliant_aha" | "exfoliant_bha" | \
"exfoliant_pha" | "retinoid" | "antioxidant" | "soothing" | "barrier_support" | \
"brightening" | "anti_acne" | "ceramide" | "niacinamide" | "sunscreen" | "peptide" | \
"hair_growth_stimulant" | "prebiotic" | "vitamin_c" | "anti_aging"
actives[].strength_level: 1 (low) | 2 (medium) | 3 (high)
actives[].irritation_potential: 1 (low) | 2 (medium) | 3 (high)
OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
{
"name": string,
"brand": string,
"line_name": string,
"sku": string,
"url": string,
"barcode": string,
"category": string,
"recommended_time": string,
"texture": string,
"absorption_speed": string,
"leave_on": boolean,
"price_amount": number,
"price_currency": string,
"size_ml": number,
"pao_months": integer,
"inci": [string, ...],
"actives": [
{
"name": string,
"percent": number,
"functions": [string, ...],
"strength_level": 1|2|3,
"irritation_potential": 1|2|3
}
],
"recommended_for": [string, ...],
"targets": [string, ...],
"fragrance_free": boolean,
"essential_oils_free": boolean,
"alcohol_denat_free": boolean,
"pregnancy_safe": boolean,
"product_effect_profile": {
"hydration_immediate": integer (0-5),
"hydration_long_term": integer (0-5),
"barrier_repair_strength": integer (0-5),
"soothing_strength": integer (0-5),
"exfoliation_strength": integer (0-5),
"retinoid_strength": integer (0-5),
"irritation_risk": integer (0-5),
"comedogenic_risk": integer (0-5),
"barrier_disruption_risk": integer (0-5),
"dryness_risk": integer (0-5),
"brightening_strength": integer (0-5),
"anti_acne_strength": integer (0-5),
"anti_aging_strength": integer (0-5)
},
"ph_min": number,
"ph_max": number,
"context_rules": {
"safe_after_shaving": boolean,
"safe_after_acids": boolean,
"safe_after_retinoids": boolean,
"safe_with_compromised_barrier": boolean,
"low_uv_only": boolean
},
"min_interval_hours": integer,
"max_frequency_per_week": integer,
"is_medication": boolean,
"is_tool": boolean,
"needle_length_mm": number
}
"""
@router.post("/parse-text", response_model=ProductParseResponse)
def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
# Phase 1: Sanitize input text
sanitized_text = sanitize_user_input(data.text, max_length=10000)
response, log_id = call_gemini(
endpoint="products/parse-text",
contents=f"Extract product data from this text:\n\n{sanitized_text}",
config=get_extraction_config(
system_instruction=_product_parse_system_prompt(),
response_schema=ProductParseLLMResponse,
max_output_tokens=16384,
),
user_input=sanitized_text,
)
raw = response.text
if not raw:
raise HTTPException(status_code=502, detail="LLM returned an empty response")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
try:
llm_parsed = ProductParseLLMResponse.model_validate(parsed)
# Phase 1: Validate the parsed product data
validator = ProductParseValidator()
validation_result = validator.validate(llm_parsed)
if not validation_result.is_valid:
logger.error(f"Product parse validation failed: {validation_result.errors}")
raise HTTPException(
status_code=502,
detail=f"Parsed product data failed validation: {'; '.join(validation_result.errors)}",
)
if validation_result.warnings:
logger.warning(f"Product parse warnings: {validation_result.warnings}")
return ProductParseResponse.model_validate(llm_parsed.model_dump())
except ValidationError as e:
raise HTTPException(status_code=422, detail=e.errors())
@router.get("/summary", response_model=list[ProductListItem])
def list_products_summary(
category: Optional[ProductCategory] = None,
brand: Optional[str] = None,
targets: Optional[list[SkinConcern]] = Query(default=None),
is_medication: Optional[bool] = None,
is_tool: Optional[bool] = None,
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
product_table = inspect(Product).local_table
stmt = sa_select(
product_table.c.id,
product_table.c.user_id,
product_table.c.name,
product_table.c.brand,
product_table.c.category,
product_table.c.recommended_time,
product_table.c.targets,
product_table.c.price_tier,
product_table.c.price_per_use_pln,
product_table.c.price_tier_source,
)
if category is not None:
stmt = stmt.where(product_table.c.category == category)
if brand is not None:
stmt = stmt.where(product_table.c.brand == brand)
if is_medication is not None:
stmt = stmt.where(product_table.c.is_medication == is_medication)
if is_tool is not None:
stmt = stmt.where(product_table.c.is_tool == is_tool)
rows = list(session.execute(stmt).all())
if current_user.role is not Role.ADMIN:
rows = [
row for row in rows if is_product_visible(session, row[0], current_user)
]
if targets:
target_values = {t.value for t in targets}
rows = [
row
for row in rows
if any(
(t.value if hasattr(t, "value") else t) in target_values
for t in (row[5] or [])
)
]
results: list[ProductListItem] = []
for row in rows:
(
product_id,
product_user_id,
name,
brand_value,
category_value,
recommended_time,
row_targets,
price_tier,
price_per_use_pln,
price_tier_source,
) = row
results.append(
ProductListItem(
id=product_id,
name=name,
brand=brand_value,
category=category_value,
recommended_time=recommended_time,
targets=row_targets or [],
is_owned=product_user_id == current_user.user_id,
price_tier=price_tier,
price_per_use_pln=price_per_use_pln,
price_tier_source=price_tier_source,
)
)
return results
@router.get("/{product_id}", response_model=ProductWithInventory)
def get_product(
product_id: UUID,
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
product = get_or_404(session, Product, product_id)
if not is_product_visible(session, product_id, current_user):
raise HTTPException(status_code=404, detail="Product not found")
inventory = session.exec(
select(ProductInventory).where(ProductInventory.product_id == product_id)
).all()
result = ProductWithInventory.model_validate(product, from_attributes=True)
result.inventory = _visible_inventory_for_product(
list(inventory), session, current_user
)
return result
@router.patch("/{product_id}", response_model=ProductPublic)
def update_product(
product_id: UUID,
data: ProductUpdate,
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
product = get_owned_or_404_admin_override(
session, Product, product_id, current_user
)
patch_data = data.model_dump(exclude_unset=True)
if patch_data.get("price_currency"):
patch_data["price_currency"] = str(patch_data["price_currency"]).upper()
for key, value in patch_data.items():
setattr(product, key, value)
session.add(product)
enqueue_pricing_recalc(session)
session.commit()
session.refresh(product)
return ProductPublic.model_validate(product, from_attributes=True)
@router.delete("/{product_id}", status_code=204)
def delete_product(
product_id: UUID,
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
product = get_owned_or_404_admin_override(
session, Product, product_id, current_user
)
session.delete(product)
enqueue_pricing_recalc(session)
session.commit()
# ---------------------------------------------------------------------------
# Product inventory sub-routes
# ---------------------------------------------------------------------------
@router.get("/{product_id}/inventory", response_model=list[ProductInventory])
def list_product_inventory(
product_id: UUID,
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
get_or_404(session, Product, product_id)
if not is_product_visible(session, product_id, current_user):
raise HTTPException(status_code=404, detail="Product not found")
stmt = select(ProductInventory).where(ProductInventory.product_id == product_id)
inventories = list(session.exec(stmt).all())
return _visible_inventory_for_product(inventories, session, current_user)
@router.post(
"/{product_id}/inventory", response_model=ProductInventory, status_code=201
)
def create_product_inventory(
product_id: UUID,
data: InventoryCreate,
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
product = get_owned_or_404_admin_override(
session, Product, product_id, current_user
)
entry = ProductInventory(
id=uuid4(),
user_id=product.user_id or current_user.user_id,
product_id=product_id,
**data.model_dump(),
)
session.add(entry)
session.commit()
session.refresh(entry)
return entry
# ---------------------------------------------------------------------------
# Shopping suggestion
# ---------------------------------------------------------------------------
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 _build_shopping_context(
session: Session,
reference_date: date,
current_user: CurrentUser,
*,
products: list[Product] | None = None,
last_used_on_by_product: dict[str, date] | None = None,
) -> str:
profile_ctx = build_user_profile_context(
session,
reference_date=reference_date,
current_user=current_user,
)
snapshot = session.exec(
select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
)
).first()
skin_lines = ["STAN SKÓRY:"]
if snapshot:
skin_lines.append(f" Data: {snapshot.snapshot_date}")
skin_lines.append(f" Ogólny stan: {_ev(snapshot.overall_state)}")
skin_lines.append(f" Typ skóry: {_ev(snapshot.skin_type)}")
skin_lines.append(f" Nawilżenie: {snapshot.hydration_level}/5")
skin_lines.append(f" Wrażliwość: {snapshot.sensitivity_level}/5")
skin_lines.append(f" Bariera: {_ev(snapshot.barrier_state)}")
concerns = ", ".join(_ev(c) for c in (snapshot.active_concerns or []))
skin_lines.append(f" Aktywne problemy: {concerns or 'brak'}")
if snapshot.priorities:
skin_lines.append(f" Priorytety: {', '.join(snapshot.priorities)}")
else:
skin_lines.append(" (brak danych)")
if products is None:
products = _get_shopping_products(session)
product_ids = [p.id for p in products]
last_used_on_by_product = last_used_on_by_product or build_last_used_on_by_product(
session,
product_ids=product_ids,
)
inventory_rows = (
session.exec(
select(ProductInventory).where(
col(ProductInventory.product_id).in_(product_ids)
)
).all()
if product_ids
else []
)
inv_by_product: dict[UUID, list[ProductInventory]] = {}
for inv in inventory_rows:
inv_by_product.setdefault(inv.product_id, []).append(inv)
products_lines = ["POSIADANE PRODUKTY:"]
products_lines.append(
" Legenda: [✓] = aktywny zapas istnieje, [✗] = brak aktywnego zapasu"
)
products_lines.append(
" Pola: stock_state, sealed_backup_count, lowest_remaining_level, days_since_last_used, replenishment_score, replenishment_priority_hint, repurchase_candidate"
)
for p in products:
inventory_summary = _summarize_inventory_state(inv_by_product.get(p.id, []))
stock = "" if inventory_summary["has_stock"] else ""
last_used_on = last_used_on_by_product.get(str(p.id))
days_since_last_used = _compute_days_since_last_used(
last_used_on, reference_date
)
replenishment = _compute_replenishment_score(
has_stock=bool(inventory_summary["has_stock"]),
sealed_backup_count=cast(int, inventory_summary["sealed_backup_count"]),
lowest_remaining_level=(
str(inventory_summary["lowest_opened_level"])
if inventory_summary["lowest_opened_level"] is not None
else None
),
days_since_last_used=days_since_last_used,
category=p.category,
)
actives = _extract_active_names(p)
ep = p.product_effect_profile
if isinstance(ep, dict):
effects = {k.replace("_strength", ""): v for k, v in ep.items() if v >= 3}
else:
effects = {
k.replace("_strength", ""): v
for k, v in ep.model_dump().items()
if v >= 3
}
targets = [_ev(t) for t in (p.targets or [])]
product_header = f" [{stock}] id={p.id} {p.name}"
if p.brand:
product_header += f" ({p.brand})"
products_lines.append(product_header)
products_lines.append(f" category={_ev(p.category)}")
products_lines.append(f" targets={targets}")
if actives:
products_lines.append(f" actives={actives}")
if effects:
products_lines.append(f" effects={effects}")
products_lines.append(f" stock_state={inventory_summary['stock_state']}")
products_lines.append(f" active_count={inventory_summary['active_count']}")
products_lines.append(f" opened_count={inventory_summary['opened_count']}")
products_lines.append(
f" sealed_backup_count={inventory_summary['sealed_backup_count']}"
)
products_lines.append(
" lowest_remaining_level="
+ (
str(inventory_summary["lowest_opened_level"])
if inventory_summary["lowest_opened_level"] is not None
else "null"
)
)
products_lines.append(
" last_used_on=" + (last_used_on.isoformat() if last_used_on else "null")
)
products_lines.append(
" days_since_last_used="
+ (
str(days_since_last_used)
if days_since_last_used is not None
else "null"
)
)
products_lines.append(
f" replenishment_score={replenishment['replenishment_score']}"
)
products_lines.append(
" replenishment_priority_hint="
+ str(replenishment["replenishment_priority_hint"])
)
products_lines.append(
" repurchase_candidate="
+ ("true" if replenishment["repurchase_candidate"] else "false")
)
products_lines.append(
" reason_codes=" + str(replenishment["replenishment_reason_codes"])
)
return (
profile_ctx + "\n" + "\n".join(skin_lines) + "\n\n" + "\n".join(products_lines)
)
def _get_shopping_products(session: Session) -> list[Product]:
stmt = select(Product).where(col(Product.is_tool).is_(False))
products = session.exec(stmt).all()
return [p for p in products if not p.is_medication]
def _extract_active_names(product: Product) -> list[str]:
names: list[str] = []
for active in product.actives or []:
if isinstance(active, dict):
name = str(active.get("name") or "").strip()
else:
name = str(getattr(active, "name", "") or "").strip()
if not name:
continue
if name in names:
continue
names.append(name)
if len(names) >= 12:
break
return names
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)
_SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry.
Oceń dwie rzeczy: realne luki w rutynie oraz odkupy produktów, które warto uzupełnić teraz.
Działaj konserwatywnie: sugeruj tylko wtedy, gdy istnieje wyraźny powód praktyczny.
Najpierw rozważ luki w rutynie, potem odkupy.
Traktuj `replenishment_score`, `replenishment_priority_hint`, `repurchase_candidate`, `stock_state`, `lowest_remaining_level`, `sealed_backup_count`, `last_used_on` i `days_since_last_used` jako główne sygnały decyzji zakupowej.
`sealed_backup_count` odnosi się do zapasu tego produktu lub bardzo zbliżonego typu; inny produkt z tej samej kategorii obniża pilność tylko wtedy, gdy realistycznie pełni podobną funkcję w rutynie.
Jeśli zakup nie jest pilny dzięki alternatywie, wyjaśnij, czy chodzi o rzeczywisty zapas tego samego typu produktu, czy o funkcjonalny zamiennik z tej samej kategorii.
Jeśli istnieje sealed backup lub bardzo bliski funkcjonalny zamiennik, sugestia zwykle nie powinna mieć `priority=high`, chyba że potrzeba odkupu jest wyraźnie wyjątkowa.
`product_type` ma być krótką nazwą typu produktu i opisywać funkcję produktu, a nie opis marketingowy lub pełną specyfikację składu.
Przy odkupie możesz odwoływać się do konkretnych już posiadanych produktów, jeśli pomaga to uzasadnić decyzję. Przy lukach w rutynie sugeruj typy produktów, nie marki.
Uwzględniaj aktywne problemy skóry, miejsce produktu w rutynie, konflikty składników i bezpieczeństwo przy naruszonej barierze.
Pisz po polsku, językiem praktycznym i zakupowym. Unikaj nadmiernie medycznego lub diagnostycznego tonu.
Nie używaj w tekstach dla użytkownika surowych sygnałów systemowych ani dosłownych etykiet z warstwy danych, takich jak `id`, `score`, `status low` czy `poziom produktu jest niski`; opisuj naturalnie wniosek, np. jako kończący się zapas, niski zapas lub wysoką pilność odkupu.
Możesz zwrócić pustą listę `suggestions`, jeśli nie widzisz realnej potrzeby.
`target_concerns` musi używać wyłącznie wartości enumu `SkinConcern` poniżej. `priority` ustawiaj jako: high = wyraźna luka lub pilna potrzeba, medium = sensowne uzupełnienie, low = opcjonalny upgrade.
DOZWOLONE WARTOŚCI ENUMÓW:
- category: "cleanser" | "toner" | "essence" | "serum" | "moisturizer" | "spf" | "mask" | "exfoliant" | "hair_treatment" | "tool" | "spot_treatment" | "oil"
- target_concerns: "acne" | "rosacea" | "hyperpigmentation" | "aging" | "dehydration" | "redness" | "damaged_barrier" | "pore_visibility" | "uneven_texture" | "hair_growth" | "sebum_excess"
- recommended_time: "am" | "pm" | "both"
Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem."""
@router.post("/suggest", response_model=ShoppingSuggestionResponse)
def suggest_shopping(
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
reference_date = date.today()
shopping_products = _get_shopping_products(session)
last_used_on_by_product = build_last_used_on_by_product(
session,
product_ids=[p.id for p in shopping_products],
)
context = _build_shopping_context(
session,
reference_date=reference_date,
current_user=current_user,
products=shopping_products,
last_used_on_by_product=last_used_on_by_product,
)
prompt = (
"Przeanalizuj dane użytkownika i zaproponuj tylko te zakupy, które mają realny sens teraz.\n\n"
"Najpierw rozważ luki w rutynie, potem ewentualny odkup kończących się produktów.\n"
"Jeśli produkt już istnieje, ale ma niski zapas i jest nadal realnie używany, możesz zasugerować odkup tego typu produktu.\n"
"Jeśli produkt ma sealed backup albo nie był używany od dawna, zwykle nie sugeruj odkupu.\n\n"
f"{context}\n\n"
"NARZEDZIA:\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."
)
config = get_creative_config(
system_instruction=_SHOPPING_SYSTEM_PROMPT,
response_schema=_ShoppingSuggestionsOut,
max_output_tokens=8192,
).model_copy(
update={
"tools": [
genai_types.Tool(
function_declarations=[
PRODUCT_DETAILS_FUNCTION_DECLARATION,
]
)
],
"tool_config": genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode=genai_types.FunctionCallingConfigMode.AUTO,
)
),
}
)
function_handlers = {
"get_product_details": build_product_details_tool_handler(
shopping_products,
last_used_on_by_product=last_used_on_by_product,
),
}
try:
response, log_id = call_gemini_with_function_tools(
endpoint="products/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"
"- Zasugeruj tylko najbardziej bezpieczne i realistyczne typy produktow do uzupelnienia brakow,"
" unikaj agresywnych aktywnych przy niepelnych danych.\n"
)
response, log_id = call_gemini(
endpoint="products/suggest",
contents=conservative_prompt,
config=get_creative_config(
system_instruction=_SHOPPING_SYSTEM_PROMPT,
response_schema=_ShoppingSuggestionsOut,
max_output_tokens=8192,
),
user_input=conservative_prompt,
tool_trace={
"mode": "fallback_conservative",
"reason": "max_tool_roundtrips_exceeded",
},
)
raw = response.text
if not raw:
raise HTTPException(status_code=502, detail="LLM returned an empty response")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
try:
parsed_response = _ShoppingSuggestionsOut.model_validate(parsed)
except ValidationError as exc:
formatted_errors = "; ".join(
f"{'/'.join(str(part) for part in err['loc'])}: {err['msg']}"
for err in exc.errors()
)
raise HTTPException(
status_code=502,
detail=(
f"LLM returned invalid shopping suggestion schema: {formatted_errors}"
),
)
# Get products with inventory (those user already owns)
products_with_inventory_ids = session.exec(
select(ProductInventory.product_id).distinct()
).all()
shopping_context = ShoppingValidationContext(
owned_product_ids=set(products_with_inventory_ids),
valid_categories=set(ProductCategory),
valid_targets=set(SkinConcern),
)
# Phase 1: Validate the shopping suggestions
validator = ShoppingValidator()
# Build initial shopping response without metadata
shopping_response = ShoppingSuggestionResponse(
suggestions=[
ProductSuggestion.model_validate(s.model_dump())
for s in parsed_response.suggestions
],
reasoning=parsed_response.reasoning,
)
validation_result = validator.validate(shopping_response, shopping_context)
if not validation_result.is_valid:
logger.error(
f"Shopping suggestion validation failed: {validation_result.errors}"
)
raise HTTPException(
status_code=502,
detail=f"Generated shopping suggestions failed validation: {'; '.join(validation_result.errors)}",
)
# Phase 3: Add warnings, auto-fixes, and metadata to response
if validation_result.warnings:
logger.warning(f"Shopping suggestion warnings: {validation_result.warnings}")
shopping_response.validation_warnings = validation_result.warnings
if validation_result.auto_fixes:
shopping_response.auto_fixes_applied = validation_result.auto_fixes
shopping_response.response_metadata = _build_response_metadata(session, log_id)
return shopping_response