feat(products): add shopping suggestions feature

- Add POST /api/products/suggest endpoint that analyzes skin condition
  and inventory to suggest product types (e.g., 'Salicylic Acid 2% Masque')
- Add MCP tool get_shopping_suggestions() for MCP clients
- Add 'Suggest' button to Products page in frontend
- Add /products/suggest page with suggestion cards
- Include product type, key ingredients, target concerns, why_needed,
  recommended_time, and frequency in suggestions
- Fix stock logic: sealed products now count as available inventory
- Add legend to clarify ✓ (in stock) vs ✗ (not in stock) markers
This commit is contained in:
Piotr Oleszczyk 2026-03-02 22:38:08 +01:00
parent 389ca5ffdc
commit 40f9a353bb
8 changed files with 583 additions and 2 deletions

View file

@ -5,6 +5,7 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query
from google.genai import types as genai_types
from pydantic import BaseModel as PydanticBase
from pydantic import ValidationError
from sqlmodel import Session, SQLModel, col, select
@ -19,6 +20,7 @@ from innercontext.models import (
ProductPublic,
ProductWithInventory,
SkinConcern,
SkinConditionSnapshot,
)
from innercontext.models.enums import (
AbsorptionSpeed,
@ -176,6 +178,41 @@ class InventoryUpdate(SQLModel):
notes: Optional[str] = None
# ---------------------------------------------------------------------------
# Shopping suggestion schemas
# ---------------------------------------------------------------------------
class ProductSuggestion(PydanticBase):
category: str
product_type: str
key_ingredients: list[str]
target_concerns: list[str]
why_needed: str
recommended_time: str
frequency: str
class ShoppingSuggestionResponse(PydanticBase):
suggestions: list[ProductSuggestion]
reasoning: str
class _ProductSuggestionOut(PydanticBase):
category: str
product_type: str
key_ingredients: list[str]
target_concerns: list[str]
why_needed: str
recommended_time: str
frequency: str
class _ShoppingSuggestionsOut(PydanticBase):
suggestions: list[_ProductSuggestionOut]
reasoning: str
# ---------------------------------------------------------------------------
# Product routes
# ---------------------------------------------------------------------------
@ -218,7 +255,9 @@ def list_products(
product_ids = [p.id for p in products]
inventory_rows = (
session.exec(
select(ProductInventory).where(col(ProductInventory.product_id).in_(product_ids))
select(ProductInventory).where(
col(ProductInventory.product_id).in_(product_ids)
)
).all()
if product_ids
else []
@ -467,3 +506,134 @@ def create_product_inventory(
session.commit()
session.refresh(entry)
return entry
# ---------------------------------------------------------------------------
# Shopping suggestion
# ---------------------------------------------------------------------------
def _ev(v: object) -> str:
if v is None:
return ""
value = getattr(v, "value", None)
if isinstance(value, str):
return value
return str(v)
def _build_shopping_context(session: Session) -> str:
snapshot = session.exec(
select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
)
).first()
skin_lines = ["STAN SKÓRY:"]
if snapshot:
skin_lines.append(f" Data: {snapshot.snapshot_date}")
skin_lines.append(f" Ogólny stan: {_ev(snapshot.overall_state)}")
skin_lines.append(f" Typ skóry: {_ev(snapshot.skin_type)}")
skin_lines.append(f" Nawilżenie: {snapshot.hydration_level}/5")
skin_lines.append(f" Wrażliwość: {snapshot.sensitivity_level}/5")
skin_lines.append(f" Bariera: {_ev(snapshot.barrier_state)}")
concerns = ", ".join(_ev(c) for c in (snapshot.active_concerns or []))
skin_lines.append(f" Aktywne problemy: {concerns or 'brak'}")
if snapshot.priorities:
skin_lines.append(f" Priorytety: {', '.join(snapshot.priorities)}")
else:
skin_lines.append(" (brak danych)")
stmt = select(Product).where(col(Product.is_tool).is_(False))
products = session.exec(stmt).all()
product_ids = [p.id for p in products]
inventory_rows = (
session.exec(
select(ProductInventory).where(
col(ProductInventory.product_id).in_(product_ids)
)
).all()
if product_ids
else []
)
inv_by_product: dict = {}
for inv in inventory_rows:
inv_by_product.setdefault(inv.product_id, []).append(inv)
products_lines = ["POSIADANE PRODUKTY:"]
products_lines.append(
" Legenda: [✓] = produkt dostępny (w magazynie), [✗] = brak w magazynie"
)
for p in products:
if p.is_medication:
continue
active_inv = [i for i in inv_by_product.get(p.id, []) if i.finished_at is None]
has_stock = len(active_inv) > 0 # any unfinished inventory = in stock
stock = "" if has_stock else ""
products_lines.append(
f" [{stock}] {p.name} ({p.brand or ''}) - {_ev(p.category)}, "
f"targets: {p.targets or []}"
)
return "\n".join(skin_lines) + "\n\n" + "\n".join(products_lines)
_SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry.
Twoim zadaniem jest przeanalizować stan skóry użytkownika oraz produkty, które już posiada,
a następnie zasugerować TYPY produktów (bez marek), które mogłyby uzupełnić ich rutynę.
LEGENDA:
- [] = produkt dostępny w magazynie (nawet jeśli jest zapieczętowany)
- [] = produkt niedostępny (brak w magazynie, wszystkie opakowania zużyte)
ZASADY:
1. Sugeruj TYLKO typy produktów, NIGDY konkretne marki (np. "Salicylic Acid 2% Masque", nie "La Roche-Posay")
2. Produkty oznaczone [] to te, których NIE MA w magazynie - możesz je zasugerować
3. Produkty oznaczone [] już dostępne - nie sugeruj ich ponownie
4. Bierz pod uwagę aktywne problemy skóry (acne, hyperpigmentacja, aging, etc.)
5. Sugeruj realistyczną częstotliwość użycia (dzienna, 2-3x tygodniowo, etc.)
6. Zachowaj kolejność warstw: cleanse toner serum moisturizer SPF
7. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów
8. Odpowiadaj w języku polskim
Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem."""
@router.post("/suggest", response_model=ShoppingSuggestionResponse)
def suggest_shopping(session: Session = Depends(get_session)):
context = _build_shopping_context(session)
prompt = (
f"Na podstawie poniższych danych przeanalizuj, jakie TYPY produktów "
f"mogłyby uzupełnić rutynę pielęgnacyjną użytkownika.\n\n"
f"{context}\n\n"
f"Zwróć wyłącznie JSON zgodny ze schematem."
)
response = call_gemini(
endpoint="products/suggest",
contents=prompt,
config=genai_types.GenerateContentConfig(
system_instruction=_SHOPPING_SYSTEM_PROMPT,
response_mime_type="application/json",
response_schema=_ShoppingSuggestionsOut,
max_output_tokens=4096,
temperature=0.4,
),
user_input=prompt,
)
raw = response.text
if not raw:
raise HTTPException(status_code=502, detail="LLM returned an empty response")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
return ShoppingSuggestionResponse(
suggestions=[ProductSuggestion(**s) for s in parsed.get("suggestions", [])],
reasoning=parsed.get("reasoning", ""),
)

View file

@ -1,13 +1,20 @@
from __future__ import annotations
import json
import os
from datetime import date, datetime, timedelta
from typing import Optional
from uuid import UUID
import httpx
from fastapi import HTTPException
from fastmcp import FastMCP
from google.genai import types as genai_types
from pydantic import BaseModel
from sqlmodel import Session, col, select
from db import engine
from innercontext.llm import call_gemini
from innercontext.models import (
GroomingSchedule,
LabResult,
@ -20,6 +27,36 @@ from innercontext.models import (
SkinConditionSnapshot,
)
# ---------------------------------------------------------------------------
# Pydantic schemas for structured output
# ---------------------------------------------------------------------------
class ProductSuggestionOut(BaseModel):
category: str
product_type: str
key_ingredients: list[str]
target_concerns: list[str]
why_needed: str
recommended_time: str
frequency: str
class ShoppingSuggestionsOut(BaseModel):
suggestions: list[ProductSuggestionOut]
reasoning: str
class MarketProduct(BaseModel):
name: str
brand: str
price: Optional[float] = None
currency: str = "PLN"
store: str
url: str
in_stock: Optional[bool] = None
mcp = FastMCP("innercontext")
@ -435,3 +472,183 @@ def get_lab_results_for_test(test_code: str) -> list[dict]:
.order_by(col(LabResult.collected_at).asc())
).all()
return [_lab_result_to_dict(r) for r in results]
# ── Shopping assistant ────────────────────────────────────────────────────────
def _ev(v: object) -> str:
if v is None:
return ""
value = getattr(v, "value", None)
if isinstance(value, str):
return value
return str(v)
def _build_shopping_context(session: Session) -> tuple[str, list[dict]]:
snapshot = session.exec(
select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
)
).first()
skin_lines = ["STAN SKÓRY:"]
if snapshot:
skin_lines.append(f" Data: {snapshot.snapshot_date}")
skin_lines.append(f" Ogólny stan: {_ev(snapshot.overall_state)}")
skin_lines.append(f" Typ skóry: {_ev(snapshot.skin_type)}")
skin_lines.append(f" Nawilżenie: {snapshot.hydration_level}/5")
skin_lines.append(f" Wrażliwość: {snapshot.sensitivity_level}/5")
skin_lines.append(f" Bariera: {_ev(snapshot.barrier_state)}")
concerns = ", ".join(_ev(c) for c in (snapshot.active_concerns or []))
skin_lines.append(f" Aktywne problemy: {concerns or 'brak'}")
if snapshot.priorities:
skin_lines.append(f" Priorytety: {', '.join(snapshot.priorities)}")
else:
skin_lines.append(" (brak danych)")
stmt = select(Product).where(col(Product.is_tool).is_(False))
products = session.exec(stmt).all()
product_ids = [p.id for p in products]
inventory_rows = (
session.exec(
select(ProductInventory).where(
col(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)
products_data = []
for p in products:
if p.is_medication:
continue
active_inv = [i for i in inv_by_product.get(p.id, []) if i.finished_at is None]
has_stock = any(i.is_opened and i.finished_at is None for i in active_inv)
products_data.append(
{
"id": str(p.id),
"name": p.name,
"brand": p.brand or "",
"category": _ev(p.category),
"targets": p.targets or [],
"actives": [
{
"name": a.get("name") if isinstance(a, dict) else a.name,
"percent": a.get("percent") if isinstance(a, dict) else None,
}
for a in (p.actives or [])
],
"has_inventory": has_stock,
}
)
products_lines = ["POSIADANE PRODUKTY:"]
for p in products_data:
stock = "" if p["has_inventory"] else ""
products_lines.append(
f" [{stock}] {p['name']} ({p['brand']}) - {p['category']}, "
f"targets: {p['targets']}"
)
return "\n".join(skin_lines) + "\n\n" + "\n".join(products_lines), products_data
_SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry.
Twoim zadaniem jest przeanalizować stan skóry użytkownika oraz produkty, które już posiada,
a następnie zasugerować TYPY produktów (bez marek), które mogłyby uzupełnić ich rutynę.
ZASADY:
1. Sugeruj TYLKO typy produktów, NIGDY konkretne marki (np. "Salicylic Acid 2% Masque", nie "La Roche-Posay")
2. Koncentruj się na produktach, których użytkownik NIE posiada w swoim inventarzu
3. Bierz pod uwagę aktywne problemy skóry (acne, hyperpigmentacja, aging, etc.)
4. Sugeruj realistyczną częstotliwość użycia (dzienna, 2-3x tygodniowo, etc.)
5. Zachowaj kolejność warstw: cleanse toner serum moisturizer SPF
6. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów
7. Odpowiadaj w języku polskim
Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem."""
@mcp.tool()
def get_shopping_suggestions() -> dict:
"""Analyze skin condition and inventory to suggest product types that could fill gaps.
Returns generic product suggestions (e.g., 'Salicylic Acid 2% Masque'), not specific brands.
"""
with Session(engine) as session:
context, products = _build_shopping_context(session)
prompt = (
f"Na podstawie poniższych danych przeanalizuj, jakie TYPY produktów "
f"mogłyby uzupełnić rutynę pielęgnacyjną użytkownika.\n\n"
f"{context}\n\n"
f"Zwróć wyłącznie JSON zgodny ze schematem."
)
response = call_gemini(
endpoint="shopping/suggest",
contents=prompt,
config=genai_types.GenerateContentConfig(
system_instruction=_SHOPPING_SYSTEM_PROMPT,
response_mime_type="application/json",
response_schema=ShoppingSuggestionsOut,
max_output_tokens=4096,
temperature=0.4,
),
user_input=prompt,
)
raw = response.text
if not raw:
raise HTTPException(status_code=502, detail="LLM returned empty response")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"Invalid JSON from LLM: {e}")
return {
"suggestions": parsed.get("suggestions", []),
"reasoning": parsed.get("reasoning", ""),
}
def _call_market_service(
query: str, stores: Optional[list[str]] = None
) -> list[MarketProduct]:
market_url = os.environ.get("MARKET_SERVICE_URL")
if not market_url:
return []
try:
with httpx.Client(timeout=10.0) as client:
resp = client.get(
f"{market_url}/search",
params={"q": query, "stores": stores or ["rossmann", "dm", "hebe"]},
)
resp.raise_for_status()
data = resp.json()
return [MarketProduct(**item) for item in data.get("products", [])]
except Exception:
return []
@mcp.tool()
def search_market_products(
query: str,
stores: Optional[list[str]] = None,
) -> list[dict]:
"""Search drug store catalogs for products matching the query.
Uses external market service to query Rossmann, DM, Hebe, etc.
Examples:
- query: "salicylic acid serum acne"
- stores: ["rossmann", "dm", "hebe"] (optional, queries all by default)"""
products = _call_market_service(query, stores)
return [p.model_dump(mode="json") for p in products]

View file

@ -33,6 +33,17 @@
"products_title": "Products",
"products_count": "{count} products",
"products_addNew": "+ Add product",
"products_suggest": "Suggest",
"products_suggestTitle": "Shopping suggestions",
"products_suggestSubtitle": "What to buy?",
"products_suggestDescription": "Based on your skin condition and products you own, I'll suggest product types that could complement your routine.",
"products_suggestGenerating": "Analyzing...",
"products_suggestBtn": "Generate suggestions",
"products_suggestResults": "Suggestions",
"products_suggestTime": "Time",
"products_suggestFrequency": "Frequency",
"products_suggestRegenerate": "Regenerate",
"products_suggestNoResults": "No suggestions.",
"products_noProducts": "No products found.",
"products_filterAll": "All",
"products_filterOwned": "Owned",

View file

@ -33,6 +33,17 @@
"products_title": "Produkty",
"products_count": "{count} produktów",
"products_addNew": "+ Dodaj produkt",
"products_suggest": "Sugeruj",
"products_suggestTitle": "Sugestie zakupowe",
"products_suggestSubtitle": "Co warto kupić?",
"products_suggestDescription": "Na podstawie Twojego stanu skóry i posiadanych produktów zasugeruję typy produktów, które mogłyby uzupełnić Twoją rutynę.",
"products_suggestGenerating": "Analizuję...",
"products_suggestBtn": "Generuj sugestie",
"products_suggestResults": "Propozycje",
"products_suggestTime": "Pora",
"products_suggestFrequency": "Częstotliwość",
"products_suggestRegenerate": "Wygeneruj ponownie",
"products_suggestNoResults": "Brak propozycji.",
"products_noProducts": "Nie znaleziono produktów.",
"products_filterAll": "Wszystkie",
"products_filterOwned": "Posiadane",

View file

@ -216,6 +216,23 @@ export interface BatchSuggestion {
overall_reasoning: string;
}
// ─── Shopping suggestion types ───────────────────────────────────────────────
export interface ProductSuggestion {
category: string;
product_type: string;
key_ingredients: string[];
target_concerns: string[];
why_needed: string;
recommended_time: string;
frequency: string;
}
export interface ShoppingSuggestionResponse {
suggestions: ProductSuggestion[];
reasoning: string;
}
// ─── Health types ────────────────────────────────────────────────────────────
export interface MedicationUsage {

View file

@ -63,8 +63,11 @@
<h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2>
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
</div>
<div class="flex gap-2">
<Button href="/products/suggest" variant="outline">{m["products_suggest"]()}</Button>
<Button href="/products/new">{m["products_addNew"]()}</Button>
</div>
</div>
<div class="flex flex-wrap gap-1">
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}

View file

@ -0,0 +1,26 @@
import type { ActionData } from './$types';
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
suggest: async ({ fetch }) => {
try {
const res = await fetch('/api/products/suggest', {
method: 'POST',
});
if (!res.ok) {
const err = await res.text();
return fail(res.status, { error: err || 'Failed to get suggestions' });
}
const data = await res.json();
return {
suggestions: data.suggestions,
reasoning: data.reasoning,
};
} catch (e) {
return fail(500, { error: String(e) });
}
},
} satisfies Actions;

View file

@ -0,0 +1,126 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types';
import type { ProductSuggestion } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
let { data, form }: { data: PageData; form: ActionData } = $props();
let suggestions = $state<ProductSuggestion[] | null>(null);
let reasoning = $state('');
let loading = $state(false);
let errorMsg = $state<string | null>(null);
function enhanceForm() {
loading = true;
errorMsg = null;
return async ({ result, update }: { result: { type: string; data?: Record<string, unknown> }; update: (opts?: { reset?: boolean }) => Promise<void> }) => {
loading = false;
if (result.type === 'success' && result.data?.suggestions) {
suggestions = result.data.suggestions as ProductSuggestion[];
reasoning = result.data.reasoning as string;
errorMsg = null;
} else if (result.type === 'failure') {
errorMsg = (result.data?.error as string) ?? m["suggest_errorDefault"]();
}
await update({ reset: false });
};
}
</script>
<svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["products_suggestTitle"]()}</h2>
</div>
{#if errorMsg}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{errorMsg}</div>
{/if}
<Card>
<CardHeader><CardTitle class="text-base">{m["products_suggestSubtitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/suggest" use:enhance={enhanceForm} class="space-y-4">
<p class="text-sm text-muted-foreground">
{m["products_suggestDescription"]()}
</p>
<Button type="submit" disabled={loading} class="w-full">
{#if loading}
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
{m["products_suggestGenerating"]()}
{:else}
{m["products_suggestBtn"]()}
{/if}
</Button>
</form>
</CardContent>
</Card>
{#if suggestions && suggestions.length > 0}
{#if reasoning}
<Card class="border-muted bg-muted/30">
<CardContent class="pt-4">
<p class="text-sm text-muted-foreground italic">{reasoning}</p>
</CardContent>
</Card>
{/if}
<div class="space-y-4">
<h3 class="text-lg font-semibold">{m["products_suggestResults"]()}</h3>
{#each suggestions as s (s.product_type)}
<Card>
<CardContent class="pt-4">
<div class="space-y-3">
<div class="flex items-start justify-between gap-2">
<h4 class="font-medium">{s.product_type}</h4>
<Badge variant="secondary" class="shrink-0">{s.category}</Badge>
</div>
{#if s.key_ingredients.length > 0}
<div class="flex flex-wrap gap-1">
{#each s.key_ingredients as ing (ing)}
<Badge variant="outline" class="text-xs">{ing}</Badge>
{/each}
</div>
{/if}
{#if s.target_concerns.length > 0}
<div class="flex flex-wrap gap-1">
{#each s.target_concerns as concern (concern)}
<Badge class="text-xs">{concern.replace(/_/g, ' ')}</Badge>
{/each}
</div>
{/if}
<p class="text-sm text-muted-foreground">{s.why_needed}</p>
<div class="flex gap-4 text-xs text-muted-foreground">
<span>{m["products_suggestTime"]()}: {s.recommended_time.toUpperCase()}</span>
<span>{m["products_suggestFrequency"]()}: {s.frequency}</span>
</div>
</div>
</CardContent>
</Card>
{/each}
</div>
<form method="POST" action="?/suggest" use:enhance={enhanceForm}>
<Button variant="outline" type="submit" disabled={loading}>
{m["products_suggestRegenerate"]()}
</Button>
</form>
{:else if suggestions && suggestions.length === 0}
<Card>
<CardContent class="py-8 text-center text-muted-foreground">
{m["products_suggestNoResults"]()}
</CardContent>
</Card>
{/if}
</div>