Compare commits

..

No commits in common. "142fbe8530b2172606fde095ff4a5dc1c213bd7e" and "c413e27768a5e7c6503012d9d2d90298ad021cce" have entirely different histories.

25 changed files with 188 additions and 2218 deletions

View file

@ -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, 0100), functions \
(use the allowed strings), and strength/irritation level if inferable.
- For effect_profile scores (05 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.05.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)

View file

@ -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 15 scale (1 = minimal, 5 = maximal).
- risks and priorities: short English phrases, max 10 words each.
- notes: 24 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 15, 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,

View file

@ -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

View file

@ -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]

View file

@ -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",

View file

@ -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):

View file

@ -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)

View file

@ -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"}

View file

@ -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",
]

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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();
}

View file

@ -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&#10;Glycerin&#10;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&#10;Niacinamide&#10;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>

View file

@ -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;

View file

@ -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}

View file

@ -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>

View file

@ -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">

View file

@ -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}

View file

@ -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}`);
}
};

View file

@ -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 {

View file

@ -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}

View file

@ -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>

View file

@ -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);

View file

@ -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 13 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 (15)</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 (15)</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 (15)</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 (15)</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>