Fas 3 steg 3b: minimala efterfrågor + hushållsantagandeprofiler per ingrediens
This commit is contained in:
@@ -213,6 +213,7 @@ export const cookingSessions = pgTable(
|
||||
actualPortionsEaten: integer("actual_portions_eaten"),
|
||||
/** Förberedda rester efter sessionen (steg 3d). */
|
||||
leftoverEstimatePortions: integer("leftover_estimate_portions"),
|
||||
leftoverNote: text("leftover_note"),
|
||||
/** Ursprungligt FEFO-förslag, sparas för ev. undo/omräkning. */
|
||||
plannedDeductions: jsonb("planned_deductions").$type<
|
||||
Array<{ itemId: string; quantity: number; unit: string; name: string }>
|
||||
@@ -256,6 +257,36 @@ export const recipeCooks = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/** Antagandeprofiler per hushåll och ingrediens (Fas 3 §6.2).
|
||||
* Lär sig över tid hur stor andel av ett recept som brukar ätas resp. bli rester.
|
||||
*/
|
||||
export const cookingAssumptionProfiles = pgTable(
|
||||
"cooking_assumption_profiles",
|
||||
{
|
||||
householdId: uuid("household_id")
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: "cascade" }),
|
||||
canonicalIngredientId: text("canonical_ingredient_id")
|
||||
.notNull()
|
||||
.references(() => canonicalIngredients.id, { onDelete: "cascade" }),
|
||||
/** Medelförbrukning i portioner per tillfälle (null tills vi har data). */
|
||||
averageEatenPortions: doublePrecision("average_eaten_portions"),
|
||||
/** Medelantal restportioner per tillfälle. */
|
||||
averageLeftoverPortions: doublePrecision("average_leftover_portions"),
|
||||
/** Hur många observationer profilen bygger på. */
|
||||
observationCount: integer("observation_count").notNull().default(0),
|
||||
/** Senaste rådata för felsökning/återspelning. */
|
||||
lastSessionAnswers: jsonb("last_session_answers").$type<
|
||||
Array<{ sessionId: string; eaten: number; leftovers: number; date: string }>
|
||||
>(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.householdId, t.canonicalIngredientId] }),
|
||||
index("cooking_assumption_profiles_household_idx").on(t.householdId),
|
||||
],
|
||||
);
|
||||
|
||||
/** Dubblett-/variantklassning mellan recept (spec §36). */
|
||||
export const recipeSimilarities = pgTable(
|
||||
"recipe_similarities",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Antagandeprofiler för matlagning (Fas 3 §6.2).
|
||||
* Rensa, deterministiska funktioner för att uppdatera per-hushålls-per-ingrediens-profiler
|
||||
* utifrån faktiska svar efter en cooking session.
|
||||
*/
|
||||
|
||||
export interface ProfileUpdateInput {
|
||||
householdId: string;
|
||||
canonicalIngredientId: string;
|
||||
plannedPortions: number;
|
||||
actualPortionsEaten: number;
|
||||
leftoverEstimatePortions: number;
|
||||
sessionId: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface ExistingProfile {
|
||||
averageEatenPortions: number | null;
|
||||
averageLeftoverPortions: number | null;
|
||||
observationCount: number;
|
||||
lastSessionAnswers?: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beräkna nya profilvärden med exponentiellt glidande medelvärde.
|
||||
* alpha = 1 / (observationCount + 1) ger jämn viktning över tid.
|
||||
*/
|
||||
export function updateCookingAssumptionProfile(
|
||||
input: ProfileUpdateInput,
|
||||
existing: ExistingProfile,
|
||||
): {
|
||||
averageEatenPortions: number;
|
||||
averageLeftoverPortions: number;
|
||||
observationCount: number;
|
||||
lastSessionAnswers: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }>;
|
||||
} {
|
||||
const eaten = clamp(input.actualPortionsEaten, 0, input.plannedPortions);
|
||||
const leftovers = clamp(input.leftoverEstimatePortions, 0, input.plannedPortions - eaten);
|
||||
|
||||
const prevEaten = existing.averageEatenPortions ?? eaten;
|
||||
const prevLeftovers = existing.averageLeftoverPortions ?? leftovers;
|
||||
const prevCount = existing.observationCount ?? 0;
|
||||
|
||||
const alpha = 1 / (prevCount + 1);
|
||||
const averageEatenPortions = round2(prevEaten + alpha * (eaten - prevEaten));
|
||||
const averageLeftoverPortions = round2(prevLeftovers + alpha * (leftovers - prevLeftovers));
|
||||
const observationCount = prevCount + 1;
|
||||
|
||||
const history = existing.lastSessionAnswers ?? [];
|
||||
const lastSessionAnswers = [
|
||||
{ sessionId: input.sessionId, eaten, leftovers, date: input.date },
|
||||
...history,
|
||||
].slice(0, 10);
|
||||
|
||||
return { averageEatenPortions, averageLeftoverPortions, observationCount, lastSessionAnswers };
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from "./forecast.js";
|
||||
export * from "./trust.js";
|
||||
export * from "./reconciliation.js";
|
||||
export * from "./scan-diff.js";
|
||||
export * from "./cooking-profiles.js";
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { updateCookingAssumptionProfile } from "../src/cooking-profiles.js";
|
||||
|
||||
describe("updateCookingAssumptionProfile", () => {
|
||||
it("initializes profile from first observation", () => {
|
||||
const result = updateCookingAssumptionProfile(
|
||||
{
|
||||
householdId: "h1",
|
||||
canonicalIngredientId: "i1",
|
||||
plannedPortions: 4,
|
||||
actualPortionsEaten: 3,
|
||||
leftoverEstimatePortions: 1,
|
||||
sessionId: "s1",
|
||||
date: "2026-08-07",
|
||||
},
|
||||
{ averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 },
|
||||
);
|
||||
expect(result.averageEatenPortions).toBe(3);
|
||||
expect(result.averageLeftoverPortions).toBe(1);
|
||||
expect(result.observationCount).toBe(1);
|
||||
});
|
||||
|
||||
it("clamps eaten and leftovers to valid ranges", () => {
|
||||
const result = updateCookingAssumptionProfile(
|
||||
{
|
||||
householdId: "h1",
|
||||
canonicalIngredientId: "i1",
|
||||
plannedPortions: 4,
|
||||
actualPortionsEaten: -1,
|
||||
leftoverEstimatePortions: 5,
|
||||
sessionId: "s1",
|
||||
date: "2026-08-07",
|
||||
},
|
||||
{ averageEatenPortions: 2, averageLeftoverPortions: 1, observationCount: 3 },
|
||||
);
|
||||
// Eaten clamps to 0, leftovers clamps to planned - eaten = 4.
|
||||
// alpha = 1/4, prevEaten=2 => 2 + 0.25*(0-2) = 1.5
|
||||
// prevLeftovers=1 => 1 + 0.25*(4-1) = 1.75
|
||||
expect(result.averageEatenPortions).toBe(1.5);
|
||||
expect(result.averageLeftoverPortions).toBe(1.75);
|
||||
});
|
||||
|
||||
it("keeps a rolling history of last 10 sessions", () => {
|
||||
type Profile = { averageEatenPortions: number | null; averageLeftoverPortions: number | null; observationCount: number; lastSessionAnswers?: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }> | null };
|
||||
let existing: Profile = { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 };
|
||||
for (let i = 0; i < 12; i++) {
|
||||
existing = updateCookingAssumptionProfile(
|
||||
{
|
||||
householdId: "h1",
|
||||
canonicalIngredientId: "i1",
|
||||
plannedPortions: 4,
|
||||
actualPortionsEaten: 2,
|
||||
leftoverEstimatePortions: 1,
|
||||
sessionId: `s${i}`,
|
||||
date: "2026-08-07",
|
||||
},
|
||||
existing,
|
||||
);
|
||||
}
|
||||
expect(existing.lastSessionAnswers).toHaveLength(10);
|
||||
expect(existing.lastSessionAnswers![0]!.sessionId).toBe("s11");
|
||||
});
|
||||
});
|
||||
@@ -152,5 +152,8 @@ export const cookingSessionCompleteInputSchema = cookRecipeInputSchema.partial()
|
||||
)
|
||||
.default([]),
|
||||
mealType: z.enum(MEAL_TYPES).optional(),
|
||||
actualPortionsEaten: z.number().int().min(0).max(24).optional(),
|
||||
leftoverEstimatePortions: z.number().int().min(0).max(24).optional(),
|
||||
leftoverNote: z.string().max(200).optional(),
|
||||
});
|
||||
export type CookingSessionCompleteInput = z.infer<typeof cookingSessionCompleteInputSchema>;
|
||||
|
||||
Reference in New Issue
Block a user