refactor(routines): streamline suggest prompt — merge inventory context, add leaving_home SPF hint

- 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 <noreply@anthropic.com>
This commit is contained in:
Piotr Oleszczyk 2026-03-01 23:47:54 +01:00
parent 258b8c4330
commit c85ca355df
6 changed files with 43 additions and 86 deletions

View file

@ -84,6 +84,7 @@ class SuggestRoutineRequest(SQLModel):
part_of_day: PartOfDay part_of_day: PartOfDay
notes: Optional[str] = None notes: Optional[str] = None
include_minoxidil_beard: bool = False include_minoxidil_beard: bool = False
leaving_home: Optional[bool] = None
class RoutineSuggestion(SQLModel): class RoutineSuggestion(SQLModel):
@ -244,8 +245,8 @@ def _build_recent_history(session: Session) -> str:
.order_by(col(Routine.routine_date).desc()) .order_by(col(Routine.routine_date).desc())
).all() ).all()
if not routines: if not routines:
return "OSTATNIE RUTYNY (7 dni): brak\n" return "OSTATNIE RUTYNY: brak\n"
lines = ["OSTATNIE RUTYNY (7 dni):"] lines = ["OSTATNIE RUTYNY:"]
for r in routines: for r in routines:
steps = session.exec( steps = session.exec(
select(RoutineStep) select(RoutineStep)
@ -315,6 +316,8 @@ def _build_products_context(session: Session, time_filter: Optional[str] = None)
) )
if pao_deadlines: if pao_deadlines:
entry += f" nearest_open_pao_deadline={pao_deadlines[0]}" 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", {}) profile = ctx.get("product_effect_profile", {})
if profile: if profile:
notable = {k: v for k, v in profile.items() if v and v > 0} 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" 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: def _build_objectives_context(include_minoxidil_beard: bool) -> str:
if include_minoxidil_beard: if include_minoxidil_beard:
return ( return (
@ -418,6 +345,13 @@ def _build_objectives_context(include_minoxidil_beard: bool) -> str:
return "" 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 = """\ _ROUTINES_SYSTEM_PROMPT = """\
Jesteś ekspertem planowania pielęgnacji. Jesteś ekspertem planowania pielęgnacji.
@ -446,10 +380,14 @@ ZASADY PLANOWANIA:
- najpierw zużywaj produkty już otwarte, - najpierw zużywaj produkty już otwarte,
- minimalizuj liczbę jednocześnie otwartych produktów funkcjonalnie podobnych, - minimalizuj liczbę jednocześnie otwartych produktów funkcjonalnie podobnych,
- nie rozpoczynaj nowego produktu, jeśli istnieje funkcjonalny odpowiednik otwarty i kompatybilny, - 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ą. - 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). - 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. - 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 - Dla minoksydylu (jeśli celem jest zarost i produkt jest dostępny): ustaw adekwatny region
broda/wąsy i nie naruszaj ograniczeń bezpieczeństwa. broda/wąsy i nie naruszaj ograniczeń bezpieczeństwa.
- Preferuj 4-7 kroków na pojedynczą rutynę; unikaj zbędnych duplikatów aktywnych. - 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) skin_ctx = _build_skin_context(session)
grooming_ctx = _build_grooming_context(session, weekdays=[weekday]) grooming_ctx = _build_grooming_context(session, weekdays=[weekday])
history_ctx = _build_recent_history(session) 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) 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) objectives_ctx = _build_objectives_context(data.include_minoxidil_beard)
notes_line = f"\nKONTEKST OD UŻYTKOWNIKA: {data.notes}\n" if data.notes else "" 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"Zaproponuj rutynę pielęgnacyjną {data.part_of_day.value.upper()} "
f"na {data.routine_date} ({day_name}).\n\n" f"na {data.routine_date} ({day_name}).\n\n"
"DANE WEJŚCIOWE:\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" f"{notes_line}\n"
"Zwróć JSON zgodny ze schematem." "Zwróć JSON zgodny ze schematem."
) )
@ -606,7 +542,6 @@ def suggest_batch(
grooming_ctx = _build_grooming_context(session, weekdays=weekdays) grooming_ctx = _build_grooming_context(session, weekdays=weekdays)
history_ctx = _build_recent_history(session) history_ctx = _build_recent_history(session)
products_ctx = _build_products_context(session) products_ctx = _build_products_context(session)
inventory_ctx = _build_inventory_context(session)
objectives_ctx = _build_objectives_context(data.include_minoxidil_beard) objectives_ctx = _build_objectives_context(data.include_minoxidil_beard)
date_range_lines = [] date_range_lines = []
@ -620,7 +555,7 @@ def suggest_batch(
prompt = ( prompt = (
f"Zaproponuj plan pielęgnacji AM + PM dla każdego dnia z zakresu:\n{dates_str}\n\n" f"Zaproponuj plan pielęgnacji AM + PM dla każdego dnia z zakresu:\n{dates_str}\n\n"
"DANE WEJŚCIOWE:\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" f"{notes_line}\n"
"\nZwróć JSON zgodny ze schematem." "\nZwróć JSON zgodny ze schematem."
) )

View file

@ -132,6 +132,8 @@
"suggest_contextLabel": "Additional context for AI", "suggest_contextLabel": "Additional context for AI",
"suggest_contextOptional": "(optional)", "suggest_contextOptional": "(optional)",
"suggest_contextPlaceholder": "e.g. party night, focusing on hydration...", "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_minoxidilToggleLabel": "Prioritize beard/mustache density (minoxidil)",
"suggest_minoxidilToggleHint": "When enabled, AI will explicitly consider minoxidil for beard/mustache areas if available.", "suggest_minoxidilToggleHint": "When enabled, AI will explicitly consider minoxidil for beard/mustache areas if available.",
"suggest_generateBtn": "Generate suggestion", "suggest_generateBtn": "Generate suggestion",

View file

@ -132,6 +132,8 @@
"suggest_contextLabel": "Dodatkowy kontekst dla AI", "suggest_contextLabel": "Dodatkowy kontekst dla AI",
"suggest_contextOptional": "(opcjonalny)", "suggest_contextOptional": "(opcjonalny)",
"suggest_contextPlaceholder": "np. wieczór imprezowy, skupiam się na nawilżeniu...", "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_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_minoxidilToggleHint": "Po włączeniu AI jawnie uwzględni minoksydyl dla obszaru brody/wąsów, jeśli jest dostępny.",
"suggest_generateBtn": "Generuj propozycję", "suggest_generateBtn": "Generuj propozycję",

View file

@ -142,6 +142,7 @@ export const suggestRoutine = (body: {
part_of_day: PartOfDay; part_of_day: PartOfDay;
notes?: string; notes?: string;
include_minoxidil_beard?: boolean; include_minoxidil_beard?: boolean;
leaving_home?: boolean;
}): Promise<RoutineSuggestion> => api.post('/routines/suggest', body); }): Promise<RoutineSuggestion> => api.post('/routines/suggest', body);
export const suggestBatch = (body: { export const suggestBatch = (body: {

View file

@ -15,6 +15,8 @@ export const actions: Actions = {
const part_of_day = form.get('part_of_day') as 'am' | 'pm'; const part_of_day = form.get('part_of_day') as 'am' | 'pm';
const notes = (form.get('notes') as string) || undefined; const notes = (form.get('notes') as string) || undefined;
const include_minoxidil_beard = form.get('include_minoxidil_beard') === 'on'; 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) { if (!routine_date || !part_of_day) {
return fail(400, { error: 'Data i pora dnia są wymagane.' }); return fail(400, { error: 'Data i pora dnia są wymagane.' });
@ -25,7 +27,8 @@ export const actions: Actions = {
routine_date, routine_date,
part_of_day, part_of_day,
notes, notes,
include_minoxidil_beard include_minoxidil_beard,
leaving_home
}); });
return { suggestion, routine_date, part_of_day }; return { suggestion, routine_date, part_of_day };
} catch (e) { } catch (e) {

View file

@ -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" 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"
></textarea> ></textarea>
</div> </div>
{#if partOfDay === 'am'}
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
id="single_leaving_home"
name="leaving_home"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="single_leaving_home" class="font-medium">{m["suggest_leavingHomeLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_leavingHomeHint"]()}</p>
</div>
</div>
{/if}
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2"> <div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input <input
id="single_include_minoxidil_beard" id="single_include_minoxidil_beard"