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:
parent
389ca5ffdc
commit
40f9a353bb
8 changed files with 583 additions and 2 deletions
|
|
@ -5,6 +5,7 @@ from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from google.genai import types as genai_types
|
from google.genai import types as genai_types
|
||||||
|
from pydantic import BaseModel as PydanticBase
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from sqlmodel import Session, SQLModel, col, select
|
from sqlmodel import Session, SQLModel, col, select
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ from innercontext.models import (
|
||||||
ProductPublic,
|
ProductPublic,
|
||||||
ProductWithInventory,
|
ProductWithInventory,
|
||||||
SkinConcern,
|
SkinConcern,
|
||||||
|
SkinConditionSnapshot,
|
||||||
)
|
)
|
||||||
from innercontext.models.enums import (
|
from innercontext.models.enums import (
|
||||||
AbsorptionSpeed,
|
AbsorptionSpeed,
|
||||||
|
|
@ -176,6 +178,41 @@ class InventoryUpdate(SQLModel):
|
||||||
notes: Optional[str] = None
|
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
|
# Product routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -218,7 +255,9 @@ def list_products(
|
||||||
product_ids = [p.id for p in products]
|
product_ids = [p.id for p in products]
|
||||||
inventory_rows = (
|
inventory_rows = (
|
||||||
session.exec(
|
session.exec(
|
||||||
select(ProductInventory).where(col(ProductInventory.product_id).in_(product_ids))
|
select(ProductInventory).where(
|
||||||
|
col(ProductInventory.product_id).in_(product_ids)
|
||||||
|
)
|
||||||
).all()
|
).all()
|
||||||
if product_ids
|
if product_ids
|
||||||
else []
|
else []
|
||||||
|
|
@ -467,3 +506,134 @@ def create_product_inventory(
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(entry)
|
session.refresh(entry)
|
||||||
return 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 [✓] są 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", ""),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
from google.genai import types as genai_types
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlmodel import Session, col, select
|
from sqlmodel import Session, col, select
|
||||||
|
|
||||||
from db import engine
|
from db import engine
|
||||||
|
from innercontext.llm import call_gemini
|
||||||
from innercontext.models import (
|
from innercontext.models import (
|
||||||
GroomingSchedule,
|
GroomingSchedule,
|
||||||
LabResult,
|
LabResult,
|
||||||
|
|
@ -20,6 +27,36 @@ from innercontext.models import (
|
||||||
SkinConditionSnapshot,
|
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")
|
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())
|
.order_by(col(LabResult.collected_at).asc())
|
||||||
).all()
|
).all()
|
||||||
return [_lab_result_to_dict(r) for r in results]
|
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]
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,17 @@
|
||||||
"products_title": "Products",
|
"products_title": "Products",
|
||||||
"products_count": "{count} products",
|
"products_count": "{count} products",
|
||||||
"products_addNew": "+ Add product",
|
"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_noProducts": "No products found.",
|
||||||
"products_filterAll": "All",
|
"products_filterAll": "All",
|
||||||
"products_filterOwned": "Owned",
|
"products_filterOwned": "Owned",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,17 @@
|
||||||
"products_title": "Produkty",
|
"products_title": "Produkty",
|
||||||
"products_count": "{count} produktów",
|
"products_count": "{count} produktów",
|
||||||
"products_addNew": "+ Dodaj produkt",
|
"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_noProducts": "Nie znaleziono produktów.",
|
||||||
"products_filterAll": "Wszystkie",
|
"products_filterAll": "Wszystkie",
|
||||||
"products_filterOwned": "Posiadane",
|
"products_filterOwned": "Posiadane",
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,23 @@ export interface BatchSuggestion {
|
||||||
overall_reasoning: string;
|
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 ────────────────────────────────────────────────────────────
|
// ─── Health types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface MedicationUsage {
|
export interface MedicationUsage {
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,11 @@
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2>
|
<h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2>
|
||||||
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
|
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button href="/products/suggest" variant="outline">✨ {m["products_suggest"]()}</Button>
|
||||||
<Button href="/products/new">{m["products_addNew"]()}</Button>
|
<Button href="/products/new">{m["products_addNew"]()}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
|
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
|
||||||
|
|
|
||||||
26
frontend/src/routes/products/suggest/+page.server.ts
Normal file
26
frontend/src/routes/products/suggest/+page.server.ts
Normal 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;
|
||||||
126
frontend/src/routes/products/suggest/+page.svelte
Normal file
126
frontend/src/routes/products/suggest/+page.svelte
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue