feat(routines): add inventory-aware product selection rules
This commit is contained in:
parent
914c6087bd
commit
f1acfa21fc
1 changed files with 126 additions and 2 deletions
|
|
@ -14,6 +14,7 @@ from innercontext.llm import call_gemini
|
||||||
from innercontext.models import (
|
from innercontext.models import (
|
||||||
GroomingSchedule,
|
GroomingSchedule,
|
||||||
Product,
|
Product,
|
||||||
|
ProductInventory,
|
||||||
Routine,
|
Routine,
|
||||||
RoutineStep,
|
RoutineStep,
|
||||||
SkinConditionSnapshot,
|
SkinConditionSnapshot,
|
||||||
|
|
@ -267,18 +268,53 @@ def _build_recent_history(session: Session) -> str:
|
||||||
def _build_products_context(session: Session, time_filter: Optional[str] = None) -> str:
|
def _build_products_context(session: Session, time_filter: Optional[str] = None) -> str:
|
||||||
stmt = select(Product).where(Product.is_tool == False) # noqa: E712
|
stmt = select(Product).where(Product.is_tool == False) # noqa: E712
|
||||||
products = session.exec(stmt).all()
|
products = session.exec(stmt).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 = ["DOSTĘPNE PRODUKTY:"]
|
lines = ["DOSTĘPNE PRODUKTY:"]
|
||||||
for p in products:
|
for p in products:
|
||||||
if p.is_medication and not _is_minoxidil_product(p):
|
if p.is_medication and not _is_minoxidil_product(p):
|
||||||
continue
|
continue
|
||||||
if time_filter and _ev(p.recommended_time) not in (time_filter, "both"):
|
if time_filter and _ev(p.recommended_time) not in (time_filter, "both"):
|
||||||
continue
|
continue
|
||||||
|
p.inventory = inv_by_product.get(p.id, [])
|
||||||
ctx = p.to_llm_context()
|
ctx = p.to_llm_context()
|
||||||
entry = (
|
entry = (
|
||||||
f" - id={ctx['id']} name=\"{ctx['name']}\" brand=\"{ctx['brand']}\""
|
f" - id={ctx['id']} name=\"{ctx['name']}\" brand=\"{ctx['brand']}\""
|
||||||
f" category={ctx.get('category', '')} recommended_time={ctx.get('recommended_time', '')}"
|
f" category={ctx.get('category', '')} recommended_time={ctx.get('recommended_time', '')}"
|
||||||
f" targets={ctx.get('targets', [])}"
|
f" targets={ctx.get('targets', [])}"
|
||||||
)
|
)
|
||||||
|
active_inventory = [inv for inv in p.inventory if inv.finished_at is None]
|
||||||
|
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]
|
||||||
|
entry += (
|
||||||
|
" inventory_status={"
|
||||||
|
f"active:{len(active_inventory)},opened:{len(open_inventory)},sealed:{len(sealed_inventory)}"
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
if open_inventory:
|
||||||
|
expiry_dates = sorted(
|
||||||
|
inv.expiry_date.isoformat() for inv in open_inventory if inv.expiry_date
|
||||||
|
)
|
||||||
|
if expiry_dates:
|
||||||
|
entry += f" nearest_open_expiry={expiry_dates[0]}"
|
||||||
|
if p.pao_months is not None:
|
||||||
|
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:
|
||||||
|
entry += f" nearest_open_pao_deadline={pao_deadlines[0]}"
|
||||||
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}
|
||||||
|
|
@ -296,6 +332,84 @@ 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 == False)
|
||||||
|
).all() # noqa: E712
|
||||||
|
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 (
|
||||||
|
|
@ -330,6 +444,12 @@ ZASADY PLANOWANIA:
|
||||||
- Kolejność warstw: cleanser -> toner -> essence -> serum -> moisturizer -> [SPF dla AM].
|
- Kolejność warstw: cleanser -> toner -> essence -> serum -> moisturizer -> [SPF dla AM].
|
||||||
- Respektuj: incompatible_with (same_step / same_day / same_period), context_rules,
|
- Respektuj: incompatible_with (same_step / same_day / same_period), context_rules,
|
||||||
min_interval_hours, max_frequency_per_week, usage_notes.
|
min_interval_hours, max_frequency_per_week, usage_notes.
|
||||||
|
- Zarządzanie inwentarzem:
|
||||||
|
- 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),
|
||||||
|
- 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.
|
||||||
- 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
|
||||||
|
|
@ -416,6 +536,9 @@ def suggest_routine(
|
||||||
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)
|
||||||
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 ""
|
||||||
|
|
@ -425,7 +548,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{objectives_ctx}"
|
f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{inventory_ctx}\n{objectives_ctx}"
|
||||||
f"{notes_line}\n"
|
f"{notes_line}\n"
|
||||||
"Zwróć JSON zgodny ze schematem."
|
"Zwróć JSON zgodny ze schematem."
|
||||||
)
|
)
|
||||||
|
|
@ -485,6 +608,7 @@ 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 = []
|
||||||
|
|
@ -498,7 +622,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{objectives_ctx}"
|
f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{inventory_ctx}\n{objectives_ctx}"
|
||||||
f"{notes_line}\n"
|
f"{notes_line}\n"
|
||||||
"\nZwróć JSON zgodny ze schematem."
|
"\nZwróć JSON zgodny ze schematem."
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue