From 1c457d62a32faa1361c5ca0198b32b7b45e9a555 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sat, 7 Mar 2026 01:39:31 +0100 Subject: [PATCH 01/24] feat(api): include 7-day upcoming grooming context in routine suggestions --- backend/innercontext/api/routines.py | 50 ++++++++++++++++++++++++-- backend/tests/test_routines.py | 1 + backend/tests/test_routines_helpers.py | 24 +++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 5535a1e..5c33536 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -327,6 +327,48 @@ def _build_grooming_context( return "\n".join(lines) + "\n" +def _build_upcoming_grooming_context( + session: Session, + start_date: date, + days: int = 7, +) -> str: + entries = session.exec( + select(GroomingSchedule).order_by(col(GroomingSchedule.day_of_week)) + ).all() + if not entries: + return f"UPCOMING GROOMING (next {days} days): none\n" + + entries_by_weekday: dict[int, list[GroomingSchedule]] = {} + for entry in entries: + entries_by_weekday.setdefault(entry.day_of_week, []).append(entry) + + lines = [f"UPCOMING GROOMING (next {days} days):"] + for offset in range(days): + target_date = start_date + timedelta(days=offset) + day_entries = entries_by_weekday.get(target_date.weekday(), []) + if not day_entries: + continue + + if offset == 0: + relative_label = "dzisiaj" + elif offset == 1: + relative_label = "jutro" + else: + relative_label = f"za {offset} dni" + + day_name = _DAY_NAMES[target_date.weekday()] + actions = ", ".join( + f"{_ev(entry.action)}" + (f" ({entry.notes})" if entry.notes else "") + for entry in day_entries + ) + lines.append(f" {relative_label} ({target_date}, {day_name}): {actions}") + + if len(lines) == 1: + lines.append(" (no entries in this window)") + + return "\n".join(lines) + "\n" + + def _build_recent_history(session: Session) -> str: cutoff = date.today() - timedelta(days=7) routines = session.exec( @@ -632,7 +674,11 @@ def suggest_routine( weekday = data.routine_date.weekday() skin_ctx = _build_skin_context(session) profile_ctx = build_user_profile_context(session, reference_date=data.routine_date) - grooming_ctx = _build_grooming_context(session, weekdays=[weekday]) + upcoming_grooming_ctx = _build_upcoming_grooming_context( + session, + start_date=data.routine_date, + days=7, + ) history_ctx = _build_recent_history(session) day_ctx = _build_day_context(data.leaving_home) available_products = _get_available_products( @@ -675,7 +721,7 @@ def suggest_routine( f"na {data.routine_date} ({day_name}).\n\n" f"{mode_line}\n" "INPUT DATA:\n" - f"{profile_ctx}\n{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{day_ctx}\n{products_ctx}\n{objectives_ctx}" + f"{profile_ctx}\n{skin_ctx}\n{upcoming_grooming_ctx}\n{history_ctx}\n{day_ctx}\n{products_ctx}\n{objectives_ctx}" "\nNARZEDZIA:\n" "- Masz dostep do funkcji: get_product_details.\n" "- Wywoluj narzedzia tylko, gdy potrzebujesz detali do decyzji klinicznej/bezpieczenstwa.\n" diff --git a/backend/tests/test_routines.py b/backend/tests/test_routines.py index b993eeb..ee1060c 100644 --- a/backend/tests/test_routines.py +++ b/backend/tests/test_routines.py @@ -249,6 +249,7 @@ def test_suggest_routine(client, session): assert data["reasoning"] == "because" kwargs = mock_gemini.call_args.kwargs assert "USER PROFILE:" in kwargs["contents"] + assert "UPCOMING GROOMING (next 7 days):" in kwargs["contents"] assert "function_handlers" in kwargs assert "get_product_details" in kwargs["function_handlers"] diff --git a/backend/tests/test_routines_helpers.py b/backend/tests/test_routines_helpers.py index 7619425..64562dd 100644 --- a/backend/tests/test_routines_helpers.py +++ b/backend/tests/test_routines_helpers.py @@ -10,6 +10,7 @@ from innercontext.api.routines import ( _build_products_context, _build_recent_history, _build_skin_context, + _build_upcoming_grooming_context, _contains_minoxidil_text, _ev, _extract_active_names, @@ -121,6 +122,29 @@ def test_build_grooming_context(session: Session): assert "(no entries for specified days)" in ctx2 +def test_build_upcoming_grooming_context(session: Session): + assert ( + _build_upcoming_grooming_context(session, start_date=date(2026, 3, 2), days=7) + == "UPCOMING GROOMING (next 7 days): none\n" + ) + + monday = GroomingSchedule( + id=uuid.uuid4(), day_of_week=0, action="shaving_oneblade", notes="Morning" + ) + wednesday = GroomingSchedule(id=uuid.uuid4(), day_of_week=2, action="dermarolling") + session.add_all([monday, wednesday]) + session.commit() + + ctx = _build_upcoming_grooming_context( + session, + start_date=date(2026, 3, 2), + days=7, + ) + assert "UPCOMING GROOMING (next 7 days):" in ctx + assert "dzisiaj (2026-03-02, poniedziałek): shaving_oneblade (Morning)" in ctx + assert "za 2 dni (2026-03-04, środa): dermarolling" in ctx + + def test_build_recent_history(session: Session): assert _build_recent_history(session) == "RECENT ROUTINES: none\n" From fecfa0b9e4cd4c8fb98f61670288a727779a1ea0 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 8 Mar 2026 11:53:59 +0100 Subject: [PATCH 02/24] feat(api): align routine context windows with recent skin history --- backend/innercontext/api/routines.py | 86 ++++++++-- backend/tests/test_routines.py | 42 ++++- backend/tests/test_routines_helpers.py | 216 +++++++++++++++++++------ 3 files changed, 273 insertions(+), 71 deletions(-) diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 5c33536..9f98b27 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -49,6 +49,9 @@ from innercontext.validators.routine_validator import RoutineValidationContext logger = logging.getLogger(__name__) +HISTORY_WINDOW_DAYS = 5 +SNAPSHOT_FALLBACK_DAYS = 14 + def _build_response_metadata(session: Session, log_id: Any) -> ResponseMetadata | None: """Build ResponseMetadata from AICallLog for Phase 3 observability.""" @@ -284,12 +287,58 @@ def _ev(v: object) -> str: return str(v) -def _build_skin_context(session: Session) -> str: +def _get_recent_skin_snapshot( + session: Session, + reference_date: date, + window_days: int = HISTORY_WINDOW_DAYS, + fallback_days: int = SNAPSHOT_FALLBACK_DAYS, +) -> SkinConditionSnapshot | None: + window_cutoff = reference_date - timedelta(days=window_days) + fallback_cutoff = reference_date - timedelta(days=fallback_days) + snapshot = session.exec( - select(SkinConditionSnapshot).order_by( - col(SkinConditionSnapshot.snapshot_date).desc() - ) + select(SkinConditionSnapshot) + .where(SkinConditionSnapshot.snapshot_date <= reference_date) + .where(SkinConditionSnapshot.snapshot_date >= window_cutoff) + .order_by(col(SkinConditionSnapshot.snapshot_date).desc()) ).first() + if snapshot is not None: + return snapshot + + return session.exec( + select(SkinConditionSnapshot) + .where(SkinConditionSnapshot.snapshot_date <= reference_date) + .where(SkinConditionSnapshot.snapshot_date >= fallback_cutoff) + .order_by(col(SkinConditionSnapshot.snapshot_date).desc()) + ).first() + + +def _get_latest_skin_snapshot_within_days( + session: Session, + reference_date: date, + max_age_days: int = SNAPSHOT_FALLBACK_DAYS, +) -> SkinConditionSnapshot | None: + cutoff = reference_date - timedelta(days=max_age_days) + return session.exec( + select(SkinConditionSnapshot) + .where(SkinConditionSnapshot.snapshot_date <= reference_date) + .where(SkinConditionSnapshot.snapshot_date >= cutoff) + .order_by(col(SkinConditionSnapshot.snapshot_date).desc()) + ).first() + + +def _build_skin_context( + session: Session, + reference_date: date, + window_days: int = HISTORY_WINDOW_DAYS, + fallback_days: int = SNAPSHOT_FALLBACK_DAYS, +) -> str: + snapshot = _get_recent_skin_snapshot( + session, + reference_date=reference_date, + window_days=window_days, + fallback_days=fallback_days, + ) if snapshot is None: return "SKIN CONDITION: no data\n" ev = _ev @@ -369,10 +418,15 @@ def _build_upcoming_grooming_context( return "\n".join(lines) + "\n" -def _build_recent_history(session: Session) -> str: - cutoff = date.today() - timedelta(days=7) +def _build_recent_history( + session: Session, + reference_date: date, + window_days: int = HISTORY_WINDOW_DAYS, +) -> str: + cutoff = reference_date - timedelta(days=window_days) routines = session.exec( select(Routine) + .where(Routine.routine_date <= reference_date) .where(Routine.routine_date >= cutoff) .order_by(col(Routine.routine_date).desc()) ).all() @@ -672,14 +726,14 @@ def suggest_routine( session: Session = Depends(get_session), ): weekday = data.routine_date.weekday() - skin_ctx = _build_skin_context(session) + skin_ctx = _build_skin_context(session, reference_date=data.routine_date) profile_ctx = build_user_profile_context(session, reference_date=data.routine_date) upcoming_grooming_ctx = _build_upcoming_grooming_context( session, start_date=data.routine_date, days=7, ) - history_ctx = _build_recent_history(session) + history_ctx = _build_recent_history(session, reference_date=data.routine_date) day_ctx = _build_day_context(data.leaving_home) available_products = _get_available_products( session, @@ -848,10 +902,10 @@ def suggest_routine( ) # Get skin snapshot for barrier state - stmt = select(SkinConditionSnapshot).order_by( - col(SkinConditionSnapshot.snapshot_date).desc() + skin_snapshot = _get_latest_skin_snapshot_within_days( + session, + reference_date=data.routine_date, ) - skin_snapshot = session.exec(stmt).first() # Build validation context products_by_id = {p.id: p for p in available_products} @@ -923,9 +977,9 @@ def suggest_batch( {(data.from_date + timedelta(days=i)).weekday() for i in range(delta)} ) profile_ctx = build_user_profile_context(session, reference_date=data.from_date) - skin_ctx = _build_skin_context(session) + skin_ctx = _build_skin_context(session, reference_date=data.from_date) grooming_ctx = _build_grooming_context(session, weekdays=weekdays) - history_ctx = _build_recent_history(session) + history_ctx = _build_recent_history(session, reference_date=data.from_date) batch_products = _get_available_products( session, include_minoxidil=data.include_minoxidil_beard, @@ -1030,10 +1084,10 @@ def suggest_batch( ) # Get skin snapshot for barrier state - stmt = select(SkinConditionSnapshot).order_by( - col(SkinConditionSnapshot.snapshot_date).desc() + skin_snapshot = _get_latest_skin_snapshot_within_days( + session, + reference_date=data.from_date, ) - skin_snapshot = session.exec(stmt).first() # Build validation context products_by_id = {p.id: p for p in batch_products} diff --git a/backend/tests/test_routines.py b/backend/tests/test_routines.py index ee1060c..dae411f 100644 --- a/backend/tests/test_routines.py +++ b/backend/tests/test_routines.py @@ -1,6 +1,10 @@ import uuid +from datetime import date from unittest.mock import patch +from innercontext.models import Routine, SkinConditionSnapshot +from innercontext.models.enums import BarrierState, OverallSkinState + # --------------------------------------------------------------------------- # Routines # --------------------------------------------------------------------------- @@ -223,6 +227,18 @@ def test_suggest_routine(client, session): with patch( "innercontext.api.routines.call_gemini_with_function_tools" ) as mock_gemini: + session.add( + SkinConditionSnapshot( + id=uuid.uuid4(), + snapshot_date=date(2026, 2, 22), + overall_state=OverallSkinState.GOOD, + hydration_level=4, + barrier_state=BarrierState.INTACT, + priorities=["hydration"], + ) + ) + session.commit() + # Mock the Gemini response mock_response = type( "Response", @@ -231,7 +247,7 @@ def test_suggest_routine(client, session): "text": '{"steps": [{"product_id": null, "action_type": "shaving_razor"}], "reasoning": "because"}' }, ) - mock_gemini.return_value = mock_response + mock_gemini.return_value = (mock_response, None) r = client.post( "/routines/suggest", @@ -250,12 +266,32 @@ def test_suggest_routine(client, session): kwargs = mock_gemini.call_args.kwargs assert "USER PROFILE:" in kwargs["contents"] assert "UPCOMING GROOMING (next 7 days):" in kwargs["contents"] + assert "snapshot from 2026-02-22" in kwargs["contents"] + assert "RECENT ROUTINES: none" in kwargs["contents"] assert "function_handlers" in kwargs assert "get_product_details" in kwargs["function_handlers"] def test_suggest_batch(client, session): with patch("innercontext.api.routines.call_gemini") as mock_gemini: + session.add( + Routine( + id=uuid.uuid4(), + routine_date=date(2026, 2, 27), + part_of_day="pm", + ) + ) + session.add( + SkinConditionSnapshot( + id=uuid.uuid4(), + snapshot_date=date(2026, 2, 20), + overall_state=OverallSkinState.GOOD, + hydration_level=4, + barrier_state=BarrierState.INTACT, + ) + ) + session.commit() + # Mock the Gemini response mock_response = type( "Response", @@ -264,7 +300,7 @@ def test_suggest_batch(client, session): "text": '{"days": [{"date": "2026-03-03", "am_steps": [], "pm_steps": [], "reasoning": "none"}], "overall_reasoning": "batch test"}' }, ) - mock_gemini.return_value = mock_response + mock_gemini.return_value = (mock_response, None) r = client.post( "/routines/suggest-batch", @@ -281,6 +317,8 @@ def test_suggest_batch(client, session): assert data["overall_reasoning"] == "batch test" kwargs = mock_gemini.call_args.kwargs assert "USER PROFILE:" in kwargs["contents"] + assert "2026-02-27 PM:" in kwargs["contents"] + assert "snapshot from 2026-02-20" in kwargs["contents"] def test_suggest_batch_invalid_date_range(client): diff --git a/backend/tests/test_routines_helpers.py b/backend/tests/test_routines_helpers.py index 64562dd..b547f62 100644 --- a/backend/tests/test_routines_helpers.py +++ b/backend/tests/test_routines_helpers.py @@ -3,11 +3,11 @@ from datetime import date, timedelta from sqlmodel import Session +from innercontext.api.llm_context import build_products_context_summary_list from innercontext.api.routines import ( _build_day_context, _build_grooming_context, _build_objectives_context, - _build_products_context, _build_recent_history, _build_skin_context, _build_upcoming_grooming_context, @@ -17,17 +17,19 @@ from innercontext.api.routines import ( _extract_requested_product_ids, _filter_products_by_interval, _get_available_products, + _get_latest_skin_snapshot_within_days, + _get_recent_skin_snapshot, _is_minoxidil_product, build_product_details_tool_handler, ) from innercontext.models import ( GroomingSchedule, Product, - ProductInventory, Routine, RoutineStep, SkinConditionSnapshot, ) +from innercontext.models.enums import BarrierState, OverallSkinState, SkinConcern def test_contains_minoxidil_text(): @@ -78,32 +80,131 @@ def test_ev(): def test_build_skin_context(session: Session): # Empty - assert _build_skin_context(session) == "SKIN CONDITION: no data\n" + reference_date = date(2026, 3, 10) + assert ( + _build_skin_context(session, reference_date=reference_date) + == "SKIN CONDITION: no data\n" + ) # With data snap = SkinConditionSnapshot( id=uuid.uuid4(), - snapshot_date=date.today(), - overall_state="good", + snapshot_date=reference_date, + overall_state=OverallSkinState.GOOD, hydration_level=4, - barrier_state="intact", - active_concerns=["acne", "dryness"], + barrier_state=BarrierState.INTACT, + active_concerns=[SkinConcern.ACNE, SkinConcern.DEHYDRATION], priorities=["hydration"], notes="Feeling good", ) session.add(snap) session.commit() - ctx = _build_skin_context(session) + ctx = _build_skin_context(session, reference_date=reference_date) assert "SKIN CONDITION (snapshot from" in ctx assert "Overall state: good" in ctx assert "Hydration: 4/5" in ctx assert "Barrier: intact" in ctx - assert "Active concerns: acne, dryness" in ctx + assert "Active concerns: acne, dehydration" in ctx assert "Priorities: hydration" in ctx assert "Notes: Feeling good" in ctx +def test_build_skin_context_falls_back_to_recent_snapshot_within_14_days( + session: Session, +): + reference_date = date(2026, 3, 20) + snap = SkinConditionSnapshot( + id=uuid.uuid4(), + snapshot_date=reference_date - timedelta(days=10), + overall_state=OverallSkinState.FAIR, + hydration_level=3, + barrier_state=BarrierState.COMPROMISED, + active_concerns=[SkinConcern.REDNESS], + priorities=["barrier"], + ) + session.add(snap) + session.commit() + + ctx = _build_skin_context(session, reference_date=reference_date) + + assert f"snapshot from {reference_date - timedelta(days=10)}" in ctx + assert "Barrier: compromised" in ctx + + +def test_build_skin_context_ignores_snapshot_older_than_14_days(session: Session): + reference_date = date(2026, 3, 20) + snap = SkinConditionSnapshot( + id=uuid.uuid4(), + snapshot_date=reference_date - timedelta(days=15), + overall_state=OverallSkinState.FAIR, + hydration_level=3, + barrier_state=BarrierState.INTACT, + ) + session.add(snap) + session.commit() + + assert ( + _build_skin_context(session, reference_date=reference_date) + == "SKIN CONDITION: no data\n" + ) + + +def test_get_recent_skin_snapshot_prefers_window_match(session: Session): + reference_date = date(2026, 3, 20) + older = SkinConditionSnapshot( + id=uuid.uuid4(), + snapshot_date=reference_date - timedelta(days=10), + overall_state=OverallSkinState.POOR, + hydration_level=2, + barrier_state=BarrierState.COMPROMISED, + ) + newer = SkinConditionSnapshot( + id=uuid.uuid4(), + snapshot_date=reference_date - timedelta(days=2), + overall_state=OverallSkinState.GOOD, + hydration_level=4, + barrier_state=BarrierState.INTACT, + ) + session.add_all([older, newer]) + session.commit() + + snapshot = _get_recent_skin_snapshot(session, reference_date=reference_date) + + assert snapshot is not None + assert snapshot.id == newer.id + + +def test_get_latest_skin_snapshot_within_days_uses_latest_within_14_days( + session: Session, +): + reference_date = date(2026, 3, 20) + older = SkinConditionSnapshot( + id=uuid.uuid4(), + snapshot_date=reference_date - timedelta(days=10), + overall_state=OverallSkinState.POOR, + hydration_level=2, + barrier_state=BarrierState.COMPROMISED, + ) + newer = SkinConditionSnapshot( + id=uuid.uuid4(), + snapshot_date=reference_date - timedelta(days=2), + overall_state=OverallSkinState.GOOD, + hydration_level=4, + barrier_state=BarrierState.INTACT, + ) + session.add_all([older, newer]) + session.commit() + + snapshot = _get_latest_skin_snapshot_within_days( + session, + reference_date=reference_date, + ) + + assert snapshot is not None + assert snapshot.id == newer.id + + def test_build_grooming_context(session: Session): assert _build_grooming_context(session) == "GROOMING SCHEDULE: none\n" @@ -146,12 +247,17 @@ def test_build_upcoming_grooming_context(session: Session): def test_build_recent_history(session: Session): - assert _build_recent_history(session) == "RECENT ROUTINES: none\n" + reference_date = date(2026, 3, 10) + assert ( + _build_recent_history(session, reference_date=reference_date) + == "RECENT ROUTINES: none\n" + ) - r = Routine(id=uuid.uuid4(), routine_date=date.today(), part_of_day="am") + r = Routine(id=uuid.uuid4(), routine_date=reference_date, part_of_day="am") session.add(r) p = Product( id=uuid.uuid4(), + short_id=str(uuid.uuid4())[:8], name="Cleanser", category="cleanser", brand="Test", @@ -174,7 +280,7 @@ def test_build_recent_history(session: Session): session.add_all([s1, s2, s3]) session.commit() - ctx = _build_recent_history(session) + ctx = _build_recent_history(session, reference_date=reference_date) assert "RECENT ROUTINES:" in ctx assert "AM:" in ctx assert "cleanser [" in ctx @@ -182,10 +288,48 @@ def test_build_recent_history(session: Session): assert "unknown [" in ctx -def test_build_products_context(session: Session): +def test_build_recent_history_uses_reference_window(session: Session): + reference_date = date(2026, 3, 10) + recent = Routine( + id=uuid.uuid4(), + routine_date=reference_date - timedelta(days=3), + part_of_day="pm", + ) + old = Routine( + id=uuid.uuid4(), + routine_date=reference_date - timedelta(days=6), + part_of_day="am", + ) + session.add_all([recent, old]) + session.commit() + + ctx = _build_recent_history(session, reference_date=reference_date) + + assert str(recent.routine_date) in ctx + assert str(old.routine_date) not in ctx + + +def test_build_recent_history_excludes_future_routines(session: Session): + reference_date = date(2026, 3, 10) + future = Routine( + id=uuid.uuid4(), + routine_date=reference_date + timedelta(days=1), + part_of_day="am", + ) + session.add(future) + session.commit() + + assert ( + _build_recent_history(session, reference_date=reference_date) + == "RECENT ROUTINES: none\n" + ) + + +def test_build_products_context_summary_list(session: Session): p1 = Product( id=uuid.uuid4(), - name="Regaine", + short_id=str(uuid.uuid4())[:8], + name="Regaine Minoxidil", category="serum", is_medication=True, brand="J&J", @@ -195,6 +339,7 @@ def test_build_products_context(session: Session): ) p2 = Product( id=uuid.uuid4(), + short_id=str(uuid.uuid4())[:8], name="Sunscreen", category="spf", brand="Test", @@ -209,49 +354,14 @@ def test_build_products_context(session: Session): session.add_all([p1, p2]) session.commit() - # Inventory - inv1 = ProductInventory( - id=uuid.uuid4(), - product_id=p2.id, - is_opened=True, - opened_at=date.today() - timedelta(days=10), - expiry_date=date.today() + timedelta(days=365), - ) - inv2 = ProductInventory(id=uuid.uuid4(), product_id=p2.id, is_opened=False) - session.add_all([inv1, inv2]) - session.commit() - - # Usage - r = Routine(id=uuid.uuid4(), routine_date=date.today(), part_of_day="am") - session.add(r) - session.commit() - s = RoutineStep(id=uuid.uuid4(), routine_id=r.id, order_index=1, product_id=p2.id) - session.add(s) - session.commit() - products_am = _get_available_products(session, time_filter="am") - ctx = _build_products_context(session, products_am, reference_date=date.today()) - # p1 is medication but not minoxidil (wait, Regaine name doesn't contain minoxidil!) -> skipped - assert "Regaine" not in ctx + ctx = build_products_context_summary_list(products_am, {p2.id}) - # Let's fix p1 to be minoxidil - p1.name = "Regaine Minoxidil" - session.add(p1) - session.commit() - - products_am = _get_available_products(session, time_filter="am") - ctx = _build_products_context(session, products_am, reference_date=date.today()) assert "Regaine Minoxidil" in ctx assert "Sunscreen" in ctx - assert "inventory_status={active:2,opened:1,sealed:1}" in ctx - assert "nearest_open_expiry=" in ctx - assert "nearest_open_pao_deadline=" in ctx - assert "pao_months=6" in ctx - assert "effects={'hydration_immediate': 2}" in ctx - assert "context_rules={'safe_after_shaving': False}" in ctx - assert "min_interval_hours=12" in ctx - assert "max_frequency_per_week=7" in ctx - assert "used_in_last_7_days=1" in ctx + assert "[✓]" in ctx + assert "hydration=2" in ctx + assert "!post_shave" in ctx def test_build_objectives_context(): From cebea2ac86e17b05f59f6cec94bb030856f0e873 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 8 Mar 2026 11:55:16 +0100 Subject: [PATCH 03/24] fix(api): avoid distinct on json product fields in shopping suggestions --- backend/innercontext/api/products.py | 6 +++--- backend/tests/test_products_helpers.py | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index 08d42f2..0763d37 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -1082,12 +1082,12 @@ def suggest_shopping(session: Session = Depends(get_session)): raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}") # Get products with inventory (those user already owns) - products_with_inventory = session.exec( - select(Product).join(ProductInventory).distinct() + products_with_inventory_ids = session.exec( + select(ProductInventory.product_id).distinct() ).all() shopping_context = ShoppingValidationContext( - owned_product_ids=set(p.id for p in products_with_inventory), + owned_product_ids=set(products_with_inventory_ids), valid_categories=set(ProductCategory), valid_targets=set(SkinConcern), ) diff --git a/backend/tests/test_products_helpers.py b/backend/tests/test_products_helpers.py index 283eb11..5940875 100644 --- a/backend/tests/test_products_helpers.py +++ b/backend/tests/test_products_helpers.py @@ -87,6 +87,23 @@ def test_suggest_shopping(client, session): with patch( "innercontext.api.products.call_gemini_with_function_tools" ) as mock_gemini: + product = Product( + id=uuid.uuid4(), + short_id=str(uuid.uuid4())[:8], + name="Owned Serum", + brand="BrandX", + category="serum", + recommended_time="both", + leave_on=True, + product_effect_profile={}, + ) + session.add(product) + session.commit() + session.add( + ProductInventory(id=uuid.uuid4(), product_id=product.id, is_opened=True) + ) + session.commit() + mock_response = type( "Response", (), @@ -94,7 +111,7 @@ def test_suggest_shopping(client, session): "text": '{"suggestions": [{"category": "cleanser", "product_type": "cleanser", "priority": "high", "key_ingredients": [], "target_concerns": [], "why_needed": "reason", "recommended_time": "am", "frequency": "daily"}], "reasoning": "Test shopping"}' }, ) - mock_gemini.return_value = mock_response + mock_gemini.return_value = (mock_response, None) r = client.post("/products/suggest") assert r.status_code == 200 From 5d9d18bd05a50017494cf84e09a4f384b9a6a8f2 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 8 Mar 2026 12:06:39 +0100 Subject: [PATCH 04/24] fix(api): constrain shopping suggestion enums --- backend/innercontext/api/products.py | 13 +++++++++---- backend/tests/test_products_helpers.py | 8 ++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index 0763d37..f9e0323 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -255,12 +255,12 @@ class InventoryUpdate(SQLModel): class ProductSuggestion(PydanticBase): - category: str + category: ProductCategory product_type: str key_ingredients: list[str] target_concerns: list[str] why_needed: str - recommended_time: str + recommended_time: DayTime frequency: str @@ -274,12 +274,12 @@ class ShoppingSuggestionResponse(PydanticBase): class _ProductSuggestionOut(PydanticBase): - category: str + category: ProductCategory product_type: str key_ingredients: list[str] target_concerns: list[str] why_needed: str - recommended_time: str + recommended_time: DayTime frequency: str @@ -981,6 +981,11 @@ ZASADY: 7. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów 8. Zwracaj uwagę na ewentualne konflikty polecanych składników z tymi, które użytkownik już posiada (np. nie polecaj peptydów miedziowych jeśli użytkownik nadużywa kwasów) 9. Odpowiadaj w języku polskim +10. Używaj wyłącznie dozwolonych wartości enumów poniżej - nie twórz synonimów typu "night", "evening" ani "treatment" + +DOZWOLONE WARTOŚCI ENUMÓW: +- category: "cleanser" | "toner" | "essence" | "serum" | "moisturizer" | "spf" | "mask" | "exfoliant" | "hair_treatment" | "tool" | "spot_treatment" | "oil" +- recommended_time: "am" | "pm" | "both" Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem.""" diff --git a/backend/tests/test_products_helpers.py b/backend/tests/test_products_helpers.py index 5940875..db9b210 100644 --- a/backend/tests/test_products_helpers.py +++ b/backend/tests/test_products_helpers.py @@ -121,6 +121,14 @@ def test_suggest_shopping(client, session): assert data["reasoning"] == "Test shopping" kwargs = mock_gemini.call_args.kwargs assert "USER PROFILE:" in kwargs["contents"] + assert ( + 'category: "cleanser" | "toner" | "essence"' + in kwargs["config"].system_instruction + ) + assert ( + 'recommended_time: "am" | "pm" | "both"' + in kwargs["config"].system_instruction + ) assert "function_handlers" in kwargs assert "get_product_details" in kwargs["function_handlers"] From bb5d402c156d9e94156de7a5d29ac89cc3730bc6 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 8 Mar 2026 22:30:30 +0100 Subject: [PATCH 05/24] feat(products): improve shopping suggestion decision support --- backend/innercontext/api/products.py | 42 +++- .../validators/shopping_validator.py | 204 ++++++++---------- backend/tests/test_products_helpers.py | 98 ++++++++- frontend/messages/en.json | 9 + frontend/messages/pl.json | 9 + frontend/src/lib/types.ts | 9 +- .../routes/products/suggest/+page.server.ts | 3 + .../src/routes/products/suggest/+page.svelte | 120 +++++++++-- 8 files changed, 352 insertions(+), 142 deletions(-) diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index f9e0323..6c13ac9 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -257,11 +257,16 @@ class InventoryUpdate(SQLModel): class ProductSuggestion(PydanticBase): category: ProductCategory product_type: str + priority: Literal["high", "medium", "low"] key_ingredients: list[str] target_concerns: list[str] - why_needed: str recommended_time: DayTime frequency: str + short_reason: str + reason_to_buy_now: str + reason_not_needed_if_budget_tight: str | None = None + fit_with_current_routine: str + usage_cautions: list[str] class ShoppingSuggestionResponse(PydanticBase): @@ -276,11 +281,16 @@ class ShoppingSuggestionResponse(PydanticBase): class _ProductSuggestionOut(PydanticBase): category: ProductCategory product_type: str + priority: Literal["high", "medium", "low"] key_ingredients: list[str] target_concerns: list[str] - why_needed: str recommended_time: DayTime frequency: str + short_reason: str + reason_to_buy_now: str + reason_not_needed_if_budget_tight: str | None = None + fit_with_current_routine: str + usage_cautions: list[str] class _ShoppingSuggestionsOut(PydanticBase): @@ -982,6 +992,13 @@ ZASADY: 8. Zwracaj uwagę na ewentualne konflikty polecanych składników z tymi, które użytkownik już posiada (np. nie polecaj peptydów miedziowych jeśli użytkownik nadużywa kwasów) 9. Odpowiadaj w języku polskim 10. Używaj wyłącznie dozwolonych wartości enumów poniżej - nie twórz synonimów typu "night", "evening" ani "treatment" +11. Możesz zwrócić pustą listę suggestions, jeśli nie widzisz realnej potrzeby zakupowej +12. Każda sugestia ma mieć charakter decision-support: konkretnie wyjaśnij, dlaczego warto kupić ją teraz, jak wpisuje się w obecną rutynę i jakie są ograniczenia +13. `short_reason` ma być krótkim, 1-zdaniowym skrótem decyzji zakupowej +14. `reason_to_buy_now` ma być konkretne i praktyczne, bez lania wody +15. `reason_not_needed_if_budget_tight` jest opcjonalne - uzupełniaj tylko wtedy, gdy zakup nie jest pilny lub istnieje rozsądny kompromis +16. `usage_cautions` ma być krótką listą praktycznych uwag; gdy brak istotnych zastrzeżeń zwróć pustą listę +17. `priority` ustawiaj jako: high = wyraźna luka lub pilna potrzeba, medium = sensowne uzupełnienie, low = opcjonalny upgrade DOZWOLONE WARTOŚCI ENUMÓW: - category: "cleanser" | "toner" | "essence" | "serum" | "moisturizer" | "spf" | "mask" | "exfoliant" | "hair_treatment" | "tool" | "spot_treatment" | "oil" @@ -1086,6 +1103,20 @@ def suggest_shopping(session: Session = Depends(get_session)): except json.JSONDecodeError as e: raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}") + try: + parsed_response = _ShoppingSuggestionsOut.model_validate(parsed) + except ValidationError as exc: + formatted_errors = "; ".join( + f"{'/'.join(str(part) for part in err['loc'])}: {err['msg']}" + for err in exc.errors() + ) + raise HTTPException( + status_code=502, + detail=( + f"LLM returned invalid shopping suggestion schema: {formatted_errors}" + ), + ) + # Get products with inventory (those user already owns) products_with_inventory_ids = session.exec( select(ProductInventory.product_id).distinct() @@ -1102,8 +1133,11 @@ def suggest_shopping(session: Session = Depends(get_session)): # Build initial shopping response without metadata shopping_response = ShoppingSuggestionResponse( - suggestions=[ProductSuggestion(**s) for s in parsed.get("suggestions", [])], - reasoning=parsed.get("reasoning", ""), + suggestions=[ + ProductSuggestion.model_validate(s.model_dump()) + for s in parsed_response.suggestions + ], + reasoning=parsed_response.reasoning, ) validation_result = validator.validate(shopping_response, shopping_context) diff --git a/backend/innercontext/validators/shopping_validator.py b/backend/innercontext/validators/shopping_validator.py index 4f21360..bbd4c17 100644 --- a/backend/innercontext/validators/shopping_validator.py +++ b/backend/innercontext/validators/shopping_validator.py @@ -22,48 +22,9 @@ class ShoppingValidationContext: class ShoppingValidator(BaseValidator): - """Validates shopping suggestions for product types.""" + """Validates shopping suggestion schema and copy quality.""" - # Realistic product type patterns (not exhaustive, just sanity checks) - VALID_PRODUCT_TYPE_PATTERNS = { - "serum", - "cream", - "cleanser", - "toner", - "essence", - "moisturizer", - "spf", - "sunscreen", - "oil", - "balm", - "mask", - "exfoliant", - "acid", - "retinoid", - "vitamin", - "niacinamide", - "hyaluronic", - "ceramide", - "peptide", - "antioxidant", - "aha", - "bha", - "pha", - } - - VALID_FREQUENCIES = { - "daily", - "twice daily", - "am", - "pm", - "both", - "2x weekly", - "3x weekly", - "2-3x weekly", - "weekly", - "as needed", - "occasional", - } + VALID_PRIORITIES = {"high", "medium", "low"} def validate( self, response: Any, context: ShoppingValidationContext @@ -73,19 +34,17 @@ class ShoppingValidator(BaseValidator): Checks: 1. suggestions field present - 2. Product types are realistic (contain known keywords) - 3. Not suggesting products user already owns (should mark as [✗]) - 4. Recommended frequencies are valid - 5. Categories are valid - 6. Targets are valid - 7. Each suggestion has required fields + 2. Categories are valid + 3. Targets are valid + 4. Each suggestion has required fields + 5. Decision-support fields are well formed Args: response: Parsed shopping suggestion response context: Validation context Returns: - ValidationResult with any errors/warnings + ValidationResult with schema errors and lightweight quality warnings """ result = ValidationResult() @@ -112,15 +71,8 @@ class ShoppingValidator(BaseValidator): f"Suggestion {sug_num}: invalid category '{suggestion.category}'" ) - # Check product type is realistic - if hasattr(suggestion, "product_type") and suggestion.product_type: - self._check_product_type_realistic( - suggestion.product_type, sug_num, result - ) - - # Check frequency is valid - if hasattr(suggestion, "frequency") and suggestion.frequency: - self._check_frequency_valid(suggestion.frequency, sug_num, result) + if hasattr(suggestion, "priority") and suggestion.priority: + self._check_priority_valid(suggestion.priority, sug_num, result) # Check targets are valid if hasattr(suggestion, "target_concerns") and suggestion.target_concerns: @@ -128,6 +80,11 @@ class ShoppingValidator(BaseValidator): suggestion.target_concerns, sug_num, context, result ) + if hasattr(suggestion, "usage_cautions"): + self._check_usage_cautions(suggestion.usage_cautions, sug_num, result) + + self._check_text_quality(suggestion, sug_num, result) + # Check recommended_time is valid if hasattr(suggestion, "recommended_time") and suggestion.recommended_time: if suggestion.recommended_time not in ("am", "pm", "both"): @@ -142,7 +99,15 @@ class ShoppingValidator(BaseValidator): self, suggestion: Any, sug_num: int, result: ValidationResult ) -> None: """Check suggestion has required fields.""" - required = ["category", "product_type", "why_needed"] + required = [ + "category", + "product_type", + "priority", + "short_reason", + "reason_to_buy_now", + "fit_with_current_routine", + "usage_cautions", + ] for field in required: if not hasattr(suggestion, field) or getattr(suggestion, field) is None: @@ -150,64 +115,14 @@ class ShoppingValidator(BaseValidator): f"Suggestion {sug_num}: missing required field '{field}'" ) - def _check_product_type_realistic( - self, product_type: str, sug_num: int, result: ValidationResult + def _check_priority_valid( + self, priority: str, sug_num: int, result: ValidationResult ) -> None: - """Check product type contains realistic keywords.""" - product_type_lower = product_type.lower() - - # Check if any valid pattern appears in the product type - has_valid_keyword = any( - pattern in product_type_lower - for pattern in self.VALID_PRODUCT_TYPE_PATTERNS - ) - - if not has_valid_keyword: - result.add_warning( - f"Suggestion {sug_num}: product type '{product_type}' looks unusual - " - "verify it's a real skincare product category" - ) - - # Check for brand names (shouldn't suggest specific brands) - suspicious_brands = [ - "la roche", - "cerave", - "paula", - "ordinary", - "skinceuticals", - "drunk elephant", - "versed", - "inkey", - "cosrx", - "pixi", - ] - - if any(brand in product_type_lower for brand in suspicious_brands): + """Check priority uses supported enum values.""" + if priority not in self.VALID_PRIORITIES: result.add_error( - f"Suggestion {sug_num}: product type contains brand name - " - "should suggest product TYPES only, not specific brands" - ) - - def _check_frequency_valid( - self, frequency: str, sug_num: int, result: ValidationResult - ) -> None: - """Check frequency is a recognized pattern.""" - frequency_lower = frequency.lower() - - # Check for exact matches or common patterns - is_valid = ( - frequency_lower in self.VALID_FREQUENCIES - or "daily" in frequency_lower - or "weekly" in frequency_lower - or "am" in frequency_lower - or "pm" in frequency_lower - or "x" in frequency_lower # e.g. "2x weekly" - ) - - if not is_valid: - result.add_warning( - f"Suggestion {sug_num}: unusual frequency '{frequency}' - " - "verify it's a realistic usage pattern" + f"Suggestion {sug_num}: invalid priority '{priority}' " + "(must be 'high', 'medium', or 'low')" ) def _check_targets_valid( @@ -227,3 +142,64 @@ class ShoppingValidator(BaseValidator): result.add_error( f"Suggestion {sug_num}: invalid target concern '{target}'" ) + + def _check_usage_cautions( + self, usage_cautions: Any, sug_num: int, result: ValidationResult + ) -> None: + """Check usage cautions are a list of short strings.""" + if not isinstance(usage_cautions, list): + result.add_error(f"Suggestion {sug_num}: usage_cautions must be a list") + return + + for caution in usage_cautions: + if not isinstance(caution, str): + result.add_error( + f"Suggestion {sug_num}: usage_cautions entries must be strings" + ) + continue + if len(caution.strip()) > 180: + result.add_warning( + f"Suggestion {sug_num}: usage caution is too long - keep it concise" + ) + + def _check_text_quality( + self, suggestion: Any, sug_num: int, result: ValidationResult + ) -> None: + """Warn when decision-support copy is too generic or empty-ish.""" + generic_phrases = { + "wspiera skore", + "pomaga skorze", + "moze pomoc", + "dobry wybor", + "uzupelnia rutyne", + "supports the skin", + "may help", + "good option", + "complements the routine", + } + + text_fields = [ + ("short_reason", getattr(suggestion, "short_reason", None), 12), + ("reason_to_buy_now", getattr(suggestion, "reason_to_buy_now", None), 18), + ( + "fit_with_current_routine", + getattr(suggestion, "fit_with_current_routine", None), + 18, + ), + ] + + for field_name, value, min_length in text_fields: + if not isinstance(value, str): + continue + stripped = value.strip() + if len(stripped) < min_length: + result.add_warning( + f"Suggestion {sug_num}: {field_name} is very short - add more decision context" + ) + continue + + lowered = stripped.lower() + if lowered in generic_phrases: + result.add_warning( + f"Suggestion {sug_num}: {field_name} is too generic - make it more specific" + ) diff --git a/backend/tests/test_products_helpers.py b/backend/tests/test_products_helpers.py index db9b210..9ce3573 100644 --- a/backend/tests/test_products_helpers.py +++ b/backend/tests/test_products_helpers.py @@ -5,17 +5,25 @@ from unittest.mock import patch from sqlmodel import Session from innercontext.api.products import ( + ProductSuggestion, + ShoppingSuggestionResponse, _build_shopping_context, _extract_requested_product_ids, build_product_details_tool_handler, ) from innercontext.models import ( Product, + ProductCategory, ProductInventory, SexAtBirth, + SkinConcern, SkinConditionSnapshot, ) from innercontext.models.profile import UserProfile +from innercontext.validators.shopping_validator import ( + ShoppingValidationContext, + ShoppingValidator, +) def test_build_shopping_context(session: Session): @@ -46,6 +54,7 @@ def test_build_shopping_context(session: Session): # Add product p = Product( id=uuid.uuid4(), + short_id=str(uuid.uuid4())[:8], name="Soothing Serum", brand="BrandX", category="serum", @@ -108,7 +117,7 @@ def test_suggest_shopping(client, session): "Response", (), { - "text": '{"suggestions": [{"category": "cleanser", "product_type": "cleanser", "priority": "high", "key_ingredients": [], "target_concerns": [], "why_needed": "reason", "recommended_time": "am", "frequency": "daily"}], "reasoning": "Test shopping"}' + "text": '{"suggestions": [{"category": "cleanser", "product_type": "cleanser", "priority": "high", "key_ingredients": ["glycerin"], "target_concerns": ["dehydration"], "recommended_time": "am", "frequency": "daily", "short_reason": "Brakuje lagodnego kroku myjacego rano.", "reason_to_buy_now": "Obecnie nie masz delikatnego produktu do porannego oczyszczania i wsparcia bariery.", "reason_not_needed_if_budget_tight": "Mozesz tymczasowo ograniczyc sie do samego splukania twarzy rano, jesli skora jest spokojna.", "fit_with_current_routine": "To domknie podstawowy krok cleanse bez dokladania agresywnych aktywow.", "usage_cautions": ["unikaj mocnego domywania przy podraznieniu"]}], "reasoning": "Test shopping"}' }, ) mock_gemini.return_value = (mock_response, None) @@ -118,6 +127,11 @@ def test_suggest_shopping(client, session): data = r.json() assert len(data["suggestions"]) == 1 assert data["suggestions"][0]["product_type"] == "cleanser" + assert data["suggestions"][0]["priority"] == "high" + assert data["suggestions"][0]["short_reason"] + assert data["suggestions"][0]["usage_cautions"] == [ + "unikaj mocnego domywania przy podraznieniu" + ] assert data["reasoning"] == "Test shopping" kwargs = mock_gemini.call_args.kwargs assert "USER PROFILE:" in kwargs["contents"] @@ -133,9 +147,43 @@ def test_suggest_shopping(client, session): assert "get_product_details" in kwargs["function_handlers"] +def test_suggest_shopping_invalid_json_returns_502(client): + with patch( + "innercontext.api.products.call_gemini_with_function_tools" + ) as mock_gemini: + mock_response = type("Response", (), {"text": "{"}) + mock_gemini.return_value = (mock_response, None) + + r = client.post("/products/suggest") + + assert r.status_code == 502 + assert "LLM returned invalid JSON" in r.json()["detail"] + + +def test_suggest_shopping_invalid_schema_returns_502(client): + with patch( + "innercontext.api.products.call_gemini_with_function_tools" + ) as mock_gemini: + mock_response = type( + "Response", + (), + { + "text": '{"suggestions": [{"category": "cleanser", "product_type": "cleanser", "priority": "urgent", "key_ingredients": [], "target_concerns": [], "recommended_time": "am", "frequency": "daily", "short_reason": "x", "reason_to_buy_now": "y", "fit_with_current_routine": "z", "usage_cautions": []}], "reasoning": "Test shopping"}' + }, + ) + mock_gemini.return_value = (mock_response, None) + + r = client.post("/products/suggest") + + assert r.status_code == 502 + assert "LLM returned invalid shopping suggestion schema" in r.json()["detail"] + assert "suggestions/0/priority" in r.json()["detail"] + + def test_shopping_context_medication_skip(session: Session): p = Product( id=uuid.uuid4(), + short_id=str(uuid.uuid4())[:8], name="Epiduo", brand="Galderma", category="serum", @@ -162,6 +210,7 @@ def test_extract_requested_product_ids_dedupes_and_limits(): def test_shopping_tool_handlers_return_payloads(session: Session): product = Product( id=uuid.uuid4(), + short_id=str(uuid.uuid4())[:8], name="Test Product", brand="Brand", category="serum", @@ -176,10 +225,10 @@ def test_shopping_tool_handlers_return_payloads(session: Session): payload = {"product_ids": [str(product.id)]} details = build_product_details_tool_handler([product])(payload) - assert details["products"][0]["inci"] == ["Water", "Niacinamide"] assert details["products"][0]["actives"][0]["name"] == "Niacinamide" assert "context_rules" in details["products"][0] assert details["products"][0]["last_used_on"] is None + assert "inci" not in details["products"][0] def test_shopping_tool_handler_includes_last_used_on_from_mapping(session: Session): @@ -200,3 +249,48 @@ def test_shopping_tool_handler_includes_last_used_on_from_mapping(session: Sessi )(payload) assert details["products"][0]["last_used_on"] == "2026-03-01" + + +def test_shopping_validator_accepts_freeform_product_type_and_frequency(): + response = ShoppingSuggestionResponse( + suggestions=[ + ProductSuggestion( + category="spot_treatment", + product_type="Punktowy preparat na wypryski z ichtiolem lub cynkiem", + priority="high", + key_ingredients=["ichtiol", "cynk"], + target_concerns=["acne"], + recommended_time="pm", + frequency="Codziennie (punktowo na zmiany)", + short_reason="Pomaga opanowac aktywne zmiany bez dokladania pelnego aktywu na cala twarz.", + reason_to_buy_now="Brakuje Ci dedykowanego produktu punktowego na pojedyncze wypryski.", + fit_with_current_routine="Mozesz dolozyc go tylko na zmiany po serum lub zamiast mocniejszego aktywu.", + usage_cautions=["stosuj tylko miejscowo"], + ), + ProductSuggestion( + category="mask", + product_type="Lagodna maska oczyszczajaca", + priority="low", + key_ingredients=["glinka"], + target_concerns=["sebum_excess"], + recommended_time="pm", + frequency="1 raz w tygodniu", + short_reason="To opcjonalne wsparcie przy nadmiarze sebum.", + reason_to_buy_now="Moze pomoc domknac sporadyczne oczyszczanie, gdy skora jest bardziej przetluszczona.", + fit_with_current_routine="Najlepiej traktowac to jako dodatkowy krok, nie zamiennik podstaw rutyny.", + usage_cautions=[], + ), + ], + reasoning="Test", + ) + + result = ShoppingValidator().validate( + response, + ShoppingValidationContext( + owned_product_ids=set(), + valid_categories=set(ProductCategory), + valid_targets=set(SkinConcern), + ), + ) + + assert not any("unusual frequency" in warning for warning in result.warnings) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b7e2447..df9d006 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -58,6 +58,15 @@ "products_suggestResults": "Suggestions", "products_suggestTime": "Time", "products_suggestFrequency": "Frequency", + "products_suggestPriorityHigh": "High priority", + "products_suggestPriorityMedium": "Medium priority", + "products_suggestPriorityLow": "Low priority", + "products_suggestBuyNow": "Buy now because", + "products_suggestRoutineFit": "How it fits your routine", + "products_suggestBudgetSkip": "If you're cutting the budget", + "products_suggestKeyIngredients": "Key ingredients", + "products_suggestTargets": "Targets", + "products_suggestCautions": "Cautions", "products_suggestRegenerate": "Regenerate", "products_suggestNoResults": "No suggestions.", "products_noProducts": "No products found.", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index f4852b8..c1c8d56 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -60,6 +60,15 @@ "products_suggestResults": "Propozycje", "products_suggestTime": "Pora", "products_suggestFrequency": "Częstotliwość", + "products_suggestPriorityHigh": "Wysoki priorytet", + "products_suggestPriorityMedium": "Średni priorytet", + "products_suggestPriorityLow": "Niski priorytet", + "products_suggestBuyNow": "Kup teraz, bo", + "products_suggestRoutineFit": "Jak wpisuje się w rutynę", + "products_suggestBudgetSkip": "Jeśli tniesz budżet", + "products_suggestKeyIngredients": "Kluczowe składniki", + "products_suggestTargets": "Cele", + "products_suggestCautions": "Uwagi", "products_suggestRegenerate": "Wygeneruj ponownie", "products_suggestNoResults": "Brak propozycji.", "products_noProducts": "Nie znaleziono produktów.", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 2fd00f6..752c29f 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -284,14 +284,21 @@ export interface BatchSuggestion { // ─── Shopping suggestion types ─────────────────────────────────────────────── +export type ShoppingPriority = 'high' | 'medium' | 'low'; + export interface ProductSuggestion { category: string; product_type: string; + priority: ShoppingPriority; key_ingredients: string[]; target_concerns: string[]; - why_needed: string; recommended_time: string; frequency: string; + short_reason: string; + reason_to_buy_now: string; + reason_not_needed_if_budget_tight?: string; + fit_with_current_routine: string; + usage_cautions: string[]; } export interface ShoppingSuggestionResponse { diff --git a/frontend/src/routes/products/suggest/+page.server.ts b/frontend/src/routes/products/suggest/+page.server.ts index 5f8b5b5..8f48644 100644 --- a/frontend/src/routes/products/suggest/+page.server.ts +++ b/frontend/src/routes/products/suggest/+page.server.ts @@ -17,6 +17,9 @@ export const actions: Actions = { return { suggestions: data.suggestions, reasoning: data.reasoning, + validation_warnings: data.validation_warnings, + auto_fixes_applied: data.auto_fixes_applied, + response_metadata: data.response_metadata, }; } catch (e) { return fail(500, { error: String(e) }); diff --git a/frontend/src/routes/products/suggest/+page.svelte b/frontend/src/routes/products/suggest/+page.svelte index a6617af..79b21bd 100644 --- a/frontend/src/routes/products/suggest/+page.svelte +++ b/frontend/src/routes/products/suggest/+page.svelte @@ -1,7 +1,7 @@ @@ -25,15 +17,6 @@ {m["productForm_personalNotes"]()} -
- - -
-