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
+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,
},
};
}