From 6555875e29f7cfafc4b1a2db897a7cc59db626ff Mon Sep 17 00:00:00 2001 From: Tautvydas Derzinskas Date: Wed, 21 Oct 2026 12:48:46 +0200 Subject: [PATCH] fix: check an imported model's description once its import is done The AI description check started as soon as the model was created, before its pictures were attached and its description's images stored locally. Storing them rewrites the description, so a suggestion that came back after that was dropped and logged as unchanged. Imports now start the check when they finish; uploads still start it straight away. --- backend/src/services/importService.ts | 30 +++++- backend/src/services/printCreation.ts | 5 +- .../tests/makerworldProfilesImport.test.ts | 98 +++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/backend/src/services/importService.ts b/backend/src/services/importService.ts index f5f8c07..b684b80 100644 --- a/backend/src/services/importService.ts +++ b/backend/src/services/importService.ts @@ -184,6 +184,7 @@ export const MAKERONLINE_GRAB_CODE = "makeronline_grab"; export const MAKERONLINE_NEEDS_GRAB = "MakerOnline only gives its files to its own pages: import it with Thingport Grab on makeronline.com."; import { listZipEntries, readZipEntry } from "../utils/zipReader"; +import { enqueueAiDescription } from "./aiDescriptionService"; import { getMyminifactoryApiKey, getThingiverseAccessToken } from "./settingsService"; import { upsertAuthorFromImport } from "./authorService"; import { @@ -203,6 +204,9 @@ import { Prisma } from "@prisma/client"; import type { Author, Plate, PreviewImage, Print } from "@prisma/client"; import { assertUrlProviderEnabled } from "./providerSettingsService"; +/** createPrint's options for an import, which checks the description itself once it's complete: importPrintFromUrl. */ +const IMPORTED = { describe: false }; + function isUniqueConstraintError(err: unknown): boolean { return err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002"; } @@ -1213,7 +1217,7 @@ async function importThingiverseThing( try { let result: { print: Print; plates: Plate[] }; try { - result = await createPrint(userId, printMeta, meta.title || `thing-${source.externalId}`, downloaded); + result = await createPrint(userId, printMeta, meta.title || `thing-${source.externalId}`, downloaded, IMPORTED); } catch (err) { // Race guard: a concurrent import of the same source model won. if (isUniqueConstraintError(err)) { @@ -1307,7 +1311,13 @@ async function importPrintablesModel( try { let result: { print: Print; plates: Plate[] }; try { - result = await createPrint(userId, printMeta, meta.title || `printables-${source.externalId}`, downloaded); + result = await createPrint( + userId, + printMeta, + meta.title || `printables-${source.externalId}`, + downloaded, + IMPORTED, + ); } catch (err) { // Race guard: a concurrent import of the same source model won. if (isUniqueConstraintError(err)) { @@ -1507,7 +1517,13 @@ async function importMyminifactoryObject( }; let created: { print: Print; plates: Plate[] }; try { - created = await createPrint(userId, printMeta, meta.title || `myminifactory-${source.externalId}`, downloaded); + created = await createPrint( + userId, + printMeta, + meta.title || `myminifactory-${source.externalId}`, + downloaded, + IMPORTED, + ); } catch (err) { // Race guard: a concurrent import of the same source model won. if (isUniqueConstraintError(err)) { @@ -1689,6 +1705,7 @@ async function createSourcedPrint( printMeta, meta.title || nameHint, nameInputsApart(downloaded, profiles.titles), + IMPORTED, ); } catch (err) { // Race guard: a concurrent import of the same source model won. @@ -2260,8 +2277,11 @@ export async function importPrintFromUrl( if (!keepGrabImports && result.print.sourceProvider === "makeronline" && result.print.sourceExternalId) { await clearGrabImports(userId, result.print.sourceExternalId).catch(() => undefined); } - if (!result.alreadyImported && !result.profileAdded) + if (!result.alreadyImported && !result.profileAdded) { void recordActivity(userId, "import", result.print.id, result.print.name); + // Only now: the pictures it may write from are attached, and its description's images stored locally. + enqueueAiDescription(userId, result.print.id); + } return result; } @@ -2346,7 +2366,7 @@ async function importNewOrExisting(userId: string, url: string, body: ImportRequ try { let result: { print: Print; plates: Plate[] }; try { - result = await createPrint(userId, printMeta, path.parse(filename).name, plates); + result = await createPrint(userId, printMeta, path.parse(filename).name, plates, IMPORTED); } catch (err) { // Race guard: a concurrent import of the same source model won. if (source && isUniqueConstraintError(err)) { diff --git a/backend/src/services/printCreation.ts b/backend/src/services/printCreation.ts index a9da6d1..48509b7 100644 --- a/backend/src/services/printCreation.ts +++ b/backend/src/services/printCreation.ts @@ -171,11 +171,14 @@ async function createPlateAtPosition( return { record, effectivePath }; } +/** `describe: false` leaves the AI description check to the caller: an import's description is rewritten after this, + * when its images are stored locally, and a check started before then would come back to a changed description. */ export async function createPrint( userId: string, meta: PrintMetaInput, nameHint: string, plateInputs: NewPlateInput[], + { describe = true } = {}, ): Promise<{ print: Print; plates: Plate[] }> { if (!plateInputs.length) throw new Error("createPrint requires at least one plate"); // categoryId may come straight from a request body, so verify ownership here. @@ -232,7 +235,7 @@ export async function createPrint( // Background classification is intentionally detached: importing a model must not wait for, // or fail because of, a third-party AI provider. if (!print.categoryId) void enqueueAiCategorization(userId, print.id); - enqueueAiDescription(userId, print.id); + if (describe) enqueueAiDescription(userId, print.id); return { print, plates }; } catch (err) { await discardPrint(print.id); diff --git a/backend/tests/makerworldProfilesImport.test.ts b/backend/tests/makerworldProfilesImport.test.ts index c40b02c..b4499db 100644 --- a/backend/tests/makerworldProfilesImport.test.ts +++ b/backend/tests/makerworldProfilesImport.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import fs from "node:fs"; +import http from "node:http"; import path from "node:path"; import request from "supertest"; import sharp from "sharp"; @@ -24,6 +25,8 @@ import { prisma } from "../src/db"; import { selectMakerworldProfiles } from "../src/services/makerworldCloudApi"; import { plateThumbPath } from "../src/services/printService"; import { plateAssetHome } from "../src/services/modelAssets"; +import { createAiConnection } from "../src/services/aiConnectionService"; +import { DEFAULT_AI_DESCRIPTION_PREFS, setAiDescriptionPrefs } from "../src/services/settingsService"; const app = createApp(); const stamp = Date.now(); @@ -387,3 +390,98 @@ describe("a MakerWorld model with no print profiles", () => { expect(await filenamesOf(job.result_print_id)).toEqual(EXPECTED_FILES); }); }); + +describe("Improved descriptions on import", () => { + let provider: http.Server | null = null; + const prompts: string[] = []; + + afterEach(async () => { + global.fetch = originalFetch; + await setAiDescriptionPrefs({ ...DEFAULT_AI_DESCRIPTION_PREFS }); + await prisma.aiConnection.deleteMany(); + provider?.closeAllConnections?.(); + await new Promise((resolve) => (provider ? provider.close(() => resolve()) : resolve())); + provider = null; + }); + + /** An AI provider that tidies the description, keeping the image it was given. */ + async function startProvider(): Promise { + prompts.length = 0; + provider = http.createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { + messages: { content: { type: string; text?: string }[] }[]; + }; + const prompt = body.messages[0].content.find((part) => part.type === "text")?.text ?? ""; + prompts.push(prompt); + const image = prompt.match(/\/description-image\/[a-z0-9]+|https:\/\/makerworld\.bblmw\.com\/[^\s)]+/)?.[0]; + const description = `A tidied phoenix. ![photo](${image})`; + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + choices: [{ message: { content: JSON.stringify({ changed: true, description }) }, finish_reason: "stop" }], + }), + ); + }); + await new Promise((resolve) => provider!.listen(0, "127.0.0.1", resolve)); + const address = provider.address(); + if (!address || typeof address === "string") throw new Error("Provider did not bind a TCP port"); + return `http://127.0.0.1:${address.port}/v1`; + } + + it("checks the description once the import is done, with its images stored locally", async () => { + const baseUrl = await startProvider(); + await createAiConnection({ + baseUrl, + apiKey: null, + model: "local", + timeoutMs: 2000, + concurrency: 1, + reasoningEffort: null, + }); + await setAiDescriptionPrefs({ ...DEFAULT_AI_DESCRIPTION_PREFS, enabled: true, onImport: true, mode: "suggest" }); + + const designId = String((stamp % 1_000_000_000) + 200); + const photoUrl = "https://makerworld.bblmw.com/phoenix/summary.png"; + const png = await solid(0, 128, 0); + global.fetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(async (input, init) => { + const url = String(input); + if (url.startsWith("http://127.0.0.1:")) return originalFetch(input, init); + if (url === `https://api.bambulab.com/v1/design-service/design/${designId}`) { + return json({ + ...design(designId), + summary: `

A phoenix.

`, + instances: [design(designId).instances[0]], + defaultInstanceId: 1, + }); + } + if (url.startsWith("https://api.bambulab.com/v1/iot-service/api/user/profile/")) { + return json({ message: "success", url: "https://s3.example.com/phoenix.stl", filename: "phoenix.stl" }); + } + if (url === "https://s3.example.com/phoenix.stl") { + return new Response("solid phoenix\nendsolid\n", { headers: { "content-type": "application/octet-stream" } }); + } + if (url === photoUrl) return new Response(png, { headers: { "content-type": "image/png" } }); + return new Response("not found", { status: 404 }); + }) as unknown as typeof fetch; + + const res = await request(app) + .post("/api/import") + .set(auth()) + .send({ url: `https://makerworld.com/en/models/${designId}-phoenix`, makerworld_cookie: "token=t" }); + expect(res.status).toBe(200); + + let suggestion: { notes?: string } | null = null; + for (let i = 0; i < 100 && !suggestion; i++) { + await new Promise((resolve) => setTimeout(resolve, 50)); + const print = await prisma.print.findUniqueOrThrow({ where: { id: res.body.id } }); + suggestion = print.aiDescriptionSuggestion as { notes?: string } | null; + } + // Asked once, about the description as imported: its image already the model's own copy. + expect(prompts).toHaveLength(1); + expect(prompts[0]).toMatch(/\/description-image\//); + expect(prompts[0]).not.toContain(photoUrl); + expect(suggestion?.notes).toMatch(/^A tidied phoenix\. !\[photo\]\(\/description-image\/[a-z0-9]+\)$/); + }); +});