Compare commits
No commits in common. "142fbe8530b2172606fde095ff4a5dc1c213bd7e" and "c413e27768a5e7c6503012d9d2d90298ad021cce" have entirely different histories.
142fbe8530
...
c413e27768
25 changed files with 188 additions and 2218 deletions
|
|
@ -1,16 +1,12 @@
|
|||
import json
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from google.genai import types as genai_types
|
||||
from pydantic import ValidationError
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlmodel import Session, SQLModel, select
|
||||
|
||||
from db import get_session
|
||||
from innercontext.api.utils import get_or_404
|
||||
from innercontext.llm import get_gemini_client
|
||||
from innercontext.models import (
|
||||
Product,
|
||||
ProductBase,
|
||||
|
|
@ -99,50 +95,6 @@ class ProductUpdate(SQLModel):
|
|||
personal_repurchase_intent: Optional[bool] = None
|
||||
|
||||
|
||||
class ProductParseRequest(SQLModel):
|
||||
text: str
|
||||
|
||||
|
||||
class ProductParseResponse(SQLModel):
|
||||
name: Optional[str] = None
|
||||
brand: Optional[str] = None
|
||||
line_name: Optional[str] = None
|
||||
sku: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
barcode: Optional[str] = None
|
||||
category: Optional[ProductCategory] = None
|
||||
recommended_time: Optional[DayTime] = None
|
||||
texture: Optional[TextureType] = None
|
||||
absorption_speed: Optional[AbsorptionSpeed] = None
|
||||
leave_on: Optional[bool] = None
|
||||
price_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
|
||||
actives: Optional[list[ActiveIngredient]] = None
|
||||
recommended_for: Optional[list[SkinType]] = None
|
||||
targets: Optional[list[SkinConcern]] = None
|
||||
contraindications: Optional[list[str]] = None
|
||||
usage_notes: Optional[str] = None
|
||||
fragrance_free: Optional[bool] = None
|
||||
essential_oils_free: Optional[bool] = None
|
||||
alcohol_denat_free: Optional[bool] = None
|
||||
pregnancy_safe: Optional[bool] = None
|
||||
product_effect_profile: Optional[ProductEffectProfile] = None
|
||||
ph_min: Optional[float] = None
|
||||
ph_max: Optional[float] = None
|
||||
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
|
||||
is_medication: Optional[bool] = None
|
||||
is_tool: Optional[bool] = None
|
||||
needle_length_mm: Optional[float] = None
|
||||
|
||||
|
||||
class InventoryCreate(SQLModel):
|
||||
is_opened: bool = False
|
||||
opened_at: Optional[date] = None
|
||||
|
|
@ -216,160 +168,6 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session))
|
|||
return product
|
||||
|
||||
|
||||
def _product_parse_system_prompt() -> str:
|
||||
return """\
|
||||
You are a skincare and cosmetics product data extraction expert. \
|
||||
Given raw text (product page copy, ingredient list, label scan, etc.), \
|
||||
extract structured product data and return it as a single JSON object.
|
||||
|
||||
RULES:
|
||||
- Return ONLY raw JSON — no markdown code fences, no explanation, no preamble.
|
||||
- Omit any field you cannot confidently determine from the text. Do not guess.
|
||||
- All enum values must exactly match the allowed strings listed below.
|
||||
- For INCI lists: return each ingredient as a separate string in the array, \
|
||||
preserving standard INCI names exactly as they appear.
|
||||
- For actives: extract name, concentration (numeric, 0–100), functions \
|
||||
(use the allowed strings), and strength/irritation level if inferable.
|
||||
- For effect_profile scores (0–5 int): ALWAYS return the full product_effect_profile \
|
||||
object with all 13 fields. Infer each score from ingredient activity and product claims. \
|
||||
Use 0 only when you truly have no basis for an estimate.
|
||||
- For pH: extract from explicit mention (e.g. "pH 5.5", "pH range 4.0–5.0"). \
|
||||
Do not infer from ingredients alone.
|
||||
- For context_rules: infer from usage instructions and ingredient interactions \
|
||||
(e.g. "do not use with AHAs" → safe_after_acids: false).
|
||||
- fragrance_free / essential_oils_free / alcohol_denat_free: infer from INCI \
|
||||
or explicit claims. Fragrance = "Parfum" or "Fragrance" in INCI → fragrance_free: false.
|
||||
- For leave_on: true = leave-on treatment, false = rinse-off (cleanser, mask to rinse).
|
||||
- recommended_time: "am" if contains SPF or vitamin C; "pm" if retinoid/retinol; \
|
||||
"both" otherwise (when unclear, use "both").
|
||||
|
||||
ENUM ALLOWED VALUES (use ONLY these exact strings):
|
||||
|
||||
category: "cleanser" | "toner" | "essence" | "serum" | "moisturizer" | "spf" | \
|
||||
"mask" | "exfoliant" | "hair_treatment" | "tool" | "spot_treatment" | "oil"
|
||||
|
||||
recommended_time: "am" | "pm" | "both"
|
||||
|
||||
texture: "watery" | "gel" | "emulsion" | "cream" | "oil" | "balm" | "foam" | "fluid"
|
||||
|
||||
absorption_speed: "very_fast" | "fast" | "moderate" | "slow" | "very_slow"
|
||||
|
||||
price_tier: "budget" | "mid" | "premium" | "luxury"
|
||||
|
||||
recommended_for (array, pick applicable):
|
||||
"dry" | "oily" | "combination" | "sensitive" | "normal" | "acne_prone"
|
||||
|
||||
targets (array, pick applicable):
|
||||
"acne" | "rosacea" | "hyperpigmentation" | "aging" | "dehydration" | "redness" | \
|
||||
"damaged_barrier" | "pore_visibility" | "uneven_texture" | "hair_growth" | "sebum_excess"
|
||||
|
||||
actives[].functions (array, pick applicable):
|
||||
"humectant" | "emollient" | "occlusive" | "exfoliant_aha" | "exfoliant_bha" | \
|
||||
"exfoliant_pha" | "retinoid" | "antioxidant" | "soothing" | "barrier_support" | \
|
||||
"brightening" | "anti_acne" | "ceramide" | "niacinamide" | "sunscreen" | "peptide" | \
|
||||
"hair_growth_stimulant" | "prebiotic" | "vitamin_c"
|
||||
|
||||
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,
|
||||
"brand": string,
|
||||
"line_name": string,
|
||||
"sku": string,
|
||||
"url": string,
|
||||
"barcode": string,
|
||||
"category": string,
|
||||
"recommended_time": string,
|
||||
"texture": string,
|
||||
"absorption_speed": string,
|
||||
"leave_on": boolean,
|
||||
"price_tier": string,
|
||||
"size_ml": number,
|
||||
"full_weight_g": number,
|
||||
"empty_weight_g": number,
|
||||
"pao_months": integer,
|
||||
"inci": [string, ...],
|
||||
"actives": [
|
||||
{
|
||||
"name": string,
|
||||
"percent": number,
|
||||
"functions": [string, ...],
|
||||
"strength_level": 1|2|3,
|
||||
"irritation_potential": 1|2|3
|
||||
}
|
||||
],
|
||||
"recommended_for": [string, ...],
|
||||
"targets": [string, ...],
|
||||
"contraindications": [string, ...],
|
||||
"usage_notes": string,
|
||||
"fragrance_free": boolean,
|
||||
"essential_oils_free": boolean,
|
||||
"alcohol_denat_free": boolean,
|
||||
"pregnancy_safe": boolean,
|
||||
"product_effect_profile": {
|
||||
"hydration_immediate": integer (0-5),
|
||||
"hydration_long_term": integer (0-5),
|
||||
"barrier_repair_strength": integer (0-5),
|
||||
"soothing_strength": integer (0-5),
|
||||
"exfoliation_strength": integer (0-5),
|
||||
"retinoid_strength": integer (0-5),
|
||||
"irritation_risk": integer (0-5),
|
||||
"comedogenic_risk": integer (0-5),
|
||||
"barrier_disruption_risk": integer (0-5),
|
||||
"dryness_risk": integer (0-5),
|
||||
"brightening_strength": integer (0-5),
|
||||
"anti_acne_strength": integer (0-5),
|
||||
"anti_aging_strength": integer (0-5)
|
||||
},
|
||||
"ph_min": number,
|
||||
"ph_max": number,
|
||||
"incompatible_with": [
|
||||
{"target": string, "scope": string, "reason": string}
|
||||
],
|
||||
"synergizes_with": [string, ...],
|
||||
"context_rules": {
|
||||
"safe_after_shaving": boolean,
|
||||
"safe_after_acids": boolean,
|
||||
"safe_after_retinoids": boolean,
|
||||
"safe_with_compromised_barrier": boolean,
|
||||
"low_uv_only": boolean
|
||||
},
|
||||
"min_interval_hours": integer,
|
||||
"max_frequency_per_week": integer,
|
||||
"is_medication": boolean,
|
||||
"is_tool": boolean,
|
||||
"needle_length_mm": number
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@router.post("/parse-text", response_model=ProductParseResponse)
|
||||
def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
|
||||
client, model = get_gemini_client()
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
contents=f"Extract product data from this text:\n\n{data.text}",
|
||||
config=genai_types.GenerateContentConfig(
|
||||
system_instruction=_product_parse_system_prompt(),
|
||||
response_mime_type="application/json",
|
||||
max_output_tokens=4096,
|
||||
temperature=0.0,
|
||||
),
|
||||
)
|
||||
try:
|
||||
parsed = json.loads(response.text)
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
|
||||
try:
|
||||
return ProductParseResponse.model_validate(parsed)
|
||||
except ValidationError as e:
|
||||
raise HTTPException(status_code=422, detail=e.errors())
|
||||
|
||||
|
||||
@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)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,12 @@
|
|||
import json
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from google.genai import types as genai_types
|
||||
from pydantic import ValidationError
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session, SQLModel, select
|
||||
|
||||
from db import get_session
|
||||
from innercontext.api.utils import get_or_404
|
||||
from innercontext.llm import get_gemini_client
|
||||
from innercontext.models import (
|
||||
SkinConditionSnapshot,
|
||||
SkinConditionSnapshotBase,
|
||||
|
|
@ -20,7 +16,7 @@ from innercontext.models.enums import (
|
|||
BarrierState,
|
||||
OverallSkinState,
|
||||
SkinConcern,
|
||||
SkinTexture,
|
||||
SkinTrend,
|
||||
SkinType,
|
||||
)
|
||||
|
||||
|
|
@ -39,8 +35,8 @@ class SnapshotCreate(SkinConditionSnapshotBase):
|
|||
class SnapshotUpdate(SQLModel):
|
||||
snapshot_date: Optional[date] = None
|
||||
overall_state: Optional[OverallSkinState] = None
|
||||
trend: Optional[SkinTrend] = None
|
||||
skin_type: Optional[SkinType] = None
|
||||
texture: Optional[SkinTexture] = None
|
||||
|
||||
hydration_level: Optional[int] = None
|
||||
sebum_tzone: Optional[int] = None
|
||||
|
|
@ -55,117 +51,11 @@ class SnapshotUpdate(SQLModel):
|
|||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class SkinPhotoAnalysisResponse(SQLModel):
|
||||
overall_state: Optional[OverallSkinState] = None
|
||||
skin_type: Optional[SkinType] = None
|
||||
texture: Optional[SkinTexture] = None
|
||||
hydration_level: Optional[int] = None
|
||||
sebum_tzone: Optional[int] = None
|
||||
sebum_cheeks: Optional[int] = None
|
||||
sensitivity_level: Optional[int] = None
|
||||
barrier_state: Optional[BarrierState] = None
|
||||
active_concerns: Optional[list[SkinConcern]] = None
|
||||
risks: Optional[list[str]] = None
|
||||
priorities: Optional[list[str]] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _skin_photo_system_prompt() -> str:
|
||||
return """\
|
||||
You are a dermatology-trained skin assessment AI. Analyze the provided photo(s) of a person's
|
||||
skin and return a structured JSON assessment.
|
||||
|
||||
RULES:
|
||||
- Return ONLY raw JSON — no markdown fences, no explanation.
|
||||
- Omit any field you cannot confidently determine from the photos. Do not guess.
|
||||
- All enum values must exactly match the allowed strings listed below.
|
||||
- Numeric metrics use a 1–5 scale (1 = minimal, 5 = maximal).
|
||||
- risks and priorities: short English phrases, max 10 words each.
|
||||
- notes: 2–4 sentence paragraph describing key observations.
|
||||
|
||||
ENUM VALUES:
|
||||
overall_state: "excellent" | "good" | "fair" | "poor"
|
||||
skin_type: "dry" | "oily" | "combination" | "sensitive" | "normal" | "acne_prone"
|
||||
texture: "smooth" | "rough" | "flaky" | "bumpy"
|
||||
barrier_state: "intact" | "mildly_compromised" | "compromised"
|
||||
active_concerns: "acne" | "rosacea" | "hyperpigmentation" | "aging" | "dehydration" |
|
||||
"redness" | "damaged_barrier" | "pore_visibility" | "uneven_texture" | "sebum_excess"
|
||||
|
||||
METRICS (int 1–5, omit if not assessable):
|
||||
hydration_level: 1=very dehydrated/dull → 5=plump/luminous
|
||||
sebum_tzone: 1=very dry T-zone → 5=very oily T-zone
|
||||
sebum_cheeks: 1=very dry cheeks → 5=very oily cheeks
|
||||
sensitivity_level: 1=no visible signs → 5=severe redness/reactivity
|
||||
|
||||
OUTPUT (all fields optional):
|
||||
{"overall_state":…, "skin_type":…, "texture":…, "hydration_level":…,
|
||||
"sebum_tzone":…, "sebum_cheeks":…, "sensitivity_level":…,
|
||||
"barrier_state":…, "active_concerns":[…], "risks":[…], "priorities":[…], "notes":…}
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||
|
||||
|
||||
@router.post("/analyze-photos", response_model=SkinPhotoAnalysisResponse)
|
||||
async def analyze_skin_photos(
|
||||
photos: list[UploadFile] = File(...),
|
||||
) -> SkinPhotoAnalysisResponse:
|
||||
if not (1 <= len(photos) <= 3):
|
||||
raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.")
|
||||
|
||||
client, model = get_gemini_client()
|
||||
|
||||
allowed = {"image/jpeg", "image/png", "image/webp"}
|
||||
parts: list[genai_types.Part] = []
|
||||
for photo in photos:
|
||||
if photo.content_type not in allowed:
|
||||
raise HTTPException(status_code=422, detail=f"Unsupported type: {photo.content_type}")
|
||||
data = await photo.read()
|
||||
if len(data) > MAX_IMAGE_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"{photo.filename} exceeds 5 MB.")
|
||||
parts.append(genai_types.Part.from_bytes(data=data, mime_type=photo.content_type))
|
||||
parts.append(
|
||||
genai_types.Part.from_text(
|
||||
text="Analyze the skin condition visible in the above photo(s) and return the JSON assessment."
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
contents=parts,
|
||||
config=genai_types.GenerateContentConfig(
|
||||
system_instruction=_skin_photo_system_prompt(),
|
||||
response_mime_type="application/json",
|
||||
max_output_tokens=2048,
|
||||
temperature=0.0,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Gemini API error: {e}")
|
||||
|
||||
try:
|
||||
parsed = json.loads(response.text)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
|
||||
|
||||
try:
|
||||
return SkinPhotoAnalysisResponse.model_validate(parsed)
|
||||
except ValidationError as e:
|
||||
raise HTTPException(status_code=422, detail=e.errors())
|
||||
|
||||
|
||||
@router.get("", response_model=list[SkinConditionSnapshotPublic])
|
||||
def list_snapshots(
|
||||
from_date: Optional[date] = None,
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
"""Shared helpers for Gemini API access."""
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import HTTPException
|
||||
from google import genai
|
||||
|
||||
|
||||
_DEFAULT_MODEL = "gemini-flash-latest"
|
||||
|
||||
|
||||
def get_gemini_client() -> tuple[genai.Client, str]:
|
||||
"""Return an authenticated Gemini client and the configured model name.
|
||||
|
||||
Raises HTTP 503 if GEMINI_API_KEY is not set.
|
||||
"""
|
||||
api_key = os.environ.get("GEMINI_API_KEY")
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=503, detail="GEMINI_API_KEY not configured")
|
||||
model = os.environ.get("GEMINI_MODEL", _DEFAULT_MODEL)
|
||||
return genai.Client(api_key=api_key), model
|
||||
|
|
@ -1,438 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from db import engine
|
||||
from innercontext.models import (
|
||||
GroomingSchedule,
|
||||
LabResult,
|
||||
MedicationEntry,
|
||||
MedicationUsage,
|
||||
Product,
|
||||
ProductInventory,
|
||||
Routine,
|
||||
RoutineStep,
|
||||
SkinConditionSnapshot,
|
||||
)
|
||||
|
||||
mcp = FastMCP("innercontext")
|
||||
|
||||
|
||||
# ── Products ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_products(
|
||||
category: Optional[str] = None,
|
||||
is_medication: bool = False,
|
||||
is_tool: bool = False,
|
||||
) -> list[dict]:
|
||||
"""List products. By default returns skincare products (excludes medications and tools).
|
||||
Pass is_medication=True or is_tool=True to retrieve those categories instead."""
|
||||
with Session(engine) as session:
|
||||
stmt = select(Product)
|
||||
if category is not None:
|
||||
stmt = stmt.where(Product.category == category)
|
||||
stmt = stmt.where(Product.is_medication == is_medication)
|
||||
stmt = stmt.where(Product.is_tool == is_tool)
|
||||
products = session.exec(stmt).all()
|
||||
return [p.to_llm_context() for p in products]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_product(product_id: str) -> dict:
|
||||
"""Get full context for a single product (UUID) including all inventory entries."""
|
||||
with Session(engine) as session:
|
||||
product = session.get(Product, UUID(product_id))
|
||||
if product is None:
|
||||
return {"error": f"Product {product_id} not found"}
|
||||
ctx = product.to_llm_context()
|
||||
entries = session.exec(
|
||||
select(ProductInventory).where(ProductInventory.product_id == product.id)
|
||||
).all()
|
||||
ctx["inventory"] = [
|
||||
{
|
||||
"id": str(inv.id),
|
||||
"is_opened": inv.is_opened,
|
||||
"opened_at": inv.opened_at.isoformat() if inv.opened_at else None,
|
||||
"finished_at": inv.finished_at.isoformat() if inv.finished_at else None,
|
||||
"expiry_date": inv.expiry_date.isoformat() if inv.expiry_date else None,
|
||||
"current_weight_g": inv.current_weight_g,
|
||||
}
|
||||
for inv in entries
|
||||
]
|
||||
return ctx
|
||||
|
||||
|
||||
# ── Inventory ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_open_inventory() -> list[dict]:
|
||||
"""Return all currently open packages (is_opened=True, finished_at=None)
|
||||
with product name, opening date, weight, and expiry date."""
|
||||
with Session(engine) as session:
|
||||
stmt = (
|
||||
select(ProductInventory, Product)
|
||||
.join(Product, ProductInventory.product_id == Product.id)
|
||||
.where(ProductInventory.is_opened == True) # noqa: E712
|
||||
.where(ProductInventory.finished_at == None) # noqa: E711
|
||||
)
|
||||
rows = session.exec(stmt).all()
|
||||
return [
|
||||
{
|
||||
"inventory_id": str(inv.id),
|
||||
"product_id": str(product.id),
|
||||
"product_name": product.name,
|
||||
"brand": product.brand,
|
||||
"opened_at": inv.opened_at.isoformat() if inv.opened_at else None,
|
||||
"current_weight_g": inv.current_weight_g,
|
||||
"expiry_date": inv.expiry_date.isoformat() if inv.expiry_date else None,
|
||||
}
|
||||
for inv, product in rows
|
||||
]
|
||||
|
||||
|
||||
# ── Routines ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_recent_routines(days: int = 14) -> list[dict]:
|
||||
"""Get skincare routines from the last N days, newest first.
|
||||
Each routine includes its ordered steps with product name or action."""
|
||||
with Session(engine) as session:
|
||||
cutoff = date.today() - timedelta(days=days)
|
||||
routines = session.exec(
|
||||
select(Routine)
|
||||
.where(Routine.routine_date >= cutoff)
|
||||
.order_by(col(Routine.routine_date).desc())
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for routine in routines:
|
||||
steps = session.exec(
|
||||
select(RoutineStep)
|
||||
.where(RoutineStep.routine_id == routine.id)
|
||||
.order_by(RoutineStep.order_index)
|
||||
).all()
|
||||
|
||||
steps_data = []
|
||||
for step in steps:
|
||||
step_dict: dict = {"order": step.order_index}
|
||||
if step.product_id:
|
||||
product = session.get(Product, step.product_id)
|
||||
if product:
|
||||
step_dict["product"] = product.name
|
||||
step_dict["product_id"] = str(product.id)
|
||||
if step.action_type:
|
||||
step_dict["action"] = (
|
||||
step.action_type.value
|
||||
if hasattr(step.action_type, "value")
|
||||
else str(step.action_type)
|
||||
)
|
||||
if step.action_notes:
|
||||
step_dict["notes"] = step.action_notes
|
||||
if step.dose:
|
||||
step_dict["dose"] = step.dose
|
||||
if step.region:
|
||||
step_dict["region"] = step.region
|
||||
steps_data.append(step_dict)
|
||||
|
||||
result.append(
|
||||
{
|
||||
"id": str(routine.id),
|
||||
"date": routine.routine_date.isoformat(),
|
||||
"part_of_day": (
|
||||
routine.part_of_day.value
|
||||
if hasattr(routine.part_of_day, "value")
|
||||
else str(routine.part_of_day)
|
||||
),
|
||||
"notes": routine.notes,
|
||||
"steps": steps_data,
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── Skin snapshots ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _snapshot_to_dict(s: SkinConditionSnapshot, *, full: bool) -> dict:
|
||||
def ev(v: object) -> object:
|
||||
return v.value if v is not None and hasattr(v, "value") else v
|
||||
|
||||
d: dict = {
|
||||
"id": str(s.id),
|
||||
"date": s.snapshot_date.isoformat(),
|
||||
"overall_state": ev(s.overall_state),
|
||||
"hydration_level": s.hydration_level,
|
||||
"sensitivity_level": s.sensitivity_level,
|
||||
"barrier_state": ev(s.barrier_state),
|
||||
"active_concerns": [ev(c) for c in (s.active_concerns or [])],
|
||||
}
|
||||
if full:
|
||||
d.update(
|
||||
{
|
||||
"skin_type": ev(s.skin_type),
|
||||
"texture": ev(s.texture),
|
||||
"sebum_tzone": s.sebum_tzone,
|
||||
"sebum_cheeks": s.sebum_cheeks,
|
||||
"risks": s.risks or [],
|
||||
"priorities": s.priorities or [],
|
||||
"notes": s.notes,
|
||||
}
|
||||
)
|
||||
return d
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_latest_skin_snapshot() -> dict | None:
|
||||
"""Get the most recent skin condition snapshot with all metrics."""
|
||||
with Session(engine) as session:
|
||||
snapshot = session.exec(
|
||||
select(SkinConditionSnapshot).order_by(
|
||||
col(SkinConditionSnapshot.snapshot_date).desc()
|
||||
)
|
||||
).first()
|
||||
if snapshot is None:
|
||||
return None
|
||||
return _snapshot_to_dict(snapshot, full=True)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_skin_history(weeks: int = 8) -> list[dict]:
|
||||
"""Get skin condition snapshots from the last N weeks with key metrics."""
|
||||
with Session(engine) as session:
|
||||
cutoff = date.today() - timedelta(weeks=weeks)
|
||||
snapshots = session.exec(
|
||||
select(SkinConditionSnapshot)
|
||||
.where(SkinConditionSnapshot.snapshot_date >= cutoff)
|
||||
.order_by(col(SkinConditionSnapshot.snapshot_date).desc())
|
||||
).all()
|
||||
return [_snapshot_to_dict(s, full=False) for s in snapshots]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_skin_snapshot_dates() -> list[str]:
|
||||
"""List all dates (YYYY-MM-DD) for which skin snapshots exist, newest first."""
|
||||
with Session(engine) as session:
|
||||
snapshots = session.exec(
|
||||
select(SkinConditionSnapshot).order_by(
|
||||
col(SkinConditionSnapshot.snapshot_date).desc()
|
||||
)
|
||||
).all()
|
||||
return [s.snapshot_date.isoformat() for s in snapshots]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_skin_snapshot(snapshot_date: str) -> dict | None:
|
||||
"""Get the full skin condition snapshot for a specific date (YYYY-MM-DD)."""
|
||||
with Session(engine) as session:
|
||||
target = date.fromisoformat(snapshot_date)
|
||||
snapshot = session.exec(
|
||||
select(SkinConditionSnapshot).where(
|
||||
SkinConditionSnapshot.snapshot_date == target
|
||||
)
|
||||
).first()
|
||||
if snapshot is None:
|
||||
return None
|
||||
return _snapshot_to_dict(snapshot, full=True)
|
||||
|
||||
|
||||
# ── Health / medications ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_medications() -> list[dict]:
|
||||
"""Get all medication entries with their currently active usage records
|
||||
(valid_to IS NULL or >= today)."""
|
||||
with Session(engine) as session:
|
||||
medications = session.exec(select(MedicationEntry)).all()
|
||||
today = date.today()
|
||||
result = []
|
||||
for med in medications:
|
||||
usages = session.exec(
|
||||
select(MedicationUsage)
|
||||
.where(MedicationUsage.medication_record_id == med.record_id)
|
||||
.where(
|
||||
(MedicationUsage.valid_to == None) # noqa: E711
|
||||
| (col(MedicationUsage.valid_to) >= today)
|
||||
)
|
||||
).all()
|
||||
result.append(
|
||||
{
|
||||
"id": str(med.record_id),
|
||||
"product_name": med.product_name,
|
||||
"kind": (
|
||||
med.kind.value if hasattr(med.kind, "value") else str(med.kind)
|
||||
),
|
||||
"active_substance": med.active_substance,
|
||||
"formulation": med.formulation,
|
||||
"route": med.route,
|
||||
"notes": med.notes,
|
||||
"active_usages": [
|
||||
{
|
||||
"id": str(u.record_id),
|
||||
"dose": (
|
||||
f"{u.dose_value} {u.dose_unit}"
|
||||
if u.dose_value is not None and u.dose_unit
|
||||
else None
|
||||
),
|
||||
"frequency": u.frequency,
|
||||
"schedule_text": u.schedule_text,
|
||||
"as_needed": u.as_needed,
|
||||
"valid_from": u.valid_from.isoformat()
|
||||
if u.valid_from
|
||||
else None,
|
||||
"valid_to": u.valid_to.isoformat() if u.valid_to else None,
|
||||
}
|
||||
for u in usages
|
||||
],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ── Expiring inventory ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_expiring_inventory(days: int = 30) -> list[dict]:
|
||||
"""List open packages whose expiry date falls within the next N days.
|
||||
Sorted by days remaining (soonest first)."""
|
||||
with Session(engine) as session:
|
||||
cutoff = date.today() + timedelta(days=days)
|
||||
stmt = (
|
||||
select(ProductInventory, Product)
|
||||
.join(Product, ProductInventory.product_id == Product.id)
|
||||
.where(ProductInventory.is_opened == True) # noqa: E712
|
||||
.where(ProductInventory.finished_at == None) # noqa: E711
|
||||
.where(ProductInventory.expiry_date != None) # noqa: E711
|
||||
.where(col(ProductInventory.expiry_date) <= cutoff)
|
||||
)
|
||||
rows = session.exec(stmt).all()
|
||||
today = date.today()
|
||||
result = [
|
||||
{
|
||||
"product_name": product.name,
|
||||
"brand": product.brand,
|
||||
"expiry_date": inv.expiry_date.isoformat() if inv.expiry_date else None,
|
||||
"days_remaining": (inv.expiry_date - today).days
|
||||
if inv.expiry_date
|
||||
else None,
|
||||
"current_weight_g": inv.current_weight_g,
|
||||
}
|
||||
for inv, product in rows
|
||||
]
|
||||
return sorted(result, key=lambda x: x["days_remaining"] or 0)
|
||||
|
||||
|
||||
# ── Grooming schedule ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_grooming_schedule() -> list[dict]:
|
||||
"""Get the full grooming schedule sorted by day of week (0=Monday, 6=Sunday)."""
|
||||
with Session(engine) as session:
|
||||
entries = session.exec(
|
||||
select(GroomingSchedule).order_by(GroomingSchedule.day_of_week)
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"id": str(e.id),
|
||||
"day_of_week": e.day_of_week,
|
||||
"action": (
|
||||
e.action.value if hasattr(e.action, "value") else str(e.action)
|
||||
),
|
||||
"notes": e.notes,
|
||||
}
|
||||
for e in entries
|
||||
]
|
||||
|
||||
|
||||
# ── Lab results ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _lab_result_to_dict(r: LabResult) -> dict:
|
||||
return {
|
||||
"id": str(r.record_id),
|
||||
"collected_at": r.collected_at.isoformat(),
|
||||
"test_code": r.test_code,
|
||||
"test_name_loinc": r.test_name_loinc,
|
||||
"test_name_original": r.test_name_original,
|
||||
"value_num": r.value_num,
|
||||
"value_text": r.value_text,
|
||||
"value_bool": r.value_bool,
|
||||
"unit": r.unit_ucum or r.unit_original,
|
||||
"ref_low": r.ref_low,
|
||||
"ref_high": r.ref_high,
|
||||
"ref_text": r.ref_text,
|
||||
"flag": r.flag.value if r.flag and hasattr(r.flag, "value") else r.flag,
|
||||
"lab": r.lab,
|
||||
"notes": r.notes,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_recent_lab_results(limit: int = 30) -> list[dict]:
|
||||
"""Get the most recent lab results sorted by collection date descending."""
|
||||
with Session(engine) as session:
|
||||
results = session.exec(
|
||||
select(LabResult)
|
||||
.order_by(col(LabResult.collected_at).desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
return [_lab_result_to_dict(r) for r in results]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_available_lab_tests() -> list[dict]:
|
||||
"""List all distinct lab tests ever performed, grouped by LOINC test_code.
|
||||
Returns test_code, LOINC name, original lab names, result count, and last collection date."""
|
||||
with Session(engine) as session:
|
||||
results = session.exec(select(LabResult)).all()
|
||||
tests: dict[str, dict] = {}
|
||||
for r in results:
|
||||
code = r.test_code
|
||||
if code not in tests:
|
||||
tests[code] = {
|
||||
"test_code": code,
|
||||
"test_name_loinc": r.test_name_loinc,
|
||||
"test_names_original": set(),
|
||||
"count": 0,
|
||||
"last_collected_at": r.collected_at,
|
||||
}
|
||||
tests[code]["count"] += 1
|
||||
if r.test_name_original:
|
||||
tests[code]["test_names_original"].add(r.test_name_original)
|
||||
if r.collected_at > tests[code]["last_collected_at"]:
|
||||
tests[code]["last_collected_at"] = r.collected_at
|
||||
|
||||
return [
|
||||
{
|
||||
"test_code": v["test_code"],
|
||||
"test_name_loinc": v["test_name_loinc"],
|
||||
"test_names_original": sorted(v["test_names_original"]),
|
||||
"count": v["count"],
|
||||
"last_collected_at": v["last_collected_at"].isoformat(),
|
||||
}
|
||||
for v in sorted(tests.values(), key=lambda x: x["test_code"])
|
||||
]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_lab_results_for_test(test_code: str) -> list[dict]:
|
||||
"""Get the full chronological history of results for a specific LOINC test code."""
|
||||
with Session(engine) as session:
|
||||
results = session.exec(
|
||||
select(LabResult)
|
||||
.where(LabResult.test_code == test_code)
|
||||
.order_by(col(LabResult.collected_at).asc())
|
||||
).all()
|
||||
return [_lab_result_to_dict(r) for r in results]
|
||||
|
|
@ -15,7 +15,7 @@ from .enums import (
|
|||
ResultFlag,
|
||||
RoutineRole,
|
||||
SkinConcern,
|
||||
SkinTexture,
|
||||
SkinTrend,
|
||||
SkinType,
|
||||
StrengthLevel,
|
||||
TextureType,
|
||||
|
|
@ -59,7 +59,7 @@ __all__ = [
|
|||
"ResultFlag",
|
||||
"RoutineRole",
|
||||
"SkinConcern",
|
||||
"SkinTexture",
|
||||
"SkinTrend",
|
||||
"SkinType",
|
||||
"StrengthLevel",
|
||||
"TextureType",
|
||||
|
|
|
|||
|
|
@ -186,11 +186,11 @@ class OverallSkinState(str, Enum):
|
|||
POOR = "poor"
|
||||
|
||||
|
||||
class SkinTexture(str, Enum):
|
||||
SMOOTH = "smooth"
|
||||
ROUGH = "rough"
|
||||
FLAKY = "flaky"
|
||||
BUMPY = "bumpy"
|
||||
class SkinTrend(str, Enum):
|
||||
IMPROVING = "improving"
|
||||
STABLE = "stable"
|
||||
WORSENING = "worsening"
|
||||
FLUCTUATING = "fluctuating"
|
||||
|
||||
|
||||
class BarrierState(str, Enum):
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from sqlmodel import Field, SQLModel
|
|||
|
||||
from .base import utc_now
|
||||
from .domain import Domain
|
||||
from .enums import BarrierState, OverallSkinState, SkinConcern, SkinTexture, SkinType
|
||||
from .enums import BarrierState, OverallSkinState, SkinConcern, SkinTrend, SkinType
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base model (pure Python types, no sa_column, no id/created_at)
|
||||
|
|
@ -20,8 +20,8 @@ class SkinConditionSnapshotBase(SQLModel):
|
|||
snapshot_date: date
|
||||
|
||||
overall_state: OverallSkinState | None = None
|
||||
trend: SkinTrend | None = None
|
||||
skin_type: SkinType | None = None
|
||||
texture: SkinTexture | None = None
|
||||
|
||||
# Metryki wizualne (1 = minimalne, 5 = maksymalne nasilenie)
|
||||
hydration_level: int | None = Field(default=None, ge=1, le=5)
|
||||
|
|
|
|||
|
|
@ -6,19 +6,9 @@ load_dotenv() # load .env before db.py reads DATABASE_URL
|
|||
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
||||
from fastmcp.utilities.lifespan import combine_lifespans # noqa: E402
|
||||
|
||||
from db import create_db_and_tables # noqa: E402
|
||||
from innercontext.api import ( # noqa: E402
|
||||
health,
|
||||
inventory,
|
||||
products,
|
||||
routines,
|
||||
skincare,
|
||||
)
|
||||
from innercontext.mcp_server import mcp # noqa: E402
|
||||
|
||||
mcp_app = mcp.http_app(path="/mcp")
|
||||
from innercontext.api import health, inventory, products, routines, skincare # noqa: E402
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -27,11 +17,7 @@ async def lifespan(app: FastAPI):
|
|||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="innercontext API",
|
||||
lifespan=combine_lifespans(lifespan, mcp_app.lifespan),
|
||||
redirect_slashes=False,
|
||||
)
|
||||
app = FastAPI(title="innercontext API", lifespan=lifespan, redirect_slashes=False)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware, # ty: ignore[invalid-argument-type]
|
||||
|
|
@ -47,9 +33,6 @@ app.include_router(routines.router, prefix="/routines", tags=["routines"])
|
|||
app.include_router(skincare.router, prefix="/skincare", tags=["skincare"])
|
||||
|
||||
|
||||
app.mount("/mcp", mcp_app)
|
||||
|
||||
|
||||
@app.get("/health-check")
|
||||
def health_check():
|
||||
return {"status": "ok"}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,8 @@ readme = "README.md"
|
|||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.132.0",
|
||||
"fastmcp>=2.0",
|
||||
"google-genai>=1.65.0",
|
||||
"psycopg>=3.3.3",
|
||||
"python-dotenv>=1.2.1",
|
||||
"python-multipart>=0.0.22",
|
||||
"sqlmodel>=0.0.37",
|
||||
"uvicorn[standard]>=0.34.0",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# Must be set before importing db (which calls create_engine at module level)
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite://")
|
||||
|
|
@ -14,18 +13,6 @@ from db import get_session
|
|||
from main import app
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _db_only_lifespan(a):
|
||||
"""Lifespan without the MCP server for test isolation.
|
||||
|
||||
StreamableHTTPSessionManager.run() can only be called once per instance,
|
||||
which conflicts with the per-test TestClient lifecycle. We replace the
|
||||
combined (db + MCP) lifespan with one that only does DB setup.
|
||||
"""
|
||||
db_module.create_db_and_tables()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session(monkeypatch):
|
||||
"""Per-test fresh SQLite in-memory database with full isolation."""
|
||||
|
|
@ -45,11 +32,8 @@ def session(monkeypatch):
|
|||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(session, monkeypatch):
|
||||
def client(session):
|
||||
"""TestClient using the per-test session for every request."""
|
||||
# Replace combined (db+MCP) lifespan with DB-only to avoid the
|
||||
# StreamableHTTPSessionManager single-run limitation.
|
||||
monkeypatch.setattr(app.router, "lifespan_context", _db_only_lifespan)
|
||||
|
||||
def _override():
|
||||
yield session
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ def test_create_snapshot_full(client):
|
|||
json={
|
||||
"snapshot_date": "2026-02-20",
|
||||
"overall_state": "good",
|
||||
"trend": "improving",
|
||||
"skin_type": "combination",
|
||||
"texture": "rough",
|
||||
"hydration_level": 3,
|
||||
"sebum_tzone": 4,
|
||||
"sebum_cheeks": 2,
|
||||
|
|
@ -32,7 +32,7 @@ def test_create_snapshot_full(client):
|
|||
assert r.status_code == 201
|
||||
data = r.json()
|
||||
assert data["overall_state"] == "good"
|
||||
assert data["texture"] == "rough"
|
||||
assert data["trend"] == "improving"
|
||||
assert "acne" in data["active_concerns"]
|
||||
assert data["hydration_level"] == 3
|
||||
|
||||
|
|
|
|||
946
backend/uv.lock
generated
946
backend/uv.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,9 @@
|
|||
import { PUBLIC_API_BASE } from '$env/static/public';
|
||||
import type {
|
||||
ActiveIngredient,
|
||||
LabResult,
|
||||
MedicationEntry,
|
||||
MedicationUsage,
|
||||
Product,
|
||||
ProductContext,
|
||||
ProductEffectProfile,
|
||||
ProductInteraction,
|
||||
ProductInventory,
|
||||
Routine,
|
||||
RoutineStep,
|
||||
|
|
@ -77,27 +73,6 @@ export const updateInventory = (id: string, body: Record<string, unknown>): Prom
|
|||
api.patch(`/inventory/${id}`, body);
|
||||
export const deleteInventory = (id: string): Promise<void> => api.del(`/inventory/${id}`);
|
||||
|
||||
export interface ProductParseResponse {
|
||||
name?: string; brand?: string; line_name?: string; sku?: string; url?: string; barcode?: string;
|
||||
category?: string; recommended_time?: string; texture?: string; absorption_speed?: string;
|
||||
leave_on?: boolean; price_tier?: string;
|
||||
size_ml?: number; full_weight_g?: number; empty_weight_g?: number; pao_months?: number;
|
||||
inci?: string[]; actives?: ActiveIngredient[];
|
||||
recommended_for?: string[]; targets?: string[];
|
||||
contraindications?: string[]; usage_notes?: string;
|
||||
fragrance_free?: boolean; essential_oils_free?: boolean;
|
||||
alcohol_denat_free?: boolean; pregnancy_safe?: boolean;
|
||||
product_effect_profile?: ProductEffectProfile;
|
||||
ph_min?: number; ph_max?: number;
|
||||
incompatible_with?: ProductInteraction[]; synergizes_with?: string[];
|
||||
context_rules?: ProductContext;
|
||||
min_interval_hours?: number; max_frequency_per_week?: number;
|
||||
is_medication?: boolean; is_tool?: boolean; needle_length_mm?: number;
|
||||
}
|
||||
|
||||
export const parseProductText = (text: string): Promise<ProductParseResponse> =>
|
||||
api.post('/products/parse-text', { text });
|
||||
|
||||
// ─── Routines ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RoutineListParams {
|
||||
|
|
@ -218,29 +193,3 @@ export const updateSkinSnapshot = (
|
|||
body: Record<string, unknown>
|
||||
): Promise<SkinConditionSnapshot> => api.patch(`/skincare/${id}`, body);
|
||||
export const deleteSkinSnapshot = (id: string): Promise<void> => api.del(`/skincare/${id}`);
|
||||
|
||||
export interface SkinPhotoAnalysisResponse {
|
||||
overall_state?: string;
|
||||
texture?: string;
|
||||
skin_type?: string;
|
||||
hydration_level?: number;
|
||||
sebum_tzone?: number;
|
||||
sebum_cheeks?: number;
|
||||
sensitivity_level?: number;
|
||||
barrier_state?: string;
|
||||
active_concerns?: string[];
|
||||
risks?: string[];
|
||||
priorities?: string[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export async function analyzeSkinPhotos(files: File[]): Promise<SkinPhotoAnalysisResponse> {
|
||||
const body = new FormData();
|
||||
for (const file of files) body.append('photos', file);
|
||||
const res = await fetch(`${PUBLIC_API_BASE}/skincare/analyze-photos`, { method: 'POST', body });
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(detail?.detail ?? res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
import { Label } from '$lib/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { parseProductText, type ProductParseResponse } from '$lib/api';
|
||||
|
||||
let { product }: { product?: Product } = $props();
|
||||
|
||||
|
|
@ -59,118 +58,6 @@
|
|||
{ key: 'anti_aging_strength', label: 'Anti-aging' }
|
||||
] as const;
|
||||
|
||||
// ── Controlled text/number inputs ────────────────────────────────────────
|
||||
|
||||
let name = $state(untrack(() => product?.name ?? ''));
|
||||
let brand = $state(untrack(() => product?.brand ?? ''));
|
||||
let lineName = $state(untrack(() => product?.line_name ?? ''));
|
||||
let url = $state(untrack(() => product?.url ?? ''));
|
||||
let sku = $state(untrack(() => product?.sku ?? ''));
|
||||
let barcode = $state(untrack(() => product?.barcode ?? ''));
|
||||
let sizeMl = $state(untrack(() => (product?.size_ml != null ? String(product.size_ml) : '')));
|
||||
let fullWeightG = $state(untrack(() => (product?.full_weight_g != null ? String(product.full_weight_g) : '')));
|
||||
let emptyWeightG = $state(untrack(() => (product?.empty_weight_g != null ? String(product.empty_weight_g) : '')));
|
||||
let paoMonths = $state(untrack(() => (product?.pao_months != null ? String(product.pao_months) : '')));
|
||||
let phMin = $state(untrack(() => (product?.ph_min != null ? String(product.ph_min) : '')));
|
||||
let phMax = $state(untrack(() => (product?.ph_max != null ? String(product.ph_max) : '')));
|
||||
let minIntervalHours = $state(untrack(() => (product?.min_interval_hours != null ? String(product.min_interval_hours) : '')));
|
||||
let maxFrequencyPerWeek = $state(untrack(() => (product?.max_frequency_per_week != null ? String(product.max_frequency_per_week) : '')));
|
||||
let needleLengthMm = $state(untrack(() => (product?.needle_length_mm != null ? String(product.needle_length_mm) : '')));
|
||||
let usageNotes = $state(untrack(() => product?.usage_notes ?? ''));
|
||||
let inciText = $state(untrack(() => product?.inci?.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 targetConcerns = $state<string[]>(untrack(() => [...(product?.targets ?? [])]));
|
||||
let isMedication = $state(untrack(() => product?.is_medication ?? false));
|
||||
let isTool = $state(untrack(() => product?.is_tool ?? false));
|
||||
|
||||
// ── AI pre-fill state ─────────────────────────────────────────────────────
|
||||
|
||||
let aiPanelOpen = $state(false);
|
||||
let aiText = $state('');
|
||||
let aiLoading = $state(false);
|
||||
let aiError = $state('');
|
||||
|
||||
async function parseWithAi() {
|
||||
if (!aiText.trim()) return;
|
||||
aiLoading = true;
|
||||
aiError = '';
|
||||
try {
|
||||
const r = await parseProductText(aiText);
|
||||
applyAiResult(r);
|
||||
aiPanelOpen = false;
|
||||
} catch (e) {
|
||||
aiError = (e as Error).message;
|
||||
} finally {
|
||||
aiLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyAiResult(r: ProductParseResponse) {
|
||||
if (r.name) name = r.name;
|
||||
if (r.brand) brand = r.brand;
|
||||
if (r.line_name) lineName = r.line_name;
|
||||
if (r.url) url = r.url;
|
||||
if (r.sku) sku = r.sku;
|
||||
if (r.barcode) barcode = r.barcode;
|
||||
if (r.usage_notes) usageNotes = r.usage_notes;
|
||||
if (r.category) category = r.category;
|
||||
if (r.recommended_time) recommendedTime = r.recommended_time;
|
||||
if (r.texture) texture = r.texture;
|
||||
if (r.absorption_speed) absorptionSpeed = r.absorption_speed;
|
||||
if (r.price_tier) priceTier = r.price_tier;
|
||||
if (r.leave_on != null) leaveOn = String(r.leave_on);
|
||||
if (r.size_ml != null) sizeMl = String(r.size_ml);
|
||||
if (r.full_weight_g != null) fullWeightG = String(r.full_weight_g);
|
||||
if (r.empty_weight_g != null) emptyWeightG = String(r.empty_weight_g);
|
||||
if (r.pao_months != null) paoMonths = String(r.pao_months);
|
||||
if (r.ph_min != null) phMin = String(r.ph_min);
|
||||
if (r.ph_max != null) phMax = String(r.ph_max);
|
||||
if (r.min_interval_hours != null) minIntervalHours = String(r.min_interval_hours);
|
||||
if (r.max_frequency_per_week != null) maxFrequencyPerWeek = String(r.max_frequency_per_week);
|
||||
if (r.needle_length_mm != null) needleLengthMm = String(r.needle_length_mm);
|
||||
if (r.fragrance_free != null) fragranceFree = String(r.fragrance_free);
|
||||
if (r.essential_oils_free != null) essentialOilsFree = String(r.essential_oils_free);
|
||||
if (r.alcohol_denat_free != null) alcoholDenatFree = String(r.alcohol_denat_free);
|
||||
if (r.pregnancy_safe != null) pregnancySafe = String(r.pregnancy_safe);
|
||||
if (r.recommended_for?.length) recommendedFor = [...r.recommended_for];
|
||||
if (r.targets?.length) targetConcerns = [...r.targets];
|
||||
if (r.is_medication != null) isMedication = r.is_medication;
|
||||
if (r.is_tool != null) isTool = r.is_tool;
|
||||
if (r.inci?.length) inciText = r.inci.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) {
|
||||
actives = r.actives.map((a) => ({
|
||||
name: a.name,
|
||||
percent: a.percent != null ? String(a.percent) : '',
|
||||
functions: [...(a.functions ?? [])] as IngredientFunction[],
|
||||
strength_level: a.strength_level != null ? String(a.strength_level) : '',
|
||||
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) {
|
||||
effectValues = { ...effectValues, ...r.product_effect_profile };
|
||||
}
|
||||
if (r.context_rules) {
|
||||
const cr = r.context_rules;
|
||||
if (cr.safe_after_shaving != null) ctxAfterShaving = String(cr.safe_after_shaving);
|
||||
if (cr.safe_after_acids != null) ctxAfterAcids = String(cr.safe_after_acids);
|
||||
if (cr.safe_after_retinoids != null) ctxAfterRetinoids = String(cr.safe_after_retinoids);
|
||||
if (cr.safe_with_compromised_barrier != null) ctxCompromisedBarrier = String(cr.safe_with_compromised_barrier);
|
||||
if (cr.low_uv_only != null) ctxLowUvOnly = String(cr.low_uv_only);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Select reactive state ─────────────────────────────────────────────────
|
||||
|
||||
let category = $state(untrack(() => product?.category ?? ''));
|
||||
|
|
@ -323,35 +210,6 @@
|
|||
'h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring';
|
||||
</script>
|
||||
|
||||
<!-- ── AI pre-fill ──────────────────────────────────────────────────────────── -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<button type="button" class="flex w-full items-center justify-between text-left"
|
||||
onclick={() => (aiPanelOpen = !aiPanelOpen)}>
|
||||
<CardTitle>AI pre-fill</CardTitle>
|
||||
<span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
</CardHeader>
|
||||
{#if aiPanelOpen}
|
||||
<CardContent class="space-y-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Wklej opis produktu ze strony, listę składników lub inny tekst.
|
||||
AI uzupełni dostępne pola — możesz je przejrzeć i poprawić przed zapisem.
|
||||
</p>
|
||||
<textarea bind:value={aiText} rows="6"
|
||||
placeholder="Wklej tutaj opis produktu, składniki INCI..."
|
||||
class={textareaClass}></textarea>
|
||||
{#if aiError}
|
||||
<p class="text-sm text-destructive">{aiError}</p>
|
||||
{/if}
|
||||
<Button type="button" onclick={parseWithAi}
|
||||
disabled={aiLoading || !aiText.trim()}>
|
||||
{aiLoading ? 'Przetwarzam…' : 'Uzupełnij pola (AI)'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- ── Basic info ──────────────────────────────────────────────────────────── -->
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Basic info</CardTitle></CardHeader>
|
||||
|
|
@ -359,31 +217,31 @@
|
|||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Name *</Label>
|
||||
<Input id="name" name="name" required placeholder="e.g. Hydro Boost Water Gel" bind:value={name} />
|
||||
<Input id="name" name="name" required placeholder="e.g. Hydro Boost Water Gel" value={product?.name ?? ''} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="brand">Brand *</Label>
|
||||
<Input id="brand" name="brand" required placeholder="e.g. Neutrogena" bind:value={brand} />
|
||||
<Input id="brand" name="brand" required placeholder="e.g. Neutrogena" value={product?.brand ?? ''} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="line_name">Line / series</Label>
|
||||
<Input id="line_name" name="line_name" placeholder="e.g. Hydro Boost" bind:value={lineName} />
|
||||
<Input id="line_name" name="line_name" placeholder="e.g. Hydro Boost" value={product?.line_name ?? ''} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="url">URL</Label>
|
||||
<Input id="url" name="url" type="url" placeholder="https://…" bind:value={url} />
|
||||
<Input id="url" name="url" type="url" placeholder="https://…" value={product?.url ?? ''} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="sku">SKU</Label>
|
||||
<Input id="sku" name="sku" placeholder="e.g. NTR-HB-50" bind:value={sku} />
|
||||
<Input id="sku" name="sku" placeholder="e.g. NTR-HB-50" value={product?.sku ?? ''} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="barcode">Barcode / EAN</Label>
|
||||
<Input id="barcode" name="barcode" placeholder="e.g. 3614273258975" bind:value={barcode} />
|
||||
<Input id="barcode" name="barcode" placeholder="e.g. 3614273258975" value={product?.barcode ?? ''} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -476,13 +334,7 @@
|
|||
type="checkbox"
|
||||
name="recommended_for"
|
||||
value={st}
|
||||
checked={recommendedFor.includes(st)}
|
||||
onchange={() => {
|
||||
if (recommendedFor.includes(st))
|
||||
recommendedFor = recommendedFor.filter((s) => s !== st);
|
||||
else
|
||||
recommendedFor = [...recommendedFor, st];
|
||||
}}
|
||||
checked={product?.recommended_for?.includes(st as never) ?? false}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
{lbl(st)}
|
||||
|
|
@ -500,13 +352,7 @@
|
|||
type="checkbox"
|
||||
name="targets"
|
||||
value={sc}
|
||||
checked={targetConcerns.includes(sc)}
|
||||
onchange={() => {
|
||||
if (targetConcerns.includes(sc))
|
||||
targetConcerns = targetConcerns.filter((s) => s !== sc);
|
||||
else
|
||||
targetConcerns = [...targetConcerns, sc];
|
||||
}}
|
||||
checked={product?.targets?.includes(sc as never) ?? false}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
{lbl(sc)}
|
||||
|
|
@ -523,8 +369,7 @@
|
|||
rows="2"
|
||||
placeholder="e.g. active rosacea flares"
|
||||
class={textareaClass}
|
||||
bind:value={contraindicationsText}
|
||||
></textarea>
|
||||
>{product?.contraindications?.join('\n') ?? ''}</textarea>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -541,8 +386,7 @@
|
|||
rows="5"
|
||||
placeholder="Aqua Glycerin Niacinamide"
|
||||
class={textareaClass}
|
||||
bind:value={inciText}
|
||||
></textarea>
|
||||
>{product?.inci?.join('\n') ?? ''}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
|
|
@ -664,8 +508,7 @@
|
|||
rows="3"
|
||||
placeholder="Ceramides Niacinamide Retinoids"
|
||||
class={textareaClass}
|
||||
bind:value={synergizesWithText}
|
||||
></textarea>
|
||||
>{product?.synergizes_with?.join('\n') ?? ''}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
|
|
@ -819,33 +662,83 @@
|
|||
|
||||
<div class="space-y-2">
|
||||
<Label for="size_ml">Size (ml)</Label>
|
||||
<Input id="size_ml" name="size_ml" type="number" min="0" step="0.1" placeholder="e.g. 50" bind:value={sizeMl} />
|
||||
<Input
|
||||
id="size_ml"
|
||||
name="size_ml"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="e.g. 50"
|
||||
value={product?.size_ml ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="full_weight_g">Full weight (g)</Label>
|
||||
<Input id="full_weight_g" name="full_weight_g" type="number" min="0" step="0.1" placeholder="e.g. 120" bind:value={fullWeightG} />
|
||||
<Input
|
||||
id="full_weight_g"
|
||||
name="full_weight_g"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="e.g. 120"
|
||||
value={product?.full_weight_g ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="empty_weight_g">Empty weight (g)</Label>
|
||||
<Input id="empty_weight_g" name="empty_weight_g" type="number" min="0" step="0.1" placeholder="e.g. 30" bind:value={emptyWeightG} />
|
||||
<Input
|
||||
id="empty_weight_g"
|
||||
name="empty_weight_g"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="e.g. 30"
|
||||
value={product?.empty_weight_g ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="pao_months">PAO (months)</Label>
|
||||
<Input id="pao_months" name="pao_months" type="number" min="1" max="60" placeholder="e.g. 12" bind:value={paoMonths} />
|
||||
<Input
|
||||
id="pao_months"
|
||||
name="pao_months"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
placeholder="e.g. 12"
|
||||
value={product?.pao_months ?? ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="ph_min">pH min</Label>
|
||||
<Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder="e.g. 3.5" bind:value={phMin} />
|
||||
<Input
|
||||
id="ph_min"
|
||||
name="ph_min"
|
||||
type="number"
|
||||
min="0"
|
||||
max="14"
|
||||
step="0.1"
|
||||
placeholder="e.g. 3.5"
|
||||
value={product?.ph_min ?? ''}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="ph_max">pH max</Label>
|
||||
<Input id="ph_max" name="ph_max" type="number" min="0" max="14" step="0.1" placeholder="e.g. 4.5" bind:value={phMax} />
|
||||
<Input
|
||||
id="ph_max"
|
||||
name="ph_max"
|
||||
type="number"
|
||||
min="0"
|
||||
max="14"
|
||||
step="0.1"
|
||||
placeholder="e.g. 4.5"
|
||||
value={product?.ph_max ?? ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -857,8 +750,7 @@
|
|||
rows="2"
|
||||
placeholder="e.g. Apply to damp skin, avoid eye area"
|
||||
class={textareaClass}
|
||||
bind:value={usageNotes}
|
||||
></textarea>
|
||||
>{product?.usage_notes ?? ''}</textarea>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -938,31 +830,46 @@
|
|||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="min_interval_hours">Min interval (hours)</Label>
|
||||
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder="e.g. 24" bind:value={minIntervalHours} />
|
||||
<Input
|
||||
id="min_interval_hours"
|
||||
name="min_interval_hours"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="e.g. 24"
|
||||
value={product?.min_interval_hours ?? ''}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="max_frequency_per_week">Max uses per week</Label>
|
||||
<Input id="max_frequency_per_week" name="max_frequency_per_week" type="number" min="1" max="14" placeholder="e.g. 3" bind:value={maxFrequencyPerWeek} />
|
||||
<Input
|
||||
id="max_frequency_per_week"
|
||||
name="max_frequency_per_week"
|
||||
type="number"
|
||||
min="1"
|
||||
max="14"
|
||||
placeholder="e.g. 3"
|
||||
value={product?.max_frequency_per_week ?? ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="hidden" name="is_medication" value={String(isMedication)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isMedication}
|
||||
onchange={() => (isMedication = !isMedication)}
|
||||
name="is_medication"
|
||||
value="true"
|
||||
checked={product?.is_medication ?? false}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
Is medication
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="hidden" name="is_tool" value={String(isTool)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isTool}
|
||||
onchange={() => (isTool = !isTool)}
|
||||
name="is_tool"
|
||||
value="true"
|
||||
checked={product?.is_tool ?? false}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
Is tool (e.g. dermaroller)
|
||||
|
|
@ -971,7 +878,15 @@
|
|||
|
||||
<div class="space-y-2">
|
||||
<Label for="needle_length_mm">Needle length (mm, tools only)</Label>
|
||||
<Input id="needle_length_mm" name="needle_length_mm" type="number" min="0" step="0.01" placeholder="e.g. 0.25" bind:value={needleLengthMm} />
|
||||
<Input
|
||||
id="needle_length_mm"
|
||||
name="needle_length_mm"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="e.g. 0.25"
|
||||
value={product?.needle_length_mm ?? ''}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export type SkinConcern =
|
|||
| 'uneven_texture'
|
||||
| 'hair_growth'
|
||||
| 'sebum_excess';
|
||||
export type SkinTexture = 'smooth' | 'rough' | 'flaky' | 'bumpy';
|
||||
export type SkinTrend = 'improving' | 'stable' | 'worsening' | 'fluctuating';
|
||||
export type SkinType = 'dry' | 'oily' | 'combination' | 'sensitive' | 'normal' | 'acne_prone';
|
||||
export type StrengthLevel = 1 | 2 | 3;
|
||||
export type TextureType = 'watery' | 'gel' | 'emulsion' | 'cream' | 'oil' | 'balm' | 'foam' | 'fluid';
|
||||
|
|
@ -250,8 +250,8 @@ export interface SkinConditionSnapshot {
|
|||
id: string;
|
||||
snapshot_date: string;
|
||||
overall_state?: OverallSkinState;
|
||||
trend?: SkinTrend;
|
||||
skin_type?: SkinType;
|
||||
texture?: SkinTexture;
|
||||
hydration_level?: number;
|
||||
sebum_tzone?: number;
|
||||
sebum_cheeks?: number;
|
||||
|
|
|
|||
|
|
@ -39,9 +39,12 @@
|
|||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if s.trend}
|
||||
<p class="text-sm">Trend: <span class="font-medium">{s.trend}</span></p>
|
||||
{/if}
|
||||
{#if s.active_concerns.length}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each s.active_concerns as concern (concern)}
|
||||
{#each s.active_concerns as concern}
|
||||
<Badge variant="secondary">{concern.replace(/_/g, ' ')}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -64,7 +67,7 @@
|
|||
<CardContent>
|
||||
{#if data.recentRoutines.length}
|
||||
<ul class="space-y-2">
|
||||
{#each data.recentRoutines as routine (routine.id)}
|
||||
{#each data.recentRoutines as routine}
|
||||
<li class="flex items-center justify-between">
|
||||
<a href="/routines/{routine.id}" class="text-sm hover:underline">
|
||||
{routine.routine_date}
|
||||
|
|
|
|||
|
|
@ -61,13 +61,14 @@
|
|||
type="single"
|
||||
value={filterFlag}
|
||||
onValueChange={(v) => {
|
||||
filterFlag = v;
|
||||
goto(v ? `/health/lab-results?flag=${v}` : '/health/lab-results');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger class="w-32">{filterFlag || 'All'}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All</SelectItem>
|
||||
{#each flags as f (f)}
|
||||
{#each flags as f}
|
||||
<SelectItem value={f}>{f}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
|
|
@ -110,7 +111,7 @@
|
|||
<SelectTrigger>{selectedFlag || 'None'}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{#each flags as f (f)}
|
||||
{#each flags as f}
|
||||
<SelectItem value={f}>{f}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
|
|
@ -137,7 +138,7 @@
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each data.results as r (r.record_id)}
|
||||
{#each data.results as r}
|
||||
<TableRow>
|
||||
<TableCell class="text-sm">{r.collected_at.slice(0, 10)}</TableCell>
|
||||
<TableCell class="font-medium">{r.test_name_original ?? r.test_code}</TableCell>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@
|
|||
<Select type="single" value={kind} onValueChange={(v) => (kind = v)}>
|
||||
<SelectTrigger>{kind}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each kinds as k (k)}
|
||||
{#each kinds as k}
|
||||
<SelectItem value={k}>{k}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
|
|
@ -81,7 +81,7 @@
|
|||
{/if}
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each data.medications as med (med.record_id)}
|
||||
{#each data.medications as med}
|
||||
<div class="rounded-md border border-border px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
let selectedCategory = $derived(data.category ?? '');
|
||||
|
||||
function filterByCategory(cat: string) {
|
||||
selectedCategory = cat;
|
||||
goto(cat ? `/products?category=${cat}` : '/products');
|
||||
}
|
||||
</script>
|
||||
|
|
@ -50,7 +51,7 @@
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All categories</SelectItem>
|
||||
{#each categories as cat (cat)}
|
||||
{#each categories as cat}
|
||||
<SelectItem value={cat}>{cat.replace(/_/g, ' ')}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
|
|
@ -69,7 +70,7 @@
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each data.products as product (product.id)}
|
||||
{#each data.products as product}
|
||||
<TableRow class="cursor-pointer hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<a href="/products/{product.id}" class="font-medium hover:underline">
|
||||
|
|
@ -82,7 +83,7 @@
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each product.targets.slice(0, 3) as t (t)}
|
||||
{#each product.targets.slice(0, 3) as t}
|
||||
<Badge variant="secondary" class="text-xs">{t.replace(/_/g, ' ')}</Badge>
|
||||
{/each}
|
||||
{#if product.targets.length > 3}
|
||||
|
|
|
|||
|
|
@ -190,6 +190,6 @@ export const actions: Actions = {
|
|||
} catch (e) {
|
||||
return fail(500, { error: (e as Error).message });
|
||||
}
|
||||
redirect(303, '/products');
|
||||
redirect(303, `/products/${product.id}`);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import { getRoutines } from '$lib/api';
|
|||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const part_of_day = url.searchParams.get('part_of_day') ?? undefined;
|
||||
const from_date = url.searchParams.get('from_date') ?? recentDate(30);
|
||||
const routines = await getRoutines({ from_date });
|
||||
return { routines };
|
||||
const routines = await getRoutines({ from_date, part_of_day });
|
||||
return { routines, part_of_day };
|
||||
};
|
||||
|
||||
function recentDate(daysAgo: number): string {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@
|
|||
import type { PageData } from './$types';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
function filterPod(pod: string) {
|
||||
goto(pod ? `/routines?part_of_day=${pod}` : '/routines');
|
||||
}
|
||||
|
||||
// Group by date
|
||||
const byDate = $derived(
|
||||
data.routines.reduce(
|
||||
|
|
@ -29,6 +34,24 @@
|
|||
<Button href="/routines/new">+ New routine</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant={!data.part_of_day ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => filterPod('')}
|
||||
>All</Button>
|
||||
<Button
|
||||
variant={data.part_of_day === 'am' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => filterPod('am')}
|
||||
>AM</Button>
|
||||
<Button
|
||||
variant={data.part_of_day === 'pm' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => filterPod('pm')}
|
||||
>PM</Button>
|
||||
</div>
|
||||
|
||||
{#if sortedDates.length}
|
||||
<div class="space-y-4">
|
||||
{#each sortedDates as date}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
{/if}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each products as p (p.id)}
|
||||
{#each products as p}
|
||||
<SelectItem value={p.id}>{p.name} ({p.brand})</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
|
|
@ -90,7 +90,7 @@
|
|||
|
||||
{#if routine.steps.length}
|
||||
<div class="space-y-2">
|
||||
{#each routine.steps.toSorted((a, b) => a.order_index - b.order_index) as step (step.id)}
|
||||
{#each routine.steps.toSorted((a, b) => a.order_index - b.order_index) as step}
|
||||
<div class="flex items-center justify-between rounded-md border border-border px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-muted-foreground w-4">{step.order_index + 1}</span>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export const actions: Actions = {
|
|||
const form = await request.formData();
|
||||
const snapshot_date = form.get('snapshot_date') as string;
|
||||
const overall_state = form.get('overall_state') as string;
|
||||
const texture = form.get('texture') as string;
|
||||
const trend = form.get('trend') as string;
|
||||
const notes = form.get('notes') as string;
|
||||
const hydration_level = form.get('hydration_level') as string;
|
||||
const sensitivity_level = form.get('sensitivity_level') as string;
|
||||
|
|
@ -29,20 +29,13 @@ export const actions: Actions = {
|
|||
.map((c) => c.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
const skin_type = form.get('skin_type') as string;
|
||||
const sebum_tzone = form.get('sebum_tzone') as string;
|
||||
const sebum_cheeks = form.get('sebum_cheeks') as string;
|
||||
|
||||
const body: Record<string, unknown> = { snapshot_date, active_concerns };
|
||||
if (overall_state) body.overall_state = overall_state;
|
||||
if (texture) body.texture = texture;
|
||||
if (trend) body.trend = trend;
|
||||
if (notes) body.notes = notes;
|
||||
if (hydration_level) body.hydration_level = Number(hydration_level);
|
||||
if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level);
|
||||
if (barrier_state) body.barrier_state = barrier_state;
|
||||
if (skin_type) body.skin_type = skin_type;
|
||||
if (sebum_tzone) body.sebum_tzone = Number(sebum_tzone);
|
||||
if (sebum_cheeks) body.sebum_cheeks = Number(sebum_cheeks);
|
||||
|
||||
try {
|
||||
await createSkinSnapshot(body);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { analyzeSkinPhotos } from '$lib/api';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
|
|
@ -12,9 +11,8 @@
|
|||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
const states = ['excellent', 'good', 'fair', 'poor'];
|
||||
const skinTextures = ['smooth', 'rough', 'flaky', 'bumpy'];
|
||||
const trends = ['improving', 'stable', 'worsening', 'fluctuating'];
|
||||
const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
|
||||
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
|
||||
|
||||
const stateColors: Record<string, string> = {
|
||||
excellent: 'bg-green-100 text-green-800',
|
||||
|
|
@ -24,62 +22,13 @@
|
|||
};
|
||||
|
||||
let showForm = $state(false);
|
||||
|
||||
// Form state (bound to inputs so AI can pre-fill)
|
||||
let snapshotDate = $state(new Date().toISOString().slice(0, 10));
|
||||
let overallState = $state('');
|
||||
let texture = $state('');
|
||||
let trend = $state('');
|
||||
let barrierState = $state('');
|
||||
let skinType = $state('');
|
||||
let hydrationLevel = $state('');
|
||||
let sensitivityLevel = $state('');
|
||||
let sebumTzone = $state('');
|
||||
let sebumCheeks = $state('');
|
||||
let activeConcernsRaw = $state('');
|
||||
let notes = $state('');
|
||||
|
||||
// AI photo analysis state
|
||||
let aiPanelOpen = $state(false);
|
||||
let selectedFiles = $state<File[]>([]);
|
||||
let previewUrls = $state<string[]>([]);
|
||||
let aiLoading = $state(false);
|
||||
let aiError = $state('');
|
||||
|
||||
const sortedSnapshots = $derived(
|
||||
[...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date))
|
||||
);
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []).slice(0, 3);
|
||||
selectedFiles = files;
|
||||
previewUrls.forEach(URL.revokeObjectURL);
|
||||
previewUrls = files.map((f) => URL.createObjectURL(f));
|
||||
}
|
||||
|
||||
async function analyzePhotos() {
|
||||
if (!selectedFiles.length) return;
|
||||
aiLoading = true;
|
||||
aiError = '';
|
||||
try {
|
||||
const r = await analyzeSkinPhotos(selectedFiles);
|
||||
if (r.overall_state) overallState = r.overall_state;
|
||||
if (r.texture) texture = r.texture;
|
||||
if (r.skin_type) skinType = r.skin_type;
|
||||
if (r.barrier_state) barrierState = r.barrier_state;
|
||||
if (r.hydration_level != null) hydrationLevel = String(r.hydration_level);
|
||||
if (r.sensitivity_level != null) sensitivityLevel = String(r.sensitivity_level);
|
||||
if (r.sebum_tzone != null) sebumTzone = String(r.sebum_tzone);
|
||||
if (r.sebum_cheeks != null) sebumCheeks = String(r.sebum_cheeks);
|
||||
if (r.active_concerns?.length) activeConcernsRaw = r.active_concerns.join(', ');
|
||||
if (r.notes) notes = r.notes;
|
||||
aiPanelOpen = false;
|
||||
} catch (e) {
|
||||
aiError = (e as Error).message;
|
||||
} finally {
|
||||
aiLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Skin — innercontext</title></svelte:head>
|
||||
|
|
@ -103,67 +52,14 @@
|
|||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<!-- AI photo analysis card -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between text-left"
|
||||
onclick={() => (aiPanelOpen = !aiPanelOpen)}
|
||||
>
|
||||
<CardTitle>AI analysis from photos</CardTitle>
|
||||
<span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
</CardHeader>
|
||||
{#if aiPanelOpen}
|
||||
<CardContent class="space-y-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Upload 1–3 photos of your skin. AI will pre-fill the form fields below.
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
onchange={handleFileSelect}
|
||||
class="block w-full text-sm text-muted-foreground
|
||||
file:mr-4 file:rounded-md file:border-0 file:bg-primary
|
||||
file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
|
||||
/>
|
||||
{#if previewUrls.length}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each previewUrls as url (url)}
|
||||
<img src={url} alt="skin preview" class="h-24 w-24 rounded-md object-cover border" />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if aiError}
|
||||
<p class="text-sm text-destructive">{aiError}</p>
|
||||
{/if}
|
||||
<Button
|
||||
type="button"
|
||||
onclick={analyzePhotos}
|
||||
disabled={aiLoading || !selectedFiles.length}
|
||||
>
|
||||
{aiLoading ? 'Analyzing…' : 'Analyze photos'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- New snapshot form -->
|
||||
<Card>
|
||||
<CardHeader><CardTitle>New skin snapshot</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<Label for="snapshot_date">Date *</Label>
|
||||
<Input
|
||||
id="snapshot_date"
|
||||
name="snapshot_date"
|
||||
type="date"
|
||||
bind:value={snapshotDate}
|
||||
required
|
||||
/>
|
||||
<Input id="snapshot_date" name="snapshot_date" type="date"
|
||||
value={new Date().toISOString().slice(0, 10)} required />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>Overall state</Label>
|
||||
|
|
@ -171,45 +67,31 @@
|
|||
<Select type="single" value={overallState} onValueChange={(v) => (overallState = v)}>
|
||||
<SelectTrigger>{overallState || 'Select'}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each states as s (s)}
|
||||
{#each states as s}
|
||||
<SelectItem value={s}>{s}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>Texture</Label>
|
||||
<input type="hidden" name="texture" value={texture} />
|
||||
<Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
|
||||
<SelectTrigger>{texture || 'Select'}</SelectTrigger>
|
||||
<Label>Trend</Label>
|
||||
<input type="hidden" name="trend" value={trend} />
|
||||
<Select type="single" value={trend} onValueChange={(v) => (trend = v)}>
|
||||
<SelectTrigger>{trend || 'Select'}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each skinTextures as t (t)}
|
||||
{#each trends as t}
|
||||
<SelectItem value={t}>{t}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>Skin type</Label>
|
||||
<input type="hidden" name="skin_type" value={skinType} />
|
||||
<Select type="single" value={skinType} onValueChange={(v) => (skinType = v)}>
|
||||
<SelectTrigger>{skinType ? skinType.replace(/_/g, ' ') : 'Select'}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each skinTypes as st (st)}
|
||||
<SelectItem value={st}>{st.replace(/_/g, ' ')}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>Barrier state</Label>
|
||||
<input type="hidden" name="barrier_state" value={barrierState} />
|
||||
<Select type="single" value={barrierState} onValueChange={(v) => (barrierState = v)}>
|
||||
<SelectTrigger
|
||||
>{barrierState ? barrierState.replace(/_/g, ' ') : 'Select'}</SelectTrigger
|
||||
>
|
||||
<SelectTrigger>{barrierState ? barrierState.replace(/_/g, ' ') : 'Select'}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each barrierStates as b (b)}
|
||||
{#each barrierStates as b}
|
||||
<SelectItem value={b}>{b.replace(/_/g, ' ')}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
|
|
@ -217,60 +99,19 @@
|
|||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="hydration_level">Hydration (1–5)</Label>
|
||||
<Input
|
||||
id="hydration_level"
|
||||
name="hydration_level"
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={hydrationLevel}
|
||||
/>
|
||||
<Input id="hydration_level" name="hydration_level" type="number" min="1" max="5" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="sensitivity_level">Sensitivity (1–5)</Label>
|
||||
<Input
|
||||
id="sensitivity_level"
|
||||
name="sensitivity_level"
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={sensitivityLevel}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="sebum_tzone">Sebum T-zone (1–5)</Label>
|
||||
<Input
|
||||
id="sebum_tzone"
|
||||
name="sebum_tzone"
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={sebumTzone}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="sebum_cheeks">Sebum cheeks (1–5)</Label>
|
||||
<Input
|
||||
id="sebum_cheeks"
|
||||
name="sebum_cheeks"
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={sebumCheeks}
|
||||
/>
|
||||
<Input id="sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" />
|
||||
</div>
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label for="active_concerns">Active concerns (comma-separated)</Label>
|
||||
<Input
|
||||
id="active_concerns"
|
||||
name="active_concerns"
|
||||
placeholder="acne, redness, dehydration"
|
||||
bind:value={activeConcernsRaw}
|
||||
/>
|
||||
<Input id="active_concerns" name="active_concerns" placeholder="acne, redness, dehydration" />
|
||||
</div>
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label for="notes">Notes</Label>
|
||||
<Input id="notes" name="notes" bind:value={notes} />
|
||||
<Input id="notes" name="notes" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<Button type="submit">Add snapshot</Button>
|
||||
|
|
@ -281,23 +122,19 @@
|
|||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each sortedSnapshots as snap (snap.id)}
|
||||
{#each sortedSnapshots as snap}
|
||||
<Card>
|
||||
<CardContent class="pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-medium">{snap.snapshot_date}</span>
|
||||
<div class="flex gap-2">
|
||||
{#if snap.overall_state}
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[
|
||||
snap.overall_state
|
||||
] ?? ''}"
|
||||
>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}">
|
||||
{snap.overall_state}
|
||||
</span>
|
||||
{/if}
|
||||
{#if snap.texture}
|
||||
<Badge variant="secondary">{snap.texture}</Badge>
|
||||
{#if snap.trend}
|
||||
<Badge variant="secondary">{snap.trend}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -323,7 +160,7 @@
|
|||
</div>
|
||||
{#if snap.active_concerns.length}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each snap.active_concerns as c (c)}
|
||||
{#each snap.active_concerns as c}
|
||||
<Badge variant="secondary" class="text-xs">{c.replace(/_/g, ' ')}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue