Initial commit: backend API, data models, and test suite
FastAPI backend for personal health and skincare data with MCP export. Includes SQLModel models for products, inventory, medications, lab results, routines, and skin condition snapshots. Pytest suite with 111 tests running on SQLite in-memory (no PostgreSQL required). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
8f7d893a63
32 changed files with 6282 additions and 0 deletions
377
backend/innercontext/models/product.py
Normal file
377
backend/innercontext/models/product.py
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
from datetime import date, datetime
|
||||
from typing import ClassVar, Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import model_validator
|
||||
from sqlalchemy import JSON, Column, DateTime
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
|
||||
from .base import utc_now
|
||||
from .domain import Domain
|
||||
from .enums import (
|
||||
AbsorptionSpeed,
|
||||
DayTime,
|
||||
EvidenceLevel,
|
||||
IngredientFunction,
|
||||
InteractionScope,
|
||||
PriceTier,
|
||||
ProductCategory,
|
||||
RoutineRole,
|
||||
SkinConcern,
|
||||
SkinType,
|
||||
StrengthLevel,
|
||||
TextureType,
|
||||
UsageFrequency,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Value objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProductEffectProfile(SQLModel):
|
||||
hydration_immediate: int = Field(default=0, ge=0, le=5)
|
||||
hydration_long_term: int = Field(default=0, ge=0, le=5)
|
||||
|
||||
barrier_repair_strength: int = Field(default=0, ge=0, le=5)
|
||||
soothing_strength: int = Field(default=0, ge=0, le=5)
|
||||
|
||||
exfoliation_strength: int = Field(default=0, ge=0, le=5)
|
||||
retinoid_strength: int = Field(default=0, ge=0, le=5)
|
||||
|
||||
irritation_risk: int = Field(default=0, ge=0, le=5)
|
||||
comedogenic_risk: int = Field(default=0, ge=0, le=5)
|
||||
|
||||
barrier_disruption_risk: int = Field(default=0, ge=0, le=5)
|
||||
dryness_risk: int = Field(default=0, ge=0, le=5)
|
||||
|
||||
brightening_strength: int = Field(default=0, ge=0, le=5)
|
||||
anti_acne_strength: int = Field(default=0, ge=0, le=5)
|
||||
anti_aging_strength: int = Field(default=0, ge=0, le=5)
|
||||
|
||||
|
||||
class ActiveIngredient(SQLModel):
|
||||
name: str
|
||||
percent: float | None = Field(default=None, ge=0, le=100)
|
||||
|
||||
functions: list[IngredientFunction] = Field(default_factory=list)
|
||||
|
||||
strength_level: StrengthLevel | None = None
|
||||
irritation_potential: StrengthLevel | None = None
|
||||
|
||||
cumulative_with: list[IngredientFunction] | 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
|
||||
safe_after_retinoids: bool | None = None
|
||||
safe_with_compromised_barrier: bool | None = None
|
||||
low_uv_only: bool | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ev(v: object) -> str:
|
||||
"""Return enum value or string as-is (handles both DB-loaded dicts and Python enums)."""
|
||||
return v.value if hasattr(v, "value") else str(v) # type: ignore[union-attr]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Table models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Product(SQLModel, table=True):
|
||||
__tablename__ = "products"
|
||||
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
|
||||
name: str
|
||||
brand: str
|
||||
line_name: str | None = Field(default=None, max_length=128)
|
||||
sku: str | None = Field(default=None, max_length=64)
|
||||
url: str | None = Field(default=None, max_length=512)
|
||||
barcode: str | None = Field(default=None, max_length=64)
|
||||
|
||||
category: ProductCategory
|
||||
routine_role: RoutineRole
|
||||
recommended_time: DayTime
|
||||
|
||||
texture: TextureType | None = None
|
||||
absorption_speed: AbsorptionSpeed | None = None
|
||||
leave_on: bool
|
||||
|
||||
price_tier: PriceTier | None = Field(default=None, index=True)
|
||||
size_ml: float | None = Field(default=None, gt=0)
|
||||
pao_months: int | None = Field(default=None, ge=1, le=60)
|
||||
|
||||
inci: list[str] = Field(
|
||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||
)
|
||||
actives: list[ActiveIngredient] | None = Field(
|
||||
default=None, sa_column=Column(JSON, nullable=True)
|
||||
)
|
||||
|
||||
recommended_for: list[SkinType] = Field(
|
||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||
)
|
||||
recommended_frequency: UsageFrequency | None = None
|
||||
|
||||
targets: list[SkinConcern] = Field(
|
||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||
)
|
||||
contraindications: list[str] = Field(
|
||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||
)
|
||||
usage_notes: str | None = None
|
||||
evidence_level: EvidenceLevel | None = Field(default=None, index=True)
|
||||
claims: list[str] = Field(
|
||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||
)
|
||||
|
||||
fragrance_free: bool | None = None
|
||||
essential_oils_free: bool | None = None
|
||||
alcohol_denat_free: bool | None = None
|
||||
pregnancy_safe: bool | None = None
|
||||
|
||||
product_effect_profile: ProductEffectProfile = Field(
|
||||
default_factory=ProductEffectProfile,
|
||||
sa_column=Column(JSON, nullable=False),
|
||||
)
|
||||
|
||||
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 = 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)
|
||||
)
|
||||
|
||||
min_interval_hours: int | None = Field(default=None, ge=0)
|
||||
max_frequency_per_week: int | None = Field(default=None, ge=1, le=14)
|
||||
|
||||
is_medication: bool = Field(default=False)
|
||||
is_tool: bool = Field(default=False)
|
||||
needle_length_mm: float | None = Field(default=None, gt=0)
|
||||
|
||||
personal_rating: int | None = Field(default=None, ge=1, le=10)
|
||||
personal_tolerance_notes: str | None = None
|
||||
personal_repurchase_intent: bool | None = None
|
||||
|
||||
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=utc_now,
|
||||
sa_column=Column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
inventory: list["ProductInventory"] = Relationship(back_populates="product")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_business_rules(self) -> "Product":
|
||||
if (
|
||||
self.ph_min is not None
|
||||
and self.ph_max is not None
|
||||
and self.ph_min > self.ph_max
|
||||
):
|
||||
raise ValueError("ph_min must be <= ph_max")
|
||||
|
||||
if self.category == ProductCategory.SPF and self.recommended_time == DayTime.PM:
|
||||
raise ValueError("SPF cannot be recommended only for PM")
|
||||
|
||||
if self.category == ProductCategory.SPF and not self.leave_on:
|
||||
raise ValueError("SPF products must be leave-on")
|
||||
|
||||
if self.is_medication and not self.usage_notes:
|
||||
raise ValueError("Medication products must have usage_notes")
|
||||
|
||||
return self
|
||||
|
||||
def to_llm_context(self) -> dict:
|
||||
ctx: dict = {
|
||||
"id": str(self.id),
|
||||
"name": self.name,
|
||||
"brand": self.brand,
|
||||
"category": _ev(self.category),
|
||||
"routine_role": _ev(self.routine_role),
|
||||
"recommended_time": _ev(self.recommended_time),
|
||||
"leave_on": self.leave_on,
|
||||
}
|
||||
|
||||
for field in ("line_name", "sku", "url", "barcode"):
|
||||
val = getattr(self, field)
|
||||
if val is not None:
|
||||
ctx[field] = val
|
||||
|
||||
if self.texture is not None:
|
||||
ctx["texture"] = _ev(self.texture)
|
||||
if self.absorption_speed is not None:
|
||||
ctx["absorption_speed"] = _ev(self.absorption_speed)
|
||||
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:
|
||||
ctx["pao_months"] = self.pao_months
|
||||
if self.recommended_frequency is not None:
|
||||
ctx["recommended_frequency"] = _ev(self.recommended_frequency)
|
||||
if self.evidence_level is not None:
|
||||
ctx["evidence_level"] = _ev(self.evidence_level)
|
||||
|
||||
if self.inci:
|
||||
ctx["inci"] = self.inci
|
||||
if self.recommended_for:
|
||||
ctx["recommended_for"] = [_ev(s) for s in self.recommended_for]
|
||||
if self.targets:
|
||||
ctx["targets"] = [_ev(s) for s in self.targets]
|
||||
if self.contraindications:
|
||||
ctx["contraindications"] = self.contraindications
|
||||
if self.claims:
|
||||
ctx["claims"] = self.claims
|
||||
|
||||
if self.actives:
|
||||
actives_ctx = []
|
||||
for a in self.actives:
|
||||
if isinstance(a, dict):
|
||||
actives_ctx.append(a)
|
||||
else:
|
||||
a_dict: dict = {"name": a.name}
|
||||
if a.percent is not None:
|
||||
a_dict["percent"] = a.percent
|
||||
if a.functions:
|
||||
a_dict["functions"] = [_ev(f) for f in a.functions]
|
||||
if a.strength_level is not None:
|
||||
a_dict["strength_level"] = a.strength_level.name.lower()
|
||||
if a.cumulative_with:
|
||||
a_dict["cumulative_with"] = [_ev(f) for f in a.cumulative_with]
|
||||
actives_ctx.append(a_dict)
|
||||
ctx["actives"] = actives_ctx
|
||||
|
||||
if self.ph_min is not None or self.ph_max is not None:
|
||||
if self.ph_min == self.ph_max and self.ph_min is not None:
|
||||
ctx["ph"] = self.ph_min
|
||||
elif self.ph_min is not None and self.ph_max is not None:
|
||||
ctx["ph_range"] = f"{self.ph_min}–{self.ph_max}"
|
||||
elif self.ph_min is not None:
|
||||
ctx["ph_min"] = self.ph_min
|
||||
else:
|
||||
ctx["ph_max"] = self.ph_max
|
||||
|
||||
ep = self.product_effect_profile
|
||||
if ep is not None:
|
||||
if isinstance(ep, dict):
|
||||
nonzero = {k: v for k, v in ep.items() if v}
|
||||
else:
|
||||
nonzero = {k: v for k, v in ep.model_dump().items() if v}
|
||||
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):
|
||||
rules = {k: v for k, v in cr.items() if v is not None}
|
||||
else:
|
||||
rules = {k: v for k, v in cr.model_dump().items() if v is not None}
|
||||
if rules:
|
||||
ctx["context_rules"] = rules
|
||||
|
||||
if self.min_interval_hours is not None:
|
||||
ctx["min_interval_hours"] = self.min_interval_hours
|
||||
if self.max_frequency_per_week is not None:
|
||||
ctx["max_frequency_per_week"] = self.max_frequency_per_week
|
||||
|
||||
safety = {}
|
||||
for flag in ("fragrance_free", "essential_oils_free", "alcohol_denat_free", "pregnancy_safe"):
|
||||
val = getattr(self, flag)
|
||||
if val is not None:
|
||||
safety[flag] = val
|
||||
if safety:
|
||||
ctx["safety"] = safety
|
||||
|
||||
if self.is_medication:
|
||||
ctx["is_medication"] = True
|
||||
if self.is_tool:
|
||||
ctx["is_tool"] = True
|
||||
if self.needle_length_mm is not None:
|
||||
ctx["needle_length_mm"] = self.needle_length_mm
|
||||
if self.usage_notes:
|
||||
ctx["usage_notes"] = self.usage_notes
|
||||
|
||||
if self.personal_rating is not None:
|
||||
ctx["personal_rating"] = self.personal_rating
|
||||
if self.personal_tolerance_notes:
|
||||
ctx["personal_tolerance_notes"] = self.personal_tolerance_notes
|
||||
if self.personal_repurchase_intent is not None:
|
||||
ctx["personal_repurchase_intent"] = self.personal_repurchase_intent
|
||||
|
||||
try:
|
||||
opened_items = [
|
||||
inv for inv in (self.inventory or []) if inv.is_opened and inv.opened_at
|
||||
]
|
||||
if opened_items:
|
||||
most_recent = max(opened_items, key=lambda x: x.opened_at)
|
||||
ctx["days_since_opened"] = (date.today() - most_recent.opened_at).days
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class ProductInventory(SQLModel, table=True):
|
||||
__tablename__ = "product_inventory"
|
||||
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
product_id: UUID = Field(foreign_key="products.id", index=True)
|
||||
|
||||
is_opened: bool = Field(default=False)
|
||||
opened_at: date | None = Field(default=None)
|
||||
finished_at: date | None = Field(default=None)
|
||||
expiry_date: date | None = Field(default=None)
|
||||
current_weight_g: float | None = Field(default=None, gt=0)
|
||||
notes: str | None = None
|
||||
|
||||
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
||||
|
||||
product: Optional["Product"] = Relationship(back_populates="inventory")
|
||||
Loading…
Add table
Add a link
Reference in a new issue