import type { ProvenanceEntry, RecommendationCandidate, RecommendationContext } from "./types.js"; import { renderProvenance } from "./provenance-templates.js"; import type { LanguageTag } from "./provenance-templates.js"; interface WhyTemplates { coverage100: string; coveragePct: string; coverageLow: string; expiringToday: string; expiringTomorrow: string; expiringDays: string; protein: string; trainingDay: string; householdRating: string; holiday: string; season: string; time: string; budget: string; craving: string; fallback: string; } const WHY_TEMPLATES: Record = { sv: { coverage100: "Ni har alla ingredienser hemma.", coveragePct: "Ni har {{pct}} % av ingredienserna hemma.", coverageLow: "Ni har {{pct}} % av ingredienserna – resten hamnar på inköpslistan.", expiringToday: "{{ingredient}} bör användas i dag.", expiringTomorrow: "{{ingredient}} bör användas senast i morgon.", expiringDays: "{{ingredient}} bör användas inom {{days}} dagar.", protein: "Rätten ger {{protein}} gram protein per portion.", trainingDay: "Bra val på en träningsdag.", householdRating: "Liknande rätter har fått höga betyg av hushållet.", holiday: "Passar den kommande högtiden.", season: "Råvarorna är i säsong just nu.", time: "Klar på {{minutes}} minuter.", budget: "Cirka {{cost}} kr per portion.", craving: "Matchar det du är sugen på.", fallback: "En balanserad rätt som passar er profil.", }, en: { coverage100: "You have all the ingredients at home.", coveragePct: "You have {{pct}} % of the ingredients at home.", coverageLow: "You have {{pct}} % of the ingredients – the rest goes on the shopping list.", expiringToday: "{{ingredient}} should be used today.", expiringTomorrow: "{{ingredient}} should be used by tomorrow.", expiringDays: "{{ingredient}} should be used within {{days}} days.", protein: "The dish provides {{protein}} grams of protein per portion.", trainingDay: "A good choice for a training day.", householdRating: "Similar dishes have been rated highly by your household.", holiday: "Fits the upcoming holiday.", season: "The ingredients are in season right now.", time: "Ready in {{minutes}} minutes.", budget: "About {{cost}} per portion.", craving: "Matches what you’re craving.", fallback: "A balanced dish that fits your profile.", }, es: { coverage100: "Tenéis todos los ingredientes en casa.", coveragePct: "Tenéis el {{pct}} % de los ingredientes en casa.", coverageLow: "Tenéis el {{pct}} % de los ingredientes; el resto irá a la lista de la compra.", expiringToday: "{{ingredient}} debería usarse hoy.", expiringTomorrow: "{{ingredient}} debería usarse como máximo mañana.", expiringDays: "{{ingredient}} debería usarse en {{days}} días.", protein: "El plato aporta {{protein}} gramos de proteína por ración.", trainingDay: "Buena elección para un día de entrenamiento.", householdRating: "Platos similares han sido valorados positivamente por tu hogar.", holiday: "Encaja con la próxima festividad.", season: "Los ingredientes están de temporada ahora mismo.", time: "Listo en {{minutes}} minutos.", budget: "Aproximadamente {{cost}} por ración.", craving: "Encaja con lo que te apetece.", fallback: "Un plato equilibrado que encaja con tu perfil.", }, it: { coverage100: "Avete tutti gli ingredienti a casa.", coveragePct: "Avete il {{pct}} % degli ingredienti a casa.", coverageLow: "Avete il {{pct}} % degli ingredienti; il resto finisce sulla lista della spesa.", expiringToday: "{{ingredient}} va usato oggi.", expiringTomorrow: "{{ingredient}} va usato al più tardi domani.", expiringDays: "{{ingredient}} va usato entro {{days}} giorni.", protein: "Il piatto fornisce {{protein}} grammi di proteine a porzione.", trainingDay: "Buona scelta per un giorno di allenamento.", householdRating: "Piatti simili sono stati valutati positivamente dalla tua famiglia.", holiday: "Perfetto per la prossima festività.", season: "Gli ingredienti sono di stagione in questo momento.", time: "Pronto in {{minutes}} minuti.", budget: "Circa {{cost}} a porzione.", craving: "Corrisponde a ciò che ti va.", fallback: "Un piatto equilibrato che si adatta al tuo profilo.", }, de: { coverage100: "Ihr habt alle Zutaten zu Hause.", coveragePct: "Ihr habt {{pct}} % der Zutaten zu Hause.", coverageLow: "Ihr habt {{pct}} % der Zutaten; der Rest kommt auf die Einkaufsliste.", expiringToday: "{{ingredient}} sollte heute verwendet werden.", expiringTomorrow: "{{ingredient}} sollte spätestens morgen verwendet werden.", expiringDays: "{{ingredient}} sollte innerhalb von {{days}} Tagen verwendet werden.", protein: "Das Gericht liefert {{protein}} Gramm Protein pro Portion.", trainingDay: "Gute Wahl für einen Trainingstag.", householdRating: "Ähnliche Gerichte wurden von deinem Haushalt gut bewertet.", holiday: "Passt zum bevorstehenden Feiertag.", season: "Die Zutaten sind gerade saisonal.", time: "Fertig in {{minutes}} Minuten.", budget: "Etwa {{cost}} pro Portion.", craving: "Passt zu dem, worauf du Lust hast.", fallback: "Ein ausgewogenes Gericht, das zu deinem Profil passt.", }, fr: { coverage100: "Vous avez tous les ingrédients à la maison.", coveragePct: "Vous avez {{pct}} % des ingrédients à la maison.", coverageLow: "Vous avez {{pct}} % des ingrédients; le reste ira sur la liste de courses.", expiringToday: "{{ingredient}} devrait être utilisé aujourd'hui.", expiringTomorrow: "{{ingredient}} devrait être utilisé au plus tard demain.", expiringDays: "{{ingredient}} devrait être utilisé dans {{days}} jours.", protein: "Le plat apporte {{protein}} grammes de protéines par portion.", trainingDay: "Bon choix pour un jour d'entraînement.", householdRating: "Des plats similaires ont été bien notés par votre foyer.", holiday: "Convient à la prochaine fête.", season: "Les ingrédients sont de saison en ce moment.", time: "Prêt en {{minutes}} minutes.", budget: "Environ {{cost}} par portion.", craving: "Correspond à ce dont vous avez envie.", fallback: "Un plat équilibré qui correspond à votre profil.", }, da: { coverage100: "I har alle ingredienserne hjemme.", coveragePct: "I har {{pct}} % af ingredienserne hjemme.", coverageLow: "I har {{pct}} % af ingredienserne; resten kommer på indkøbslisten.", expiringToday: "{{ingredient}} bør bruges i dag.", expiringTomorrow: "{{ingredient}} bør bruges senest i morgen.", expiringDays: "{{ingredient}} bør bruges inden for {{days}} dage.", protein: "Retten giver {{protein}} gram protein pr. portion.", trainingDay: "Et godt valg på en træningsdag.", householdRating: "Lignende retter er blevet bedømt højt af husstanden.", holiday: "Passer til den kommende højtid.", season: "Råvarerne er i sæson lige nu.", time: "Klar på {{minutes}} minutter.", budget: "Cirka {{cost}} pr. portion.", craving: "Matcher det, du har lyst til.", fallback: "En balanceret ret, der passer til din profil.", }, nb: { coverage100: "Dere har alle ingrediensene hjemme.", coveragePct: "Dere har {{pct}} % av ingrediensene hjemme.", coverageLow: "Dere har {{pct}} % av ingrediensene; resten kommer på handlelisten.", expiringToday: "{{ingredient}} bør brukes i dag.", expiringTomorrow: "{{ingredient}} bør brukes senest i morgen.", expiringDays: "{{ingredient}} bør brukes innen {{days}} dager.", protein: "Retten gir {{protein}} gram protein per porsjon.", trainingDay: "Et godt valg på en treningsdag.", householdRating: "Lignende retter er blitt vurdert høyt av husstanden.", holiday: "Passer til den kommende høytiden.", season: "Råvarene er i sesong akkurat nå.", time: "Klar på {{minutes}} minutter.", budget: "Omtrent {{cost}} per porsjon.", craving: "Matcher det du har lyst på.", fallback: "En balansert rett som passer profilen din.", }, fi: { coverage100: "Teillä on kaikki ainekset kotona.", coveragePct: "Teillä on {{pct}} % aineksista kotona.", coverageLow: "Teillä on {{pct}} % aineksista; loput menevät ostoslistalle.", expiringToday: "{{ingredient}} tulisi käyttää tänään.", expiringTomorrow: "{{ingredient}} tulisi käyttää viimeistään huomenna.", expiringDays: "{{ingredient}} tulisi käyttää {{days}} päivän kuluessa.", protein: "Ruoka antaa {{protein}} grammaa proteiinia annosta kohti.", trainingDay: "Hyvä valinta treenipäivälle.", householdRating: "Samankaltaiset ruoat on arvioitu korkealle taloudessasi.", holiday: "Sopii lähestyvään juhlapäivään.", season: "Ainekset ovat sesongissa juuri nyt.", time: "Valmista {{minutes}} minuutissa.", budget: "Noin {{cost}} annosta kohti.", craving: "Vastaa sitä, mitä haluat.", fallback: "Tasapainoinen ruoka, joka sopii profiilisi.", }, nl: { coverage100: "Jullie hebben alle ingrediënten thuis.", coveragePct: "Jullie hebben {{pct}} % van de ingrediënten thuis.", coverageLow: "Jullie hebben {{pct}} % van de ingrediënten; de rest komt op het boodschappenlijstje.", expiringToday: "{{ingredient}} moet vandaag gebruikt worden.", expiringTomorrow: "{{ingredient}} moet uiterlijk morgen gebruikt worden.", expiringDays: "{{ingredient}} moet binnen {{days}} dagen gebruikt worden.", protein: "Het gerecht levert {{protein}} gram eiwit per portie.", trainingDay: "Een goede keuze voor een trainingdag.", householdRating: "Vergelijkbare gerechten zijn hoog beoordeeld door je huishouden.", holiday: "Past bij de komende feestdag.", season: "De ingrediënten zijn nu in het seizoen.", time: "Klaar in {{minutes}} minuten.", budget: "Ongeveer {{cost}} per portie.", craving: "Past bij waar je zin in hebt.", fallback: "Een evenwichtig gerecht dat bij je profiel past.", }, pl: { coverage100: "Macie w domu wszystkie składniki.", coveragePct: "Macie w domu {{pct}} % składników.", coverageLow: "Macie w domu {{pct}} % składników; reszta trafi na listę zakupów.", expiringToday: "{{ingredient}} należy użyć dzisiaj.", expiringTomorrow: "{{ingredient}} należy użyć najpóźniej jutro.", expiringDays: "{{ingredient}} należy użyć w ciągu {{days}} dni.", protein: "Danie dostarcza {{protein}} gramów białka na porcję.", trainingDay: "Dobry wybór na dzień treningowy.", householdRating: "Podobne dania zostały wysoko ocenione przez Twoje gospodarstwo domowe.", holiday: "Pasuje do zbliżającego się święta.", season: "Składniki są teraz w sezonie.", time: "Gotowe w {{minutes}} minut.", budget: "Około {{cost}} za porcję.", craving: "Pasuje do tego, na co masz ochotę.", fallback: "Zrównoważone danie, które pasuje do Twojego profilu.", }, pt: { coverage100: "Tens todos os ingredientes em casa.", coveragePct: "Tens {{pct}} % dos ingredientes em casa.", coverageLow: "Tens {{pct}} % dos ingredientes; o resto vai para a lista de compras.", expiringToday: "{{ingredient}} deve ser usado hoje.", expiringTomorrow: "{{ingredient}} deve ser usado até amanhã.", expiringDays: "{{ingredient}} deve ser usado em {{days}} dias.", protein: "O prato fornece {{protein}} gramas de proteína por porção.", trainingDay: "Boa escolha para um dia de treino.", householdRating: "Pratos semelhantes foram bem avaliados pelo teu agregado.", holiday: "Combina com o próximo feriado.", season: "Os ingredientes estão na época neste momento.", time: "Pronto em {{minutes}} minutos.", budget: "Aproximadamente {{cost}} por porção.", craving: "Combina com o que te apetece.", fallback: "Um prato equilibrado que se adequa ao teu perfil.", }, }; function getTemplates(lang: string): WhyTemplates { return WHY_TEMPLATES[(lang.split("-")[0] ?? "sv") as LanguageTag] ?? WHY_TEMPLATES.sv; } /** Returnerar de språk som why-mallarna täcker. */ export function whyTemplateLanguages(): LanguageTag[] { return Object.keys(WHY_TEMPLATES) as LanguageTag[]; } function render(template: string, vars: Record): string { return template.replace(/\{\{(\w+)\}\}/g, (_m, key) => String(vars[key] ?? "")); } /** * "Varför rekommenderar vi detta?" (spec §18, S1). * Bygger en ärlig, konkret förklaring ur: * 1. Grundade delpoäng (täckning, utgångsdatum, etc.). * 2. Personaliserings-mallar från provenans (endast om samtycke granted). * * Alla användarvisningstexter kommer från mallar, inte fri AI-text. * R7: näringsrelaterad copy är positiv/stödjande, aldrig restriktiv. */ export function buildWhy( candidate: RecommendationCandidate, ctx: RecommendationContext, parts: Record, provenance: ProvenanceEntry[] = [], languageTag = "sv-SE", ): string { const t = getTemplates(languageTag); const sentences: string[] = []; const pct = Math.round(candidate.coverage.coverage * 100); if (pct >= 100) { sentences.push(t.coverage100); } else if (pct >= 60) { sentences.push(render(t.coveragePct, { pct })); } else if (pct > 0) { sentences.push(render(t.coverageLow, { pct })); } const urgent = candidate.coverage.expiringUsed .filter((m) => m.mostUrgentDaysLeft != null) .sort((a, b) => (a.mostUrgentDaysLeft ?? 99) - (b.mostUrgentDaysLeft ?? 99))[0]; if (urgent) { const days = urgent.mostUrgentDaysLeft ?? 0; const tpl = days <= 0 ? t.expiringToday : days === 1 ? t.expiringTomorrow : t.expiringDays; sentences.push(render(tpl, { ingredient: capitalize(urgent.displayNameSv), days })); } // R7: positiv näringsframing const protein = Math.round(candidate.nutritionPerPortion.proteinG); if ((parts.nutritionFit ?? 0) >= 0.7 && protein >= 25) { sentences.push(render(t.protein, { protein })); } if (ctx.isTrainingDay && protein >= 35) { sentences.push(t.trainingDay); } if ((parts.rating ?? 0) >= 0.8 && candidate.householdRating != null) { sentences.push(t.householdRating); } if ((parts.holiday ?? 0) >= 1 && ctx.activeHolidayTags.length > 0) { sentences.push(t.holiday); } else if ((parts.season ?? 0) >= 1) { sentences.push(t.season); } if ((parts.time ?? 0) >= 1 && ctx.isWeekday) { sentences.push(render(t.time, { minutes: candidate.totalTimeMinutes })); } if ((parts.budget ?? 0) >= 1 && candidate.estimatedCostMinorPerPortion != null) { sentences.push( render(t.budget, { cost: Math.round(candidate.estimatedCostMinorPerPortion / 100) }), ); } if ((parts.craving ?? 0) >= 1) { sentences.push(t.craving); } // S1: personaliserings-mallar från provenans if (ctx.personalizationEnabled && provenance.length > 0) { const rendered = renderProvenance(provenance.slice(0, 2), languageTag); // max 2 personliga satser if (rendered) { sentences.push(rendered); } } if (sentences.length === 0) { sentences.push(t.fallback); } return sentences.join(" "); } /** Bakåtkompatibel wrapper: svensk whySv. */ export function buildWhySv( candidate: RecommendationCandidate, ctx: RecommendationContext, parts: Record, provenance: ProvenanceEntry[] = [], ): string { return buildWhy(candidate, ctx, parts, provenance, "sv-SE"); } function capitalize(s: string): string { return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1); } /** Kompakt kontextsammanfattning (utan persondata) till AAMOS RANK_RECIPES. */ export function summarizeContext(ctx: RecommendationContext): string { const parts = [ `måltid=${ctx.mealType}`, `personer=${ctx.persons}`, `säsong=${ctx.currentSeason}`, ctx.isWeekday ? "vardag" : "helg", ]; if (ctx.maxMinutes != null) parts.push(`maxMinuter=${ctx.maxMinutes}`); if (ctx.activeHolidayTags.length > 0) parts.push(`högtider=${ctx.activeHolidayTags.join("+")}`); if (ctx.cravingTags && ctx.cravingTags.length > 0) parts.push(`sugen_på=${ctx.cravingTags.join("+")}`); if (ctx.cravingCuisine) parts.push(`kök=${ctx.cravingCuisine}`); return parts.join(", "); }