Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@app/recipe-engine",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Receptmatchning, deterministisk allergi-/dietfiltrering, substitution och skalning (spec §17, §20, §61.2)",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@app/inventory-engine": "workspace:*",
"@app/nutrition-engine": "workspace:*",
"@app/shared-types": "workspace:*"
}
}
+59
View File
@@ -0,0 +1,59 @@
import type { Unit } from "@app/shared-types";
import { toGrams, type IngredientUnitInfo } from "@app/nutrition-engine";
export interface CostableIngredient {
canonicalIngredientId: string;
quantity: number;
unit: Unit;
optional: boolean;
}
export interface PriceInfo extends IngredientUnitInfo {
/** SEK per kg från kvittohistorik, användardata eller standardvärde (spec §26). */
pricePerKgMinor: number;
source: "receipt_history" | "user" | "default";
}
export interface CostEstimate {
totalMinor: number | null;
perPortionMinor: number | null;
/** Andel av ingredienserna (viktat) som hade prisdata. */
priceCoverage: number;
sourcesUsed: PriceInfo["source"][];
}
/** Kostnadsuppskattning per portion alltid märkt som uppskattning. */
export function estimateCost(
ingredients: CostableIngredient[],
portions: number,
prices: Map<string, PriceInfo>,
): CostEstimate {
let total = 0;
let pricedCount = 0;
let mandatoryCount = 0;
const sources = new Set<PriceInfo["source"]>();
for (const ing of ingredients) {
if (ing.optional) continue;
mandatoryCount += 1;
const price = prices.get(ing.canonicalIngredientId);
if (!price) continue;
const grams = toGrams(ing.quantity, ing.unit, price);
if (grams == null) continue;
total += (grams / 1000) * price.pricePerKgMinor;
pricedCount += 1;
sources.add(price.source);
}
if (pricedCount === 0 || mandatoryCount === 0) {
return { totalMinor: null, perPortionMinor: null, priceCoverage: 0, sourcesUsed: [] };
}
const coverage = pricedCount / mandatoryCount;
return {
totalMinor: Math.round(total),
perPortionMinor: portions > 0 ? Math.round(total / portions) : null,
priceCoverage: Math.round(coverage * 100) / 100,
sourcesUsed: [...sources],
};
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./safety.js";
export * from "./matching.js";
export * from "./scaling.js";
export * from "./substitution.js";
export * from "./cost.js";
+103
View File
@@ -0,0 +1,103 @@
import type { Unit } from "@app/shared-types";
import { convert, type IngredientUnitInfo } from "@app/nutrition-engine";
import { classifyExpiry, type ExpiryInput } from "@app/inventory-engine";
export interface PantryItem extends ExpiryInput {
id: string;
canonicalIngredientId?: string | null;
quantity: number;
unit: Unit;
}
export interface RecipeIngredientRequirement {
canonicalIngredientId: string;
displayNameSv: string;
quantity: number;
unit: Unit;
optional: boolean;
}
export interface IngredientMatch {
canonicalIngredientId: string;
displayNameSv: string;
required: number;
unit: Unit;
availableInUnit: number;
covered: boolean;
optional: boolean;
/** Mest brådskande status bland matchande lagerposter. */
mostUrgentDaysLeft: number | null;
usesExpiringItem: boolean;
}
export interface CoverageResult {
/** Andel obligatoriska ingredienser som täcks helt (01). */
coverage: number;
matches: IngredientMatch[];
missing: IngredientMatch[];
/** Ingredienser som finns hemma och snart går ut guld för rekommendationen. */
expiringUsed: IngredientMatch[];
}
/**
* Matcha receptets ingredienser mot hushållets lager (spec §1718).
* Enhetskonvertering via nutrition-engine; poster som inte kan konverteras
* räknas som otäckta i stället för att gissas.
*/
export function computeCoverage(
requirements: RecipeIngredientRequirement[],
pantry: PantryItem[],
unitInfo: Map<string, IngredientUnitInfo>,
today: Date = new Date(),
): CoverageResult {
const byIngredient = new Map<string, PantryItem[]>();
for (const item of pantry) {
if (!item.canonicalIngredientId || item.quantity <= 0) continue;
const list = byIngredient.get(item.canonicalIngredientId) ?? [];
list.push(item);
byIngredient.set(item.canonicalIngredientId, list);
}
const matches: IngredientMatch[] = [];
for (const req of requirements) {
const stock = byIngredient.get(req.canonicalIngredientId) ?? [];
const info = unitInfo.get(req.canonicalIngredientId) ?? {};
let available = 0;
let mostUrgent: number | null = null;
let usesExpiring = false;
for (const item of stock) {
const inReqUnit = convert(item.quantity, item.unit, req.unit, info);
if (inReqUnit == null) continue;
available += inReqUnit;
const expiry = classifyExpiry(item, today);
if (expiry.daysLeft != null) {
mostUrgent = mostUrgent == null ? expiry.daysLeft : Math.min(mostUrgent, expiry.daysLeft);
}
if (expiry.status === "expiring" || expiry.status === "use_soon") usesExpiring = true;
}
matches.push({
canonicalIngredientId: req.canonicalIngredientId,
displayNameSv: req.displayNameSv,
required: req.quantity,
unit: req.unit,
availableInUnit: Math.round(available * 1000) / 1000,
covered: available + 1e-9 >= req.quantity,
optional: req.optional,
mostUrgentDaysLeft: mostUrgent,
usesExpiringItem: usesExpiring,
});
}
const mandatory = matches.filter((m) => !m.optional);
const coveredCount = mandatory.filter((m) => m.covered).length;
const coverage = mandatory.length === 0 ? 1 : coveredCount / mandatory.length;
return {
coverage: Math.round(coverage * 100) / 100,
matches,
missing: matches.filter((m) => !m.covered),
expiringUsed: matches.filter((m) => m.covered && m.usesExpiringItem),
};
}
+207
View File
@@ -0,0 +1,207 @@
import type { Allergen, DietPattern, ReligiousRule } from "@app/shared-types";
/**
* DETERMINISTISK säkerhets- och kostfiltrering (spec §13, §61.2).
* Ingen AI får någonsin avgöra allergisäkerhet. Denna modul är enda vägen.
*
* Försiktighetsprincip: om en ingrediens saknar data ("unknown") behandlas
* receptet som EJ säkert för berörda filter och flaggas för granskning.
*/
export interface IngredientSafetyInfo {
id: string;
allergens: Allergen[];
mayContainAllergens?: Allergen[];
isVegan: boolean;
isVegetarian: boolean;
containsGluten: boolean;
containsLactose: boolean;
isPork: boolean;
isBeef: boolean;
isAlcohol: boolean;
/** true när datan är verifierad; ovverifierad data ger varning i stället för tyst OK. */
dataVerified?: boolean;
}
export interface DietaryConstraints {
allergens: Allergen[];
intolerances?: string[];
dietPattern?: DietPattern;
religiousRule?: ReligiousRule;
avoidIngredientIds?: string[];
spiceLevelMax?: number;
/** Behandla "kan innehålla spår av" som blockerande (default true vid allergi). */
blockMayContain?: boolean;
}
export type ViolationSeverity = "blocker" | "warning";
export interface SafetyViolation {
severity: ViolationSeverity;
code:
| "allergen"
| "allergen_may_contain"
| "diet_pattern"
| "religious_rule"
| "avoided_ingredient"
| "spice_level"
| "unverified_data";
ingredientId?: string;
allergen?: Allergen;
messageSv: string;
}
export interface RecipeSafetyInput {
ingredients: Array<{ canonicalIngredientId: string; optional: boolean }>;
spiceLevel: number;
}
/**
* Returnerar ALLA överträdelser (tom lista = säkert enligt tillgänglig data).
* Valfria ingredienser ger varning i stället för blocker (kan uteslutas).
*/
export function checkRecipeSafety(
recipe: RecipeSafetyInput,
constraints: DietaryConstraints,
ingredientInfo: Map<string, IngredientSafetyInfo>,
): SafetyViolation[] {
const violations: SafetyViolation[] = [];
const userAllergens = new Set(constraints.allergens);
const avoid = new Set(constraints.avoidIngredientIds ?? []);
const blockMayContain = constraints.blockMayContain ?? userAllergens.size > 0;
for (const ing of recipe.ingredients) {
const info = ingredientInfo.get(ing.canonicalIngredientId);
const severity: ViolationSeverity = ing.optional ? "warning" : "blocker";
if (!info) {
violations.push({
severity: "warning",
code: "unverified_data",
ingredientId: ing.canonicalIngredientId,
messageSv: `Ingrediensen ${ing.canonicalIngredientId} saknar säkerhetsdata kontrollera manuellt.`,
});
continue;
}
for (const allergen of info.allergens) {
if (userAllergens.has(allergen)) {
violations.push({
severity,
code: "allergen",
ingredientId: info.id,
allergen,
messageSv: `Innehåller ${allergen} (${info.id}).`,
});
}
}
if (blockMayContain) {
for (const allergen of info.mayContainAllergens ?? []) {
if (userAllergens.has(allergen)) {
violations.push({
severity: "warning",
code: "allergen_may_contain",
ingredientId: info.id,
allergen,
messageSv: `Kan innehålla spår av ${allergen} (${info.id}).`,
});
}
}
}
const diet = constraints.dietPattern;
if (diet === "vegan" && !info.isVegan) {
violations.push({
severity,
code: "diet_pattern",
ingredientId: info.id,
messageSv: `${info.id} är inte veganskt.`,
});
} else if ((diet === "vegetarian" || diet === "pescatarian") && !info.isVegetarian) {
const isFish = info.allergens.includes("fish") || info.allergens.includes("crustaceans");
const allowed = diet === "pescatarian" && isFish;
if (!allowed) {
violations.push({
severity,
code: "diet_pattern",
ingredientId: info.id,
messageSv: `${info.id} är inte ${diet === "vegetarian" ? "vegetariskt" : "pescetarianskt"}.`,
});
}
}
const rule = constraints.religiousRule;
if (rule === "halal" && (info.isPork || info.isAlcohol)) {
violations.push({
severity,
code: "religious_rule",
ingredientId: info.id,
messageSv: `${info.id} är inte förenligt med halal (${info.isPork ? "fläsk" : "alkohol"}).`,
});
}
if (
rule === "kosher" &&
(info.isPork || info.allergens.includes("crustaceans") || info.allergens.includes("molluscs"))
) {
violations.push({
severity,
code: "religious_rule",
ingredientId: info.id,
messageSv: `${info.id} är inte förenligt med kosher.`,
});
}
if (rule === "hindu_no_beef" && info.isBeef) {
violations.push({
severity,
code: "religious_rule",
ingredientId: info.id,
messageSv: `${info.id} innehåller nötkött.`,
});
}
if (rule === "buddhist_vegetarian" && !info.isVegetarian) {
violations.push({
severity,
code: "religious_rule",
ingredientId: info.id,
messageSv: `${info.id} är inte vegetariskt.`,
});
}
if (avoid.has(info.id)) {
violations.push({
severity,
code: "avoided_ingredient",
ingredientId: info.id,
messageSv: `${info.id} finns på din undviklista.`,
});
}
}
if (constraints.spiceLevelMax != null && recipe.spiceLevel > constraints.spiceLevelMax) {
violations.push({
severity: "warning",
code: "spice_level",
messageSv: `Styrka ${recipe.spiceLevel} överstiger din maxnivå ${constraints.spiceLevelMax}.`,
});
}
return violations;
}
/** true om receptet är helt fritt från blockers. */
export function isRecipeSafe(violations: SafetyViolation[]): boolean {
return !violations.some((v) => v.severity === "blocker");
}
/** Härled ett recepts allergener deterministiskt ur ingredienserna (spec §61.2). */
export function deriveRecipeAllergens(
ingredientIds: string[],
ingredientInfo: Map<string, IngredientSafetyInfo>,
): Allergen[] {
const set = new Set<Allergen>();
for (const id of ingredientIds) {
const info = ingredientInfo.get(id);
for (const a of info?.allergens ?? []) set.add(a);
}
return [...set].sort();
}
+40
View File
@@ -0,0 +1,40 @@
import { UNIT_INFO, type Unit } from "@app/shared-types";
export interface ScalableIngredient {
canonicalIngredientId: string;
displayNameSv: string;
quantity: number;
unit: Unit;
optional: boolean;
}
/** Enheter som avrundas till "köksvänliga" mängder vid skalning. */
const SPOON_UNITS: ReadonlySet<Unit> = new Set(["TABLESPOON", "TEASPOON", "PINCH"]);
/**
* Skala recept till annat antal portioner (Cooking Mode: "skala till sex personer").
* Kryddmått avrundas till halva mått; styck till kvartar.
*/
export function scaleIngredients(
ingredients: ScalableIngredient[],
fromPortions: number,
toPortions: number,
): ScalableIngredient[] {
if (fromPortions <= 0 || toPortions <= 0) {
throw new Error("Portioner måste vara > 0");
}
const factor = toPortions / fromPortions;
return ingredients.map((ing) => ({
...ing,
quantity: roundForUnit(ing.quantity * factor, ing.unit),
}));
}
function roundForUnit(value: number, unit: Unit): number {
if (SPOON_UNITS.has(unit)) return Math.round(value * 2) / 2;
if (UNIT_INFO[unit].kind === "count") return Math.round(value * 4) / 4;
if (unit === "KILOGRAM" || unit === "LITER" || unit === "POUND")
return Math.round(value * 100) / 100;
if (unit === "CUP_US") return Math.round(value * 4) / 4;
return Math.round(value * 10) / 10;
}
@@ -0,0 +1,69 @@
import type { Substitution, Unit } from "@app/shared-types";
export interface SubstitutableIngredient {
canonicalIngredientId: string;
displayNameSv: string;
quantity: number;
unit: Unit;
optional: boolean;
}
export interface SubstitutionResult {
ingredients: SubstitutableIngredient[];
applied: {
fromId: string;
toId: string;
ratio: number;
instructionsSv?: string | undefined;
warningSv?: string | undefined;
};
}
/**
* Substitutionsmotor (spec §20): byt ingrediens med mängdfaktor och
* instruktionspåverkan. Näringen räknas ALLTID om av nutrition-engine efteråt
* denna modul ändrar bara ingredienslistan.
*/
export function applySubstitution(
ingredients: SubstitutableIngredient[],
substitution: Pick<
Substitution,
"fromIngredientId" | "toIngredientId" | "ratio" | "instructionsSv" | "notRecommendedFor"
>,
toDisplayNameSv: string,
context?: string,
): SubstitutionResult {
const target = ingredients.find((i) => i.canonicalIngredientId === substitution.fromIngredientId);
if (!target) {
throw new Error(
`Ingrediensen ${substitution.fromIngredientId} finns inte i receptet och kan inte bytas.`,
);
}
let warningSv: string | undefined;
if (context && substitution.notRecommendedFor.includes(context)) {
warningSv = `Observera: detta byte rekommenderas inte för ${context}.`;
}
const next = ingredients.map((ing) =>
ing.canonicalIngredientId === substitution.fromIngredientId
? {
...ing,
canonicalIngredientId: substitution.toIngredientId,
displayNameSv: toDisplayNameSv,
quantity: Math.round(ing.quantity * substitution.ratio * 100) / 100,
}
: ing,
);
return {
ingredients: next,
applied: {
fromId: substitution.fromIngredientId,
toId: substitution.toIngredientId,
ratio: substitution.ratio,
instructionsSv: substitution.instructionsSv ?? undefined,
warningSv,
},
};
}
+299
View File
@@ -0,0 +1,299 @@
import { describe, expect, it } from "vitest";
import {
applySubstitution,
checkRecipeSafety,
computeCoverage,
deriveRecipeAllergens,
estimateCost,
isRecipeSafe,
scaleIngredients,
type IngredientSafetyInfo,
} from "../src/index.js";
const INFO = new Map<string, IngredientSafetyInfo>([
[
"chicken",
{
id: "chicken",
allergens: [],
isVegan: false,
isVegetarian: false,
containsGluten: false,
containsLactose: false,
isPork: false,
isBeef: false,
isAlcohol: false,
},
],
[
"milk",
{
id: "milk",
allergens: ["milk"],
isVegan: false,
isVegetarian: true,
containsGluten: false,
containsLactose: true,
isPork: false,
isBeef: false,
isAlcohol: false,
},
],
[
"pasta",
{
id: "pasta",
allergens: ["gluten"],
mayContainAllergens: ["eggs"],
isVegan: true,
isVegetarian: true,
containsGluten: true,
containsLactose: false,
isPork: false,
isBeef: false,
isAlcohol: false,
},
],
[
"bacon",
{
id: "bacon",
allergens: [],
isVegan: false,
isVegetarian: false,
containsGluten: false,
containsLactose: false,
isPork: true,
isBeef: false,
isAlcohol: false,
},
],
[
"tofu",
{
id: "tofu",
allergens: ["soy"],
isVegan: true,
isVegetarian: true,
containsGluten: false,
containsLactose: false,
isPork: false,
isBeef: false,
isAlcohol: false,
},
],
]);
describe("KRITISKT: deterministisk allergisäkerhet (spec §61.2)", () => {
it("blockerar recept med användarens allergen", () => {
const violations = checkRecipeSafety(
{ ingredients: [{ canonicalIngredientId: "milk", optional: false }], spiceLevel: 0 },
{ allergens: ["milk"] },
INFO,
);
expect(isRecipeSafe(violations)).toBe(false);
expect(violations[0]?.code).toBe("allergen");
expect(violations[0]?.severity).toBe("blocker");
});
it("'kan innehålla spår' ger varning när användaren har allergi", () => {
const violations = checkRecipeSafety(
{ ingredients: [{ canonicalIngredientId: "pasta", optional: false }], spiceLevel: 0 },
{ allergens: ["eggs"] },
INFO,
);
expect(violations.some((v) => v.code === "allergen_may_contain")).toBe(true);
});
it("okänd ingrediens ger varning aldrig tyst OK (försiktighetsprincipen)", () => {
const violations = checkRecipeSafety(
{ ingredients: [{ canonicalIngredientId: "mystery", optional: false }], spiceLevel: 0 },
{ allergens: [] },
INFO,
);
expect(violations.some((v) => v.code === "unverified_data")).toBe(true);
});
it("valfri ingrediens med allergen → varning, inte blocker", () => {
const violations = checkRecipeSafety(
{ ingredients: [{ canonicalIngredientId: "milk", optional: true }], spiceLevel: 0 },
{ allergens: ["milk"] },
INFO,
);
expect(isRecipeSafe(violations)).toBe(true);
expect(violations[0]?.severity).toBe("warning");
});
it("vegan blockerar kött och mjölk", () => {
const violations = checkRecipeSafety(
{
ingredients: [
{ canonicalIngredientId: "chicken", optional: false },
{ canonicalIngredientId: "milk", optional: false },
],
spiceLevel: 0,
},
{ allergens: [], dietPattern: "vegan" },
INFO,
);
expect(violations.filter((v) => v.code === "diet_pattern")).toHaveLength(2);
});
it("halal blockerar fläsk", () => {
const violations = checkRecipeSafety(
{ ingredients: [{ canonicalIngredientId: "bacon", optional: false }], spiceLevel: 0 },
{ allergens: [], religiousRule: "halal" },
INFO,
);
expect(isRecipeSafe(violations)).toBe(false);
});
it("härleder receptallergener ur ingredienserna", () => {
expect(deriveRecipeAllergens(["pasta", "milk", "chicken"], INFO)).toEqual(["gluten", "milk"]);
});
});
describe("lagermatchning (spec §17)", () => {
it("beräknar täckning, saknat och utgående", () => {
const result = computeCoverage(
[
{
canonicalIngredientId: "chicken",
displayNameSv: "Kyckling",
quantity: 500,
unit: "GRAM",
optional: false,
},
{
canonicalIngredientId: "pasta",
displayNameSv: "Pasta",
quantity: 300,
unit: "GRAM",
optional: false,
},
],
[
{
id: "1",
canonicalIngredientId: "chicken",
quantity: 600,
unit: "GRAM",
bestBeforeDate: "2026-08-03",
},
],
new Map(),
new Date("2026-08-02T00:00:00Z"), // UTC testet ska vara sant i alla tidszoner
);
expect(result.coverage).toBe(0.5);
expect(result.missing.map((m) => m.canonicalIngredientId)).toEqual(["pasta"]);
expect(result.expiringUsed[0]?.canonicalIngredientId).toBe("chicken");
});
it("mjölkprincipen: passerat bäst före räknas som tillgängligt, inte som borta", () => {
// Mjölken som gick ut igår är inte automatiskt dålig recepten ska
// fortsätta föreslå den (användaren luktar/smakar) i stället för att
// tyst behandla varan som obefintlig och driva nyinköp/svinn.
const result = computeCoverage(
[
{
canonicalIngredientId: "milk",
displayNameSv: "Mjölk",
quantity: 500,
unit: "MILLILITER",
optional: false,
},
],
[
{
id: "1",
canonicalIngredientId: "milk",
quantity: 1000,
unit: "MILLILITER",
bestBeforeDate: "2026-08-01", // gick ut igår
},
],
new Map(),
new Date("2026-08-02T00:00:00Z"), // UTC testet ska vara sant i alla tidszoner
);
expect(result.coverage).toBe(1);
expect(result.missing).toHaveLength(0);
expect(result.expiringUsed[0]?.canonicalIngredientId).toBe("milk"); // prioriteras "räddar mjölken"
});
});
describe("skalning", () => {
it("skalar mängder och avrundar köksvänligt", () => {
const scaled = scaleIngredients(
[
{
canonicalIngredientId: "chicken",
displayNameSv: "Kyckling",
quantity: 500,
unit: "GRAM",
optional: false,
},
{
canonicalIngredientId: "salt",
displayNameSv: "Salt",
quantity: 1,
unit: "TEASPOON",
optional: false,
},
],
4,
6,
);
expect(scaled[0]?.quantity).toBe(750);
expect(scaled[1]?.quantity).toBe(1.5);
});
it("kastar på ogiltiga portioner", () => {
expect(() => scaleIngredients([], 0, 4)).toThrow();
});
});
describe("substitution (spec §20)", () => {
it("byter ingrediens med mängdfaktor och varnar för fel kontext", () => {
const result = applySubstitution(
[
{
canonicalIngredientId: "milk",
displayNameSv: "Mjölk",
quantity: 200,
unit: "MILLILITER",
optional: false,
},
],
{
fromIngredientId: "milk",
toIngredientId: "tofu",
ratio: 0.8,
instructionsSv: "Tillsätt sist.",
notRecommendedFor: ["whipping"],
},
"Tofu",
"whipping",
);
expect(result.ingredients[0]?.canonicalIngredientId).toBe("tofu");
expect(result.ingredients[0]?.quantity).toBe(160);
expect(result.applied.warningSv).toContain("rekommenderas inte");
});
it("kastar när ingrediensen inte finns i receptet", () => {
expect(() =>
applySubstitution(
[],
{ fromIngredientId: "x", toIngredientId: "y", ratio: 1, notRecommendedFor: [] },
"Y",
),
).toThrow();
});
});
describe("kostnadsuppskattning (spec §26)", () => {
it("beräknar pris per portion med täckningsgrad", () => {
const result = estimateCost(
[
{ canonicalIngredientId: "chicken", quantity: 500, unit: "GRAM", optional: false },
{ canonicalIngredientId: "unknown", quantity: 100, unit: "GRAM", optional: false },
],
4,
// 130 kr/kg = 13000 minor/kg; 500 g -> 6500 minor totalt, 1625 minor/portion.
new Map([["chicken", { pricePerKgMinor: 13_000, source: "default" as const }]]),
);
expect(result.totalMinor).toBe(6500);
expect(result.perPortionMinor).toBe(1625);
expect(result.priceCoverage).toBe(0.5);
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"],
"compilerOptions": {
"types": ["node"]
}
}