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]+\)$/); + }); +});