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:
parent
258b8c4330
commit
c85ca355df
6 changed files with 43 additions and 86 deletions
|
|
@ -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."
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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ę",
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue