This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-5262-bde8e88971c33b3e3948f96ef8d86e68b7713962
in repository https://gitbox.apache.org/repos/asf/texera.git

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];
+  }
+}

Reply via email to