mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 20:06:42 +02:00
feat(parsers): add intelligent quick-create parsers for 6 apps with multilingual support
- Base parser: multilingual (DE/EN/FR/ES/IT) date, time, weekday, month parsing - Base parser: fuzzy/typo tolerance (Levenshtein), recurrence (RRULE), relative time - Base parser: timezone extraction, date ranges, ordinal dates, confidence scoring - Base parser: past dates (gestern/yesterday), this/next week distinction - Base parser: compose helper (createAppParser), multiple @references - Calendar: event-parser with duration, time ranges, location, all-day, calendar ref - Calendar: wire up UnifiedBar with onCreate/onParseCreate for quick event creation - Todo: task-parser multilingual priority keywords (urgent/important/normal/later) - Planta: plant-parser with acquisition keywords (gekauft/bought/acheté) - Mukke: song-parser with Artist-Title format, BPM, genre, playlist/project creation - NutriPhi: meal-parser with meal type detection, add QuickInputBar to layout - All parsers: 210 tests across 7 test suites, all passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5c2a8d07e3
commit
5286404129
16 changed files with 3291 additions and 94 deletions
83
apps/nutriphi/apps/web/src/lib/utils/meal-parser.test.ts
Normal file
83
apps/nutriphi/apps/web/src/lib/utils/meal-parser.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { parseMealInput, formatParsedMealPreview } from './meal-parser';
|
||||
|
||||
describe('parseMealInput', () => {
|
||||
it('should parse food description', () => {
|
||||
const result = parseMealInput('Spaghetti Bolognese');
|
||||
expect(result.description).toBe('Spaghetti Bolognese');
|
||||
expect(result.mealTypeExplicit).toBe(false);
|
||||
});
|
||||
|
||||
it('should extract frühstück', () => {
|
||||
const result = parseMealInput('2 Eier Toast Frühstück');
|
||||
expect(result.description).toBe('2 Eier Toast');
|
||||
expect(result.mealType).toBe('breakfast');
|
||||
expect(result.mealTypeExplicit).toBe(true);
|
||||
});
|
||||
|
||||
it('should extract mittagessen', () => {
|
||||
const result = parseMealInput('Spaghetti Bolognese Mittagessen');
|
||||
expect(result.description).toBe('Spaghetti Bolognese');
|
||||
expect(result.mealType).toBe('lunch');
|
||||
expect(result.mealTypeExplicit).toBe(true);
|
||||
});
|
||||
|
||||
it('should extract abendessen', () => {
|
||||
const result = parseMealInput('Pizza abendessen');
|
||||
expect(result.description).toBe('Pizza');
|
||||
expect(result.mealType).toBe('dinner');
|
||||
expect(result.mealTypeExplicit).toBe(true);
|
||||
});
|
||||
|
||||
it('should extract snack', () => {
|
||||
const result = parseMealInput('Apfel snack');
|
||||
expect(result.description).toBe('Apfel');
|
||||
expect(result.mealType).toBe('snack');
|
||||
expect(result.mealTypeExplicit).toBe(true);
|
||||
});
|
||||
|
||||
it('should extract morgens/mittags/abends', () => {
|
||||
expect(parseMealInput('Müsli morgens').mealType).toBe('breakfast');
|
||||
expect(parseMealInput('Salat mittags').mealType).toBe('lunch');
|
||||
expect(parseMealInput('Suppe abends').mealType).toBe('dinner');
|
||||
});
|
||||
|
||||
it('should auto-detect meal type when not specified', () => {
|
||||
const result = parseMealInput('Käsebrot');
|
||||
expect(result.description).toBe('Käsebrot');
|
||||
expect(result.mealTypeExplicit).toBe(false);
|
||||
// mealType is auto-detected based on time of day
|
||||
expect(['breakfast', 'lunch', 'dinner', 'snack']).toContain(result.mealType);
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
const result = parseMealInput('');
|
||||
expect(result.description).toBe('');
|
||||
});
|
||||
|
||||
it('should handle comma-separated foods', () => {
|
||||
const result = parseMealInput('Reis, Hähnchen, Brokkoli Mittagessen');
|
||||
expect(result.description).toBe('Reis, Hähnchen, Brokkoli');
|
||||
expect(result.mealType).toBe('lunch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatParsedMealPreview', () => {
|
||||
it('should show meal type', () => {
|
||||
const parsed = parseMealInput('Toast Frühstück');
|
||||
const preview = formatParsedMealPreview(parsed);
|
||||
expect(preview).toContain('Frühstück');
|
||||
});
|
||||
|
||||
it('should show auto-detection hint', () => {
|
||||
const parsed = parseMealInput('Apfel');
|
||||
const preview = formatParsedMealPreview(parsed);
|
||||
expect(preview).toContain('automatisch');
|
||||
});
|
||||
|
||||
it('should not show auto hint when explicit', () => {
|
||||
const parsed = parseMealInput('Apfel snack');
|
||||
const preview = formatParsedMealPreview(parsed);
|
||||
expect(preview).not.toContain('automatisch');
|
||||
});
|
||||
});
|
||||
165
apps/nutriphi/apps/web/src/lib/utils/meal-parser.ts
Normal file
165
apps/nutriphi/apps/web/src/lib/utils/meal-parser.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* Meal Parser for NutriPhi App
|
||||
*
|
||||
* Parses natural language input for quick meal logging.
|
||||
* Extracts meal type and food description for AI analysis.
|
||||
*
|
||||
* Examples:
|
||||
* - "Spaghetti Bolognese mittagessen"
|
||||
* - "2 Eier, Toast, Orangensaft frühstück"
|
||||
* - "Apfel snack"
|
||||
* - "Hähnchenbrust mit Reis und Salat"
|
||||
*/
|
||||
|
||||
import type { MealType } from '@nutriphi/shared';
|
||||
import { suggestMealType } from '@nutriphi/shared';
|
||||
import type { ParserLocale } from '@manacore/shared-utils';
|
||||
|
||||
export interface ParsedMeal {
|
||||
description: string;
|
||||
mealType: MealType;
|
||||
mealTypeExplicit: boolean; // Was meal type explicitly mentioned?
|
||||
}
|
||||
|
||||
// Meal type patterns per locale
|
||||
const MEAL_TYPE_PATTERNS_BY_LOCALE: Record<ParserLocale, { pattern: RegExp; type: MealType }[]> = {
|
||||
de: [
|
||||
{ pattern: /\bfrühstück\b/i, type: 'breakfast' },
|
||||
{ pattern: /\bmittagessen\b/i, type: 'lunch' },
|
||||
{ pattern: /\babendessen\b/i, type: 'dinner' },
|
||||
{ pattern: /\bsnack\b/i, type: 'snack' },
|
||||
{ pattern: /\bmorgens\b/i, type: 'breakfast' },
|
||||
{ pattern: /\bmittags\b/i, type: 'lunch' },
|
||||
{ pattern: /\babends\b/i, type: 'dinner' },
|
||||
{ pattern: /\bnachtisch\b/i, type: 'snack' },
|
||||
{ pattern: /\bzwischenmahlzeit\b/i, type: 'snack' },
|
||||
],
|
||||
en: [
|
||||
{ pattern: /\bbreakfast\b/i, type: 'breakfast' },
|
||||
{ pattern: /\blunch\b/i, type: 'lunch' },
|
||||
{ pattern: /\bdinner\b/i, type: 'dinner' },
|
||||
{ pattern: /\bsnack\b/i, type: 'snack' },
|
||||
{ pattern: /\bmorning\b/i, type: 'breakfast' },
|
||||
{ pattern: /\bnoon\b/i, type: 'lunch' },
|
||||
{ pattern: /\bevening\b/i, type: 'dinner' },
|
||||
],
|
||||
fr: [
|
||||
{ pattern: /\bpetit[- ]d[ée]jeuner\b/i, type: 'breakfast' },
|
||||
{ pattern: /\bd[ée]jeuner\b/i, type: 'lunch' },
|
||||
{ pattern: /\bd[îi]ner\b/i, type: 'dinner' },
|
||||
{ pattern: /\bgo[ûu]ter\b/i, type: 'snack' },
|
||||
{ pattern: /\bmatin\b/i, type: 'breakfast' },
|
||||
{ pattern: /\bmidi\b/i, type: 'lunch' },
|
||||
{ pattern: /\bsoir\b/i, type: 'dinner' },
|
||||
],
|
||||
es: [
|
||||
{ pattern: /\bdesayuno\b/i, type: 'breakfast' },
|
||||
{ pattern: /\balmuerzo\b/i, type: 'lunch' },
|
||||
{ pattern: /\bcena\b/i, type: 'dinner' },
|
||||
{ pattern: /\bmerienda\b/i, type: 'snack' },
|
||||
],
|
||||
it: [
|
||||
{ pattern: /\bcolazione\b/i, type: 'breakfast' },
|
||||
{ pattern: /\bpranzo\b/i, type: 'lunch' },
|
||||
{ pattern: /\bcena\b/i, type: 'dinner' },
|
||||
{ pattern: /\bspuntino\b/i, type: 'snack' },
|
||||
],
|
||||
};
|
||||
|
||||
// Meal type labels per locale
|
||||
const MEAL_TYPE_LABELS_BY_LOCALE: Record<ParserLocale, Record<MealType, string>> = {
|
||||
de: {
|
||||
breakfast: 'Frühstück',
|
||||
lunch: 'Mittagessen',
|
||||
dinner: 'Abendessen',
|
||||
snack: 'Snack',
|
||||
},
|
||||
en: {
|
||||
breakfast: 'Breakfast',
|
||||
lunch: 'Lunch',
|
||||
dinner: 'Dinner',
|
||||
snack: 'Snack',
|
||||
},
|
||||
fr: {
|
||||
breakfast: 'Petit-déjeuner',
|
||||
lunch: 'Déjeuner',
|
||||
dinner: 'Dîner',
|
||||
snack: 'Goûter',
|
||||
},
|
||||
es: {
|
||||
breakfast: 'Desayuno',
|
||||
lunch: 'Almuerzo',
|
||||
dinner: 'Cena',
|
||||
snack: 'Merienda',
|
||||
},
|
||||
it: {
|
||||
breakfast: 'Colazione',
|
||||
lunch: 'Pranzo',
|
||||
dinner: 'Cena',
|
||||
snack: 'Spuntino',
|
||||
},
|
||||
};
|
||||
|
||||
// Auto-detection hint per locale
|
||||
const AUTO_HINT_BY_LOCALE: Record<ParserLocale, string> = {
|
||||
de: 'automatisch',
|
||||
en: 'auto-detected',
|
||||
fr: 'automatique',
|
||||
es: 'automático',
|
||||
it: 'automatico',
|
||||
};
|
||||
|
||||
function extractMealType(
|
||||
text: string,
|
||||
locale: ParserLocale = 'de'
|
||||
): { mealType?: MealType; remaining: string } {
|
||||
const patterns = MEAL_TYPE_PATTERNS_BY_LOCALE[locale];
|
||||
for (const { pattern, type } of patterns) {
|
||||
if (pattern.test(text)) {
|
||||
return {
|
||||
mealType: type,
|
||||
remaining: text.replace(pattern, '').trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { mealType: undefined, remaining: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse natural language meal input
|
||||
*/
|
||||
export function parseMealInput(input: string, locale: ParserLocale = 'de'): ParsedMeal {
|
||||
let text = input.trim();
|
||||
|
||||
// Extract explicit meal type
|
||||
const mealTypeResult = extractMealType(text, locale);
|
||||
text = mealTypeResult.remaining;
|
||||
|
||||
// Clean up description
|
||||
const description = text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Use explicit meal type or auto-detect based on time of day
|
||||
const mealType = mealTypeResult.mealType || suggestMealType();
|
||||
|
||||
return {
|
||||
description,
|
||||
mealType,
|
||||
mealTypeExplicit: !!mealTypeResult.mealType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format parsed meal for preview display
|
||||
*/
|
||||
export function formatParsedMealPreview(parsed: ParsedMeal, locale: ParserLocale = 'de'): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
const labels = MEAL_TYPE_LABELS_BY_LOCALE[locale];
|
||||
parts.push(`🍽️ ${labels[parsed.mealType]}`);
|
||||
|
||||
if (!parsed.mealTypeExplicit) {
|
||||
parts.push(`(${AUTO_HINT_BY_LOCALE[locale]})`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
|
@ -2,7 +2,13 @@
|
|||
import '../app.css';
|
||||
import '$lib/i18n';
|
||||
import { isLoading as i18nLoading, _ as t } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { QuickInputBar } from '@manacore/shared-ui';
|
||||
import type { QuickInputItem, CreatePreview } from '@manacore/shared-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { mealsStore } from '$lib/stores/meals.svelte';
|
||||
import { parseMealInput, formatParsedMealPreview } from '$lib/utils/meal-parser';
|
||||
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
|
@ -10,6 +16,46 @@
|
|||
let loading = $state(true);
|
||||
let appReady = $derived(!loading && !$i18nLoading);
|
||||
|
||||
// QuickInputBar handlers - search recent meals
|
||||
async function handleSearch(query: string): Promise<QuickInputItem[]> {
|
||||
const q = query.toLowerCase();
|
||||
return mealsStore.meals
|
||||
.filter((m) => m.description?.toLowerCase().includes(q))
|
||||
.slice(0, 10)
|
||||
.map((meal) => ({
|
||||
id: meal.id,
|
||||
title: meal.description || 'Mahlzeit',
|
||||
subtitle: meal.mealType,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleSelect(item: QuickInputItem) {
|
||||
// No detail page for meals - just scroll to it
|
||||
}
|
||||
|
||||
function handleParseCreate(query: string): CreatePreview | null {
|
||||
if (!query.trim()) return null;
|
||||
const parsed = parseMealInput(query);
|
||||
if (!parsed.description) return null;
|
||||
return {
|
||||
title: `"${parsed.description}" analysieren`,
|
||||
subtitle: formatParsedMealPreview(parsed),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCreate(query: string): Promise<void> {
|
||||
if (!query.trim()) return;
|
||||
const parsed = parseMealInput(query);
|
||||
if (!parsed.description) return;
|
||||
// Navigate to add page with pre-filled description and meal type
|
||||
const params = new URLSearchParams({
|
||||
type: 'text',
|
||||
description: parsed.description,
|
||||
mealType: parsed.mealType,
|
||||
});
|
||||
goto(`/add?${params.toString()}`);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
authStore.initialize().then(() => {
|
||||
loading = false;
|
||||
|
|
@ -33,4 +79,22 @@
|
|||
</div>
|
||||
{:else}
|
||||
{@render children()}
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
<QuickInputBar
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
onParseCreate={handleParseCreate}
|
||||
onCreate={handleCreate}
|
||||
placeholder="Mahlzeit eingeben..."
|
||||
emptyText="Keine Mahlzeiten gefunden"
|
||||
searchingText="Suche..."
|
||||
createText="Analysieren"
|
||||
deferSearch={true}
|
||||
locale="de"
|
||||
appIcon="search"
|
||||
bottomOffset="70px"
|
||||
/>
|
||||
{/if}
|
||||
<SessionExpiredBanner locale="de" loginHref="/login" />
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue