diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index e20156b..48610b5 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -1,13 +1,17 @@ -from datetime import date +import json +from datetime import date, timedelta from typing import Optional from uuid import UUID, uuid4 -from fastapi import APIRouter, Depends -from sqlmodel import Session, SQLModel, select +from fastapi import APIRouter, Depends, HTTPException +from google.genai import types as genai_types +from pydantic import BaseModel as PydanticBase +from sqlmodel import Session, SQLModel, col, select from db import get_session from innercontext.api.utils import get_or_404 -from innercontext.models import GroomingSchedule, Routine, RoutineStep +from innercontext.llm import get_gemini_client +from innercontext.models import GroomingSchedule, Product, Routine, RoutineStep, SkinConditionSnapshot from innercontext.models.enums import GroomingAction, PartOfDay router = APIRouter() @@ -60,6 +64,188 @@ class GroomingScheduleUpdate(SQLModel): notes: Optional[str] = None +class SuggestedStep(SQLModel): + product_id: Optional[UUID] = None + action_type: Optional[GroomingAction] = None + action_notes: Optional[str] = None + dose: Optional[str] = None + region: Optional[str] = None + + +class SuggestRoutineRequest(SQLModel): + routine_date: date + part_of_day: PartOfDay + notes: Optional[str] = None + + +class RoutineSuggestion(SQLModel): + steps: list[SuggestedStep] + reasoning: str + + +class SuggestBatchRequest(SQLModel): + from_date: date + to_date: date + notes: Optional[str] = None + + +class DayPlan(SQLModel): + date: date + am_steps: list[SuggestedStep] + pm_steps: list[SuggestedStep] + reasoning: str + + +class BatchSuggestion(SQLModel): + days: list[DayPlan] + overall_reasoning: str + + +# --------------------------------------------------------------------------- +# Pydantic schemas for Gemini structured output +# --------------------------------------------------------------------------- + + +class _StepOut(PydanticBase): + product_id: Optional[str] = None + action_type: Optional[str] = None + dose: Optional[str] = None + region: Optional[str] = None + action_notes: Optional[str] = None + + +class _SuggestionOut(PydanticBase): + steps: list[_StepOut] + reasoning: str + + +class _DayPlanOut(PydanticBase): + date: str + am_steps: list[_StepOut] + pm_steps: list[_StepOut] + reasoning: str + + +class _BatchOut(PydanticBase): + days: list[_DayPlanOut] + overall_reasoning: str + + +# --------------------------------------------------------------------------- +# Prompt helpers +# --------------------------------------------------------------------------- + +_DAY_NAMES = ["poniedziałek", "wtorek", "środa", "czwartek", "piątek", "sobota", "niedziela"] + + +def _ev(v: object) -> str: + return v.value if v is not None and hasattr(v, "value") else str(v) if v is not None else "" + + +def _build_skin_context(session: Session) -> str: + snapshot = session.exec( + select(SkinConditionSnapshot).order_by(col(SkinConditionSnapshot.snapshot_date).desc()) + ).first() + if snapshot is None: + return "STAN SKÓRY: brak danych\n" + ev = _ev + return ( + f"STAN SKÓRY (snapshot z {snapshot.snapshot_date}):\n" + f" Ogólny stan: {ev(snapshot.overall_state)}\n" + f" Nawilżenie: {snapshot.hydration_level}/5\n" + f" Bariera: {ev(snapshot.barrier_state)}\n" + f" Aktywne problemy: {', '.join(ev(c) for c in (snapshot.active_concerns or []))}\n" + f" Priorytety: {', '.join(snapshot.priorities or [])}\n" + f" Uwagi: {snapshot.notes or 'brak'}\n" + ) + + +def _build_grooming_context(session: Session, weekdays: Optional[list[int]] = None) -> str: + entries = session.exec(select(GroomingSchedule).order_by(GroomingSchedule.day_of_week)).all() + if not entries: + return "HARMONOGRAM PIELĘGNACJI: brak\n" + lines = ["HARMONOGRAM PIELĘGNACJI:"] + for e in entries: + if weekdays is not None and e.day_of_week not in weekdays: + continue + day_name = _DAY_NAMES[e.day_of_week] if 0 <= e.day_of_week <= 6 else str(e.day_of_week) + lines.append(f" {day_name}: {_ev(e.action)}" + (f" ({e.notes})" if e.notes else "")) + if len(lines) == 1: + lines.append(" (brak wpisów dla podanych dni)") + return "\n".join(lines) + "\n" + + +def _build_recent_history(session: Session) -> str: + cutoff = date.today() - timedelta(days=7) + routines = session.exec( + select(Routine) + .where(Routine.routine_date >= cutoff) + .order_by(col(Routine.routine_date).desc()) + ).all() + if not routines: + return "OSTATNIE RUTYNY (7 dni): brak\n" + lines = ["OSTATNIE RUTYNY (7 dni):"] + for r in routines: + steps = session.exec( + select(RoutineStep) + .where(RoutineStep.routine_id == r.id) + .order_by(RoutineStep.order_index) + ).all() + step_names = [] + for s in steps: + if s.product_id: + p = session.get(Product, s.product_id) + step_names.append(p.name if p else str(s.product_id)) + elif s.action_type: + step_names.append(_ev(s.action_type)) + lines.append(f" {r.routine_date} {_ev(r.part_of_day).upper()}: {', '.join(step_names)}") + return "\n".join(lines) + "\n" + + +def _build_products_context(session: Session, time_filter: Optional[str] = None) -> str: + stmt = select(Product).where(Product.is_medication == False).where(Product.is_tool == False) # noqa: E712 + products = session.exec(stmt).all() + lines = ["DOSTĘPNE PRODUKTY:"] + for p in products: + if time_filter and _ev(p.recommended_time) not in (time_filter, "both"): + continue + ctx = p.to_llm_context() + entry = ( + f" - id={ctx['id']} name=\"{ctx['name']}\" brand=\"{ctx['brand']}\"" + f" category={ctx.get('category', '')} recommended_time={ctx.get('recommended_time', '')}" + f" targets={ctx.get('targets', [])}" + ) + profile = ctx.get("product_effect_profile", {}) + if profile: + notable = {k: v for k, v in profile.items() if v and v > 0} + if notable: + entry += f" effects={notable}" + if ctx.get("incompatible_with"): + entry += f" incompatible_with={ctx['incompatible_with']}" + if ctx.get("context_rules"): + entry += f" context_rules={ctx['context_rules']}" + if ctx.get("min_interval_hours"): + entry += f" min_interval_hours={ctx['min_interval_hours']}" + if ctx.get("max_frequency_per_week"): + entry += f" max_frequency_per_week={ctx['max_frequency_per_week']}" + lines.append(entry) + return "\n".join(lines) + "\n" + + +_RULES = """\ +ZASADY: + - Kolejność warstw: cleanser → toner → essence → serum → moisturizer → [SPF dla AM] + - Respektuj incompatible_with (scope: same_step / same_day / same_period) + - Respektuj context_rules (safe_after_shaving, safe_after_acids itp.) + - Respektuj min_interval_hours i max_frequency_per_week + - 4–7 kroków na rutynę + - product_id musi być UUID produktu z listy lub null dla czynności pielęgnacyjnych + - action_type: tylko shaving_razor | shaving_oneblade | dermarolling (lub null) + - Nie używaj retinoidów i kwasów w tej samej rutynie + - W AM zawsze uwzględnij SPF jeśli dostępny +""" + + # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- @@ -96,6 +282,164 @@ def create_routine(data: RoutineCreate, session: Session = Depends(get_session)) return routine +# --------------------------------------------------------------------------- +# AI suggestion endpoints (must appear BEFORE /{routine_id}) +# --------------------------------------------------------------------------- + + +@router.post("/suggest", response_model=RoutineSuggestion) +def suggest_routine( + data: SuggestRoutineRequest, + session: Session = Depends(get_session), +): + client, model = get_gemini_client() + + weekday = data.routine_date.weekday() + skin_ctx = _build_skin_context(session) + grooming_ctx = _build_grooming_context(session, weekdays=[weekday]) + history_ctx = _build_recent_history(session) + products_ctx = _build_products_context(session, time_filter=data.part_of_day.value) + + notes_line = f"\nKONTEKST OD UŻYTKOWNIKA: {data.notes}\n" if data.notes else "" + day_name = _DAY_NAMES[weekday] + + prompt = ( + f"Zaproponuj rutynę pielęgnacyjną {data.part_of_day.value.upper()} " + f"na {data.routine_date} ({day_name}).\n\n" + f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{_RULES}{notes_line}" + "\nZwróć JSON zgodny ze schematem." + ) + + try: + response = client.models.generate_content( + model=model, + contents=prompt, + config=genai_types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=_SuggestionOut, + max_output_tokens=4096, + temperature=0.4, + ), + ) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Gemini API error: {e}") + + raw = response.text + if not raw: + raise HTTPException(status_code=502, detail="LLM returned an empty response") + + try: + parsed = json.loads(raw) + except json.JSONDecodeError as e: + raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}") + + steps = [ + SuggestedStep( + product_id=UUID(s["product_id"]) if s.get("product_id") else None, + action_type=s.get("action_type") or None, + action_notes=s.get("action_notes"), + dose=s.get("dose"), + region=s.get("region"), + ) + for s in parsed.get("steps", []) + ] + return RoutineSuggestion(steps=steps, reasoning=parsed.get("reasoning", "")) + + +@router.post("/suggest-batch", response_model=BatchSuggestion) +def suggest_batch( + data: SuggestBatchRequest, + session: Session = Depends(get_session), +): + delta = (data.to_date - data.from_date).days + 1 + if delta > 14: + raise HTTPException(status_code=400, detail="Date range must not exceed 14 days.") + if data.from_date > data.to_date: + raise HTTPException(status_code=400, detail="from_date must be <= to_date.") + + client, model = get_gemini_client() + + weekdays = list({(data.from_date + timedelta(days=i)).weekday() for i in range(delta)}) + skin_ctx = _build_skin_context(session) + grooming_ctx = _build_grooming_context(session, weekdays=weekdays) + history_ctx = _build_recent_history(session) + products_ctx = _build_products_context(session) + + date_range_lines = [] + for i in range(delta): + d = data.from_date + timedelta(days=i) + date_range_lines.append(f" {d} ({_DAY_NAMES[d.weekday()]})") + dates_str = "\n".join(date_range_lines) + + notes_line = f"\nKONTEKST OD UŻYTKOWNIKA: {data.notes}\n" if data.notes else "" + + prompt = ( + f"Zaproponuj plan pielęgnacji AM + PM dla każdego dnia z zakresu:\n{dates_str}\n\n" + f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{_RULES}{notes_line}" + "\nDodatkowe zasady dla planu wielodniowego:\n" + " - Retinol/retinoidy: przestrzegaj max_frequency_per_week i min_interval_hours między użyciami\n" + " - Nie stosuj kwasów i retinoidów tego samego dnia\n" + " - Uwzględnij safe_after_shaving dla dni golenia\n" + " - Zmienność aktywnych składników przez dni dla lepszej tolerancji\n" + " - Pole date w każdym dniu MUSI być w formacie YYYY-MM-DD\n" + "\nZwróć JSON zgodny ze schematem." + ) + + try: + response = client.models.generate_content( + model=model, + contents=prompt, + config=genai_types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=_BatchOut, + max_output_tokens=8192, + temperature=0.4, + ), + ) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Gemini API error: {e}") + + raw = response.text + if not raw: + raise HTTPException(status_code=502, detail="LLM returned an empty response") + + try: + parsed = json.loads(raw) + except json.JSONDecodeError as e: + raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}") + + def _parse_steps(raw_steps: list) -> list[SuggestedStep]: + result = [] + for s in raw_steps: + result.append( + SuggestedStep( + product_id=UUID(s["product_id"]) if s.get("product_id") else None, + action_type=s.get("action_type") or None, + action_notes=s.get("action_notes"), + dose=s.get("dose"), + region=s.get("region"), + ) + ) + return result + + days = [] + for day_raw in parsed.get("days", []): + try: + day_date = date.fromisoformat(day_raw["date"]) + except (KeyError, ValueError): + continue + days.append( + DayPlan( + date=day_date, + am_steps=_parse_steps(day_raw.get("am_steps", [])), + pm_steps=_parse_steps(day_raw.get("pm_steps", [])), + reasoning=day_raw.get("reasoning", ""), + ) + ) + + return BatchSuggestion(days=days, overall_reasoning=parsed.get("overall_reasoning", "")) + + # Grooming-schedule GET must appear before /{routine_id} to avoid being shadowed @router.get("/grooming-schedule", response_model=list[GroomingSchedule]) def list_grooming_schedule(session: Session = Depends(get_session)): diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 69089b4..dc0f712 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,16 +1,19 @@ import { PUBLIC_API_BASE } from '$env/static/public'; import type { ActiveIngredient, + BatchSuggestion, GroomingSchedule, LabResult, MedicationEntry, MedicationUsage, + PartOfDay, Product, ProductContext, ProductEffectProfile, ProductInteraction, ProductInventory, Routine, + RoutineSuggestion, RoutineStep, SkinConditionSnapshot } from './types'; @@ -130,6 +133,18 @@ export const updateRoutineStep = (stepId: string, body: Record) export const deleteRoutineStep = (stepId: string): Promise => api.del(`/routines/steps/${stepId}`); +export const suggestRoutine = (body: { + routine_date: string; + part_of_day: PartOfDay; + notes?: string; +}): Promise => api.post('/routines/suggest', body); + +export const suggestBatch = (body: { + from_date: string; + to_date: string; + notes?: string; +}): Promise => api.post('/routines/suggest-batch', body); + export const getGroomingSchedule = (): Promise => api.get('/routines/grooming-schedule'); export const createGroomingScheduleEntry = (body: Record): Promise => diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 150c1bf..b644a49 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -191,6 +191,31 @@ export interface GroomingSchedule { notes?: string; } +export interface SuggestedStep { + product_id?: string; + action_type?: GroomingAction; + action_notes?: string; + dose?: string; + region?: string; +} + +export interface RoutineSuggestion { + steps: SuggestedStep[]; + reasoning: string; +} + +export interface DayPlan { + date: string; + am_steps: SuggestedStep[]; + pm_steps: SuggestedStep[]; + reasoning: string; +} + +export interface BatchSuggestion { + days: DayPlan[]; + overall_reasoning: string; +} + // ─── Health types ──────────────────────────────────────────────────────────── export interface MedicationUsage { diff --git a/frontend/src/routes/routines/+page.svelte b/frontend/src/routes/routines/+page.svelte index 4a8e5fa..28fa6fe 100644 --- a/frontend/src/routes/routines/+page.svelte +++ b/frontend/src/routes/routines/+page.svelte @@ -26,7 +26,10 @@

Routines

{data.routines.length} routines (last 30 days)

- +
+ + +
{#if sortedDates.length} diff --git a/frontend/src/routes/routines/suggest/+page.server.ts b/frontend/src/routes/routines/suggest/+page.server.ts new file mode 100644 index 0000000..026a352 --- /dev/null +++ b/frontend/src/routes/routines/suggest/+page.server.ts @@ -0,0 +1,152 @@ +import { addRoutineStep, createRoutine, getProducts, suggestBatch, suggestRoutine } from '$lib/api'; +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + const products = await getProducts(); + const today = new Date().toISOString().slice(0, 10); + return { products, today }; +}; + +export const actions: Actions = { + suggest: async ({ request }) => { + const form = await request.formData(); + const routine_date = form.get('routine_date') as string; + const part_of_day = form.get('part_of_day') as 'am' | 'pm'; + const notes = (form.get('notes') as string) || undefined; + + if (!routine_date || !part_of_day) { + return fail(400, { error: 'Data i pora dnia są wymagane.' }); + } + + try { + const suggestion = await suggestRoutine({ routine_date, part_of_day, notes }); + return { suggestion, routine_date, part_of_day }; + } catch (e) { + return fail(502, { error: (e as Error).message }); + } + }, + + suggestBatch: async ({ request }) => { + const form = await request.formData(); + const from_date = form.get('from_date') as string; + const to_date = form.get('to_date') as string; + const notes = (form.get('notes') as string) || undefined; + + if (!from_date || !to_date) { + return fail(400, { error: 'Daty początkowa i końcowa są wymagane.' }); + } + + const delta = + (new Date(to_date).getTime() - new Date(from_date).getTime()) / (1000 * 60 * 60 * 24) + 1; + if (delta > 14) { + return fail(400, { error: 'Zakres dat nie może przekraczać 14 dni.' }); + } + + try { + const batch = await suggestBatch({ from_date, to_date, notes }); + return { batch, from_date, to_date }; + } catch (e) { + return fail(502, { error: (e as Error).message }); + } + }, + + save: async ({ request }) => { + const form = await request.formData(); + const routine_date = form.get('routine_date') as string; + const part_of_day = form.get('part_of_day') as string; + const steps_json = form.get('steps') as string; + + if (!routine_date || !part_of_day || !steps_json) { + return fail(400, { error: 'Brakujące dane do zapisania.' }); + } + + let steps: Array<{ + product_id?: string; + action_type?: string; + action_notes?: string; + dose?: string; + region?: string; + }>; + try { + steps = JSON.parse(steps_json); + } catch { + return fail(400, { error: 'Nieprawidłowy format kroków.' }); + } + + try { + const routine = await createRoutine({ routine_date, part_of_day }); + for (let i = 0; i < steps.length; i++) { + const s = steps[i]; + await addRoutineStep(routine.id, { + order_index: i + 1, + product_id: s.product_id || undefined, + action_type: s.action_type || undefined, + action_notes: s.action_notes || undefined, + dose: s.dose || undefined, + region: s.region || undefined + }); + } + redirect(303, `/routines/${routine.id}`); + } catch (e) { + return fail(500, { error: (e as Error).message }); + } + }, + + saveBatch: async ({ request }) => { + const form = await request.formData(); + const days_json = form.get('days') as string; + + if (!days_json) { + return fail(400, { error: 'Brakujące dane do zapisania.' }); + } + + let days: Array<{ + date: string; + am_steps: Array<{ + product_id?: string; + action_type?: string; + action_notes?: string; + dose?: string; + region?: string; + }>; + pm_steps: Array<{ + product_id?: string; + action_type?: string; + action_notes?: string; + dose?: string; + region?: string; + }>; + }>; + try { + days = JSON.parse(days_json); + } catch { + return fail(400, { error: 'Nieprawidłowy format danych.' }); + } + + try { + for (const day of days) { + for (const part_of_day of ['am', 'pm'] as const) { + const steps = part_of_day === 'am' ? day.am_steps : day.pm_steps; + if (steps.length === 0) continue; + const routine = await createRoutine({ routine_date: day.date, part_of_day }); + for (let i = 0; i < steps.length; i++) { + const s = steps[i]; + await addRoutineStep(routine.id, { + order_index: i + 1, + product_id: s.product_id || undefined, + action_type: s.action_type || undefined, + action_notes: s.action_notes || undefined, + dose: s.dose || undefined, + region: s.region || undefined + }); + } + } + } + } catch (e) { + return fail(500, { error: (e as Error).message }); + } + + redirect(303, '/routines'); + } +}; diff --git a/frontend/src/routes/routines/suggest/+page.svelte b/frontend/src/routes/routines/suggest/+page.svelte new file mode 100644 index 0000000..34a1052 --- /dev/null +++ b/frontend/src/routes/routines/suggest/+page.svelte @@ -0,0 +1,374 @@ + + +Zaproponuj rutynę AI — innercontext + +
+
+ ← Rutyny +

Propozycja rutyny AI

+
+ + {#if errorMsg} +
{errorMsg}
+ {/if} + + + + { errorMsg = null; }}>Jedna rutyna + { errorMsg = null; }}>Batch / Urlop + + + + + + Parametry + +
+
+
+ + +
+
+ + + +
+
+ +
+ + +
+ + +
+
+
+ + {#if suggestion} +
+
+

Propozycja

+ + {suggestionPod.toUpperCase()} + + {suggestionDate} +
+ + + + +

{suggestion.reasoning}

+
+
+ + +
+ {#each suggestion.steps as step, i (i)} +
+ + {i + 1} + +
+

{stepLabel(step)}

+ {#if stepMeta(step)} +

{stepMeta(step)}

+ {/if} +
+
+ {/each} +
+ + +
+ + + + + +
+
+ {/if} +
+ + + + + Zakres dat + +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+
+
+ + {#if batch} +
+

Plan ({batch.days.length} dni)

+ + + {#if batch.overall_reasoning} + + +

{batch.overall_reasoning}

+
+
+ {/if} + + +
+ {#each batch.days as day (day.date)} + {@const isOpen = expandedDays.has(day.date)} +
+ + + {#if isOpen} +
+ {#if day.reasoning} +

{day.reasoning}

+ {/if} + + +
+
+ AM + {day.am_steps.length} kroków +
+ {#if day.am_steps.length} +
+ {#each day.am_steps as step, i (i)} +
+ {i + 1}. +
+

{stepLabel(step)}

+ {#if stepMeta(step)} +

{stepMeta(step)}

+ {/if} +
+
+ {/each} +
+ {:else} +

Brak kroków AM.

+ {/if} +
+ + +
+
+ PM + {day.pm_steps.length} kroków +
+ {#if day.pm_steps.length} +
+ {#each day.pm_steps as step, i (i)} +
+ {i + 1}. +
+

{stepLabel(step)}

+ {#if stepMeta(step)} +

{stepMeta(step)}

+ {/if} +
+
+ {/each} +
+ {:else} +

Brak kroków PM.

+ {/if} +
+
+ {/if} +
+ {/each} +
+ + +
+ + + +
+
+ {/if} +
+
+