diff --git a/backend/alembic/versions/7c91e4b2af38_replace_price_tier_with_objective_price_fields.py b/backend/alembic/versions/7c91e4b2af38_replace_price_tier_with_objective_price_fields.py deleted file mode 100644 index 1030660..0000000 --- a/backend/alembic/versions/7c91e4b2af38_replace_price_tier_with_objective_price_fields.py +++ /dev/null @@ -1,45 +0,0 @@ -"""replace_price_tier_with_objective_price_fields - -Revision ID: 7c91e4b2af38 -Revises: e4f5a6b7c8d9 -Create Date: 2026-03-04 00:00:00.000000 - -""" - -from typing import Sequence, Union - -import sqlalchemy as sa - -from alembic import op - -revision: str = "7c91e4b2af38" -down_revision: Union[str, None] = "e4f5a6b7c8d9" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.add_column("products", sa.Column("price_amount", sa.Float(), nullable=True)) - op.add_column( - "products", sa.Column("price_currency", sa.String(length=3), nullable=True) - ) - op.drop_index(op.f("ix_products_price_tier"), table_name="products") - op.drop_column("products", "price_tier") - op.execute("DROP TYPE IF EXISTS pricetier") - - -def downgrade() -> None: - op.execute("CREATE TYPE pricetier AS ENUM ('BUDGET', 'MID', 'PREMIUM', 'LUXURY')") - op.add_column( - "products", - sa.Column( - "price_tier", - sa.Enum("BUDGET", "MID", "PREMIUM", "LUXURY", name="pricetier"), - nullable=True, - ), - ) - op.create_index( - op.f("ix_products_price_tier"), "products", ["price_tier"], unique=False - ) - op.drop_column("products", "price_currency") - op.drop_column("products", "price_amount") diff --git a/backend/alembic/versions/e4f5a6b7c8d9_drop_product_interaction_columns.py b/backend/alembic/versions/e4f5a6b7c8d9_drop_product_interaction_columns.py deleted file mode 100644 index c09a6b2..0000000 --- a/backend/alembic/versions/e4f5a6b7c8d9_drop_product_interaction_columns.py +++ /dev/null @@ -1,28 +0,0 @@ -"""drop_product_interaction_columns - -Revision ID: e4f5a6b7c8d9 -Revises: d3e4f5a6b7c8 -Create Date: 2026-03-04 00:00:00.000000 - -""" - -from typing import Sequence, Union - -import sqlalchemy as sa - -from alembic import op - -revision: str = "e4f5a6b7c8d9" -down_revision: Union[str, None] = "d3e4f5a6b7c8" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.drop_column("products", "incompatible_with") - op.drop_column("products", "synergizes_with") - - -def downgrade() -> None: - op.add_column("products", sa.Column("synergizes_with", sa.JSON(), nullable=True)) - op.add_column("products", sa.Column("incompatible_with", sa.JSON(), nullable=True)) diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index 36eef02..743979b 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -1,6 +1,6 @@ import json from datetime import date -from typing import Literal, Optional +from typing import Optional from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException, Query @@ -17,7 +17,6 @@ from innercontext.llm import ( get_creative_config, get_extraction_config, ) -from innercontext.services.fx import convert_to_pln from innercontext.models import ( Product, ProductBase, @@ -39,6 +38,7 @@ from innercontext.models.product import ( ActiveIngredient, ProductContext, ProductEffectProfile, + ProductInteraction, ) router = APIRouter() @@ -68,11 +68,8 @@ class ProductUpdate(SQLModel): absorption_speed: Optional[AbsorptionSpeed] = None leave_on: Optional[bool] = None - price_amount: Optional[float] = None - price_currency: Optional[str] = None + price_tier: Optional[PriceTier] = None size_ml: Optional[float] = None - full_weight_g: Optional[float] = None - empty_weight_g: Optional[float] = None pao_months: Optional[int] = None inci: Optional[list[str]] = None @@ -94,6 +91,8 @@ class ProductUpdate(SQLModel): ph_min: Optional[float] = None ph_max: Optional[float] = None + incompatible_with: Optional[list[ProductInteraction]] = None + synergizes_with: Optional[list[str]] = None context_rules: Optional[ProductContext] = None min_interval_hours: Optional[int] = None @@ -123,8 +122,7 @@ class ProductParseResponse(SQLModel): texture: Optional[TextureType] = None absorption_speed: Optional[AbsorptionSpeed] = None leave_on: Optional[bool] = None - price_amount: Optional[float] = None - price_currency: Optional[str] = None + price_tier: Optional[PriceTier] = None size_ml: Optional[float] = None full_weight_g: Optional[float] = None empty_weight_g: Optional[float] = None @@ -142,6 +140,8 @@ class ProductParseResponse(SQLModel): product_effect_profile: Optional[ProductEffectProfile] = None ph_min: Optional[float] = None ph_max: Optional[float] = None + incompatible_with: Optional[list[ProductInteraction]] = None + synergizes_with: Optional[list[str]] = None context_rules: Optional[ProductContext] = None min_interval_hours: Optional[int] = None max_frequency_per_week: Optional[int] = None @@ -218,188 +218,6 @@ class _ShoppingSuggestionsOut(PydanticBase): 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 _net_weight_g(product: Product) -> float | None: - if product.full_weight_g is None or product.empty_weight_g is None: - return None - net = product.full_weight_g - product.empty_weight_g - if net <= 0: - return None - return net - - -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: - pack_amount = _net_weight_g(product) - 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], -) -> dict[ - UUID, - tuple[ - PriceTier | None, - float | None, - Literal["category", "fallback", "insufficient_data"] | None, - ], -]: - 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: dict[ - UUID, - tuple[ - PriceTier | None, - float | None, - Literal["category", "fallback", "insufficient_data"] | None, - ], - ] = { - 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 - - -def _with_pricing( - view: ProductPublic, - pricing: tuple[ - PriceTier | None, - float | None, - Literal["category", "fallback", "insufficient_data"] | None, - ], -) -> ProductPublic: - price_tier, price_per_use_pln, price_tier_source = pricing - view.price_tier = price_tier - view.price_per_use_pln = price_per_use_pln - view.price_tier_source = price_tier_source - return view - - # --------------------------------------------------------------------------- # Product routes # --------------------------------------------------------------------------- @@ -454,12 +272,8 @@ def list_products( inv_by_product.setdefault(inv.product_id, []).append(inv) results = [] - pricing_pool = list(session.exec(select(Product)).all()) if products else [] - pricing_outputs = _compute_pricing_outputs(pricing_pool) - for p in products: r = ProductWithInventory.model_validate(p, from_attributes=True) - _with_pricing(r, pricing_outputs.get(p.id, (None, None, None))) r.inventory = inv_by_product.get(p.id, []) results.append(r) return results @@ -467,13 +281,9 @@ def list_products( @router.post("", response_model=ProductPublic, status_code=201) def create_product(data: ProductCreate, session: Session = Depends(get_session)): - payload = data.model_dump() - if payload.get("price_currency"): - payload["price_currency"] = str(payload["price_currency"]).upper() - product = Product( id=uuid4(), - **payload, + **data.model_dump(), ) session.add(product) session.commit() @@ -519,6 +329,8 @@ texture: "watery" | "gel" | "emulsion" | "cream" | "oil" | "balm" | "foam" | "fl absorption_speed: "very_fast" | "fast" | "moderate" | "slow" | "very_slow" +price_tier: "budget" | "mid" | "premium" | "luxury" + recommended_for (array, pick applicable): "dry" | "oily" | "combination" | "sensitive" | "normal" | "acne_prone" @@ -535,6 +347,8 @@ actives[].functions (array, pick applicable): actives[].strength_level: 1 (low) | 2 (medium) | 3 (high) actives[].irritation_potential: 1 (low) | 2 (medium) | 3 (high) +incompatible_with[].scope: "same_step" | "same_day" | "same_period" + OUTPUT SCHEMA (all fields optional — omit what you cannot determine): { "name": string, @@ -548,8 +362,7 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine): "texture": string, "absorption_speed": string, "leave_on": boolean, - "price_amount": number, - "price_currency": string, + "price_tier": string, "size_ml": number, "full_weight_g": number, "empty_weight_g": number, @@ -589,6 +402,10 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine): }, "ph_min": number, "ph_max": number, + "incompatible_with": [ + {"target": string, "scope": string, "reason": string} + ], + "synergizes_with": [string, ...], "context_rules": { "safe_after_shaving": boolean, "safe_after_acids": boolean, @@ -634,14 +451,10 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse: @router.get("/{product_id}", response_model=ProductWithInventory) def get_product(product_id: UUID, session: Session = Depends(get_session)): product = get_or_404(session, Product, product_id) - pricing_pool = list(session.exec(select(Product)).all()) - pricing_outputs = _compute_pricing_outputs(pricing_pool) - inventory = session.exec( select(ProductInventory).where(ProductInventory.product_id == product_id) ).all() result = ProductWithInventory.model_validate(product, from_attributes=True) - _with_pricing(result, pricing_outputs.get(product.id, (None, None, None))) result.inventory = list(inventory) return result @@ -651,19 +464,12 @@ def update_product( product_id: UUID, data: ProductUpdate, session: Session = Depends(get_session) ): product = get_or_404(session, Product, product_id) - 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(): + for key, value in data.model_dump(exclude_unset=True).items(): setattr(product, key, value) session.add(product) session.commit() session.refresh(product) - pricing_pool = list(session.exec(select(Product)).all()) - pricing_outputs = _compute_pricing_outputs(pricing_pool) - result = ProductPublic.model_validate(product, from_attributes=True) - return _with_pricing(result, pricing_outputs.get(product.id, (None, None, None))) + return product @router.delete("/{product_id}", status_code=204) @@ -915,6 +721,7 @@ def _build_safety_rules_tool_handler(products: list[Product]): return { "id": pid, "name": product.name, + "incompatible_with": (ctx.get("incompatible_with") or [])[:24], "contraindications": (ctx.get("contraindications") or [])[:24], "context_rules": ctx.get("context_rules") or {}, "safety": ctx.get("safety") or {}, @@ -948,7 +755,7 @@ _SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration( name="get_product_safety_rules", description=( "Return safety and compatibility rules for selected product UUIDs, " - "including contraindications, context_rules and safety flags." + "including incompatible_with, contraindications, context_rules and safety flags." ), parameters=genai_types.Schema( type=genai_types.Type.OBJECT, diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 0cd860e..503e0c9 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -378,6 +378,8 @@ def _build_products_context( notable = {k: v for k, v in profile.items() if v and v > 0} if notable: entry += f" effects={notable}" + if ctx.get("incompatible_with"): + entry += f" incompatible_with={ctx['incompatible_with']}" if ctx.get("contraindications"): entry += f" contraindications={ctx['contraindications']}" if ctx.get("context_rules"): @@ -499,6 +501,7 @@ def _build_safety_rules_tool_handler( return { "id": pid, "name": product.name, + "incompatible_with": (ctx.get("incompatible_with") or [])[:24], "contraindications": (ctx.get("contraindications") or [])[:24], "context_rules": ctx.get("context_rules") or {}, "safety": ctx.get("safety") or {}, @@ -627,7 +630,7 @@ _SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration( name="get_product_safety_rules", description=( "Return safety and compatibility rules for selected product UUIDs: " - "contraindications, context_rules and safety flags." + "incompatible_with, contraindications, context_rules and safety flags." ), parameters=genai_types.Schema( type=genai_types.Type.OBJECT, @@ -685,7 +688,8 @@ WYMAGANIA ODPOWIEDZI: ZASADY PLANOWANIA: - Kolejność warstw: cleanser -> toner -> essence -> serum -> moisturizer -> [SPF dla AM]. -- Respektuj: context_rules, min_interval_hours, max_frequency_per_week, usage_notes. +- Respektuj: incompatible_with (same_step / same_day / same_period), context_rules, + min_interval_hours, max_frequency_per_week, usage_notes. - Zarządzanie inwentarzem: - Preferuj produkty już otwarte (miękka preferencja). - Unikaj funkcjonalnej redundancji (np. wielokrotne źródła panthenolu, ceramidów lub niacynamidu w tej samej rutynie), diff --git a/backend/innercontext/models/__init__.py b/backend/innercontext/models/__init__.py index 73939cc..5fa50b0 100644 --- a/backend/innercontext/models/__init__.py +++ b/backend/innercontext/models/__init__.py @@ -7,6 +7,7 @@ from .enums import ( EvidenceLevel, GroomingAction, IngredientFunction, + InteractionScope, MedicationKind, OverallSkinState, PartOfDay, @@ -28,6 +29,7 @@ from .product import ( ProductBase, ProductContext, ProductEffectProfile, + ProductInteraction, ProductInventory, ProductPublic, ProductWithInventory, @@ -51,6 +53,7 @@ __all__ = [ "EvidenceLevel", "GroomingAction", "IngredientFunction", + "InteractionScope", "MedicationKind", "OverallSkinState", "PartOfDay", @@ -74,6 +77,7 @@ __all__ = [ "ProductBase", "ProductContext", "ProductEffectProfile", + "ProductInteraction", "ProductInventory", "ProductPublic", "ProductWithInventory", diff --git a/backend/innercontext/models/enums.py b/backend/innercontext/models/enums.py index a18e85c..293e280 100644 --- a/backend/innercontext/models/enums.py +++ b/backend/innercontext/models/enums.py @@ -131,6 +131,12 @@ class EvidenceLevel(str, Enum): HIGH = "high" +class InteractionScope(str, Enum): + SAME_STEP = "same_step" + SAME_DAY = "same_day" + SAME_PERIOD = "same_period" + + # --------------------------------------------------------------------------- # Health # --------------------------------------------------------------------------- diff --git a/backend/innercontext/models/product.py b/backend/innercontext/models/product.py index afd74c7..bb5dc0b 100644 --- a/backend/innercontext/models/product.py +++ b/backend/innercontext/models/product.py @@ -12,6 +12,7 @@ from .enums import ( AbsorptionSpeed, DayTime, IngredientFunction, + InteractionScope, PriceTier, ProductCategory, SkinConcern, @@ -56,6 +57,12 @@ class ActiveIngredient(SQLModel): irritation_potential: StrengthLevel | None = None +class ProductInteraction(SQLModel): + target: str + scope: InteractionScope + reason: str | None = None + + class ProductContext(SQLModel): safe_after_shaving: bool | None = None safe_after_acids: bool | None = None @@ -94,8 +101,7 @@ class ProductBase(SQLModel): absorption_speed: AbsorptionSpeed | None = None leave_on: bool - price_amount: float | None = Field(default=None, gt=0) - price_currency: str | None = Field(default=None, min_length=3, max_length=3) + price_tier: PriceTier | None = None size_ml: float | None = Field(default=None, gt=0) full_weight_g: float | None = Field(default=None, gt=0) empty_weight_g: float | None = Field(default=None, gt=0) @@ -122,6 +128,8 @@ class ProductBase(SQLModel): ph_min: float | None = Field(default=None, ge=0, le=14) ph_max: float | None = Field(default=None, ge=0, le=14) + incompatible_with: list[ProductInteraction] | None = None + synergizes_with: list[str] | None = None context_rules: ProductContext | None = None min_interval_hours: int | None = Field(default=None, ge=0) @@ -146,6 +154,9 @@ class Product(ProductBase, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) + # Override: add index for table context + price_tier: PriceTier | None = Field(default=None, index=True) + # Override 9 JSON fields with sa_column (only in table model) inci: list[str] = Field( default_factory=list, sa_column=Column(JSON, nullable=False) @@ -170,6 +181,12 @@ class Product(ProductBase, table=True): sa_column=Column(JSON, nullable=False), ) + incompatible_with: list[ProductInteraction] | None = Field( + default=None, sa_column=Column(JSON, nullable=True) + ) + synergizes_with: list[str] | None = Field( + default=None, sa_column=Column(JSON, nullable=True) + ) context_rules: ProductContext | None = Field( default=None, sa_column=Column(JSON, nullable=True) ) @@ -215,17 +232,9 @@ class Product(ProductBase, table=True): if self.is_medication and not self.usage_notes: raise ValueError("Medication products must have usage_notes") - if self.price_currency is not None: - self.price_currency = self.price_currency.upper() - return self - def to_llm_context( - self, - *, - computed_price_tier: PriceTier | None = None, - price_per_use_pln: float | None = None, - ) -> dict: + def to_llm_context(self) -> dict: ctx: dict = { "id": str(self.id), "name": self.name, @@ -244,14 +253,8 @@ class Product(ProductBase, table=True): ctx["texture"] = _ev(self.texture) if self.absorption_speed is not None: ctx["absorption_speed"] = _ev(self.absorption_speed) - if self.price_amount is not None: - ctx["price_amount"] = self.price_amount - if self.price_currency is not None: - ctx["price_currency"] = self.price_currency - if computed_price_tier is not None: - ctx["price_tier"] = _ev(computed_price_tier) - if price_per_use_pln is not None: - ctx["price_per_use_pln"] = round(price_per_use_pln, 4) + if self.price_tier is not None: + ctx["price_tier"] = _ev(self.price_tier) if self.size_ml is not None: ctx["size_ml"] = self.size_ml if self.pao_months is not None: @@ -299,6 +302,26 @@ class Product(ProductBase, table=True): if nonzero: ctx["effect_profile"] = nonzero + if self.incompatible_with: + parts = [] + for inc in self.incompatible_with: + if isinstance(inc, dict): + scope = inc.get("scope", "") + target = inc.get("target", "") + reason = inc.get("reason") + else: + scope = _ev(inc.scope) + target = inc.target + reason = inc.reason + msg = f"avoid {target} ({scope})" + if reason: + msg += f": {reason}" + parts.append(msg) + ctx["incompatible_with"] = parts + + if self.synergizes_with: + ctx["synergizes_with"] = self.synergizes_with + if self.context_rules is not None: cr = self.context_rules if isinstance(cr, dict): @@ -382,9 +405,6 @@ class ProductPublic(ProductBase): id: UUID created_at: datetime updated_at: datetime - price_tier: PriceTier | None = None - price_per_use_pln: float | None = None - price_tier_source: str | None = None class ProductWithInventory(ProductPublic): diff --git a/backend/innercontext/services/__init__.py b/backend/innercontext/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/innercontext/services/fx.py b/backend/innercontext/services/fx.py deleted file mode 100644 index f9f13c0..0000000 --- a/backend/innercontext/services/fx.py +++ /dev/null @@ -1,77 +0,0 @@ -import json -from datetime import datetime, timedelta, timezone -from threading import Lock -from urllib.error import URLError -from urllib.request import Request, urlopen - -NBP_TABLE_A_URL = "https://api.nbp.pl/api/exchangerates/tables/A" -_CACHE_TTL = timedelta(hours=24) - -_cache_lock = Lock() -_cached_rates: dict[str, float] | None = None -_cached_at: datetime | None = None - - -def _now_utc() -> datetime: - return datetime.now(timezone.utc) - - -def _cache_is_fresh() -> bool: - if _cached_rates is None or _cached_at is None: - return False - return _now_utc() - _cached_at < _CACHE_TTL - - -def _fetch_rates_from_nbp() -> dict[str, float]: - req = Request(NBP_TABLE_A_URL, headers={"Accept": "application/json"}) - with urlopen(req, timeout=10) as response: - payload = json.loads(response.read().decode("utf-8")) - - if not isinstance(payload, list) or not payload: - raise ValueError("Unexpected NBP payload") - - table = payload[0] - rates = table.get("rates") if isinstance(table, dict) else None - if not isinstance(rates, list): - raise ValueError("NBP payload does not include rates") - - parsed: dict[str, float] = {"PLN": 1.0} - for row in rates: - if not isinstance(row, dict): - continue - code = row.get("code") - mid = row.get("mid") - if not isinstance(code, str) or not isinstance(mid, (int, float)): - continue - parsed[code.upper()] = float(mid) - - return parsed - - -def get_pln_rates() -> dict[str, float]: - global _cached_rates, _cached_at - - with _cache_lock: - if _cache_is_fresh() and _cached_rates is not None: - return dict(_cached_rates) - - try: - fresh = _fetch_rates_from_nbp() - except (URLError, TimeoutError, ValueError): - if _cached_rates is not None: - return dict(_cached_rates) - return {"PLN": 1.0} - - _cached_rates = fresh - _cached_at = _now_utc() - return dict(fresh) - - -def convert_to_pln(amount: float, currency: str) -> float | None: - if amount <= 0: - return None - rates = get_pln_rates() - rate = rates.get(currency.upper()) - if rate is None: - return None - return amount * rate diff --git a/backend/pyproject.toml b/backend/pyproject.toml index eeddb55..49f1708 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "alembic>=1.14", "fastapi>=0.132.0", "google-genai>=1.65.0", - "psycopg[binary]>=3.3.3", + "psycopg>=3.3.3", "python-dotenv>=1.2.1", "python-multipart>=0.0.22", "sqlmodel>=0.0.37", diff --git a/backend/tests/test_product_model.py b/backend/tests/test_product_model.py index 77290ac..f2076b3 100644 --- a/backend/tests/test_product_model.py +++ b/backend/tests/test_product_model.py @@ -7,12 +7,14 @@ from innercontext.models import Product from innercontext.models.enums import ( DayTime, IngredientFunction, + InteractionScope, ProductCategory, ) from innercontext.models.product import ( ActiveIngredient, ProductContext, ProductEffectProfile, + ProductInteraction, ) @@ -154,6 +156,29 @@ def test_effect_profile_nonzero_included(): assert "retinoid_strength" not in ctx["effect_profile"] +# --------------------------------------------------------------------------- +# Incompatible_with +# --------------------------------------------------------------------------- + + +def test_incompatible_with_pydantic_objects(): + inc = ProductInteraction( + target="AHA", scope=InteractionScope.SAME_DAY, reason="increases irritation" + ) + p = _make(incompatible_with=[inc]) + ctx = p.to_llm_context() + assert "incompatible_with" in ctx + assert ctx["incompatible_with"][0] == "avoid AHA (same_day): increases irritation" + + +def test_incompatible_with_raw_dicts(): + raw = {"target": "Vitamin C", "scope": "same_step", "reason": None} + p = _make(incompatible_with=[raw]) + ctx = p.to_llm_context() + assert "incompatible_with" in ctx + assert ctx["incompatible_with"][0] == "avoid Vitamin C (same_step)" + + # --------------------------------------------------------------------------- # Context rules # --------------------------------------------------------------------------- diff --git a/backend/tests/test_products_helpers.py b/backend/tests/test_products_helpers.py index c4046f9..805f35f 100644 --- a/backend/tests/test_products_helpers.py +++ b/backend/tests/test_products_helpers.py @@ -136,6 +136,7 @@ def test_shopping_tool_handlers_return_payloads(session: Session): usage_notes="Use AM and PM on clean skin.", inci=["Water", "Niacinamide"], actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}], + incompatible_with=[{"target": "Vitamin C", "scope": "same_step"}], context_rules={"safe_after_shaving": True}, product_effect_profile={}, ) @@ -152,4 +153,5 @@ def test_shopping_tool_handlers_return_payloads(session: Session): assert notes_data["products"][0]["usage_notes"] == "Use AM and PM on clean skin." safety_data = _build_safety_rules_tool_handler([product])(payload) + assert "incompatible_with" in safety_data["products"][0] assert "context_rules" in safety_data["products"][0] diff --git a/backend/tests/test_products_pricing.py b/backend/tests/test_products_pricing.py deleted file mode 100644 index 1abdeca..0000000 --- a/backend/tests/test_products_pricing.py +++ /dev/null @@ -1,177 +0,0 @@ -import uuid - -from innercontext.api import products as products_api -from innercontext.models import Product -from innercontext.models.enums import DayTime, ProductCategory - - -def _product( - *, category: ProductCategory, price_amount: float, size_ml: float -) -> Product: - return Product( - id=uuid.uuid4(), - name=f"{category}-{price_amount}", - brand="Brand", - category=category, - recommended_time=DayTime.BOTH, - leave_on=True, - price_amount=price_amount, - price_currency="PLN", - size_ml=size_ml, - ) - - -def test_compute_pricing_outputs_groups_by_category(monkeypatch): - monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) - - serums = [ - _product(category=ProductCategory.SERUM, price_amount=float(i), size_ml=35.0) - for i in range(10, 90, 10) - ] - cleansers = [ - _product( - category=ProductCategory.CLEANSER, price_amount=float(i), size_ml=200.0 - ) - for i in range(40, 120, 10) - ] - outputs = products_api._compute_pricing_outputs(serums + cleansers) - - serum_tiers = [outputs[p.id][0] for p in serums] - cleanser_tiers = [outputs[p.id][0] for p in cleansers] - - assert serum_tiers[0] == "budget" - assert serum_tiers[-1] == "luxury" - assert cleanser_tiers[0] == "budget" - assert cleanser_tiers[-1] == "luxury" - - -def test_price_tier_is_null_when_not_enough_products(client, monkeypatch): - monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) - - base = { - "brand": "B", - "recommended_time": "both", - "leave_on": True, - "size_ml": 35.0, - "price_currency": "PLN", - } - for i in range(7): - response = client.post( - "/products", - json={ - **base, - "name": f"Serum {i}", - "category": "serum", - "price_amount": 30 + i, - }, - ) - assert response.status_code == 201 - - products = client.get("/products").json() - assert len(products) == 7 - assert all(p["price_tier"] is None for p in products) - assert all(p["price_per_use_pln"] is not None for p in products) - - -def test_price_tier_is_computed_on_list(client, monkeypatch): - monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) - - base = { - "brand": "B", - "recommended_time": "both", - "leave_on": True, - "size_ml": 35.0, - "price_currency": "PLN", - "category": "serum", - } - for i in range(8): - response = client.post( - "/products", - json={**base, "name": f"Serum {i}", "price_amount": 20 + i * 10}, - ) - assert response.status_code == 201 - - products = client.get("/products").json() - assert len(products) == 8 - assert any(p["price_tier"] == "budget" for p in products) - assert any(p["price_tier"] == "luxury" for p in products) - - -def test_price_tier_uses_fallback_for_medium_categories(client, monkeypatch): - monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) - - serum_base = { - "brand": "B", - "recommended_time": "both", - "leave_on": True, - "size_ml": 35.0, - "price_currency": "PLN", - "category": "serum", - } - for i in range(8): - response = client.post( - "/products", - json={**serum_base, "name": f"Serum {i}", "price_amount": 20 + i * 5}, - ) - assert response.status_code == 201 - - toner_base = { - "brand": "B", - "recommended_time": "both", - "leave_on": True, - "size_ml": 100.0, - "price_currency": "PLN", - "category": "toner", - } - for i in range(5): - response = client.post( - "/products", - json={**toner_base, "name": f"Toner {i}", "price_amount": 10 + i * 5}, - ) - assert response.status_code == 201 - - products = client.get("/products?category=toner").json() - assert len(products) == 5 - assert all(p["price_tier"] is not None for p in products) - assert all(p["price_tier_source"] == "fallback" for p in products) - - -def test_price_tier_stays_null_for_tiny_categories_even_with_fallback_pool( - client, monkeypatch -): - monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) - - serum_base = { - "brand": "B", - "recommended_time": "both", - "leave_on": True, - "size_ml": 35.0, - "price_currency": "PLN", - "category": "serum", - } - for i in range(8): - response = client.post( - "/products", - json={**serum_base, "name": f"Serum {i}", "price_amount": 20 + i * 5}, - ) - assert response.status_code == 201 - - oil_base = { - "brand": "B", - "recommended_time": "both", - "leave_on": True, - "size_ml": 30.0, - "price_currency": "PLN", - "category": "oil", - } - for i in range(3): - response = client.post( - "/products", - json={**oil_base, "name": f"Oil {i}", "price_amount": 30 + i * 10}, - ) - assert response.status_code == 201 - - oils = client.get("/products?category=oil").json() - assert len(oils) == 3 - assert all(p["price_tier"] is None for p in oils) - assert all(p["price_tier_source"] == "insufficient_data" for p in oils) diff --git a/backend/tests/test_routines_helpers.py b/backend/tests/test_routines_helpers.py index 9e0ec09..5d78872 100644 --- a/backend/tests/test_routines_helpers.py +++ b/backend/tests/test_routines_helpers.py @@ -184,6 +184,7 @@ def test_build_products_context(session: Session): recommended_time="am", pao_months=6, product_effect_profile={"hydration_immediate": 2, "exfoliation_strength": 0}, + incompatible_with=[{"target": "retinol", "scope": "same_routine"}], context_rules={"safe_after_shaving": False}, min_interval_hours=12, max_frequency_per_week=7, @@ -232,6 +233,7 @@ def test_build_products_context(session: Session): assert "nearest_open_pao_deadline=" in ctx assert "pao_months=6" in ctx assert "effects={'hydration_immediate': 2}" in ctx + assert "incompatible_with=['avoid retinol (same_routine)']" in ctx assert "context_rules={'safe_after_shaving': False}" in ctx assert "min_interval_hours=12" in ctx assert "max_frequency_per_week=7" in ctx @@ -388,6 +390,7 @@ def test_additional_tool_handlers_return_product_payloads(session: Session): leave_on=True, usage_notes="Apply morning and evening.", actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}], + incompatible_with=[{"target": "Retinol", "scope": "same_step"}], context_rules={"safe_after_shaving": True}, product_effect_profile={}, ) @@ -401,4 +404,5 @@ def test_additional_tool_handlers_return_product_payloads(session: Session): assert notes_out["products"][0]["usage_notes"] == "Apply morning and evening." safety_out = _build_safety_rules_tool_handler([p])(ids_payload) + assert "incompatible_with" in safety_out["products"][0] assert "context_rules" in safety_out["products"][0] diff --git a/backend/uv.lock b/backend/uv.lock index a911208..52286f9 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -556,7 +556,7 @@ dependencies = [ { name = "alembic" }, { name = "fastapi" }, { name = "google-genai" }, - { name = "psycopg", extra = ["binary"] }, + { name = "psycopg" }, { name = "python-dotenv" }, { name = "python-multipart" }, { name = "sqlmodel" }, @@ -579,7 +579,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1.14" }, { name = "fastapi", specifier = ">=0.132.0" }, { name = "google-genai", specifier = ">=1.65.0" }, - { name = "psycopg", extras = ["binary"], specifier = ">=3.3.3" }, + { name = "psycopg", specifier = ">=3.3.3" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-multipart", specifier = ">=0.0.22" }, { name = "sqlmodel", specifier = ">=0.0.37" }, @@ -739,51 +739,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, ] -[package.optional-dependencies] -binary = [ - { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, -] - -[[package]] -name = "psycopg-binary" -version = "3.3.3" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" }, - { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" }, - { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" }, - { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" }, - { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" }, - { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" }, - { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" }, - { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" }, - { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, - { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, - { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, - { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, - { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, - { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, - { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, - { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, - { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, - { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, - { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, - { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, - { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, - { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, - { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, -] - [[package]] name = "pyasn1" version = "0.6.2" diff --git a/docs/frontend-design-cookbook.md b/docs/frontend-design-cookbook.md deleted file mode 100644 index e916f3d..0000000 --- a/docs/frontend-design-cookbook.md +++ /dev/null @@ -1,189 +0,0 @@ -# Frontend Design Cookbook - -This cookbook defines the visual system for the frontend so every new change extends the existing style instead of inventing a new one. - -## Design intent - -- Core tone: light editorial, calm and information-first. -- Product feel: premium personal logbook, not generic SaaS dashboard. -- Contrast model: neutral paper and ink do most of the work; accents are restrained. - -## Non-negotiables - -- Keep layouts readable first. Aesthetic details support hierarchy, not the other way around. -- Use one visual language across the app shell, cards, forms, tables, and actions. -- Prefer subtle depth (borders, layered paper, soft shadows) over loud gradients. -- Keep motion purposeful and short; always preserve `prefers-reduced-motion` behavior. - -## Typography - -- Display/headings: `Cormorant Infant`. -- Body/UI text: `Manrope`. -- Use display typography for page titles and section heads only. -- Keep paragraph text in body font for legibility. - -## Color system - -Global neutrals are defined in `frontend/src/app.css` using CSS variables. - -- `--background`, `--card`, `--foreground`, `--muted-foreground`, `--border` -- `--page-accent` drives route-level emphasis. -- `--page-accent-soft` is the low-contrast companion tint. - -### Domain accents (muted and professional) - -- Dashboard: `--accent-dashboard` -- Products: `--accent-products` -- Routines: `--accent-routines` -- Skin: `--accent-skin` -- Health labs: `--accent-health-labs` -- Health medications: `--accent-health-meds` - -The app shell assigns a domain class per route and maps it to `--page-accent`. - -## Where to use accent color - -Use accent for: - -- active navigation state -- important buttons and key badges -- focus ring tint and hover border tint -- section separators and small status markers - -Do not use accent for: - -- full-page backgrounds -- body text -- large surfaces that reduce readability - -Guideline: accent should occupy roughly 10-15% of visual area per screen. - -## Layout rules - -- Use the app shell spacing rhythm from `app.css` (`.app-main`, editorial cards). -- Keep max content width constrained for readability. -- Prefer asymmetry in hero/summary areas, but keep forms and dense data grids regular. - -### Shared page wrappers - -Use these wrappers before introducing route-specific structure: - -- `editorial-page`: standard constrained content width for route pages. -- `editorial-hero`: top summary strip for title, subtitle, and primary actions. -- `editorial-panel`: primary surface for forms, tables, and ledgers. -- `editorial-toolbar`: compact action row under hero copy. -- `editorial-backlink`: standard top-left back navigation style. -- `editorial-alert`, `editorial-alert--error`, `editorial-alert--success`: feedback banners. - -## Component rules - -- Reuse UI primitives under `frontend/src/lib/components/ui/*`. -- Keep primitive APIs stable when changing visual treatment. -- Style via tokens and shared classes, not one-off hardcoded colors. -- New variants must be documented in this file. - -### Existing shared utility patterns - -These classes are already in use and should be reused: - -- Lists and ledgers: `routine-ledger-row`, `products-mobile-card`, `health-entry-row` -- Group headers: `products-section-title` -- Table shell: `products-table-shell` -- Tabs shell: `products-tabs`, `editorial-tabs` -- Health semantic pills: `health-kind-pill*`, `health-flag-pill*` - -## Forms and data views - -- Inputs should remain high-contrast and calm. -- Validation/error states should be explicit and never color-only. -- Tables and dense lists should prioritize scanning: spacing, row separators, concise metadata. - -### DRY form primitives - -- Use shared form components for repeated native select markup: - - `frontend/src/lib/components/forms/SimpleSelect.svelte` - - `frontend/src/lib/components/forms/GroupedSelect.svelte` -- Use shared checkbox helper for repeated label+hint toggles: - - `frontend/src/lib/components/forms/HintCheckbox.svelte` -- Use shared input field helper for repeated label+input rows: - - `frontend/src/lib/components/forms/LabeledInputField.svelte` -- Use shared section card helper for repeated titled form panels: - - `frontend/src/lib/components/forms/FormSectionCard.svelte` -- Use shared class tokens from: - - `frontend/src/lib/components/forms/form-classes.ts` -- Prefer passing option labels from route files via `m.*` to keep i18n explicit. - -### Select policy (performance + maintainability) - -- Default to native ` + {#if aiError} +

{aiError}

+ {/if} + + + {/if} + - - - {m["productForm_basicInfo"]()} - {m["productForm_ingredients"]()} - {m["productForm_effectProfile"]()} - {m["productForm_productDetails"]()} - {m["productForm_personalNotes"]()} - - + + + {m["productForm_basicInfo"]()} + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
-{#if showAiTrigger} -
- -
-{/if} + + + {m["productForm_classification"]()} + +
+
+ + + +
-{#if aiModalOpen} - {#await import('$lib/components/ProductFormAiModal.svelte') then mod} - {@const AiModal = mod.default} - - {/await} -{/if} +
+ + + +
-{#await import('$lib/components/product-form/ProductFormBasicSection.svelte') then mod} - {@const BasicSection = mod.default} - -{/await} +
+ + + +
-{#await import('$lib/components/product-form/ProductFormClassificationSection.svelte') then mod} - {@const ClassificationSection = mod.default} - -{/await} +
+ + + +
+ +
+ + + +
+
+
+
- + {m["productForm_skinProfile"]()}
- {#each skinTypes as st (st)} + {#each skinTypes as st}