Files
Cibello-app/packages/recipe-generation/src/gap-report.ts
T
Sven (AAMOS AI) c32a7e33c7
CI / Typecheck, test & build (push) Failing after 2s
ci: trigga på master + formatfix inför Gitea Actions
2026-08-13 17:25:14 +07:00

180 lines
5.9 KiB
TypeScript

/**
* Katalog-gaprapport (docs/32 §4).
*
* Byggs ur consent-gatad nollträff-bankning + cook/rating-signaler.
* Identifierar vad som saknas i receptkatalogen så påfyllningen kan
* styras mot verklig efterfrågan.
*/
import type { GapReport, GapReportEntry } from "./types.js";
export interface AnalyticsEvent {
eventName: string;
occurredAt: Date;
properties: Record<string, unknown>;
}
export interface RecipeSignal {
recipeId: string | null;
titleSv: string | null;
cookCount: number;
ratingAverage: number | null;
ratingCount: number;
searchQuery?: string | null;
}
export interface GapReportOptions {
/** Minsta antal nollträffar för att en sökning ska räknas. */
minMissThreshold?: number;
/** Hur långt bak i tiden (dagar). */
lookbackDays?: number;
/** Max antal entries i rapporten. */
maxEntries?: number;
}
const DEFAULT_GAP_OPTIONS: Required<GapReportOptions> = {
minMissThreshold: 2,
lookbackDays: 30,
maxEntries: 50,
};
/**
* Bygg gaprapport ur analytics-events och receptsignaler.
*
* @param searchMissEvents productAnalyticsEvents med eventName = "recipe_search_zero_results"
* @param cookSignals recipe_cooks aggregerade per recept
* @param ratingSignals recipe_ratings aggregerade per recept
* @param options Filter och begränsningar
*/
export function buildGapReport(
searchMissEvents: AnalyticsEvent[],
cookSignals: RecipeSignal[],
ratingSignals: RecipeSignal[],
options: GapReportOptions = {},
): GapReport {
const opts = { ...DEFAULT_GAP_OPTIONS, ...options };
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - opts.lookbackDays);
// ── 1. Samla nollträffar ─────────────────────────────────────────────────
const missMap = new Map<string, { count: number; lastAt: Date; queries: Set<string> }>();
for (const ev of searchMissEvents) {
if (ev.occurredAt < cutoff) continue;
const query = String(ev.properties?.query ?? "")
.toLowerCase()
.trim();
if (!query) continue;
const ingredientId = ev.properties?.suggestedIngredientId
? String(ev.properties.suggestedIngredientId)
: null;
const mealType = ev.properties?.mealType ? String(ev.properties.mealType) : null;
const key = `${query}::${ingredientId ?? "_"}::${mealType ?? "_"}`;
const existing = missMap.get(key);
if (existing) {
existing.count++;
existing.queries.add(query);
if (ev.occurredAt > existing.lastAt) existing.lastAt = ev.occurredAt;
} else {
missMap.set(key, {
count: 1,
lastAt: ev.occurredAt,
queries: new Set([query]),
});
}
}
// ── 2. Filtrera och rangordna ────────────────────────────────────────────
const entries: GapReportEntry[] = [];
for (const [key, data] of missMap) {
if (data.count < opts.minMissThreshold) continue;
const parts = key.split("::");
const ingredientId = parts[1] ?? null;
const mealType = parts[2] ?? null;
entries.push({
searchQuery: [...data.queries][0] ?? "",
missCount: data.count,
lastMissedAt: data.lastAt,
suggestedIngredientId: ingredientId === "_" ? null : ingredientId,
suggestedMealType: mealType === "_" ? null : mealType,
priority: data.count >= 10 ? "high" : data.count >= 5 ? "medium" : "low",
});
}
// Sortera: high → medium → low, sedan antal
entries.sort((a, b) => {
const pOrder = { high: 0, medium: 1, low: 2 };
if (pOrder[a.priority] !== pOrder[b.priority]) {
return pOrder[a.priority] - pOrder[b.priority];
}
return b.missCount - a.missCount;
});
const limitedEntries = entries.slice(0, opts.maxEntries);
// ── 3. Aggregera topp-saknade ────────────────────────────────────────────
const ingredientCounts = new Map<string | null, number>();
const mealTypeCounts = new Map<string | null, number>();
for (const e of limitedEntries) {
ingredientCounts.set(
e.suggestedIngredientId,
(ingredientCounts.get(e.suggestedIngredientId) ?? 0) + e.missCount,
);
mealTypeCounts.set(
e.suggestedMealType,
(mealTypeCounts.get(e.suggestedMealType) ?? 0) + e.missCount,
);
}
const topMissingIngredients = [...ingredientCounts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([id, count]) => ({ canonicalIngredientId: id as string | null, count }));
const topMissingMealTypes = [...mealTypeCounts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([mealType, count]) => ({ mealType: mealType as string | null, count }));
return {
generatedAt: new Date(),
entries: limitedEntries,
topMissingIngredients,
topMissingMealTypes,
};
}
/**
* Formatera gaprapporten som läsbar text för granskning.
*/
export function formatGapReport(report: GapReport): string {
const lines: string[] = [
`# Katalog-gaprapport`,
`Genererad: ${report.generatedAt.toISOString()}`,
``,
`## Topp-saknade ingredienser`,
...report.topMissingIngredients.map(
(i) => `- ${i.canonicalIngredientId ?? "(okänd)"}: ${i.count} nollträffar`,
),
``,
`## Topp-saknade måltidstyper`,
...report.topMissingMealTypes.map(
(m) => `- ${m.mealType ?? "(okänd)"}: ${m.count} nollträffar`,
),
``,
`## Detaljer (${report.entries.length} entries)`,
...report.entries.map(
(e) =>
`- [${e.priority.toUpperCase()}] "${e.searchQuery}" — ${e.missCount} missar, senast ${e.lastMissedAt.toISOString().slice(0, 10)}${e.suggestedIngredientId ? ` (ingrediens: ${e.suggestedIngredientId})` : ""}${e.suggestedMealType ? ` (måltid: ${e.suggestedMealType})` : ""}`,
),
];
return lines.join("\n");
}