New import in version 0.1

This commit is contained in:
Nils-Johan Gynther
2026-04-11 15:38:24 +02:00
parent 8552c6f757
commit 5448da1b98
12 changed files with 868 additions and 7 deletions
+19 -6
View File
@@ -1,19 +1,32 @@
# Stage 1: Installera beroenden
# Byggas från projektets rot: docker build -f backend/Dockerfile -t recipe-api:local .
# Stage 1: Bygg recipe-document-converter
FROM node:22-alpine AS converter-build
WORKDIR /converter
COPY recipe-document-converter/package.json ./
RUN npm install
COPY recipe-document-converter/src ./src
COPY recipe-document-converter/tsconfig.json ./
RUN npm run build
# Stage 2: Installera backend-beroenden
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json ./
COPY prisma ./prisma
# Gör converter tillgänglig för npm:s file:-referens (../recipe-document-converter från /app)
COPY --from=converter-build /converter /recipe-document-converter
COPY backend/package.json ./
COPY backend/prisma ./prisma
RUN npm install
# Stage 2: Bygg applikationen
# Stage 3: Bygg applikationen
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
COPY backend/ .
RUN npx prisma generate
RUN npm run build
# Stage 3: Kör applikationen
# Stage 4: Kör applikationen
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
+1
View File
@@ -11,6 +11,7 @@
"prisma:deploy": "prisma migrate deploy"
},
"dependencies": {
"recipe-document-converter": "file:../recipe-document-converter",
"@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class ParseMarkdownDto {
@IsString()
@MinLength(1)
markdown!: string;
}
@@ -1,11 +1,17 @@
import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Post, Patch } from '@nestjs/common';
import { RecipesService } from './recipes.service';
import { CreateRecipeDto } from './dto/create-recipe.dto';
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
@Controller('recipes')
export class RecipesController {
constructor(private readonly recipesService: RecipesService) {}
@Post('parse-markdown')
parseMarkdown(@Body() dto: ParseMarkdownDto) {
return this.recipesService.parseMarkdown(dto);
}
@Get()
findAll() {
return this.recipesService.findAll();
+83
View File
@@ -2,6 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateRecipeDto } from './dto/create-recipe.dto';
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
import { parseRecipeMarkdown } from 'recipe-document-converter';
@Injectable()
export class RecipesService {
@@ -384,4 +386,85 @@ export class RecipesService {
return recipe;
}
async parseMarkdown(dto: ParseMarkdownDto) {
const parsed = parseRecipeMarkdown(dto.markdown);
const allProducts = await this.prisma.product.findMany({
where: { isActive: true },
select: { id: true, name: true, canonicalName: true, normalizedName: true },
});
// Normalisera en sträng för jämförelse (lowercase, trim, ta bort skiljetecken)
const normalize = (s: string) =>
s.toLowerCase().trim().replace(/[^a-zåäö0-9\s]/gi, '').replace(/\s+/g, ' ');
// Enkel Levenshtein-distans
const levenshtein = (a: string, b: string): number => {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] =
a[i - 1] === b[j - 1]
? dp[i - 1][j - 1]
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n];
};
const ingredientsWithSuggestions = parsed.ingredients.map((ingredient) => {
const query = normalize(ingredient.rawName);
const scored = allProducts
.map((product) => {
const targetName = normalize(product.canonicalName || product.name);
const targetNormalized = normalize(product.normalizedName);
// Exakt träff på normalizedName prioriteras
if (targetNormalized === query || targetName === query) {
return { product, score: 100 };
}
// Delsträng-match
if (targetName.includes(query) || query.includes(targetName)) {
return { product, score: 70 };
}
// Levenshtein-baserad likhet
const dist = levenshtein(query, targetName);
const maxLen = Math.max(query.length, targetName.length);
const similarity = maxLen === 0 ? 100 : Math.round((1 - dist / maxLen) * 100);
return { product, score: similarity };
})
.filter((s) => s.score >= 40)
.sort((a, b) => b.score - a.score)
.slice(0, 5)
.map((s) => ({
productId: s.product.id,
productName: s.product.canonicalName || s.product.name,
score: s.score,
}));
return {
rawName: ingredient.rawName,
quantity: ingredient.quantity,
unit: ingredient.unit,
note: ingredient.note,
suggestions: scored,
};
});
return {
name: parsed.name,
description: parsed.description,
instructions: parsed.instructions,
ingredients: ingredientsWithSuggestions,
};
}
}