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 <noreply@anthropic.com>
This commit is contained in:
parent
761535acdc
commit
9f54896545
|
|
@ -149,6 +149,7 @@ export type FlowBlock = FlowNodeBase & {
|
|||
|
||||
export type FlowContainer = FlowNodeBase & {
|
||||
nodeFamily: 'container';
|
||||
biasAnnotations?: BiasAnnotation[];
|
||||
};
|
||||
|
||||
export type FlowNode = FlowBlock | FlowContainer;
|
||||
|
|
|
|||
|
|
@ -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<BlockType[]>;
|
||||
|
||||
abstract retrieveBiasCapabilities(containerType: string): Observable<BiasCapabilities>;
|
||||
|
||||
abstract retrieveBiasCapabilitiesForInstance(containerType: string, container: FlowContainer): Observable<BiasCapabilities>;
|
||||
|
||||
abstract createEmptyContainer(containerType: BlockTypeName): Observable<FlowContainer>;
|
||||
|
||||
abstract createContainer(containerId: string, configuration: any): Observable<FlowContainer>;
|
||||
|
|
|
|||
|
|
@ -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<BiasCapabilities> {
|
||||
return of(this.biasCapabilities(containerType));
|
||||
}
|
||||
|
||||
override retrieveBiasCapabilitiesForInstance(containerType: string, _container: FlowContainer): Observable<BiasCapabilities> {
|
||||
return of(this.biasCapabilities(containerType));
|
||||
}
|
||||
|
||||
override createEmptyContainer(containerType: string): Observable<FlowContainer> {
|
||||
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<string, unknown>) {
|
||||
const explicitType = configuration['type'];
|
||||
if (typeof explicitType === 'string' && explicitType.length > 0) {
|
||||
|
|
|
|||
|
|
@ -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<string, BiasCapabilities>();
|
||||
|
||||
override retrieveAllContainerTypes(): Observable<BlockType[]> {
|
||||
return this.http
|
||||
|
|
@ -27,6 +30,30 @@ export class ContainersCallService extends ContainersCallServiceBase {
|
|||
);
|
||||
}
|
||||
|
||||
override retrieveBiasCapabilities(containerType: string): Observable<BiasCapabilities> {
|
||||
const cached = this.biasCapabilitiesCache.get(containerType);
|
||||
if (cached) return of(cached);
|
||||
|
||||
return this.http
|
||||
.get<unknown>(`${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<BiasCapabilities> {
|
||||
return this.http
|
||||
.post<unknown>(
|
||||
`${environment.apiUrl}/containers/types/${encodeURIComponent(containerType)}/bias-capabilities`,
|
||||
container
|
||||
)
|
||||
.pipe(map((raw) => this.biasCapabilitiesFromApi(raw, containerType)));
|
||||
}
|
||||
|
||||
override createEmptyContainer(containerType: string): Observable<FlowContainer> {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<BlockType> {
|
|||
private readonly emptyContainerCache = new EmptyNodeCache<FlowNode>();
|
||||
private readonly serverSync = new PendingSyncCounter();
|
||||
|
||||
private readonly _biasCapabilities = signal<Record<string, BiasCapabilities>>({});
|
||||
|
||||
readonly hasPendingServerSync = this.serverSync.active;
|
||||
readonly containerTypes = this.types;
|
||||
readonly catalogLoading = this.loading;
|
||||
readonly biasCapabilities = this._biasCapabilities.asReadonly();
|
||||
|
||||
retrieveBiasCapabilities(containerType: string, force = false): Observable<BiasCapabilities> {
|
||||
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<BiasCapabilities> {
|
||||
return this.containersCallService.retrieveBiasCapabilitiesForInstance(containerType, container);
|
||||
}
|
||||
|
||||
hasLoadedContainerTypes() {
|
||||
return this.hasLoadedTypes();
|
||||
|
|
|
|||
|
|
@ -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<BehavioralProbeEditorComponent>;
|
||||
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<Element>)
|
||||
.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'] }));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<BehavioralProbe | undefined>();
|
||||
|
|
@ -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<BiasCapabilities> {
|
||||
return this.block?.nodeFamily === 'container'
|
||||
? this.containers.retrieveBiasCapabilities(nodeType)
|
||||
: this.blocks.retrieveBiasCapabilities(nodeType);
|
||||
}
|
||||
|
||||
private retrieveCapabilitiesForInstance(nodeType: string): Observable<BiasCapabilities> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<BiasAnnotation[]>();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -52,6 +52,22 @@
|
|||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (biasAnnotationBadge; as biasBadge) {
|
||||
<div class="container-node__bias-badge-wrap" [class.container-node__bias-badge-wrap-probe]="biasBadge.hasExecutableProbe">
|
||||
<div class="container-node__bias-badge">
|
||||
<i class="bi bi-clipboard2-pulse-fill"></i>
|
||||
<span>{{ biasBadge.count }}</span>
|
||||
</div>
|
||||
<div class="container-node__bias-badge-tooltip">
|
||||
<div class="container-node__bias-badge-title">
|
||||
{{ biasBadge.count }} bias annotation{{ biasBadge.count === 1 ? '' : 's' }}@if (biasBadge.maxSeverityLabel) {, max severity: {{ biasBadge.maxSeverityLabel }}}
|
||||
</div>
|
||||
@if (biasBadge.hasExecutableProbe) {
|
||||
<div class="container-node__bias-badge-item">Includes an executable behavioral probe</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!isReadonly && deleteConfirmOpen) {
|
||||
<div class="container-node__delete-confirm" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
|
||||
<span class="container-node__delete-confirm-text">Delete container?</span>
|
||||
|
|
@ -416,4 +432,11 @@
|
|||
{{ assignmentErrorMessage }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-bias-annotations
|
||||
[blockId]="blockId ?? ''"
|
||||
[block]="biasContainer"
|
||||
[annotations]="biasAnnotations"
|
||||
[readonly]="isReadonly"
|
||||
(annotationsChange)="updateBiasAnnotations($event)" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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' }]
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, any> | 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<string, unknown> | 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<string, unknown> | 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<string, any> | 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<string, unknown>)[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<string, unknown> : 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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1027,21 +1027,24 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
}
|
||||
|
||||
private async biasRerunCandidates(): Promise<BiasRerunCandidate[]> {
|
||||
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;
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -170,9 +170,7 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
|
|||
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'
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue