refactor(products): remove obsolete interaction fields across stack
This commit is contained in:
parent
1d8a8eafb8
commit
c5ea38880c
16 changed files with 32 additions and 278 deletions
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""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))
|
||||||
|
|
@ -38,7 +38,6 @@ from innercontext.models.product import (
|
||||||
ActiveIngredient,
|
ActiveIngredient,
|
||||||
ProductContext,
|
ProductContext,
|
||||||
ProductEffectProfile,
|
ProductEffectProfile,
|
||||||
ProductInteraction,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -91,8 +90,6 @@ class ProductUpdate(SQLModel):
|
||||||
ph_min: Optional[float] = None
|
ph_min: Optional[float] = None
|
||||||
ph_max: 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
|
context_rules: Optional[ProductContext] = None
|
||||||
|
|
||||||
min_interval_hours: Optional[int] = None
|
min_interval_hours: Optional[int] = None
|
||||||
|
|
@ -140,8 +137,6 @@ class ProductParseResponse(SQLModel):
|
||||||
product_effect_profile: Optional[ProductEffectProfile] = None
|
product_effect_profile: Optional[ProductEffectProfile] = None
|
||||||
ph_min: Optional[float] = None
|
ph_min: Optional[float] = None
|
||||||
ph_max: 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
|
context_rules: Optional[ProductContext] = None
|
||||||
min_interval_hours: Optional[int] = None
|
min_interval_hours: Optional[int] = None
|
||||||
max_frequency_per_week: Optional[int] = None
|
max_frequency_per_week: Optional[int] = None
|
||||||
|
|
@ -347,8 +342,6 @@ actives[].functions (array, pick applicable):
|
||||||
actives[].strength_level: 1 (low) | 2 (medium) | 3 (high)
|
actives[].strength_level: 1 (low) | 2 (medium) | 3 (high)
|
||||||
actives[].irritation_potential: 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):
|
OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
|
||||||
{
|
{
|
||||||
"name": string,
|
"name": string,
|
||||||
|
|
@ -402,10 +395,6 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
|
||||||
},
|
},
|
||||||
"ph_min": number,
|
"ph_min": number,
|
||||||
"ph_max": number,
|
"ph_max": number,
|
||||||
"incompatible_with": [
|
|
||||||
{"target": string, "scope": string, "reason": string}
|
|
||||||
],
|
|
||||||
"synergizes_with": [string, ...],
|
|
||||||
"context_rules": {
|
"context_rules": {
|
||||||
"safe_after_shaving": boolean,
|
"safe_after_shaving": boolean,
|
||||||
"safe_after_acids": boolean,
|
"safe_after_acids": boolean,
|
||||||
|
|
@ -721,7 +710,6 @@ def _build_safety_rules_tool_handler(products: list[Product]):
|
||||||
return {
|
return {
|
||||||
"id": pid,
|
"id": pid,
|
||||||
"name": product.name,
|
"name": product.name,
|
||||||
"incompatible_with": (ctx.get("incompatible_with") or [])[:24],
|
|
||||||
"contraindications": (ctx.get("contraindications") or [])[:24],
|
"contraindications": (ctx.get("contraindications") or [])[:24],
|
||||||
"context_rules": ctx.get("context_rules") or {},
|
"context_rules": ctx.get("context_rules") or {},
|
||||||
"safety": ctx.get("safety") or {},
|
"safety": ctx.get("safety") or {},
|
||||||
|
|
@ -755,7 +743,7 @@ _SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
|
||||||
name="get_product_safety_rules",
|
name="get_product_safety_rules",
|
||||||
description=(
|
description=(
|
||||||
"Return safety and compatibility rules for selected product UUIDs, "
|
"Return safety and compatibility rules for selected product UUIDs, "
|
||||||
"including incompatible_with, contraindications, context_rules and safety flags."
|
"including contraindications, context_rules and safety flags."
|
||||||
),
|
),
|
||||||
parameters=genai_types.Schema(
|
parameters=genai_types.Schema(
|
||||||
type=genai_types.Type.OBJECT,
|
type=genai_types.Type.OBJECT,
|
||||||
|
|
|
||||||
|
|
@ -378,8 +378,6 @@ def _build_products_context(
|
||||||
notable = {k: v for k, v in profile.items() if v and v > 0}
|
notable = {k: v for k, v in profile.items() if v and v > 0}
|
||||||
if notable:
|
if notable:
|
||||||
entry += f" effects={notable}"
|
entry += f" effects={notable}"
|
||||||
if ctx.get("incompatible_with"):
|
|
||||||
entry += f" incompatible_with={ctx['incompatible_with']}"
|
|
||||||
if ctx.get("contraindications"):
|
if ctx.get("contraindications"):
|
||||||
entry += f" contraindications={ctx['contraindications']}"
|
entry += f" contraindications={ctx['contraindications']}"
|
||||||
if ctx.get("context_rules"):
|
if ctx.get("context_rules"):
|
||||||
|
|
@ -501,7 +499,6 @@ def _build_safety_rules_tool_handler(
|
||||||
return {
|
return {
|
||||||
"id": pid,
|
"id": pid,
|
||||||
"name": product.name,
|
"name": product.name,
|
||||||
"incompatible_with": (ctx.get("incompatible_with") or [])[:24],
|
|
||||||
"contraindications": (ctx.get("contraindications") or [])[:24],
|
"contraindications": (ctx.get("contraindications") or [])[:24],
|
||||||
"context_rules": ctx.get("context_rules") or {},
|
"context_rules": ctx.get("context_rules") or {},
|
||||||
"safety": ctx.get("safety") or {},
|
"safety": ctx.get("safety") or {},
|
||||||
|
|
@ -630,7 +627,7 @@ _SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
|
||||||
name="get_product_safety_rules",
|
name="get_product_safety_rules",
|
||||||
description=(
|
description=(
|
||||||
"Return safety and compatibility rules for selected product UUIDs: "
|
"Return safety and compatibility rules for selected product UUIDs: "
|
||||||
"incompatible_with, contraindications, context_rules and safety flags."
|
"contraindications, context_rules and safety flags."
|
||||||
),
|
),
|
||||||
parameters=genai_types.Schema(
|
parameters=genai_types.Schema(
|
||||||
type=genai_types.Type.OBJECT,
|
type=genai_types.Type.OBJECT,
|
||||||
|
|
@ -688,8 +685,7 @@ WYMAGANIA ODPOWIEDZI:
|
||||||
|
|
||||||
ZASADY PLANOWANIA:
|
ZASADY PLANOWANIA:
|
||||||
- Kolejność warstw: cleanser -> toner -> essence -> serum -> moisturizer -> [SPF dla AM].
|
- Kolejność warstw: cleanser -> toner -> essence -> serum -> moisturizer -> [SPF dla AM].
|
||||||
- Respektuj: incompatible_with (same_step / same_day / same_period), context_rules,
|
- Respektuj: context_rules, min_interval_hours, max_frequency_per_week, usage_notes.
|
||||||
min_interval_hours, max_frequency_per_week, usage_notes.
|
|
||||||
- Zarządzanie inwentarzem:
|
- Zarządzanie inwentarzem:
|
||||||
- Preferuj produkty już otwarte (miękka preferencja).
|
- Preferuj produkty już otwarte (miękka preferencja).
|
||||||
- Unikaj funkcjonalnej redundancji (np. wielokrotne źródła panthenolu, ceramidów lub niacynamidu w tej samej rutynie),
|
- Unikaj funkcjonalnej redundancji (np. wielokrotne źródła panthenolu, ceramidów lub niacynamidu w tej samej rutynie),
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ from .enums import (
|
||||||
EvidenceLevel,
|
EvidenceLevel,
|
||||||
GroomingAction,
|
GroomingAction,
|
||||||
IngredientFunction,
|
IngredientFunction,
|
||||||
InteractionScope,
|
|
||||||
MedicationKind,
|
MedicationKind,
|
||||||
OverallSkinState,
|
OverallSkinState,
|
||||||
PartOfDay,
|
PartOfDay,
|
||||||
|
|
@ -29,7 +28,6 @@ from .product import (
|
||||||
ProductBase,
|
ProductBase,
|
||||||
ProductContext,
|
ProductContext,
|
||||||
ProductEffectProfile,
|
ProductEffectProfile,
|
||||||
ProductInteraction,
|
|
||||||
ProductInventory,
|
ProductInventory,
|
||||||
ProductPublic,
|
ProductPublic,
|
||||||
ProductWithInventory,
|
ProductWithInventory,
|
||||||
|
|
@ -53,7 +51,6 @@ __all__ = [
|
||||||
"EvidenceLevel",
|
"EvidenceLevel",
|
||||||
"GroomingAction",
|
"GroomingAction",
|
||||||
"IngredientFunction",
|
"IngredientFunction",
|
||||||
"InteractionScope",
|
|
||||||
"MedicationKind",
|
"MedicationKind",
|
||||||
"OverallSkinState",
|
"OverallSkinState",
|
||||||
"PartOfDay",
|
"PartOfDay",
|
||||||
|
|
@ -77,7 +74,6 @@ __all__ = [
|
||||||
"ProductBase",
|
"ProductBase",
|
||||||
"ProductContext",
|
"ProductContext",
|
||||||
"ProductEffectProfile",
|
"ProductEffectProfile",
|
||||||
"ProductInteraction",
|
|
||||||
"ProductInventory",
|
"ProductInventory",
|
||||||
"ProductPublic",
|
"ProductPublic",
|
||||||
"ProductWithInventory",
|
"ProductWithInventory",
|
||||||
|
|
|
||||||
|
|
@ -131,12 +131,6 @@ class EvidenceLevel(str, Enum):
|
||||||
HIGH = "high"
|
HIGH = "high"
|
||||||
|
|
||||||
|
|
||||||
class InteractionScope(str, Enum):
|
|
||||||
SAME_STEP = "same_step"
|
|
||||||
SAME_DAY = "same_day"
|
|
||||||
SAME_PERIOD = "same_period"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Health
|
# Health
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ from .enums import (
|
||||||
AbsorptionSpeed,
|
AbsorptionSpeed,
|
||||||
DayTime,
|
DayTime,
|
||||||
IngredientFunction,
|
IngredientFunction,
|
||||||
InteractionScope,
|
|
||||||
PriceTier,
|
PriceTier,
|
||||||
ProductCategory,
|
ProductCategory,
|
||||||
SkinConcern,
|
SkinConcern,
|
||||||
|
|
@ -57,12 +56,6 @@ class ActiveIngredient(SQLModel):
|
||||||
irritation_potential: StrengthLevel | None = None
|
irritation_potential: StrengthLevel | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProductInteraction(SQLModel):
|
|
||||||
target: str
|
|
||||||
scope: InteractionScope
|
|
||||||
reason: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProductContext(SQLModel):
|
class ProductContext(SQLModel):
|
||||||
safe_after_shaving: bool | None = None
|
safe_after_shaving: bool | None = None
|
||||||
safe_after_acids: bool | None = None
|
safe_after_acids: bool | None = None
|
||||||
|
|
@ -128,8 +121,6 @@ class ProductBase(SQLModel):
|
||||||
ph_min: float | None = Field(default=None, ge=0, le=14)
|
ph_min: float | None = Field(default=None, ge=0, le=14)
|
||||||
ph_max: 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
|
context_rules: ProductContext | None = None
|
||||||
|
|
||||||
min_interval_hours: int | None = Field(default=None, ge=0)
|
min_interval_hours: int | None = Field(default=None, ge=0)
|
||||||
|
|
@ -181,12 +172,6 @@ class Product(ProductBase, table=True):
|
||||||
sa_column=Column(JSON, nullable=False),
|
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(
|
context_rules: ProductContext | None = Field(
|
||||||
default=None, sa_column=Column(JSON, nullable=True)
|
default=None, sa_column=Column(JSON, nullable=True)
|
||||||
)
|
)
|
||||||
|
|
@ -302,26 +287,6 @@ class Product(ProductBase, table=True):
|
||||||
if nonzero:
|
if nonzero:
|
||||||
ctx["effect_profile"] = 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:
|
if self.context_rules is not None:
|
||||||
cr = self.context_rules
|
cr = self.context_rules
|
||||||
if isinstance(cr, dict):
|
if isinstance(cr, dict):
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,12 @@ from innercontext.models import Product
|
||||||
from innercontext.models.enums import (
|
from innercontext.models.enums import (
|
||||||
DayTime,
|
DayTime,
|
||||||
IngredientFunction,
|
IngredientFunction,
|
||||||
InteractionScope,
|
|
||||||
ProductCategory,
|
ProductCategory,
|
||||||
)
|
)
|
||||||
from innercontext.models.product import (
|
from innercontext.models.product import (
|
||||||
ActiveIngredient,
|
ActiveIngredient,
|
||||||
ProductContext,
|
ProductContext,
|
||||||
ProductEffectProfile,
|
ProductEffectProfile,
|
||||||
ProductInteraction,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -156,29 +154,6 @@ def test_effect_profile_nonzero_included():
|
||||||
assert "retinoid_strength" not in ctx["effect_profile"]
|
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
|
# Context rules
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,6 @@ def test_shopping_tool_handlers_return_payloads(session: Session):
|
||||||
usage_notes="Use AM and PM on clean skin.",
|
usage_notes="Use AM and PM on clean skin.",
|
||||||
inci=["Water", "Niacinamide"],
|
inci=["Water", "Niacinamide"],
|
||||||
actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}],
|
actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}],
|
||||||
incompatible_with=[{"target": "Vitamin C", "scope": "same_step"}],
|
|
||||||
context_rules={"safe_after_shaving": True},
|
context_rules={"safe_after_shaving": True},
|
||||||
product_effect_profile={},
|
product_effect_profile={},
|
||||||
)
|
)
|
||||||
|
|
@ -153,5 +152,4 @@ def test_shopping_tool_handlers_return_payloads(session: Session):
|
||||||
assert notes_data["products"][0]["usage_notes"] == "Use AM and PM on clean skin."
|
assert notes_data["products"][0]["usage_notes"] == "Use AM and PM on clean skin."
|
||||||
|
|
||||||
safety_data = _build_safety_rules_tool_handler([product])(payload)
|
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]
|
assert "context_rules" in safety_data["products"][0]
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,6 @@ def test_build_products_context(session: Session):
|
||||||
recommended_time="am",
|
recommended_time="am",
|
||||||
pao_months=6,
|
pao_months=6,
|
||||||
product_effect_profile={"hydration_immediate": 2, "exfoliation_strength": 0},
|
product_effect_profile={"hydration_immediate": 2, "exfoliation_strength": 0},
|
||||||
incompatible_with=[{"target": "retinol", "scope": "same_routine"}],
|
|
||||||
context_rules={"safe_after_shaving": False},
|
context_rules={"safe_after_shaving": False},
|
||||||
min_interval_hours=12,
|
min_interval_hours=12,
|
||||||
max_frequency_per_week=7,
|
max_frequency_per_week=7,
|
||||||
|
|
@ -233,7 +232,6 @@ def test_build_products_context(session: Session):
|
||||||
assert "nearest_open_pao_deadline=" in ctx
|
assert "nearest_open_pao_deadline=" in ctx
|
||||||
assert "pao_months=6" in ctx
|
assert "pao_months=6" in ctx
|
||||||
assert "effects={'hydration_immediate': 2}" 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 "context_rules={'safe_after_shaving': False}" in ctx
|
||||||
assert "min_interval_hours=12" in ctx
|
assert "min_interval_hours=12" in ctx
|
||||||
assert "max_frequency_per_week=7" in ctx
|
assert "max_frequency_per_week=7" in ctx
|
||||||
|
|
@ -390,7 +388,6 @@ def test_additional_tool_handlers_return_product_payloads(session: Session):
|
||||||
leave_on=True,
|
leave_on=True,
|
||||||
usage_notes="Apply morning and evening.",
|
usage_notes="Apply morning and evening.",
|
||||||
actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}],
|
actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}],
|
||||||
incompatible_with=[{"target": "Retinol", "scope": "same_step"}],
|
|
||||||
context_rules={"safe_after_shaving": True},
|
context_rules={"safe_after_shaving": True},
|
||||||
product_effect_profile={},
|
product_effect_profile={},
|
||||||
)
|
)
|
||||||
|
|
@ -404,5 +401,4 @@ def test_additional_tool_handlers_return_product_payloads(session: Session):
|
||||||
assert notes_out["products"][0]["usage_notes"] == "Apply morning and evening."
|
assert notes_out["products"][0]["usage_notes"] == "Apply morning and evening."
|
||||||
|
|
||||||
safety_out = _build_safety_rules_tool_handler([p])(ids_payload)
|
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]
|
assert "context_rules" in safety_out["products"][0]
|
||||||
|
|
|
||||||
|
|
@ -379,16 +379,6 @@
|
||||||
"productForm_activeIrritation": "Irritation",
|
"productForm_activeIrritation": "Irritation",
|
||||||
"productForm_activeFunctions": "Functions",
|
"productForm_activeFunctions": "Functions",
|
||||||
"productForm_effectProfile": "Effect profile (0–5)",
|
"productForm_effectProfile": "Effect profile (0–5)",
|
||||||
"productForm_interactions": "Interactions",
|
|
||||||
"productForm_synergizesWith": "Synergizes with (one per line)",
|
|
||||||
"productForm_incompatibleWith": "Incompatible with",
|
|
||||||
"productForm_addIncompatibility": "+ Add incompatibility",
|
|
||||||
"productForm_noIncompatibilities": "No incompatibilities added.",
|
|
||||||
"productForm_incompTarget": "Target ingredient",
|
|
||||||
"productForm_incompScope": "Scope",
|
|
||||||
"productForm_incompReason": "Reason (optional)",
|
|
||||||
"productForm_incompReasonPlaceholder": "e.g. reduces efficacy",
|
|
||||||
"productForm_incompScopeSelect": "Select…",
|
|
||||||
"productForm_contextRules": "Context rules",
|
"productForm_contextRules": "Context rules",
|
||||||
"productForm_ctxAfterShaving": "Safe after shaving",
|
"productForm_ctxAfterShaving": "Safe after shaving",
|
||||||
"productForm_ctxAfterAcids": "Safe after acids",
|
"productForm_ctxAfterAcids": "Safe after acids",
|
||||||
|
|
@ -495,10 +485,6 @@
|
||||||
"productForm_fnVitaminC": "vitamin C",
|
"productForm_fnVitaminC": "vitamin C",
|
||||||
"productForm_fnAntiAging": "anti-aging",
|
"productForm_fnAntiAging": "anti-aging",
|
||||||
|
|
||||||
"productForm_scopeSameStep": "same step",
|
|
||||||
"productForm_scopeSameDay": "same day",
|
|
||||||
"productForm_scopeSamePeriod": "same period",
|
|
||||||
|
|
||||||
"productForm_strengthLow": "1 Low",
|
"productForm_strengthLow": "1 Low",
|
||||||
"productForm_strengthMedium": "2 Medium",
|
"productForm_strengthMedium": "2 Medium",
|
||||||
"productForm_strengthHigh": "3 High",
|
"productForm_strengthHigh": "3 High",
|
||||||
|
|
|
||||||
|
|
@ -393,16 +393,6 @@
|
||||||
"productForm_activeIrritation": "Podrażnienie",
|
"productForm_activeIrritation": "Podrażnienie",
|
||||||
"productForm_activeFunctions": "Funkcje",
|
"productForm_activeFunctions": "Funkcje",
|
||||||
"productForm_effectProfile": "Profil działania (0–5)",
|
"productForm_effectProfile": "Profil działania (0–5)",
|
||||||
"productForm_interactions": "Interakcje",
|
|
||||||
"productForm_synergizesWith": "Synergizuje z (jedno na linię)",
|
|
||||||
"productForm_incompatibleWith": "Niekompatybilny z",
|
|
||||||
"productForm_addIncompatibility": "+ Dodaj niekompatybilność",
|
|
||||||
"productForm_noIncompatibilities": "Brak niekompatybilności.",
|
|
||||||
"productForm_incompTarget": "Składnik docelowy",
|
|
||||||
"productForm_incompScope": "Zakres",
|
|
||||||
"productForm_incompReason": "Powód (opcjonalny)",
|
|
||||||
"productForm_incompReasonPlaceholder": "np. zmniejsza skuteczność",
|
|
||||||
"productForm_incompScopeSelect": "Wybierz…",
|
|
||||||
"productForm_contextRules": "Reguły kontekstu",
|
"productForm_contextRules": "Reguły kontekstu",
|
||||||
"productForm_ctxAfterShaving": "Bezpieczny po goleniu",
|
"productForm_ctxAfterShaving": "Bezpieczny po goleniu",
|
||||||
"productForm_ctxAfterAcids": "Bezpieczny po kwasach",
|
"productForm_ctxAfterAcids": "Bezpieczny po kwasach",
|
||||||
|
|
@ -509,10 +499,6 @@
|
||||||
"productForm_fnVitaminC": "witamina C",
|
"productForm_fnVitaminC": "witamina C",
|
||||||
"productForm_fnAntiAging": "przeciwstarzeniowy",
|
"productForm_fnAntiAging": "przeciwstarzeniowy",
|
||||||
|
|
||||||
"productForm_scopeSameStep": "ten sam krok",
|
|
||||||
"productForm_scopeSameDay": "ten sam dzień",
|
|
||||||
"productForm_scopeSamePeriod": "ten sam okres",
|
|
||||||
|
|
||||||
"productForm_strengthLow": "1 Niskie",
|
"productForm_strengthLow": "1 Niskie",
|
||||||
"productForm_strengthMedium": "2 Średnie",
|
"productForm_strengthMedium": "2 Średnie",
|
||||||
"productForm_strengthHigh": "3 Wysokie",
|
"productForm_strengthHigh": "3 Wysokie",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import type {
|
||||||
Product,
|
Product,
|
||||||
ProductContext,
|
ProductContext,
|
||||||
ProductEffectProfile,
|
ProductEffectProfile,
|
||||||
ProductInteraction,
|
|
||||||
ProductInventory,
|
ProductInventory,
|
||||||
Routine,
|
Routine,
|
||||||
RoutineSuggestion,
|
RoutineSuggestion,
|
||||||
|
|
@ -127,8 +126,6 @@ export interface ProductParseResponse {
|
||||||
product_effect_profile?: ProductEffectProfile;
|
product_effect_profile?: ProductEffectProfile;
|
||||||
ph_min?: number;
|
ph_min?: number;
|
||||||
ph_max?: number;
|
ph_max?: number;
|
||||||
incompatible_with?: ProductInteraction[];
|
|
||||||
synergizes_with?: string[];
|
|
||||||
context_rules?: ProductContext;
|
context_rules?: ProductContext;
|
||||||
min_interval_hours?: number;
|
min_interval_hours?: number;
|
||||||
max_frequency_per_week?: number;
|
max_frequency_per_week?: number;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import type { Product } from '$lib/types';
|
import type { Product } from '$lib/types';
|
||||||
import type { IngredientFunction, InteractionScope } from '$lib/types';
|
import type { IngredientFunction } from '$lib/types';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
@ -34,7 +34,6 @@
|
||||||
'brightening', 'anti_acne', 'ceramide', 'niacinamide',
|
'brightening', 'anti_acne', 'ceramide', 'niacinamide',
|
||||||
'sunscreen', 'peptide', 'hair_growth_stimulant', 'prebiotic', 'vitamin_c', 'anti_aging'
|
'sunscreen', 'peptide', 'hair_growth_stimulant', 'prebiotic', 'vitamin_c', 'anti_aging'
|
||||||
];
|
];
|
||||||
const interactionScopes: InteractionScope[] = ['same_step', 'same_day', 'same_period'];
|
|
||||||
|
|
||||||
// ── Translated label maps ─────────────────────────────────────────────────
|
// ── Translated label maps ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -125,12 +124,6 @@
|
||||||
anti_aging: m["productForm_fnAntiAging"]()
|
anti_aging: m["productForm_fnAntiAging"]()
|
||||||
});
|
});
|
||||||
|
|
||||||
const scopeLabels = $derived<Record<string, string>>({
|
|
||||||
same_step: m["productForm_scopeSameStep"](),
|
|
||||||
same_day: m["productForm_scopeSameDay"](),
|
|
||||||
same_period: m["productForm_scopeSamePeriod"]()
|
|
||||||
});
|
|
||||||
|
|
||||||
const tristate = $derived([
|
const tristate = $derived([
|
||||||
{ value: '', label: m.common_unknown() },
|
{ value: '', label: m.common_unknown() },
|
||||||
{ value: 'true', label: m.common_yes() },
|
{ value: 'true', label: m.common_yes() },
|
||||||
|
|
@ -177,7 +170,6 @@
|
||||||
let usageNotes = $state(untrack(() => product?.usage_notes ?? ''));
|
let usageNotes = $state(untrack(() => product?.usage_notes ?? ''));
|
||||||
let inciText = $state(untrack(() => product?.inci?.join('\n') ?? ''));
|
let inciText = $state(untrack(() => product?.inci?.join('\n') ?? ''));
|
||||||
let contraindicationsText = $state(untrack(() => product?.contraindications?.join('\n') ?? ''));
|
let contraindicationsText = $state(untrack(() => product?.contraindications?.join('\n') ?? ''));
|
||||||
let synergizesWithText = $state(untrack(() => product?.synergizes_with?.join('\n') ?? ''));
|
|
||||||
|
|
||||||
let recommendedFor = $state<string[]>(untrack(() => [...(product?.recommended_for ?? [])]));
|
let recommendedFor = $state<string[]>(untrack(() => [...(product?.recommended_for ?? [])]));
|
||||||
let targetConcerns = $state<string[]>(untrack(() => [...(product?.targets ?? [])]));
|
let targetConcerns = $state<string[]>(untrack(() => [...(product?.targets ?? [])]));
|
||||||
|
|
@ -239,7 +231,6 @@
|
||||||
if (r.is_tool != null) isTool = r.is_tool;
|
if (r.is_tool != null) isTool = r.is_tool;
|
||||||
if (r.inci?.length) inciText = r.inci.join('\n');
|
if (r.inci?.length) inciText = r.inci.join('\n');
|
||||||
if (r.contraindications?.length) contraindicationsText = r.contraindications.join('\n');
|
if (r.contraindications?.length) contraindicationsText = r.contraindications.join('\n');
|
||||||
if (r.synergizes_with?.length) synergizesWithText = r.synergizes_with.join('\n');
|
|
||||||
if (r.actives?.length) {
|
if (r.actives?.length) {
|
||||||
actives = r.actives.map((a) => ({
|
actives = r.actives.map((a) => ({
|
||||||
name: a.name,
|
name: a.name,
|
||||||
|
|
@ -249,13 +240,6 @@
|
||||||
irritation_potential: a.irritation_potential != null ? String(a.irritation_potential) : ''
|
irritation_potential: a.irritation_potential != null ? String(a.irritation_potential) : ''
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (r.incompatible_with?.length) {
|
|
||||||
incompatibleWith = r.incompatible_with.map((i) => ({
|
|
||||||
target: i.target,
|
|
||||||
scope: i.scope,
|
|
||||||
reason: i.reason ?? ''
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (r.product_effect_profile) {
|
if (r.product_effect_profile) {
|
||||||
effectValues = { ...effectValues, ...r.product_effect_profile };
|
effectValues = { ...effectValues, ...r.product_effect_profile };
|
||||||
}
|
}
|
||||||
|
|
@ -380,40 +364,6 @@
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Dynamic incompatible_with ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
type IncompatibleRow = { target: string; scope: string; reason: string };
|
|
||||||
|
|
||||||
let incompatibleWith: IncompatibleRow[] = $state(
|
|
||||||
untrack(() =>
|
|
||||||
product?.incompatible_with?.map((i) => ({
|
|
||||||
target: i.target,
|
|
||||||
scope: i.scope,
|
|
||||||
reason: i.reason ?? ''
|
|
||||||
})) ?? []
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
function addIncompatible() {
|
|
||||||
incompatibleWith = [...incompatibleWith, { target: '', scope: '', reason: '' }];
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeIncompatible(i: number) {
|
|
||||||
incompatibleWith = incompatibleWith.filter((_, idx) => idx !== i);
|
|
||||||
}
|
|
||||||
|
|
||||||
let incompatibleJson = $derived(
|
|
||||||
JSON.stringify(
|
|
||||||
incompatibleWith
|
|
||||||
.filter((i) => i.target.trim() && i.scope)
|
|
||||||
.map((i) => ({
|
|
||||||
target: i.target.trim(),
|
|
||||||
scope: i.scope,
|
|
||||||
...(i.reason.trim() ? { reason: i.reason.trim() } : {})
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const textareaClass =
|
const textareaClass =
|
||||||
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
|
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
|
||||||
|
|
||||||
|
|
@ -749,68 +699,6 @@
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- ── Interactions ───────────────────────────────────────────────────────── -->
|
|
||||||
<Card>
|
|
||||||
<CardHeader><CardTitle>{m["productForm_interactions"]()}</CardTitle></CardHeader>
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="synergizes_with">{m["productForm_synergizesWith"]()}</Label>
|
|
||||||
<textarea
|
|
||||||
id="synergizes_with"
|
|
||||||
name="synergizes_with"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Ceramides Niacinamide Retinoids"
|
|
||||||
class={textareaClass}
|
|
||||||
bind:value={synergizesWithText}
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<Label>{m["productForm_incompatibleWith"]()}</Label>
|
|
||||||
<Button type="button" variant="outline" size="sm" onclick={addIncompatible}>
|
|
||||||
{m["productForm_addIncompatibility"]()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input type="hidden" name="incompatible_with_json" value={incompatibleJson} />
|
|
||||||
|
|
||||||
{#each incompatibleWith as row, i}
|
|
||||||
<div class="grid grid-cols-2 gap-2 items-end sm:grid-cols-[1fr_140px_1fr_auto]">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label class="text-xs">{m["productForm_incompTarget"]()}</Label>
|
|
||||||
<Input placeholder="e.g. Vitamin C" bind:value={row.target} />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label class="text-xs">{m["productForm_incompScope"]()}</Label>
|
|
||||||
<select class={selectClass} bind:value={row.scope}>
|
|
||||||
<option value="">{m["productForm_incompScopeSelect"]()}</option>
|
|
||||||
{#each interactionScopes as s}
|
|
||||||
<option value={s}>{scopeLabels[s]}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label class="text-xs">{m["productForm_incompReason"]()}</Label>
|
|
||||||
<Input placeholder={m["productForm_incompReasonPlaceholder"]()} bind:value={row.reason} />
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => removeIncompatible(i)}
|
|
||||||
class="text-destructive hover:text-destructive"
|
|
||||||
>✕</Button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if incompatibleWith.length === 0}
|
|
||||||
<p class="text-sm text-muted-foreground">{m["productForm_noIncompatibilities"]()}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- ── Context rules ──────────────────────────────────────────────────────── -->
|
<!-- ── Context rules ──────────────────────────────────────────────────────── -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
|
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ export type IngredientFunction =
|
||||||
| "prebiotic"
|
| "prebiotic"
|
||||||
| "vitamin_c"
|
| "vitamin_c"
|
||||||
| "anti_aging";
|
| "anti_aging";
|
||||||
export type InteractionScope = "same_step" | "same_day" | "same_period";
|
|
||||||
export type MedicationKind =
|
export type MedicationKind =
|
||||||
| "prescription"
|
| "prescription"
|
||||||
| "otc"
|
| "otc"
|
||||||
|
|
@ -113,12 +112,6 @@ export interface ProductEffectProfile {
|
||||||
anti_aging_strength: number;
|
anti_aging_strength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductInteraction {
|
|
||||||
target: string;
|
|
||||||
scope: InteractionScope;
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProductContext {
|
export interface ProductContext {
|
||||||
safe_after_shaving?: boolean;
|
safe_after_shaving?: boolean;
|
||||||
safe_after_acids?: boolean;
|
safe_after_acids?: boolean;
|
||||||
|
|
@ -172,8 +165,6 @@ export interface Product {
|
||||||
product_effect_profile: ProductEffectProfile;
|
product_effect_profile: ProductEffectProfile;
|
||||||
ph_min?: number;
|
ph_min?: number;
|
||||||
ph_max?: number;
|
ph_max?: number;
|
||||||
incompatible_with?: ProductInteraction[];
|
|
||||||
synergizes_with?: string[];
|
|
||||||
context_rules?: ProductContext;
|
context_rules?: ProductContext;
|
||||||
min_interval_hours?: number;
|
min_interval_hours?: number;
|
||||||
max_frequency_per_week?: number;
|
max_frequency_per_week?: number;
|
||||||
|
|
|
||||||
|
|
@ -166,23 +166,6 @@ export const actions: Actions = {
|
||||||
body.actives = null;
|
body.actives = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Incompatible with
|
|
||||||
try {
|
|
||||||
const raw = form.get('incompatible_with_json') as string | null;
|
|
||||||
if (raw) {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
body.incompatible_with = Array.isArray(parsed) && parsed.length > 0 ? parsed : null;
|
|
||||||
} else {
|
|
||||||
body.incompatible_with = null;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
body.incompatible_with = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Synergizes with
|
|
||||||
const synergizes = parseTextList(form.get('synergizes_with') as string | null);
|
|
||||||
body.synergizes_with = synergizes.length > 0 ? synergizes : null;
|
|
||||||
|
|
||||||
// Context rules
|
// Context rules
|
||||||
body.context_rules = parseContextRules(form) ?? null;
|
body.context_rules = parseContextRules(form) ?? null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,19 +167,6 @@ export const actions: Actions = {
|
||||||
}
|
}
|
||||||
} catch { /* ignore malformed JSON */ }
|
} catch { /* ignore malformed JSON */ }
|
||||||
|
|
||||||
// Incompatible with (JSON array)
|
|
||||||
try {
|
|
||||||
const raw = form.get('incompatible_with_json') as string | null;
|
|
||||||
if (raw) {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (Array.isArray(parsed) && parsed.length > 0) payload.incompatible_with = parsed;
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
// Synergizes with
|
|
||||||
const synergizes = parseTextList(form.get('synergizes_with') as string | null);
|
|
||||||
if (synergizes.length > 0) payload.synergizes_with = synergizes;
|
|
||||||
|
|
||||||
// Context rules
|
// Context rules
|
||||||
const contextRules = parseContextRules(form);
|
const contextRules = parseContextRules(form);
|
||||||
if (contextRules) payload.context_rules = contextRules;
|
if (contextRules) payload.context_rules = contextRules;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue