diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index 945ab53..6dff032 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -1,8 +1,12 @@ import json +import logging +import re from datetime import date from typing import Optional from uuid import UUID, uuid4 +log = logging.getLogger(__name__) + from fastapi import APIRouter, Depends, HTTPException, Query from google.genai import types as genai_types from pydantic import ValidationError @@ -374,17 +378,37 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse: config=genai_types.GenerateContentConfig( system_instruction=_product_parse_system_prompt(), response_mime_type="application/json", - response_schema=ProductParseResponse, max_output_tokens=16384, temperature=0.0, ), ) + candidate = response.candidates[0] if response.candidates else None + finish_reason = str(candidate.finish_reason) if candidate else "unknown" raw = response.text if not raw: - raise HTTPException(status_code=502, detail="LLM returned an empty response") + raise HTTPException( + status_code=502, + detail=f"LLM returned an empty response (finish_reason={finish_reason})", + ) + # Fallback: extract JSON object in case the model adds preamble or markdown fences + if not raw.lstrip().startswith("{"): + start = raw.find("{") + end = raw.rfind("}") + if start != -1 and end != -1: + raw = raw[start : end + 1] + # Replace JS-style non-JSON literals that some models emit + raw = re.sub(r":\s*NaN\b", ": null", raw) + raw = re.sub(r":\s*Infinity\b", ": null", raw) + raw = re.sub(r":\s*undefined\b", ": null", raw) try: parsed = json.loads(raw) except json.JSONDecodeError as e: + log.error( + "Gemini parse-text JSON error at pos %d finish_reason=%s context=%r", + e.pos, + finish_reason, + raw[max(0, e.pos - 80) : e.pos + 80], + ) raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}") try: return ProductParseResponse.model_validate(parsed) diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 5cc129f..e20156b 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -1,17 +1,13 @@ -import json -from datetime import date, timedelta +from datetime import date from typing import Optional from uuid import UUID, uuid4 -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 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 GroomingSchedule, Product, Routine, RoutineStep, SkinConditionSnapshot +from innercontext.models import GroomingSchedule, Routine, RoutineStep from innercontext.models.enums import GroomingAction, PartOfDay router = APIRouter() @@ -64,188 +60,6 @@ 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[GroomingAction] = 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 # --------------------------------------------------------------------------- @@ -282,164 +96,6 @@ 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/backend/innercontext/api/skincare.py b/backend/innercontext/api/skincare.py index 57b60b1..8e2cf9c 100644 --- a/backend/innercontext/api/skincare.py +++ b/backend/innercontext/api/skincare.py @@ -5,7 +5,6 @@ from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from google.genai import types as genai_types -from pydantic import BaseModel as PydanticBase from pydantic import ValidationError from sqlmodel import Session, SQLModel, select @@ -71,21 +70,6 @@ class SkinPhotoAnalysisResponse(SQLModel): notes: Optional[str] = None -class _SkinAnalysisOut(PydanticBase): - 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 # --------------------------------------------------------------------------- @@ -164,7 +148,6 @@ async def analyze_skin_photos( config=genai_types.GenerateContentConfig( system_instruction=_skin_photo_system_prompt(), response_mime_type="application/json", - response_schema=_SkinAnalysisOut, max_output_tokens=2048, temperature=0.0, ), diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dc0f712..69089b4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,19 +1,16 @@ 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'; @@ -133,18 +130,6 @@ 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 b644a49..150c1bf 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -191,31 +191,6 @@ 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 28fa6fe..4a8e5fa 100644 --- a/frontend/src/routes/routines/+page.svelte +++ b/frontend/src/routes/routines/+page.svelte @@ -26,10 +26,7 @@

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 deleted file mode 100644 index 026a352..0000000 --- a/frontend/src/routes/routines/suggest/+page.server.ts +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index 34a1052..0000000 --- a/frontend/src/routes/routines/suggest/+page.svelte +++ /dev/null @@ -1,374 +0,0 @@ - - -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} -
-
-