Ma77Ball commented on code in PR #8637:
URL: https://github.com/apache/texera/pull/8637#discussion_r4194281100
##########
frontend/src/app/workspace/component/result-panel/result-panel.component.ts:
##########
@@ -314,6 +343,15 @@ export class ResultPanelComponent implements OnInit,
OnDestroy {
component: ErrorFrameComponent,
componentInputs: { operatorId },
});
+ // The AI tab only offers the analysis: the model is called when the user
asks for it,
+ // never on render, because this method runs again on every re-render of
the panel.
+ // The LiteLLM proxy rejects the call outright when the copilot feature is
off.
+ if (this.config.env.copilotEnabled) {
+ this.frameComponentConfigs.set("AI Fix", {
+ component: AiFixFrameComponent,
+ componentInputs: { operatorId },
+ });
Review Comment:
This AI-Fix registration in `displayError` is either dead or harmful,
because the real registration happens in the `rerenderResultPanel`
console-error block. I recommend removing this duplicate registration.
```suggestion
```
##########
frontend/src/app/workspace/component/result-panel/ai-fix/ai-workflow-fixer.service.ts:
##########
@@ -0,0 +1,254 @@
+/**
+ * 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 { BehaviorSubject, Observable } from "rxjs";
+import { createOpenAI } from "@ai-sdk/openai";
+import { generateText, type ModelMessage } from "ai";
+import { AppSettings } from "../../../../common/app-setting";
+import { AuthService } from "../../../../common/service/user/auth.service";
+import { WorkflowActionService } from
"../../../service/workflow-graph/model/workflow-action.service";
+import { ExecuteWorkflowService } from
"../../../service/execute-workflow/execute-workflow.service";
+import { WarehouseService } from
"../../../../common/service/warehouse/warehouse.service";
+import { GuiConfigService } from
"../../../../common/service/gui-config.service";
+import { PortSchema } from "../../../types/workflow-compiling.interface";
+import { buildFixPrompt } from "./ai-fix-prompt";
+
+export type FixErrorType = "missing_column" | "type_error" | "null_error" |
"model_not_found" | "unsupported";
+export type FixConfidence = "high" | "medium" | "low";
+
+export interface SuggestedFix {
+ type: "code_change" | "property_change";
+ original: string;
+ suggested: string;
+ explanation: string;
+ // Rendered as the confidence badge; `fieldName` names the patched property
+ // (property_change only) so applyFix knows which field to write.
+ confidence: FixConfidence;
+ fieldName?: string;
+}
+
+export interface FixState {
+ operatorId: string;
+ errorMessage: string;
+ errorType: FixErrorType;
+ originalCode?: string;
+ originalProperties?: object;
+ suggestedFix?: SuggestedFix;
+ // `applied_without_run`: the fix was written but the deployment refused to
start a
+ // run (a warehouse is required and none is selected), so the panel must not
claim one.
+ status: "idle" | "analyzing" | "ready" | "applying" | "applied" |
"applied_without_run" | "error";
+}
+
+// Model id as LiteLLM exposes it (bin/single-node/litellm-config.yaml), not
the provider's
+// id. A deployment whose proxy publishes a different alias has to change
this: there is no
+// GuiConfig field for it today, and adding one is a backend change.
+export const AI_FIXER_MODEL = "claude-haiku-4.5";
+export const AI_FIXER_TIMEOUT_MS = 120_000;
+export const UNSUPPORTED_MESSAGE = "This error type is not yet supported for
automatic fixing.";
+
+export const IDLE_STATE: FixState = { operatorId: "", errorMessage: "",
errorType: "unsupported", status: "idle" };
+
+// Each pattern matches a line anywhere inside a traceback, not the whole
message.
+const PATTERNS: ReadonlyArray<[RegExp, FixErrorType]> = [
+ [/KeyError:\s*['"][^'"]+['"]/, "missing_column"],
+ [/TypeError:\s*unsupported operand/i, "type_error"],
+ [/ValueError:[^\n]*\bNaN\b|NullPointerException/i, "null_error"],
+ // `404` only counts beside a model reference. On its own it also matches a
line number
+ // in a traceback, an HTTP status from unrelated user code, or a literal in
the data --
+ // all of which would be sent off asking for a model-config fix that makes
no sense.
+ [/ModelNotFound|\bmodel[^\n]*\b404\b|\b404\b[^\n]*\bmodel/i,
"model_not_found"],
+];
+
+export function classifyError(errorMessage: string): FixErrorType {
+ return PATTERNS.find(([pattern]) => pattern.test(errorMessage))?.[1] ??
"unsupported";
+}
+
+/**
+ * Holds the AI-fix loop for one failed operator: classify -> ask the LLM ->
apply -> re-run.
+ *
+ * The state lives here rather than in the frame component because
+ * ResultPanelComponent.clearResultPanel() destroys and rebuilds the frames on
+ * every re-render, which would otherwise discard an in-flight suggestion.
+ */
+@Injectable({ providedIn: "root" })
+export class AiWorkflowFixerService {
+ private readonly stateSubject = new BehaviorSubject<FixState>(IDLE_STATE);
+ private model: any;
+
+ constructor(
+ private workflowActionService: WorkflowActionService,
+ private executeWorkflowService: ExecuteWorkflowService,
+ private warehouseService: WarehouseService,
+ private config: GuiConfigService
+ ) {}
+
+ public getState$(): Observable<FixState> {
+ return this.stateSubject.asObservable();
+ }
+
+ public async analyzeError(
+ operatorId: string,
+ errorMessage: string,
+ schema: PortSchema | undefined,
+ code: string | undefined,
+ properties: Readonly<Record<string, unknown>>
+ ): Promise<void> {
+ const errorType = classifyError(errorMessage);
+ const base: FixState = {
+ operatorId,
+ errorMessage,
+ errorType,
+ originalCode: code,
+ originalProperties: properties,
+ status: "analyzing",
+ };
+
+ // Out of scope: report it without spending a model call.
+ if (errorType === "unsupported") {
+ this.stateSubject.next({ ...base, status: "ready" });
+ return;
+ }
+
+ this.stateSubject.next(base);
+ try {
+ const { text } = await
this.callModelWithTimeout(buildFixPrompt(errorMessage, code, schema,
properties));
+ this.stateSubject.next({ ...base, status: "ready", suggestedFix:
this.toFix(text, properties) });
+ } catch (err) {
+ console.error("AI workflow fixer: analysis failed", err);
+ this.stateSubject.next({ ...base, status: "error" });
+ }
+ }
+
+ /**
+ * Applies the suggestion to the live operator and re-runs the workflow.
+ * Properties are re-read from the graph so a concurrent edit is not
clobbered.
+ */
+ public applyFix(): void {
+ const current = this.stateSubject.getValue();
+ const fix = current.suggestedFix;
+ if (!fix || current.status !== "ready") {
+ return;
+ }
+ this.stateSubject.next({ ...current, status: "applying" });
+ try {
+ const properties: Record<string, unknown> = {
+
...this.workflowActionService.getTexeraGraph().getOperator(current.operatorId).operatorProperties,
+ };
+ if (fix.type === "code_change") {
+ const code = String(properties.code ?? "");
+ if (!code.includes(fix.original)) {
+ throw new Error("the code changed since the suggestion was
generated");
+ }
+ properties.code = code.replace(fix.original, fix.suggested);
+ } else {
+ const field = String(fix.fieldName);
+ // Same staleness guard as the code branch: re-reading the properties
keeps the
+ // unrelated fields, but the field being replaced can itself have
moved on since
+ // the suggestion was generated, and overwriting it would discard that
newer value.
+ if (String(properties[field] ?? "") !== fix.original) {
+ throw new Error(`${field} changed since the suggestion was
generated`);
+ }
+ properties[field] = fix.suggested;
+ }
+ this.workflowActionService.setOperatorProperty(current.operatorId,
properties);
+ if (!this.canStartRun()) {
+ // ExecuteWorkflowService refuses the run and shows its own toast;
reporting
+ // "re-running" here would contradict it.
+ this.stateSubject.next({ ...current, status: "applied_without_run" });
+ return;
+ }
+ this.stateSubject.next({ ...current, status: "applied" });
+ // Same empty execution name the operator menu uses for an ad-hoc run.
+ this.executeWorkflowService.executeWorkflow("");
+ } catch (err) {
+ console.error("AI workflow fixer: apply failed", err);
+ this.stateSubject.next({ ...current, status: "error" });
+ }
+ }
+
+ /**
+ * Mirrors ExecuteWorkflowService's own entry guard: while the deployment
requires a
+ * warehouse and none is selected, a run request is refused before it starts.
+ */
+ private canStartRun(): boolean {
+ return !this.config.env.warehouseEnabled ||
this.warehouseService.getSelectedWarehouseIdValue() !== undefined;
+ }
+
+ public discardFix(): void {
+ this.stateSubject.next(IDLE_STATE);
+ }
+
+ // Seam over the `ai` transport: specs spy this instead of mocking the "ai"
module,
+ // which leaks across specs sharing the import and hangs on a real network
call.
+ protected callModel(messages: ModelMessage[], abortSignal?: AbortSignal):
Promise<{ text: string }> {
+ if (!this.model) {
+ // The /api/chat/* LiteLLM proxy authenticates with the Texera JWT and
swaps in
+ // the master key upstream, so the user's access token is the only
credential sent.
+ this.model = createOpenAI({
+ baseURL: new URL(`${AppSettings.getApiEndpoint()}`,
document.baseURI).toString(),
+ apiKey: AuthService.getAccessToken() ?? "",
+ }).chat(AI_FIXER_MODEL);
+ }
+ return generateText({ model: this.model, messages, abortSignal });
Review Comment:
The provider is memoized with the access token captured on the first call,
so the JWT is frozen for the lifetime of this root singleton. Texera updates
this access token; if a fix spans long enough, it will hit this refresh and
fail.
```suggestion
// Built per call so a refreshed access token is used: caching the
provider would pin the
// JWT captured on the first Analyze, and a later call after a token
rotation would 401.
// The /api/chat/* LiteLLM proxy authenticates with the Texera JWT and
swaps in the
// master key upstream, so the user's access token is the only
credential sent.
const model = createOpenAI({
baseURL: new URL(`${AppSettings.getApiEndpoint()}`,
document.baseURI).toString(),
apiKey: AuthService.getAccessToken() ?? "",
}).chat(AI_FIXER_MODEL);
return generateText({ model, messages, abortSignal });
```
##########
frontend/src/app/workspace/component/result-panel/ai-fix/ai-workflow-fixer.service.ts:
##########
@@ -0,0 +1,254 @@
+/**
+ * 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 { BehaviorSubject, Observable } from "rxjs";
+import { createOpenAI } from "@ai-sdk/openai";
+import { generateText, type ModelMessage } from "ai";
+import { AppSettings } from "../../../../common/app-setting";
+import { AuthService } from "../../../../common/service/user/auth.service";
+import { WorkflowActionService } from
"../../../service/workflow-graph/model/workflow-action.service";
+import { ExecuteWorkflowService } from
"../../../service/execute-workflow/execute-workflow.service";
+import { WarehouseService } from
"../../../../common/service/warehouse/warehouse.service";
+import { GuiConfigService } from
"../../../../common/service/gui-config.service";
+import { PortSchema } from "../../../types/workflow-compiling.interface";
+import { buildFixPrompt } from "./ai-fix-prompt";
+
+export type FixErrorType = "missing_column" | "type_error" | "null_error" |
"model_not_found" | "unsupported";
+export type FixConfidence = "high" | "medium" | "low";
+
+export interface SuggestedFix {
+ type: "code_change" | "property_change";
+ original: string;
+ suggested: string;
+ explanation: string;
+ // Rendered as the confidence badge; `fieldName` names the patched property
+ // (property_change only) so applyFix knows which field to write.
+ confidence: FixConfidence;
+ fieldName?: string;
+}
+
+export interface FixState {
+ operatorId: string;
+ errorMessage: string;
+ errorType: FixErrorType;
+ originalCode?: string;
+ originalProperties?: object;
+ suggestedFix?: SuggestedFix;
+ // `applied_without_run`: the fix was written but the deployment refused to
start a
+ // run (a warehouse is required and none is selected), so the panel must not
claim one.
+ status: "idle" | "analyzing" | "ready" | "applying" | "applied" |
"applied_without_run" | "error";
+}
+
+// Model id as LiteLLM exposes it (bin/single-node/litellm-config.yaml), not
the provider's
+// id. A deployment whose proxy publishes a different alias has to change
this: there is no
+// GuiConfig field for it today, and adding one is a backend change.
+export const AI_FIXER_MODEL = "claude-haiku-4.5";
+export const AI_FIXER_TIMEOUT_MS = 120_000;
+export const UNSUPPORTED_MESSAGE = "This error type is not yet supported for
automatic fixing.";
+
+export const IDLE_STATE: FixState = { operatorId: "", errorMessage: "",
errorType: "unsupported", status: "idle" };
+
+// Each pattern matches a line anywhere inside a traceback, not the whole
message.
+const PATTERNS: ReadonlyArray<[RegExp, FixErrorType]> = [
+ [/KeyError:\s*['"][^'"]+['"]/, "missing_column"],
+ [/TypeError:\s*unsupported operand/i, "type_error"],
+ [/ValueError:[^\n]*\bNaN\b|NullPointerException/i, "null_error"],
+ // `404` only counts beside a model reference. On its own it also matches a
line number
+ // in a traceback, an HTTP status from unrelated user code, or a literal in
the data --
+ // all of which would be sent off asking for a model-config fix that makes
no sense.
+ [/ModelNotFound|\bmodel[^\n]*\b404\b|\b404\b[^\n]*\bmodel/i,
"model_not_found"],
+];
+
+export function classifyError(errorMessage: string): FixErrorType {
+ return PATTERNS.find(([pattern]) => pattern.test(errorMessage))?.[1] ??
"unsupported";
+}
+
+/**
+ * Holds the AI-fix loop for one failed operator: classify -> ask the LLM ->
apply -> re-run.
+ *
+ * The state lives here rather than in the frame component because
+ * ResultPanelComponent.clearResultPanel() destroys and rebuilds the frames on
+ * every re-render, which would otherwise discard an in-flight suggestion.
+ */
+@Injectable({ providedIn: "root" })
+export class AiWorkflowFixerService {
+ private readonly stateSubject = new BehaviorSubject<FixState>(IDLE_STATE);
+ private model: any;
+
+ constructor(
+ private workflowActionService: WorkflowActionService,
+ private executeWorkflowService: ExecuteWorkflowService,
+ private warehouseService: WarehouseService,
+ private config: GuiConfigService
+ ) {}
+
+ public getState$(): Observable<FixState> {
+ return this.stateSubject.asObservable();
+ }
+
+ public async analyzeError(
+ operatorId: string,
+ errorMessage: string,
+ schema: PortSchema | undefined,
+ code: string | undefined,
+ properties: Readonly<Record<string, unknown>>
+ ): Promise<void> {
+ const errorType = classifyError(errorMessage);
+ const base: FixState = {
+ operatorId,
+ errorMessage,
+ errorType,
+ originalCode: code,
+ originalProperties: properties,
+ status: "analyzing",
+ };
+
+ // Out of scope: report it without spending a model call.
+ if (errorType === "unsupported") {
+ this.stateSubject.next({ ...base, status: "ready" });
+ return;
+ }
+
+ this.stateSubject.next(base);
+ try {
+ const { text } = await
this.callModelWithTimeout(buildFixPrompt(errorMessage, code, schema,
properties));
+ this.stateSubject.next({ ...base, status: "ready", suggestedFix:
this.toFix(text, properties) });
+ } catch (err) {
+ console.error("AI workflow fixer: analysis failed", err);
+ this.stateSubject.next({ ...base, status: "error" });
+ }
+ }
+
+ /**
+ * Applies the suggestion to the live operator and re-runs the workflow.
+ * Properties are re-read from the graph so a concurrent edit is not
clobbered.
+ */
+ public applyFix(): void {
+ const current = this.stateSubject.getValue();
+ const fix = current.suggestedFix;
+ if (!fix || current.status !== "ready") {
+ return;
+ }
+ this.stateSubject.next({ ...current, status: "applying" });
+ try {
+ const properties: Record<string, unknown> = {
+
...this.workflowActionService.getTexeraGraph().getOperator(current.operatorId).operatorProperties,
+ };
+ if (fix.type === "code_change") {
+ const code = String(properties.code ?? "");
+ if (!code.includes(fix.original)) {
+ throw new Error("the code changed since the suggestion was
generated");
+ }
+ properties.code = code.replace(fix.original, fix.suggested);
Review Comment:
`String.prototype.replace` with a string argument replaces only the first
occurrence. To replace every occurrence, keep the match literal (no regex
escaping needed).
```suggestion
properties.code = code.split(fix.original).join(fix.suggested);
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]