This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 0467c76529 feat(python-notebook-migration): add notebook migration
orchestration service (#5262)
0467c76529 is described below
commit 0467c76529981b3951bceb33eedf6bcc776f16a3
Author: Ryan Zhang <[email protected]>
AuthorDate: Mon Jul 20 16:11:48 2026 -0700
feat(python-notebook-migration): add notebook migration orchestration
service (#5262)
### What changes were proposed in this PR?
Introduces `NotebookMigrationService`, the frontend orchestration
service that sits between the migration-tool UI and the lower layers:
the LLM client (`migration-tool-llm-client`) and the backend
notebook-migration microservice
(`migration-tool-backend-notebook-migration-service`).
**`notebook-migration.service.ts`**
- `getAvailableModels()` — `GET /api/models` against the existing
LiteLLM proxy, returns the model dropdown options.
- `sendToAIGenerateWorkflow(notebook, modelType)` — drives the full
`NotebookMigrationLLM` lifecycle (initialize → verify connection →
convert → close in `finally`) and returns `{ workflowContent,
mappingContent }`.
- `sendNotebookToJupyter(notebookData)` — `POST
/api/notebook-migration/set-notebook`; surfaces a `NotificationService`
toast on success and failure; returns `1` / `0`.
- `getJupyterURL()`, `getJupyterIframeURL()` — calls the matching
microservice endpoints to retrieve URLs to embed.
- `storeNotebookAndMapping(wid, vid, mappingContent, notebookContent)` —
`POST /api/notebook-migration/store-notebook-and-mapping`; returns the
`HttpClient` observable directly so callers can compose with
`switchMap`.
- Mapping cache — small in-memory dictionary `{ [key: string]:
MappingContent }` keyed by `mapping_wid_<workflowId>`, with
`hasMapping`, `getMapping`, `setMapping`, `deleteMapping`.
**`notebook-migration.service.spec.ts`**
- `getAvailableModels`: maps the LiteLLM `data[].id` array correctly;
falls back to an empty array on HTTP error.
- `sendNotebookToJupyter`: success → returns `1`; error → returns `0`
and toasts.
- `getJupyterURL` / `getJupyterIframeURL`: success → returns the URL;
non-OK response or thrown error → returns `null`.
- Mapping cache: `setMapping` then `getMapping` round-trips;
`deleteMapping` removes the entry.
- `storeNotebookAndMapping`: makes the expected `POST` to the
persistence endpoint.
### Any related issues, documentation, discussions?
Closes #5261
Parent issue #4301
### How was this PR tested?
The new `notebook-migration.service.spec.ts` adds
`HttpClientTestingModule`-driven test cases
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 4.7)
---
.../notebook-migration/migration-llm.spec.ts | 37 +--
.../service/notebook-migration/migration-llm.ts | 25 +-
.../notebook-migration.service.spec.ts | 327 +++++++++++++++++++++
.../notebook-migration.service.ts | 227 ++++++++++++++
4 files changed, 584 insertions(+), 32 deletions(-)
diff --git
a/frontend/src/app/workspace/service/notebook-migration/migration-llm.spec.ts
b/frontend/src/app/workspace/service/notebook-migration/migration-llm.spec.ts
index 58c17cdfc3..b68a6d2d96 100644
---
a/frontend/src/app/workspace/service/notebook-migration/migration-llm.spec.ts
+++
b/frontend/src/app/workspace/service/notebook-migration/migration-llm.spec.ts
@@ -20,21 +20,15 @@
import { NotebookMigrationLLM, Notebook } from "./migration-llm";
import { GuiConfigService } from "../../../common/service/gui-config.service";
import { WorkflowUtilService } from
"../workflow-graph/util/workflow-util.service";
-import { generateText } from "ai";
-import type { Mock } from "vitest";
-
-// The LLM transport and OpenAI client are mocked so the tests exercise only
the
-// deterministic transformation (parsing, operator/edge construction,
cell<->operator mapping).
-vi.mock("ai", () => ({ generateText: vi.fn() }));
-vi.mock("@ai-sdk/openai", () => ({
- createOpenAI: vi.fn(() => ({ chat: vi.fn(() => ({})) })),
-}));
-
-const mockGenerateText = generateText as unknown as Mock;
describe("NotebookMigrationLLM", () => {
let opIdCounter = 0;
let stubUtil: WorkflowUtilService;
+ // Stub the model transport at the class seam (callModel) rather than
mocking the
+ // "ai" module. Module mocks are unreliable in the Angular unit-test builder
when
+ // "ai" is also loaded by a sibling spec, which silently hangs these tests
on real
+ // network calls.
+ let callModelSpy: ReturnType<typeof vi.spyOn>;
// Build a fresh, initialized session with stubbed dependencies. The stubbed
// getNewOperatorPredicate hands out deterministic ids (PythonUDFV2-0, -1,
...).
@@ -75,12 +69,17 @@ describe("NotebookMigrationLLM", () => {
// Queue the two responses convertNotebookToWorkflow consumes, in order.
function mockResponses(workflowResponse: string, mappingResponse: string) {
- mockGenerateText.mockResolvedValueOnce({ text: workflowResponse
}).mockResolvedValueOnce({ text: mappingResponse });
+ callModelSpy.mockResolvedValueOnce({ text: workflowResponse
}).mockResolvedValueOnce({ text: mappingResponse });
}
beforeEach(() => {
opIdCounter = 0;
- mockGenerateText.mockReset();
+ // Default: resolve empty so an unarmed call never reaches the real
transport.
+ callModelSpy = vi.spyOn(NotebookMigrationLLM.prototype as any,
"callModel").mockResolvedValue({ text: "" });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
});
describe("convertNotebookToWorkflow", () => {
@@ -218,7 +217,7 @@ describe("NotebookMigrationLLM", () => {
await
expect(makeLLM().convertNotebookToWorkflow(notebook)).rejects.toThrow(/metadata\.uuid/);
// It fails before prompting, so the LLM is never called.
- expect(mockGenerateText).not.toHaveBeenCalled();
+ expect(callModelSpy).not.toHaveBeenCalled();
});
it("joins array-form cell source (nbformat lines) without inserting
commas", async () => {
@@ -238,8 +237,9 @@ describe("NotebookMigrationLLM", () => {
await makeLLM().convertNotebookToWorkflow(notebook);
- const allPromptContent = mockGenerateText.mock.calls
- .flatMap(call => call[0].messages.map((m: any) => m.content))
+ // callModel's first argument is the messages array.
+ const allPromptContent = callModelSpy.mock.calls
+ .flatMap((call: any[]) => (call[0] as any[]).map((m: any) =>
m.content))
.join("\n");
expect(allPromptContent).toContain("import pandas as pd\nx = 1\n");
expect(allPromptContent).not.toContain("import pandas as pd\n,");
@@ -262,8 +262,9 @@ describe("NotebookMigrationLLM", () => {
);
await llm.convertNotebookToWorkflow({ cells: [codeCell("BBB", "b = 2")]
});
- // The 3rd generateText call is the workflow prompt of the second
conversion.
- const secondConversionMessages =
mockGenerateText.mock.calls[2][0].messages.map((m: any) =>
m.content).join("\n");
+ // The 3rd callModel call is the workflow prompt of the second
conversion;
+ // its first argument is the messages array.
+ const secondConversionMessages = (callModelSpy.mock.calls[2][0] as
any[]).map((m: any) => m.content).join("\n");
expect(secondConversionMessages).toContain("# START BBB");
expect(secondConversionMessages).not.toContain("AAA");
diff --git
a/frontend/src/app/workspace/service/notebook-migration/migration-llm.ts
b/frontend/src/app/workspace/service/notebook-migration/migration-llm.ts
index 2922c3ee0e..6a1ba8b489 100644
--- a/frontend/src/app/workspace/service/notebook-migration/migration-llm.ts
+++ b/frontend/src/app/workspace/service/notebook-migration/migration-llm.ts
@@ -175,16 +175,7 @@ export class NotebookMigrationLLM {
}
try {
- await generateText({
- model: this.model,
- messages: [
- {
- role: "user",
- content: "ping",
- },
- ],
- maxOutputTokens: 10,
- });
+ await this.callModel([{ role: "user", content: "ping" }], 10);
return true;
} catch (err) {
@@ -193,6 +184,15 @@ export class NotebookMigrationLLM {
}
}
+ // Seam over the `ai` transport. Specs stub this by spying the method,
instead of
+ // mocking the "ai" module — module mocks are unreliable in the Angular
unit-test
+ // builder when "ai" is also loaded by a sibling spec (e.g. via
+ // NotebookMigrationService), which silently breaks the mock and hangs these
+ // tests on a real network call.
+ protected callModel(messages: ModelMessage[], maxOutputTokens?: number):
Promise<{ text: string }> {
+ return generateText({ model: this.model, messages, maxOutputTokens });
+ }
+
/**
* Send a prompt and receive a response.
* All prior documentation and conversation is preserved.
@@ -207,10 +207,7 @@ export class NotebookMigrationLLM {
content: prompt,
});
- const result = await generateText({
- model: this.model,
- messages: this.messages,
- });
+ const result = await this.callModel(this.messages);
this.messages.push({
role: "assistant",
diff --git
a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts
b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts
new file mode 100644
index 0000000000..6de43b4146
--- /dev/null
+++
b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts
@@ -0,0 +1,327 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { TestBed } from "@angular/core/testing";
+import { NotebookMigrationService } from "./notebook-migration.service";
+import { HttpClient } from "@angular/common/http";
+import { HttpClientTestingModule, HttpTestingController } from
"@angular/common/http/testing";
+import { NotificationService } from
"src/app/common/service/notification/notification.service";
+import { GuiConfigService } from "src/app/common/service/gui-config.service";
+import { WorkflowUtilService } from
"../workflow-graph/util/workflow-util.service";
+import { firstValueFrom, throwError } from "rxjs";
+
+describe("NotebookMigrationService", () => {
+ let service: NotebookMigrationService;
+ let httpMock: HttpTestingController;
+ let mockNotificationService: { success: ReturnType<typeof vi.fn>; error:
ReturnType<typeof vi.fn> };
+ // Mutable so individual describe blocks can flip the flag mid-spec by
+ // reassigning `mockGuiConfigService.env.pythonNotebookMigrationEnabled`.
+ // The service stores a reference to this object, so mutations are observed
+ // on the next read of `this.enabled`.
+ let mockGuiConfigService: { env: { pythonNotebookMigrationEnabled: boolean }
};
+
+ beforeEach(() => {
+ mockNotificationService = {
+ success: vi.fn(),
+ error: vi.fn(),
+ };
+ mockGuiConfigService = { env: { pythonNotebookMigrationEnabled: true } };
+
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ providers: [
+ NotebookMigrationService,
+ { provide: NotificationService, useValue: mockNotificationService },
+ { provide: GuiConfigService, useValue: mockGuiConfigService },
+ // Stub so the real WorkflowUtilService (and its
OperatorMetadataService,
+ // which fires GET /api/resources/operator-metadata on construction) is
+ // never built. The service only passes it to NotebookMigrationLLM,
which
+ // no test exercises.
+ { provide: WorkflowUtilService, useValue: {} },
+ ],
+ });
+
+ service = TestBed.inject(NotebookMigrationService);
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ vi.restoreAllMocks();
+ });
+
+ // getAvailableModels
+ it("should fetch and map available models", async () => {
+ const mockResponse = {
+ data: [
+ { id: "gpt-4", object: "", created: 0, owned_by: "" },
+ { id: "gpt-3.5", object: "", created: 0, owned_by: "" },
+ ],
+ object: "",
+ };
+
+ const promise = firstValueFrom(service.getAvailableModels());
+
+ const req = httpMock.expectOne(req => req.url.includes("/models"));
+ expect(req.request.method).toBe("GET");
+ req.flush(mockResponse);
+
+ const models = await promise;
+ expect(models.length).toBe(2);
+ expect(models[0].name).toBe("gpt-4");
+ });
+
+ it("should return empty array on getAvailableModels error", async () => {
+ const promise = firstValueFrom(service.getAvailableModels());
+
+ const req = httpMock.expectOne(req => req.url.includes("/models"));
+ req.error(new ErrorEvent("Network error"));
+
+ expect(await promise).toEqual([]);
+ });
+
+ // sendNotebookToJupyter
+ it("should send notebook successfully and return 1", async () => {
+ const mockNotebook: any = { cells: [] };
+
+ const promise = service.sendNotebookToJupyter(mockNotebook);
+
+ const req = httpMock.expectOne(req =>
req.url.includes("/notebook-migration/set-notebook"));
+
+ expect(req.request.method).toBe("POST");
+
+ req.flush({ success: true });
+
+ const result = await promise;
+
+ expect(result).toBe(1);
+ expect(mockNotificationService.success).toHaveBeenCalled();
+ });
+
+ it("should handle error when sending notebook and return 0", async () => {
+ const mockNotebook: any = { cells: [] };
+
+ const promise = service.sendNotebookToJupyter(mockNotebook);
+
+ const req = httpMock.expectOne(req =>
req.url.includes("/notebook-migration/set-notebook"));
+
+ req.error(new ErrorEvent("Server error"));
+
+ const result = await promise;
+
+ expect(result).toBe(0);
+ expect(mockNotificationService.error).toHaveBeenCalled();
+ });
+
+ it("includes the Error message in the failure toast when an Error is
thrown", async () => {
+ // HttpTestingController's req.error yields an HttpErrorResponse (not an
Error
+ // instance), so spy on http.post directly to exercise the `error
instanceof
+ // Error` branch. No request reaches the testing backend, so verify()
stays happy.
+ vi.spyOn(TestBed.inject(HttpClient), "post").mockReturnValue(throwError(()
=> new Error("network down")));
+
+ const result = await service.sendNotebookToJupyter({ cells: [] } as any);
+
+ expect(result).toBe(0);
+
expect(mockNotificationService.error).toHaveBeenCalledWith(expect.stringContaining("network
down"));
+ });
+
+ // jupyter URL methods (HttpClient so the JwtModule interceptor attaches the
auth token)
+ it("should return Jupyter URL when the request succeeds", async () => {
+ const promise = service.getJupyterURL();
+
+ const req = httpMock.expectOne(req =>
req.url.includes("/notebook-migration/get-jupyter-url"));
+ expect(req.request.method).toBe("GET");
+ req.flush({ success: true, url: "http://jupyter" });
+
+ expect(await promise).toBe("http://jupyter");
+ });
+
+ it("should return null when the Jupyter URL request fails", async () => {
+ const promise = service.getJupyterURL();
+
+ const req = httpMock.expectOne(req =>
req.url.includes("/notebook-migration/get-jupyter-url"));
+ req.flush({ success: false }, { status: 500, statusText: "Server Error" });
+
+ expect(await promise).toBeNull();
+ });
+
+ it("should return null when the Jupyter URL response is 200 but
unsuccessful", async () => {
+ const promise = service.getJupyterURL();
+
+ const req = httpMock.expectOne(req =>
req.url.includes("/notebook-migration/get-jupyter-url"));
+ req.flush({ success: false });
+
+ expect(await promise).toBeNull();
+ });
+
+ it("should return iframe URL when the request succeeds", async () => {
+ const promise = service.getJupyterIframeURL();
+
+ const req = httpMock.expectOne(req =>
req.url.includes("/notebook-migration/get-jupyter-iframe-url"));
+ expect(req.request.method).toBe("GET");
+ req.flush({ success: true, url: "http://iframe" });
+
+ expect(await promise).toBe("http://iframe");
+ });
+
+ it("should return null when the iframe URL request fails", async () => {
+ const promise = service.getJupyterIframeURL();
+
+ const req = httpMock.expectOne(req =>
req.url.includes("/notebook-migration/get-jupyter-iframe-url"));
+ req.flush({ success: false }, { status: 500, statusText: "Server Error" });
+
+ expect(await promise).toBeNull();
+ });
+
+ it("should return null when the iframe URL response is 200 but
unsuccessful", async () => {
+ const promise = service.getJupyterIframeURL();
+
+ const req = httpMock.expectOne(req =>
req.url.includes("/notebook-migration/get-jupyter-iframe-url"));
+ req.flush({ success: false });
+
+ expect(await promise).toBeNull();
+ });
+
+ // mapping logic
+ it("should set and get mapping", () => {
+ const mockMapping: any = {
+ cell_to_operator: { a: 1 },
+ operator_to_cell: { b: 2 },
+ };
+
+ service.setMapping("test", mockMapping);
+
+ expect(service.hasMapping("test")).toBe(true);
+ expect(service.getMapping("test")).toEqual(mockMapping);
+ });
+
+ it("should delete mapping", () => {
+ service.setMapping("test", { cell_to_operator: {}, operator_to_cell: {} });
+
+ service.deleteMapping("test");
+
+ expect(service.hasMapping("test")).toBe(false);
+ });
+
+ // storeNotebookAndMapping
+ it("should call storeNotebookAndMapping API", () => {
+ service.storeNotebookAndMapping(1, 1, {}, {}).subscribe();
+
+ const req = httpMock.expectOne(req =>
req.url.includes("/notebook-migration/store-notebook-and-mapping"));
+
+ expect(req.request.method).toBe("POST");
+ req.flush({ success: true, message: "stored" });
+ });
+
+ // sendToAIGenerateWorkflow (enabled) — drives the NotebookMigrationLLM
lifecycle.
+ // The service builds the client through its createMigrationLLM() seam, so
stub
+ // that with a plain fake. This keeps the real NotebookMigrationLLM (and its
"ai"
+ // transport) out of this spec's module graph, avoiding collisions with the
"ai"
+ // mock in migration-llm.spec.ts.
+ describe("sendToAIGenerateWorkflow (enabled)", () => {
+ let fakeLLM: {
+ initialize: ReturnType<typeof vi.fn>;
+ verifyConnection: ReturnType<typeof vi.fn>;
+ convertNotebookToWorkflow: ReturnType<typeof vi.fn>;
+ close: ReturnType<typeof vi.fn>;
+ };
+
+ beforeEach(() => {
+ fakeLLM = {
+ initialize: vi.fn(),
+ verifyConnection: vi.fn().mockResolvedValue(true),
+ convertNotebookToWorkflow: vi.fn(),
+ close: vi.fn(),
+ };
+ vi.spyOn(service as any, "createMigrationLLM").mockReturnValue(fakeLLM);
+ });
+
+ it("returns the parsed workflow and mapping, and closes the client", async
() => {
+ fakeLLM.convertNotebookToWorkflow.mockResolvedValue(
+ JSON.stringify({ workflowJSON: { ops: 1 }, workflowNotebookMapping: {
m: 2 } })
+ );
+
+ const result = await service.sendToAIGenerateWorkflow({ cells: [] } as
any, "gpt-4");
+
+ expect(result).toEqual({ workflowContent: { ops: 1 }, mappingContent: {
m: 2 } });
+ expect(fakeLLM.initialize).toHaveBeenCalledWith("gpt-4");
+ expect(fakeLLM.close).toHaveBeenCalled();
+ });
+
+ it("rejects when the connection cannot be verified, and still closes the
client", async () => {
+ fakeLLM.verifyConnection.mockResolvedValue(false);
+
+ await expect(service.sendToAIGenerateWorkflow({ cells: [] } as any,
"gpt-4")).rejects.toThrow(/authenticate/i);
+ // verifyConnection runs inside the outer try, so the finally still
closes the client.
+ expect(fakeLLM.close).toHaveBeenCalled();
+ });
+
+ it("rethrows conversion errors and still closes the client", async () => {
+ fakeLLM.convertNotebookToWorkflow.mockRejectedValue(new
Error("conversion boom"));
+
+ await expect(service.sendToAIGenerateWorkflow({ cells: [] } as any,
"gpt-4")).rejects.toThrow(/conversion boom/);
+ expect(fakeLLM.close).toHaveBeenCalled();
+ });
+ });
+
+ // Feature flag gate (defence in depth). With the flag off, every public
+ // method must short-circuit — no HTTP traffic, no fetch, no LLM lifecycle,
+ // no notifications.
+ describe("when the feature flag is disabled", () => {
+ beforeEach(() => {
+ mockGuiConfigService.env.pythonNotebookMigrationEnabled = false;
+ });
+
+ it("getAvailableModels emits an empty array and makes no HTTP call", async
() => {
+ const models = await firstValueFrom(service.getAvailableModels());
+ expect(models).toEqual([]);
+ httpMock.expectNone(req => req.url.includes("/models"));
+ });
+
+ it("sendToAIGenerateWorkflow rejects with a disabled-feature error", async
() => {
+ await expect(service.sendToAIGenerateWorkflow({ cells: [] } as any,
"gpt-4")).rejects.toThrow(/disabled/i);
+ });
+
+ it("sendNotebookToJupyter returns 0 with no HTTP call or notification",
async () => {
+ const result = await service.sendNotebookToJupyter({ cells: [] } as any);
+ expect(result).toBe(0);
+ expect(mockNotificationService.success).not.toHaveBeenCalled();
+ expect(mockNotificationService.error).not.toHaveBeenCalled();
+ httpMock.expectNone(req =>
req.url.includes("/notebook-migration/set-notebook"));
+ });
+
+ it("getJupyterURL returns null without making an HTTP call", async () => {
+ const result = await service.getJupyterURL();
+ expect(result).toBeNull();
+ httpMock.expectNone(req =>
req.url.includes("/notebook-migration/get-jupyter-url"));
+ });
+
+ it("getJupyterIframeURL returns null without making an HTTP call", async
() => {
+ const result = await service.getJupyterIframeURL();
+ expect(result).toBeNull();
+ httpMock.expectNone(req =>
req.url.includes("/notebook-migration/get-jupyter-iframe-url"));
+ });
+
+ it("storeNotebookAndMapping emits a disabled result without making an HTTP
call", async () => {
+ const result = await firstValueFrom(service.storeNotebookAndMapping(1,
1, {}, {}));
+ expect(result.success).toBe(false);
+ httpMock.expectNone(req =>
req.url.includes("/notebook-migration/store-notebook-and-mapping"));
+ });
+ });
+});
diff --git
a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts
b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts
new file mode 100644
index 0000000000..7bfd3ef541
--- /dev/null
+++
b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts
@@ -0,0 +1,227 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Injectable } from "@angular/core";
+import { AppSettings } from "../../../common/app-setting";
+import { Notebook, NotebookMigrationLLM } from "./migration-llm";
+import { HttpClient, HttpHeaders } from "@angular/common/http";
+import { NotificationService } from
"src/app/common/service/notification/notification.service";
+import { GuiConfigService } from "../../../common/service/gui-config.service";
+import { WorkflowUtilService } from
"../workflow-graph/util/workflow-util.service";
+import { catchError, firstValueFrom, map, Observable, of } from "rxjs";
+
+interface LiteLLMModel {
+ id: string;
+ object: string;
+ created: number;
+ owned_by: string;
+}
+
+interface LiteLLMModelsResponse {
+ data: LiteLLMModel[];
+ object: string;
+}
+
+interface MappingContent {
+ cell_to_operator: Record<string, string[]>;
+ operator_to_cell: Record<string, string[]>;
+}
+
+interface StoreNotebookResponse {
+ success: boolean;
+ message: string;
+}
+
+@Injectable({
+ providedIn: "root",
+})
+export class NotebookMigrationService {
+ private mapping: { [key: string]: MappingContent } = {};
+
+ constructor(
+ private http: HttpClient,
+ private notificationService: NotificationService,
+ private config: GuiConfigService,
+ private workflowUtilService: WorkflowUtilService
+ ) {}
+
+ private get enabled(): boolean {
+ return this.config.env.pythonNotebookMigrationEnabled;
+ }
+
+ public getAvailableModels(): Observable<{ name: string }[]> {
+ if (!this.enabled) return of([]);
+ return
this.http.get<LiteLLMModelsResponse>(`${AppSettings.getApiEndpoint()}/models`).pipe(
+ map(response =>
+ response.data.map(model => ({
+ name: model.id,
+ }))
+ ),
+ catchError((err: unknown) => {
+ console.error("Failed to fetch models", err);
+ return of([]);
+ })
+ );
+ }
+
+ public async sendToAIGenerateWorkflow(notebookContent: Notebook, modelType:
string) {
+ if (!this.enabled) throw new Error("Notebook migration feature is
disabled");
+ const migrationLLM = this.createMigrationLLM();
+ // initialize() defaults to the user's Texera JWT via
AuthService.getAccessToken().
+ // The outer try/finally guarantees close() runs for the whole lifecycle,
+ // including a verifyConnection failure.
+ try {
+ migrationLLM.initialize(modelType);
+
+ const isValid = await migrationLLM.verifyConnection();
+ if (!isValid) {
+ throw new Error("Unable to authenticate with or reach the LLM
backend");
+ }
+
+ try {
+ const result = await
migrationLLM.convertNotebookToWorkflow(notebookContent);
+ const parsedResult = JSON.parse(result);
+ const workflowContent = parsedResult.workflowJSON;
+ const mappingContent = parsedResult.workflowNotebookMapping;
+ return { workflowContent, mappingContent };
+ } catch (error) {
+ console.error("Error converting notebook:", error);
+ throw error;
+ }
+ } finally {
+ migrationLLM.close();
+ }
+ }
+
+ // Factory seam for the LLM client. Extracted so specs can override it to
supply
+ // a fake, keeping the real NotebookMigrationLLM (and its `ai` transport)
out of
+ // the test module graph. A new instance is created per conversion.
+ protected createMigrationLLM(): NotebookMigrationLLM {
+ return new NotebookMigrationLLM(this.config, this.workflowUtilService);
+ }
+
+ public async sendNotebookToJupyter(notebookData: Notebook) {
+ if (!this.enabled) return 0;
+ const jupyterAPIUrl =
`${AppSettings.getApiEndpoint()}/notebook-migration/set-notebook`;
+
+ const requestBody = {
+ // Fixed filename is intentional for the v1 per-user-pod design: each
user runs
+ // their own notebook-migration-service and Jupyter, so a single
notebook.ipynb
+ // never collides. A shared multi-user (global) service would need
per-user or
+ // per-workflow keying here and for the backend's process-global
jupyterIframeURL.
+ notebookName: "notebook.ipynb",
+ notebookData: notebookData,
+ };
+
+ const headers = new HttpHeaders({
+ "Content-Type": "application/json",
+ });
+
+ try {
+ await firstValueFrom(this.http.post(jupyterAPIUrl, requestBody, {
headers }));
+ this.notificationService.success("Notebook successfully sent to
Jupyter");
+ return 1;
+ } catch (error) {
+ console.error("Error sending notebook to pod: ", error);
+ const message = error instanceof Error ? error.message : String(error);
+ this.notificationService.error("Error sending notebook to Jupyter: " +
message);
+ return 0;
+ }
+ }
+
+ public async getJupyterURL(): Promise<string | null> {
+ if (!this.enabled) return null;
+ try {
+ const data = await firstValueFrom(
+ this.http.get<{ success: boolean; url?: string }>(
+ `${AppSettings.getApiEndpoint()}/notebook-migration/get-jupyter-url`
+ )
+ );
+
+ if (!data.success || !data.url) {
+ console.error("Jupyter server unavailable");
+ return null;
+ }
+
+ return data.url;
+ } catch (err) {
+ console.error("Error fetching Jupyter URL:", err);
+ return null;
+ }
+ }
+
+ public async getJupyterIframeURL(): Promise<string | null> {
+ if (!this.enabled) return null;
+ try {
+ const data = await firstValueFrom(
+ this.http.get<{ success: boolean; url?: string }>(
+
`${AppSettings.getApiEndpoint()}/notebook-migration/get-jupyter-iframe-url`
+ )
+ );
+
+ if (!data.success || !data.url) {
+ console.error("Jupyter server unavailable");
+ return null;
+ }
+
+ return data.url;
+ } catch (err) {
+ console.error("Error fetching Jupyter iframe URL:", err);
+ return null;
+ }
+ }
+
+ public storeNotebookAndMapping(
+ wid: number | undefined,
+ vid: number = 1,
+ mappingContent: any,
+ notebookContent: any
+ ): Observable<StoreNotebookResponse> {
+ if (!this.enabled) {
+ return of({ success: false, message: "Notebook migration feature is
disabled" });
+ }
+ const dbAPIUrl =
`${AppSettings.getApiEndpoint()}/notebook-migration/store-notebook-and-mapping`;
+ const headers = new HttpHeaders({ "Content-Type": "application/json" });
+
+ const payload = {
+ wid,
+ vid,
+ mapping: mappingContent,
+ notebook: notebookContent,
+ };
+
+ return this.http.post<StoreNotebookResponse>(dbAPIUrl, payload, { headers
});
+ }
+
+ public hasMapping(id: string): boolean {
+ return id in this.mapping;
+ }
+
+ public getMapping(id: string): MappingContent | undefined {
+ return this.mapping[id];
+ }
+
+ public setMapping(id: string, value: MappingContent): void {
+ this.mapping[id] = value;
+ }
+
+ public deleteMapping(id: string): void {
+ delete this.mapping[id];
+ }
+}