1457 lines
50 KiB
Python
1457 lines
50 KiB
Python
# 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, 0–100), functions \
|
||
(use the allowed strings), and strength/irritation level if inferable.
|
||
- For effect_profile scores (0–5 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.0–5.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
|