From c85ca355dfea64db2bde40f8a4406b2d4e3fb9fd Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 1 Mar 2026 23:47:54 +0100 Subject: [PATCH] =?UTF-8?q?refactor(routines):=20streamline=20suggest=20pr?= =?UTF-8?q?ompt=20=E2=80=94=20merge=20inventory=20context,=20add=20leaving?= =?UTF-8?q?=5Fhome=20SPF=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove _build_inventory_context; fold pao_months into DOSTĘPNE PRODUKTY entries - Remove "Otwarte równolegle" duplicate section from prompt - Rename OSTATNIE RUTYNY (7 dni) → OSTATNIE RUTYNY - Add _build_day_context and SuggestRoutineRequest.leaving_home (optional bool) - System prompt: replace unconditional PAO rule with conditional; add SPF factor selection logic based on KONTEKST DNIA leaving_home value - Frontend: leaving_home checkbox (AM only) + i18n keys pl/en Co-Authored-By: Claude Sonnet 4.6 --- backend/innercontext/api/routines.py | 105 ++++-------------- frontend/messages/en.json | 2 + frontend/messages/pl.json | 2 + frontend/src/lib/api.ts | 1 + .../routes/routines/suggest/+page.server.ts | 5 +- .../src/routes/routines/suggest/+page.svelte | 14 +++ 6 files changed, 43 insertions(+), 86 deletions(-) diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 0536123..95e977f 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -84,6 +84,7 @@ class SuggestRoutineRequest(SQLModel): part_of_day: PartOfDay notes: Optional[str] = None include_minoxidil_beard: bool = False + leaving_home: Optional[bool] = None class RoutineSuggestion(SQLModel): @@ -244,8 +245,8 @@ def _build_recent_history(session: Session) -> str: .order_by(col(Routine.routine_date).desc()) ).all() if not routines: - return "OSTATNIE RUTYNY (7 dni): brak\n" - lines = ["OSTATNIE RUTYNY (7 dni):"] + return "OSTATNIE RUTYNY: brak\n" + lines = ["OSTATNIE RUTYNY:"] for r in routines: steps = session.exec( select(RoutineStep) @@ -315,6 +316,8 @@ def _build_products_context(session: Session, time_filter: Optional[str] = None) ) if pao_deadlines: entry += f" nearest_open_pao_deadline={pao_deadlines[0]}" + if p.pao_months is not None: + entry += f" pao_months={p.pao_months}" profile = ctx.get("product_effect_profile", {}) if profile: notable = {k: v for k, v in profile.items() if v and v > 0} @@ -332,82 +335,6 @@ def _build_products_context(session: Session, time_filter: Optional[str] = None) return "\n".join(lines) + "\n" -def _build_inventory_context( - session: Session, time_filter: Optional[str] = None -) -> str: - products = session.exec(select(Product).where(Product.is_tool.is_(False))).all() - product_ids = [p.id for p in products] - inventory_rows = ( - session.exec( - select(ProductInventory).where(ProductInventory.product_id.in_(product_ids)) - ).all() - if product_ids - else [] - ) - inv_by_product: dict[UUID, list[ProductInventory]] = {} - for inv in inventory_rows: - inv_by_product.setdefault(inv.product_id, []).append(inv) - - lines = ["KONTEKST INWENTARZA (PRIORYTET ZUŻYCIA):"] - open_by_category: dict[str, list[str]] = {} - has_any = False - - for p in products: - if p.is_medication and not _is_minoxidil_product(p): - continue - if time_filter and _ev(p.recommended_time) not in (time_filter, "both"): - continue - - active_inventory = [ - inv for inv in inv_by_product.get(p.id, []) if inv.finished_at is None - ] - if not active_inventory: - continue - has_any = True - open_inventory = [inv for inv in active_inventory if inv.is_opened] - sealed_inventory = [inv for inv in active_inventory if not inv.is_opened] - category = _ev(p.category) - if open_inventory: - open_by_category.setdefault(category, []).append(p.name) - - line = ( - f' - product_id={p.id} name="{p.name}" category={category}' - f" active={len(active_inventory)} opened={len(open_inventory)} sealed={len(sealed_inventory)}" - ) - - expiry_dates = sorted( - inv.expiry_date.isoformat() for inv in open_inventory if inv.expiry_date - ) - if expiry_dates: - line += f" nearest_open_expiry={expiry_dates[0]}" - - if p.pao_months is not None and open_inventory: - pao_deadlines = sorted( - (inv.opened_at + timedelta(days=30 * p.pao_months)).isoformat() - for inv in open_inventory - if inv.opened_at - ) - if pao_deadlines: - line += f" nearest_open_pao_deadline={pao_deadlines[0]}" - - if p.pao_months is not None: - line += f" pao_months={p.pao_months}" - lines.append(line) - - if not has_any: - return "KONTEKST INWENTARZA (PRIORYTET ZUŻYCIA): brak aktywnych opakowań\n" - - duplicate_open_categories = { - cat: names for cat, names in open_by_category.items() if len(names) > 1 - } - if duplicate_open_categories: - lines.append(" Otwarte równolegle podobne kategorie (ograniczaj rotację):") - for cat, names in duplicate_open_categories.items(): - lines.append(f" - {cat}: {', '.join(sorted(set(names)))}") - - return "\n".join(lines) + "\n" - - def _build_objectives_context(include_minoxidil_beard: bool) -> str: if include_minoxidil_beard: return ( @@ -418,6 +345,13 @@ def _build_objectives_context(include_minoxidil_beard: bool) -> str: return "" +def _build_day_context(leaving_home: Optional[bool]) -> str: + if leaving_home is None: + return "" + val = "tak" if leaving_home else "nie" + return f"KONTEKST DNIA:\n Wyjście z domu: {val}\n" + + _ROUTINES_SYSTEM_PROMPT = """\ Jesteś ekspertem planowania pielęgnacji. @@ -446,10 +380,14 @@ ZASADY PLANOWANIA: - najpierw zużywaj produkty już otwarte, - minimalizuj liczbę jednocześnie otwartych produktów funkcjonalnie podobnych, - nie rozpoczynaj nowego produktu, jeśli istnieje funkcjonalny odpowiednik otwarty i kompatybilny, - - preferuj produkty z krótszym terminem ważności po otwarciu (expiry_date / PAO), + - jeśli nearest_open_pao_deadline lub nearest_open_expiry jest dostępne, preferuj produkt z wcześniejszą datą w swojej kategorii, - rotuj tylko gdy daje to wartość terapeutyczną. - Nie łącz retinoidów i kwasów w tej samej rutynie ani tego samego dnia (dla planu wielodniowego). - W AM zawsze uwzględnij SPF, jeśli kompatybilny produkt SPF istnieje na liście. + Wybór filtra (na podstawie KONTEKST DNIA): + - "Wyjście z domu: tak" → najwyższy współczynnik dostępny w nazwie (SPF50+, SPF50, SPF30); + - "Wyjście z domu: nie" → SPF30 wystarczy; wyższy dopuszczalny jeśli brak SPF30; + - brak KONTEKST DNIA → wybierz najwyższy dostępny. - Dla minoksydylu (jeśli celem jest zarost i produkt jest dostępny): ustaw adekwatny region broda/wąsy i nie naruszaj ograniczeń bezpieczeństwa. - Preferuj 4-7 kroków na pojedynczą rutynę; unikaj zbędnych duplikatów aktywnych. @@ -533,10 +471,8 @@ def suggest_routine( skin_ctx = _build_skin_context(session) grooming_ctx = _build_grooming_context(session, weekdays=[weekday]) history_ctx = _build_recent_history(session) + day_ctx = _build_day_context(data.leaving_home) products_ctx = _build_products_context(session, time_filter=data.part_of_day.value) - inventory_ctx = _build_inventory_context( - session, time_filter=data.part_of_day.value - ) objectives_ctx = _build_objectives_context(data.include_minoxidil_beard) notes_line = f"\nKONTEKST OD UŻYTKOWNIKA: {data.notes}\n" if data.notes else "" @@ -546,7 +482,7 @@ def suggest_routine( f"Zaproponuj rutynę pielęgnacyjną {data.part_of_day.value.upper()} " f"na {data.routine_date} ({day_name}).\n\n" "DANE WEJŚCIOWE:\n" - f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{inventory_ctx}\n{objectives_ctx}" + f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{day_ctx}\n{products_ctx}\n{objectives_ctx}" f"{notes_line}\n" "Zwróć JSON zgodny ze schematem." ) @@ -606,7 +542,6 @@ def suggest_batch( grooming_ctx = _build_grooming_context(session, weekdays=weekdays) history_ctx = _build_recent_history(session) products_ctx = _build_products_context(session) - inventory_ctx = _build_inventory_context(session) objectives_ctx = _build_objectives_context(data.include_minoxidil_beard) date_range_lines = [] @@ -620,7 +555,7 @@ def suggest_batch( prompt = ( f"Zaproponuj plan pielęgnacji AM + PM dla każdego dnia z zakresu:\n{dates_str}\n\n" "DANE WEJŚCIOWE:\n" - f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{inventory_ctx}\n{objectives_ctx}" + f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{objectives_ctx}" f"{notes_line}\n" "\nZwróć JSON zgodny ze schematem." ) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index e9503c5..ec26efb 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -132,6 +132,8 @@ "suggest_contextLabel": "Additional context for AI", "suggest_contextOptional": "(optional)", "suggest_contextPlaceholder": "e.g. party night, focusing on hydration...", + "suggest_leavingHomeLabel": "Going outside today", + "suggest_leavingHomeHint": "Affects SPF selection — checked: SPF50+, unchecked: SPF30.", "suggest_minoxidilToggleLabel": "Prioritize beard/mustache density (minoxidil)", "suggest_minoxidilToggleHint": "When enabled, AI will explicitly consider minoxidil for beard/mustache areas if available.", "suggest_generateBtn": "Generate suggestion", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 0b7dde9..a69932b 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -132,6 +132,8 @@ "suggest_contextLabel": "Dodatkowy kontekst dla AI", "suggest_contextOptional": "(opcjonalny)", "suggest_contextPlaceholder": "np. wieczór imprezowy, skupiam się na nawilżeniu...", + "suggest_leavingHomeLabel": "Wychodzę dziś z domu", + "suggest_leavingHomeHint": "Wpływa na wybór SPF — zaznaczone: SPF50+, odznaczone: SPF30.", "suggest_minoxidilToggleLabel": "Priorytet: gęstość brody/wąsów (minoksydyl)", "suggest_minoxidilToggleHint": "Po włączeniu AI jawnie uwzględni minoksydyl dla obszaru brody/wąsów, jeśli jest dostępny.", "suggest_generateBtn": "Generuj propozycję", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 454073c..75717c7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -142,6 +142,7 @@ export const suggestRoutine = (body: { part_of_day: PartOfDay; notes?: string; include_minoxidil_beard?: boolean; + leaving_home?: boolean; }): Promise => api.post('/routines/suggest', body); export const suggestBatch = (body: { diff --git a/frontend/src/routes/routines/suggest/+page.server.ts b/frontend/src/routes/routines/suggest/+page.server.ts index 93e3309..311e247 100644 --- a/frontend/src/routes/routines/suggest/+page.server.ts +++ b/frontend/src/routes/routines/suggest/+page.server.ts @@ -15,6 +15,8 @@ export const actions: Actions = { const part_of_day = form.get('part_of_day') as 'am' | 'pm'; const notes = (form.get('notes') as string) || undefined; const include_minoxidil_beard = form.get('include_minoxidil_beard') === 'on'; + const leaving_home = + part_of_day === 'am' ? form.get('leaving_home') === 'on' : undefined; if (!routine_date || !part_of_day) { return fail(400, { error: 'Data i pora dnia są wymagane.' }); @@ -25,7 +27,8 @@ export const actions: Actions = { routine_date, part_of_day, notes, - include_minoxidil_beard + include_minoxidil_beard, + leaving_home }); return { suggestion, routine_date, part_of_day }; } catch (e) { diff --git a/frontend/src/routes/routines/suggest/+page.svelte b/frontend/src/routes/routines/suggest/+page.svelte index 832a859..f0ef4ce 100644 --- a/frontend/src/routes/routines/suggest/+page.svelte +++ b/frontend/src/routes/routines/suggest/+page.svelte @@ -153,6 +153,20 @@ class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none" > + {#if partOfDay === 'am'} +
+ +
+ +

{m["suggest_leavingHomeHint"]()}

+
+
+ {/if}