From 9f54896545aefa3b7d8cf0e1ef428f9ac2d46e26 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Wed, 22 Jul 2026 09:59:46 +0200 Subject: [PATCH] feat(bias-impact): extend bias annotation support to containers Mirrors the existing block-side bias impact feature onto containers, reusing the same descriptor, form and error handling as the backend integration doc specifies, rather than duplicating them: - FlowContainer now carries biasAnnotations, matching FlowBlock. - ContainersService/ContainersCallService gained retrieveBiasCapabilities/retrieveBiasCapabilitiesForInstance, backed by GET/POST /containers/types/{type}/bias-capabilities. - bias-annotations and behavioral-probe-editor now accept FlowNode instead of FlowBlock only; the probe editor dispatches capability lookups to BlocksService or ContainersService based on nodeFamily, so container annotations reuse the exact same shared components and UI instead of a second, parallel implementation. - container-node gained the bias badge, the bias-annotations panel, and preserves annotations across recreateContainer() the same way generic-node already does across block regeneration. - task-execution-viewer's bias-rerun candidate list now includes containers with executable probes, not just blocks. Also fixes a real data-loss bug found while wiring this up: rete-editor's exportGraph explicitly stripped bias annotations from containers on every flow save, silently discarding anything entered through the new panel. Verified with the full suite (238/238, 6 new tests) and a live end-to-end browser check: an annotation added to a container only offered INPUT_TRANSFORMATION/OUTPUT_TRANSFORMATION (per the fake container capabilities), and survived a save + reopen of the saved flow, proving the exportGraph fix actually round-trips the data. Co-Authored-By: Claude Sonnet 5 --- src/app/models/flow.ts | 1 + .../containers/container-call.base.ts | 5 + .../containers/containers-call.fake.ts | 21 +++ .../services/containers/containers-call.ts | 53 +++++++- .../services/containers/containers.spec.ts | 19 +++ src/app/services/containers/containers.ts | 25 +++- .../behavioral-probe-editor.spec.ts | 63 ++++++++- .../behavioral-probe-editor.ts | 24 +++- .../bias-annotations/bias-annotations.ts | 4 +- .../nodes/container-node/container-node.css | 63 +++++++++ .../nodes/container-node/container-node.html | 23 ++++ .../container-node/container-node.spec.ts | 71 +++++++++- .../nodes/container-node/container-node.ts | 123 +++++++++++++++++- .../task-execution-viewer.ts | 17 ++- src/app/utilities/rete-editor.ts | 4 +- 15 files changed, 489 insertions(+), 27 deletions(-) diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index 364534b..370333d 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -149,6 +149,7 @@ export type FlowBlock = FlowNodeBase & { export type FlowContainer = FlowNodeBase & { nodeFamily: 'container'; + biasAnnotations?: BiasAnnotation[]; }; export type FlowNode = FlowBlock | FlowContainer; diff --git a/src/app/services/containers/container-call.base.ts b/src/app/services/containers/container-call.base.ts index 934211a..e03e4e5 100644 --- a/src/app/services/containers/container-call.base.ts +++ b/src/app/services/containers/container-call.base.ts @@ -1,9 +1,14 @@ import { BlockType, BlockTypeName, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow"; +import { BiasCapabilities } from "@models/bias-impact"; import { Observable } from "rxjs"; export abstract class ContainersCallServiceBase { abstract retrieveAllContainerTypes(): Observable; + abstract retrieveBiasCapabilities(containerType: string): Observable; + + abstract retrieveBiasCapabilitiesForInstance(containerType: string, container: FlowContainer): Observable; + abstract createEmptyContainer(containerType: BlockTypeName): Observable; abstract createContainer(containerId: string, configuration: any): Observable; diff --git a/src/app/services/containers/containers-call.fake.ts b/src/app/services/containers/containers-call.fake.ts index c5fa5c9..a29a486 100644 --- a/src/app/services/containers/containers-call.fake.ts +++ b/src/app/services/containers/containers-call.fake.ts @@ -1,4 +1,5 @@ import { BlockType, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow"; +import { BiasCapabilities } from "@models/bias-impact"; import { Observable, of } from "rxjs"; import { ContainersCallServiceBase } from "./container-call.base"; @@ -49,6 +50,14 @@ export class ContainersCallServiceFake extends ContainersCallServiceBase { return of(this.containerTypes); } + override retrieveBiasCapabilities(containerType: string): Observable { + return of(this.biasCapabilities(containerType)); + } + + override retrieveBiasCapabilitiesForInstance(containerType: string, _container: FlowContainer): Observable { + return of(this.biasCapabilities(containerType)); + } + override createEmptyContainer(containerType: string): Observable { const descriptor = this.containerTypes.find((container) => container.type === containerType); return of({ @@ -131,6 +140,18 @@ export class ContainersCallServiceFake extends ContainersCallServiceBase { }); } + private biasCapabilities(containerType: string): BiasCapabilities { + return { + blockType: containerType, + supported: true, + isolatedExperimentSupported: false, + fullFlowExperimentSupported: true, + externalSideEffects: false, + configurationDependent: false, + activationModes: ['INPUT_TRANSFORMATION', 'OUTPUT_TRANSFORMATION'] + }; + } + private resolveConfigurationType(containerType: string, configuration: Record) { const explicitType = configuration['type']; if (typeof explicitType === 'string' && explicitType.length > 0) { diff --git a/src/app/services/containers/containers-call.ts b/src/app/services/containers/containers-call.ts index c2858b3..3d6b250 100644 --- a/src/app/services/containers/containers-call.ts +++ b/src/app/services/containers/containers-call.ts @@ -2,18 +2,21 @@ import { HttpClient } from "@angular/common/http"; import { inject } from "@angular/core"; import { environment } from "@environment"; import { + BiasActivationMode, BlockType, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow"; -import { map, Observable } from "rxjs"; +import { BiasCapabilities } from "@models/bias-impact"; +import { map, Observable, of } from "rxjs"; import { ContainersCallServiceBase } from "./container-call.base"; import { attachSharedDefinitions, toApiPath, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from "@services/shared/flow-node-mapping"; export class ContainersCallService extends ContainersCallServiceBase { private readonly http = inject(HttpClient); private containerTypesCache: BlockType[] | null = null; + private readonly biasCapabilitiesCache = new Map(); override retrieveAllContainerTypes(): Observable { return this.http @@ -27,6 +30,30 @@ export class ContainersCallService extends ContainersCallServiceBase { ); } + override retrieveBiasCapabilities(containerType: string): Observable { + const cached = this.biasCapabilitiesCache.get(containerType); + if (cached) return of(cached); + + return this.http + .get(`${environment.apiUrl}/containers/types/${encodeURIComponent(containerType)}/bias-capabilities`) + .pipe( + map((raw) => this.biasCapabilitiesFromApi(raw, containerType)), + map((capabilities) => { + this.biasCapabilitiesCache.set(containerType, capabilities); + return capabilities; + }) + ); + } + + override retrieveBiasCapabilitiesForInstance(containerType: string, container: FlowContainer): Observable { + return this.http + .post( + `${environment.apiUrl}/containers/types/${encodeURIComponent(containerType)}/bias-capabilities`, + container + ) + .pipe(map((raw) => this.biasCapabilitiesFromApi(raw, containerType))); + } + override createEmptyContainer(containerType: string): Observable { const descriptor = this.containerTypesCache?.find((candidate) => candidate.type === containerType); const exampleEndpoint = this.resolveExampleEndpoint(containerType, descriptor); @@ -120,7 +147,29 @@ export class ContainersCallService extends ContainersCallServiceBase { outputs: toPorts(value["outputs"]), specificConfiguration, typeName, - nodeFamily: 'container' + nodeFamily: 'container', + biasAnnotations: Array.isArray(value["biasAnnotations"]) + ? value["biasAnnotations"] as FlowContainer["biasAnnotations"] + : [] + }; + } + + private biasCapabilitiesFromApi(raw: unknown, fallbackContainerType: string): BiasCapabilities { + const value = toRecord(raw); + const activationModes = Array.isArray(value['activationModes']) + ? value['activationModes'] + .filter((mode): mode is string => typeof mode === 'string') + .map((mode) => mode as BiasActivationMode) + : []; + + return { + blockType: String(value['containerType'] ?? fallbackContainerType), + supported: value['supported'] === true, + isolatedExperimentSupported: value['isolatedExperimentSupported'] === true, + fullFlowExperimentSupported: value['fullFlowExperimentSupported'] === true, + externalSideEffects: value['externalSideEffects'] === true, + configurationDependent: value['configurationDependent'] === true, + activationModes }; } diff --git a/src/app/services/containers/containers.spec.ts b/src/app/services/containers/containers.spec.ts index 301388f..8a435a4 100644 --- a/src/app/services/containers/containers.spec.ts +++ b/src/app/services/containers/containers.spec.ts @@ -28,4 +28,23 @@ describe('ContainersService', () => { await service.getAllContainerTypes(); expect(retrieveAllContainerTypes).toHaveBeenCalledTimes(2); }); + + it('caches bias capabilities per container type until a forced refresh', async () => { + const capabilities = { + blockType: 'GenericContainer', supported: true, isolatedExperimentSupported: false, + fullFlowExperimentSupported: true, externalSideEffects: false, configurationDependent: false, + activationModes: ['INPUT_TRANSFORMATION', 'OUTPUT_TRANSFORMATION'] + }; + const retrieveBiasCapabilities = vi.fn().mockReturnValue(of(capabilities)); + service.containersCallService = { retrieveBiasCapabilities } as unknown as typeof service.containersCallService; + + await expect(new Promise((resolve) => service.retrieveBiasCapabilities('GenericContainer').subscribe(resolve))) + .resolves.toEqual(capabilities); + await expect(new Promise((resolve) => service.retrieveBiasCapabilities('GenericContainer').subscribe(resolve))) + .resolves.toEqual(capabilities); + expect(retrieveBiasCapabilities).toHaveBeenCalledTimes(1); + + await new Promise((resolve) => service.retrieveBiasCapabilities('GenericContainer', true).subscribe(resolve)); + expect(retrieveBiasCapabilities).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/app/services/containers/containers.ts b/src/app/services/containers/containers.ts index 2ff52c2..0728c82 100644 --- a/src/app/services/containers/containers.ts +++ b/src/app/services/containers/containers.ts @@ -1,7 +1,8 @@ -import { Injectable, Signal } from '@angular/core'; +import { Injectable, Signal, signal } from '@angular/core'; import { environment } from '@environment'; -import { BlockType, BlockTypeName, FlowData, FlowNode } from '@models/flow'; -import { catchError, Observable, throwError } from 'rxjs'; +import { BlockType, BlockTypeName, FlowContainer, FlowData, FlowNode } from '@models/flow'; +import { BiasCapabilities } from '@models/bias-impact'; +import { catchError, Observable, of, tap, throwError } from 'rxjs'; import { ContainersCallServiceBase } from './container-call.base'; import { CatalogStore } from '@services/shared/catalog-store'; import { EmptyNodeCache } from '@services/shared/empty-node-cache'; @@ -19,9 +20,27 @@ export class ContainersService extends CatalogStore { private readonly emptyContainerCache = new EmptyNodeCache(); private readonly serverSync = new PendingSyncCounter(); + private readonly _biasCapabilities = signal>({}); + readonly hasPendingServerSync = this.serverSync.active; readonly containerTypes = this.types; readonly catalogLoading = this.loading; + readonly biasCapabilities = this._biasCapabilities.asReadonly(); + + retrieveBiasCapabilities(containerType: string, force = false): Observable { + const cached = this._biasCapabilities()[containerType]; + if (cached && !force) return of(cached); + + return this.containersCallService.retrieveBiasCapabilities(containerType).pipe( + tap((capabilities) => { + this._biasCapabilities.update((current) => ({ ...current, [containerType]: capabilities })); + }) + ); + } + + retrieveBiasCapabilitiesForInstance(containerType: string, container: FlowContainer): Observable { + return this.containersCallService.retrieveBiasCapabilitiesForInstance(containerType, container); + } hasLoadedContainerTypes() { return this.hasLoadedTypes(); diff --git a/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.spec.ts b/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.spec.ts index e996d2a..a057ee2 100644 --- a/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.spec.ts +++ b/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FlowBlock } from '@models/flow'; +import { FlowBlock, FlowContainer } from '@models/flow'; import { BlocksService } from '@services/blocks/blocks'; +import { ContainersService } from '@services/containers/containers'; import { of } from 'rxjs'; import { vi } from 'vitest'; @@ -87,3 +88,63 @@ describe('BehavioralProbeEditorComponent', () => { expect(changed).toHaveBeenLastCalledWith(expect.objectContaining({ instruction: 'accepted' })); }); }); + +describe('BehavioralProbeEditorComponent (container nodes)', () => { + let fixture: ComponentFixture; + let component: BehavioralProbeEditorComponent; + + const container: FlowContainer = { + id: 'container-1', + name: 'Candidate review', + inputs: [{ name: 'candidateData', type: 'TEXT', multiple: false }], + outputs: [{ name: 'decision', type: 'TEXT', multiple: false }], + specificConfiguration: { name: 'Candidate review' }, + typeName: 'GenericContainer', + nodeFamily: 'container' + }; + + const blocksRetrieveBiasCapabilities = vi.fn(); + const containersRetrieveBiasCapabilities = vi.fn(); + + beforeEach(async () => { + blocksRetrieveBiasCapabilities.mockReset(); + containersRetrieveBiasCapabilities.mockReset(); + containersRetrieveBiasCapabilities.mockReturnValue(of({ + blockType: 'GenericContainer', supported: true, isolatedExperimentSupported: false, + fullFlowExperimentSupported: true, externalSideEffects: false, + configurationDependent: false, activationModes: ['INPUT_TRANSFORMATION', 'OUTPUT_TRANSFORMATION'] + })); + + await TestBed.configureTestingModule({ + imports: [BehavioralProbeEditorComponent], + providers: [ + { provide: BlocksService, useValue: { retrieveBiasCapabilities: blocksRetrieveBiasCapabilities } }, + { provide: ContainersService, useValue: { retrieveBiasCapabilities: containersRetrieveBiasCapabilities } } + ] + }).compileComponents(); + fixture = TestBed.createComponent(BehavioralProbeEditorComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('block', container); + fixture.detectChanges(); + }); + + it('loads capabilities from the containers service, not the blocks service, for container nodes', () => { + expect(containersRetrieveBiasCapabilities).toHaveBeenCalledWith('GenericContainer'); + expect(blocksRetrieveBiasCapabilities).not.toHaveBeenCalled(); + expect(component.activationModes).toEqual(['INPUT_TRANSFORMATION', 'OUTPUT_TRANSFORMATION']); + }); + + it('lists target inputs from the container public inputs', () => { + fixture.componentRef.setInput('probe', { activationMode: 'INPUT_TRANSFORMATION' }); + fixture.detectChanges(); + + const checkboxLabels = Array.from(fixture.nativeElement.querySelectorAll('.probe-check span') as NodeListOf) + .map((el) => el.textContent?.trim()); + expect(checkboxLabels.some((label) => label?.startsWith('candidateData'))).toBe(true); + + const changed = vi.fn(); + component.probeChange.subscribe(changed); + component.toggleTargetInput('candidateData', true); + expect(changed).toHaveBeenLastCalledWith(expect.objectContaining({ targetInputs: ['candidateData'] })); + }); +}); diff --git a/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.ts b/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.ts index 96e5ad9..c90484d 100644 --- a/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.ts +++ b/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.ts @@ -2,9 +2,10 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, Input, OnChanges, Output, EventEmitter, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { BiasCapabilities } from '@models/bias-impact'; -import { BehavioralProbe, BiasActivationMode, FlowBlock, FlowPort } from '@models/flow'; +import { BehavioralProbe, BiasActivationMode, FlowBlock, FlowContainer, FlowNode, FlowPort } from '@models/flow'; import { BlocksService } from '@services/blocks/blocks'; -import { take } from 'rxjs'; +import { ContainersService } from '@services/containers/containers'; +import { Observable, take } from 'rxjs'; @Component({ selector: 'app-behavioral-probe-editor', @@ -16,10 +17,11 @@ import { take } from 'rxjs'; }) export class BehavioralProbeEditorComponent implements OnChanges { private readonly blocks = inject(BlocksService); + private readonly containers = inject(ContainersService); private capabilityRequestVersion = 0; private capabilityKey: string | null = null; - @Input() block: FlowBlock | null = null; + @Input() block: FlowNode | null = null; @Input() probe: BehavioralProbe | undefined; @Input() readonly = false; @Output() probeChange = new EventEmitter(); @@ -153,7 +155,7 @@ export class BehavioralProbeEditorComponent implements OnChanges { const requestVersion = ++this.capabilityRequestVersion; this.loadingCapabilities = true; this.capabilityError = null; - this.blocks.retrieveBiasCapabilities(blockType).pipe(take(1)).subscribe({ + this.retrieveCapabilities(blockType).pipe(take(1)).subscribe({ next: (capabilities) => { if (requestVersion !== this.capabilityRequestVersion) return; if (!capabilities.configurationDependent || !this.block) { @@ -161,7 +163,7 @@ export class BehavioralProbeEditorComponent implements OnChanges { this.loadingCapabilities = false; return; } - this.blocks.retrieveBiasCapabilitiesForInstance(blockType, this.block).pipe(take(1)).subscribe({ + this.retrieveCapabilitiesForInstance(blockType).pipe(take(1)).subscribe({ next: (instanceCapabilities) => { if (requestVersion !== this.capabilityRequestVersion) return; this.capabilities = instanceCapabilities; @@ -182,6 +184,18 @@ export class BehavioralProbeEditorComponent implements OnChanges { }); } + private retrieveCapabilities(nodeType: string): Observable { + return this.block?.nodeFamily === 'container' + ? this.containers.retrieveBiasCapabilities(nodeType) + : this.blocks.retrieveBiasCapabilities(nodeType); + } + + private retrieveCapabilitiesForInstance(nodeType: string): Observable { + return this.block?.nodeFamily === 'container' + ? this.containers.retrieveBiasCapabilitiesForInstance(nodeType, this.block as FlowContainer) + : this.blocks.retrieveBiasCapabilitiesForInstance(nodeType, this.block as FlowBlock); + } + private setMockOutput(name: string, value: unknown) { this.emit({ ...this.currentProbe, diff --git a/src/app/shared/bias-annotations/bias-annotations.ts b/src/app/shared/bias-annotations/bias-annotations.ts index eb05393..1e273ac 100644 --- a/src/app/shared/bias-annotations/bias-annotations.ts +++ b/src/app/shared/bias-annotations/bias-annotations.ts @@ -6,7 +6,7 @@ import { BiasAnnotationOption, BiasAnnotationsDescriptor, BehavioralProbe, - FlowBlock, + FlowNode, FlowValidationError, isProbeExecutable } from '@models/flow'; @@ -45,7 +45,7 @@ export class BiasAnnotationsComponent { private readonly editorState = inject(EditorStateHolder); @Input({ required: true }) blockId = ''; - @Input() block: FlowBlock | null = null; + @Input() block: FlowNode | null = null; @Input() annotations: BiasAnnotation[] = []; @Input() readonly = false; @Output() annotationsChange = new EventEmitter(); diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css index 64ed55c..d25259e 100644 --- a/src/app/shared/nodes/container-node/container-node.css +++ b/src/app/shared/nodes/container-node/container-node.css @@ -276,6 +276,69 @@ line-height: 1.3; } +.container-node__bias-badge-wrap { + position: relative; + margin-left: 0; +} + +.container-node__bias-badge { + min-width: 24px; + height: 24px; + padding: 0 6px; + border-radius: 999px; + border: 1px solid #ddd6fe; + background: #7c3aed; + color: #f5f3ff; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + font-size: 12px; + font-weight: 700; + box-shadow: 0 0 0 2px rgba(76, 29, 149, 0.18); +} + +.container-node__bias-badge-wrap-probe .container-node__bias-badge { + background: #4c1d95; + border-color: #c4b5fd; + box-shadow: 0 0 0 2px rgba(76, 29, 149, 0.32); +} + +.container-node__bias-badge-tooltip { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 200px; + max-width: 260px; + border: 1px solid #ddd6fe; + border-radius: 8px; + background: #f5f3ff; + color: #4c1d95; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2); + padding: 8px; + z-index: 80; + opacity: 0; + visibility: hidden; + transform: translateY(-2px); + transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease; +} + +.container-node__bias-badge-wrap:hover .container-node__bias-badge-tooltip { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.container-node__bias-badge-title { + font-size: 11px; + font-weight: 700; + margin-bottom: 4px; +} + +.container-node__bias-badge-item { + font-size: 11px; +} + .container-node__delete-confirm { position: absolute; top: calc(100% + 8px); diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index 5898787..dfed9a0 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -52,6 +52,22 @@ } + @if (biasAnnotationBadge; as biasBadge) { +
+
+ + {{ biasBadge.count }} +
+
+
+ {{ biasBadge.count }} bias annotation{{ biasBadge.count === 1 ? '' : 's' }}@if (biasBadge.maxSeverityLabel) {, max severity: {{ biasBadge.maxSeverityLabel }}} +
+ @if (biasBadge.hasExecutableProbe) { +
Includes an executable behavioral probe
+ } +
+
+ } @if (!isReadonly && deleteConfirmOpen) {
Delete container? @@ -416,4 +432,11 @@ {{ assignmentErrorMessage }}
} + + diff --git a/src/app/shared/nodes/container-node/container-node.spec.ts b/src/app/shared/nodes/container-node/container-node.spec.ts index 7fc6b94..0455dda 100644 --- a/src/app/shared/nodes/container-node/container-node.spec.ts +++ b/src/app/shared/nodes/container-node/container-node.spec.ts @@ -4,6 +4,8 @@ import { vi } from 'vitest'; import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog'; import { FieldRetriever } from '@services/retriever/field-retriever'; +import { BlocksService } from '@services/blocks/blocks'; +import { ContainersService } from '@services/containers/containers'; import { ContainerNodeComponent } from './container-node'; describe('ContainerNodeComponent', () => { @@ -14,7 +16,15 @@ describe('ContainerNodeComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ContainerNodeComponent] + imports: [ContainerNodeComponent], + providers: [ + { + provide: BlocksService, + useValue: { + biasAnnotationsDescriptor: vi.fn().mockReturnValue(null) + } + } + ] }).compileComponents(); fixture = TestBed.createComponent(ContainerNodeComponent); @@ -185,4 +195,63 @@ describe('ContainerNodeComponent', () => { } }); }); + + it('has no bias annotation badge when the container has no annotations', () => { + component.data = { data: { id: 'container-1', specificConfiguration: {}, inputs: [], outputs: [] } }; + expect(component.biasAnnotationBadge).toBeNull(); + }); + + it('computes the bias annotation badge from the container annotations and the severity catalog', () => { + const blocks = TestBed.inject(BlocksService) as any; + blocks.biasAnnotationsDescriptor.mockReturnValue({ + options: { + severity: [ + { value: 'LOW', label: 'Low' }, + { value: 'HIGH', label: 'High' } + ] + } + }); + component.data = { + data: { + id: 'container-1', + specificConfiguration: {}, + inputs: [], + outputs: [], + biasAnnotations: [ + { id: 'a1', severity: 'LOW' }, + { id: 'a2', severity: 'HIGH', behavioralProbe: { activationMode: 'INPUT_TRANSFORMATION', instruction: 'do it' } } + ] + } + }; + + expect(component.biasAnnotationBadge).toEqual({ + count: 2, + hasExecutableProbe: true, + maxSeverityLabel: 'High' + }); + }); + + it('preserves id, position and bias annotations during container regeneration', async () => { + const containers = TestBed.inject(ContainersService); + const replacement = vi.fn().mockResolvedValue(undefined); + component.data = { + data: { + id: 'old-id', typeName: 'GenericContainer', position: { x: 10, y: 20 }, + specificConfiguration: { name: 'Container' }, inputs: [], outputs: [], + biasAnnotations: [{ id: 'bias-1', category: 'DYNAMIC', issue: 'keep me' }], + replaceWithCreatedNode: replacement + } + }; + vi.spyOn(containers, 'createContainer').mockReturnValue(of({ + id: 'old-id', name: 'Generated', typeName: 'GenericContainer', inputs: [], outputs: [], + specificConfiguration: { name: 'Container' }, position: { x: 99, y: 99 }, nodeFamily: 'container' + })); + + await (component as any).recreateContainer({ name: 'Container' }); + + expect(replacement).toHaveBeenCalledWith(expect.objectContaining({ + position: { x: 10, y: 20 }, + biasAnnotations: [{ id: 'bias-1', category: 'DYNAMIC', issue: 'keep me' }] + })); + }); }); diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index 60b6288..443de2a 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -2,9 +2,21 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, HostListener, Input, OnDestroy, effect, inject, viewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { currentFlowPortValueKind, flowValueKindLabel, FlowData, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow'; +import { + BiasAnnotation, + BiasAnnotationsDescriptor, + currentFlowPortValueKind, + flowValueKindLabel, + FlowContainer, + FlowData, + FlowPort, + FLOW_DEPENDANT_PORT_KEY, + FLOW_DEPENDENCY_PORT_KEY, + isProbeExecutable +} from '@models/flow'; import { NodeSettingField, NodeSettingOption, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog'; import { ContainersService } from '@services/containers/containers'; +import { BlocksService } from '@services/blocks/blocks'; import { FieldRetriever } from '@services/retriever/field-retriever'; import { ClassicPreset } from 'rete'; import { ReteModule } from 'rete-angular-plugin/21'; @@ -12,6 +24,7 @@ import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-d import { EditorStateHolder } from '@stores/flow-editor'; import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag'; import { NodeFocusModalController } from '../node-focus-modal-controller'; +import { BiasAnnotationsComponent } from '@shared/bias-annotations/bias-annotations'; import { firstValueFrom } from 'rxjs'; import { extractSchemaRequirements, SchemaRequirements } from '../schema-requirements'; import { evaluateUiConditionRule, getValueByPath, parentPath, pathToLabel, resolveNodeIcon, resolveSchemaPath, splitTemplatedTextParts, valueToDisplayString } from '../node-utility'; @@ -73,7 +86,7 @@ type ContainerFlowFieldView = SchemaFlowDataFieldDefinition & { @Component({ selector: 'app-container-node', - imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule], + imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule, BiasAnnotationsComponent], templateUrl: './container-node.html', styleUrl: './container-node.css', host: { @@ -89,6 +102,7 @@ export class ContainerNodeComponent implements OnDestroy { private subflowPreview = inject(SubflowPreviewDialogService); private fieldRetriever = inject(FieldRetriever); private containersService = inject(ContainersService); + private blocksService = inject(BlocksService); private settingsDialog = inject(NodeSettingsDialogService); private containerSchema: Record | null = null; private schemaRequirements: SchemaRequirements = { required: [], requiredObjects: [], conditional: [] }; @@ -354,6 +368,100 @@ export class ContainerNodeComponent implements OnDestroy { return this.richContentFields.length > 0; } + get biasAnnotations(): BiasAnnotation[] { + const nodeData = this.data?.data as Record | undefined; + const value = nodeData?.[this.biasAnnotationsProperty]; + return Array.isArray(value) ? value as BiasAnnotation[] : []; + } + + get biasAnnotationBadge(): { count: number; hasExecutableProbe: boolean; maxSeverityLabel: string | null } | null { + const annotations = this.biasAnnotations; + if (!annotations.length) return null; + return { + count: annotations.length, + hasExecutableProbe: annotations.some((annotation) => isProbeExecutable(annotation.behavioralProbe)), + maxSeverityLabel: this.mostSevereBiasLabel(annotations) + }; + } + + get biasContainer(): FlowContainer | null { + const nodeData = this.data?.data as Record | undefined; + const containerId = this.blockId; + if (!nodeData || !containerId) return null; + return { + id: containerId, + name: this.name, + position: nodeData['position'] as { x: number; y: number } | undefined, + inputs: (Array.isArray(nodeData['inputs']) ? nodeData['inputs'] as FlowPort[] : []).map((port) => ({ ...port })), + outputs: (Array.isArray(nodeData['outputs']) ? nodeData['outputs'] as FlowPort[] : []).map((port) => ({ ...port })), + specificConfiguration: this.configuration ?? {}, + typeName: this.typeName, + nodeFamily: 'container', + biasAnnotations: this.biasAnnotations + }; + } + + updateBiasAnnotations(annotations: BiasAnnotation[]) { + if (this.isReadonly || !this.data?.data) return; + this.data.data[this.biasAnnotationsProperty] = this.cloneConfigurationValue(annotations); + this.data.data.__biasAnnotationsProperty = this.biasAnnotationsProperty; + this.markBiasAnnotationsDirty(); + this.refreshView(); + } + + private get biasAnnotationsProperty(): string { + const descriptorSignal = (this.blocksService as BlocksService & { + biasAnnotationsDescriptor?: () => { blockProperty?: string } | null + }).biasAnnotationsDescriptor; + const property = typeof descriptorSignal === 'function' ? descriptorSignal()?.blockProperty : null; + return typeof property === 'string' && property.length ? property : 'biasAnnotations'; + } + + private mostSevereBiasLabel(annotations: BiasAnnotation[]): string | null { + const descriptorSignal = (this.blocksService as BlocksService & { + biasAnnotationsDescriptor?: () => BiasAnnotationsDescriptor | null + }).biasAnnotationsDescriptor; + const severityOptions = descriptorSignal?.()?.options?.['severity'] ?? []; + const rankByValue = new Map(severityOptions.map((option, index) => [option.value, index])); + + let mostSevereValue: unknown = undefined; + let mostSevereRank = -1; + for (const annotation of annotations) { + const value = annotation.severity; + const rank = typeof value === 'string' && rankByValue.has(value) ? rankByValue.get(value)! : -1; + if (mostSevereValue === undefined || rank > mostSevereRank) { + mostSevereValue = value; + mostSevereRank = rank; + } + } + + if (mostSevereValue == null) return null; + return severityOptions.find((option) => option.value === mostSevereValue)?.label + ?? (typeof mostSevereValue === 'string' ? mostSevereValue : null); + } + + private markBiasAnnotationsDirty() { + const flow = this.editorState.currentFlow(); + if (!flow) return; + this.editorState.updateData(this.cloneCurrentFlowWithBiasAnnotations(flow.data)); + } + + private cloneCurrentFlowWithBiasAnnotations(flowData: FlowData): FlowData { + const nextFlowData = this.cloneConfigurationValue(flowData); + const nodeData = this.data?.data as Record | undefined; + const containerId = typeof nodeData?.['id'] === 'string' ? nodeData['id'] : null; + if (!containerId) return nextFlowData; + + const container = nextFlowData.containers.find((item) => item.id === containerId); + if (!container) return nextFlowData; + + (container as unknown as Record)[this.biasAnnotationsProperty] = this.cloneConfigurationValue( + Array.isArray(nodeData?.[this.biasAnnotationsProperty]) ? nodeData[this.biasAnnotationsProperty] : [] + ); + + return nextFlowData; + } + formatDynamicInputToken(token: string): string { const match = token.match(/^\$\{\{\s*([^}]+?)\s*\}\}$/); return match ? match[1] : token; @@ -659,7 +767,7 @@ export class ContainerNodeComponent implements OnDestroy { return value && typeof value === 'object' ? value as Record : null; } - private get blockId(): string | null { + get blockId(): string | null { const blockId = this.data?.data?.id; return typeof blockId === 'string' && blockId.length > 0 ? blockId : null; } @@ -1032,11 +1140,18 @@ export class ContainerNodeComponent implements OnDestroy { }) ); + const annotationsProperty = this.biasAnnotationsProperty; + const preservedAnnotations = Array.isArray(current[annotationsProperty]) + ? this.cloneConfigurationValue(current[annotationsProperty]) + : []; + const replaceNode = current['replaceWithCreatedNode']; if (typeof replaceNode === 'function') { await replaceNode({ ...createdContainer, position: (current['position'] as { x: number; y: number } | undefined) ?? createdContainer.position, + [annotationsProperty]: preservedAnnotations, + __biasAnnotationsProperty: annotationsProperty, __focusOpen: current['__focusOpen'] === true }); return; @@ -1047,6 +1162,8 @@ export class ContainerNodeComponent implements OnDestroy { ...createdContainer, specificConfiguration: nextConfiguration, position: (current['position'] as { x: number; y: number } | undefined) ?? createdContainer.position, + [annotationsProperty]: preservedAnnotations, + __biasAnnotationsProperty: annotationsProperty, __containerAssigning: false, __containerAssignmentError: null }; diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index fdfd8fe..613fab7 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -1027,21 +1027,24 @@ export class TaskExecutionViewerComponent implements OnDestroy { } private async biasRerunCandidates(): Promise { - const candidates = this.stepsArray().flatMap((step): Array<{ nodeId: string; nodeName: string; block: FlowBlock }> => { + const candidates = this.stepsArray().flatMap((step): Array<{ nodeId: string; nodeName: string; node: FlowNode }> => { const node = getTaskExecutionStepNode(step); - if (!node || node.nodeFamily === 'container') return []; - const block = node as FlowBlock; - const annotations = (block.biasAnnotations ?? []).filter((annotation) => isProbeExecutable(annotation.behavioralProbe)); - return annotations.length ? [{ nodeId: step.id, nodeName: block.name || step.id, block: { ...block, biasAnnotations: annotations } }] : []; + if (!node) return []; + const annotations = (node.biasAnnotations ?? []).filter((annotation) => isProbeExecutable(annotation.behavioralProbe)); + return annotations.length ? [{ nodeId: step.id, nodeName: node.name || step.id, node: { ...node, biasAnnotations: annotations } }] : []; }); const resolved = await Promise.all(candidates.map(async (candidate) => { - const capabilities = await firstValueFrom(this.blocksService.retrieveBiasCapabilities(candidate.block.typeName)); + const capabilities = await firstValueFrom( + candidate.node.nodeFamily === 'container' + ? this.containersService.retrieveBiasCapabilities(candidate.node.typeName) + : this.blocksService.retrieveBiasCapabilities(candidate.node.typeName) + ); if (!capabilities.fullFlowExperimentSupported) return null; return { nodeId: candidate.nodeId, nodeName: candidate.nodeName, - annotations: candidate.block.biasAnnotations ?? [], + annotations: candidate.node.biasAnnotations ?? [], capabilities } satisfies BiasRerunCandidate; })); diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 744463a..252ebfc 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -170,9 +170,7 @@ export function exportGraph(editor: NodeEditor) { inputs, outputs, specificConfiguration: cloneValue(blockData?.specificConfiguration ?? {}), - ...(blockData?.nodeFamily === 'container' - ? {} - : { [biasAnnotationsProperty]: cloneValue(blockRecord?.[biasAnnotationsProperty] ?? []) }), + [biasAnnotationsProperty]: cloneValue(blockRecord?.[biasAnnotationsProperty] ?? []), typeName: blockData?.typeName ?? "LLMBlock", nodeFamily: blockData?.nodeFamily === 'container' ? 'container' : 'block' };