From f1acfa21fc7f628d20ea76ef960d3df8eff6a192 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 1 Mar 2026 22:15:04 +0100 Subject: [PATCH] feat(routines): add inventory-aware product selection rules --- backend/innercontext/api/routines.py | 128 ++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index a7e1f43..e8ce6e5 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -14,6 +14,7 @@ from innercontext.llm import call_gemini from innercontext.models import ( GroomingSchedule, Product, + ProductInventory, Routine, RoutineStep, SkinConditionSnapshot, @@ -267,18 +268,53 @@ def _build_recent_history(session: Session) -> str: def _build_products_context(session: Session, time_filter: Optional[str] = None) -> str: stmt = select(Product).where(Product.is_tool == False) # noqa: E712 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:"] 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 + p.inventory = inv_by_product.get(p.id, []) 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', [])}" ) + 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", {}) if profile: 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" +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: if include_minoxidil_beard: return ( @@ -330,6 +444,12 @@ ZASADY PLANOWANIA: - Kolejność warstw: cleanser -> toner -> essence -> serum -> moisturizer -> [SPF dla AM]. - Respektuj: incompatible_with (same_step / same_day / same_period), context_rules, 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). - 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 @@ -416,6 +536,9 @@ def suggest_routine( 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) + 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 "" @@ -425,7 +548,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{objectives_ctx}" + f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{inventory_ctx}\n{objectives_ctx}" f"{notes_line}\n" "Zwróć JSON zgodny ze schematem." ) @@ -485,6 +608,7 @@ 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 = [] @@ -498,7 +622,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{objectives_ctx}" + f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{inventory_ctx}\n{objectives_ctx}" f"{notes_line}\n" "\nZwróć JSON zgodny ze schematem." )