feat(flow-editor): support bias annotations

This commit is contained in:
Lucio Lelii 2026-07-20 16:20:28 +02:00
parent a258064eb5
commit 0d60da1874
18 changed files with 688 additions and 12 deletions

View File

@ -131,6 +131,9 @@ export class FlowEditor {
activeTourStep = computed(() => this.tourSteps()[this.tourStepIndex()] ?? null);
constructor() {
void this.blocksService.getBiasAnnotationsDescriptor().catch((error) => {
console.error('Retrieve bias annotations descriptor failed', error);
});
effect(() => {
const username = this.authorization.loggedInUser()?.username ?? null;
if (!username || this.tourBootstrapped) return;

View File

@ -86,8 +86,38 @@ export type FlowNodeBase = {
nodeFamily?: NodeFamily;
};
export type BiasAnnotation = Record<string, unknown> & {
id?: string;
category?: string;
severity?: string;
issue?: string;
rationale?: string;
mitigation?: string;
status?: string;
source?: string;
analysisId?: string;
};
export type BiasAnnotationOption = {
value: string;
label: string;
description?: string;
};
export type BiasAnnotationsDescriptor = {
type: string;
blockProperty: string;
multiple: boolean;
maxItems: number | null;
schema: Record<string, unknown>;
options: Record<string, BiasAnnotationOption[]>;
defaults: Record<string, unknown>;
serverGeneratedFields: string[];
};
export type FlowBlock = FlowNodeBase & {
nodeFamily?: 'block';
biasAnnotations?: BiasAnnotation[];
};
export type FlowContainer = FlowNodeBase & {

View File

@ -17,6 +17,7 @@ export type HFNodeData = FlowNode & {
__containerValidationErrors?: unknown[];
__containerAssignmentError?: string | null;
__containerAssigning?: boolean;
__biasAnnotationsProperty?: string;
};
export type HFNode = ClassicPreset.Node & {

View File

@ -1,4 +1,4 @@
import { BlockType, BlockTypeName, FlowBlock } from "@models/flow";
import { BiasAnnotationsDescriptor, BlockType, BlockTypeName, FlowBlock } from "@models/flow";
import { Observable } from "rxjs";
export type BlockDraftContext = {
@ -10,6 +10,8 @@ export abstract class BlocksCallServiceBase {
abstract retrieveAllBlocksTypes() : Observable<BlockType[]>;
abstract retrieveBiasAnnotationsDescriptor(): Observable<BiasAnnotationsDescriptor>;
abstract createEmptyBlock(blockType: BlockTypeName, context?: BlockDraftContext) : Observable<FlowBlock>;
abstract updateBlock(blockId : string, configuration : any, context?: BlockDraftContext) : Observable<FlowBlock>;

View File

@ -1,4 +1,4 @@
import { BlockType, FlowBlock } from "@models/flow";
import { BiasAnnotationsDescriptor, BlockType, FlowBlock } from "@models/flow";
import { Observable, of } from "rxjs";
import { BlockDraftContext, BlocksCallServiceBase } from "./block-call.base";
@ -147,6 +147,38 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
return of(this.blockTypes);
}
override retrieveBiasAnnotationsDescriptor(): Observable<BiasAnnotationsDescriptor> {
return of({
type: 'BiasAnnotation',
blockProperty: 'biasAnnotations',
multiple: true,
maxItems: 20,
schema: {
type: 'object',
required: ['category', 'severity', 'issue'],
properties: {
id: { type: 'string' },
category: { type: 'string', 'x-ui-label': 'Category', 'x-ui-order': 1 },
severity: { type: 'string', 'x-ui-label': 'Severity', 'x-ui-order': 2 },
issue: { type: 'string', maxLength: 2000, 'x-ui-widget': 'textarea', 'x-ui-order': 3 },
rationale: { type: 'string', maxLength: 4000, 'x-ui-widget': 'textarea', 'x-ui-order': 4 },
mitigation: { type: 'string', maxLength: 4000, 'x-ui-widget': 'textarea', 'x-ui-order': 5 },
status: { type: 'string', 'x-ui-order': 6 },
source: { type: 'string', 'x-ui-order': 7 },
analysisId: { type: 'string', maxLength: 255, 'x-ui-order': 8 }
}
},
options: {
category: [{ value: 'AUTOMATION_BIAS', label: 'Automation bias', description: 'Over-reliance on automated decisions.' }],
severity: [{ value: 'HIGH', label: 'High' }],
status: [{ value: 'PROPOSED', label: 'Proposed' }],
source: [{ value: 'MANUAL', label: 'Manual' }]
},
defaults: { status: 'PROPOSED', source: 'MANUAL' },
serverGeneratedFields: ['id']
});
}
override createEmptyBlock(blockType: string, _context?: BlockDraftContext): Observable<FlowBlock> {
const descriptor = this.blockTypes.find((b) => b.type === blockType);
const typeName = descriptor?.type ?? blockType ?? "LLMBlock";

View File

@ -99,4 +99,21 @@ describe('BlocksCallService', () => {
'Invalid block catalog response: expected reduced catalog format with a descriptors array'
);
});
it('loads and normalizes the dynamic bias annotations descriptor', async () => {
const request = firstValueFrom(service.retrieveBiasAnnotationsDescriptor());
const httpRequest = httpMock.expectOne(`${environment.apiUrl}/blocks/bias-annotations/descriptor`);
expect(httpRequest.request.method).toBe('GET');
httpRequest.flush({
type: 'BiasAnnotation', blockProperty: 'biasAnnotations', multiple: true, maxItems: 3,
schema: { type: 'object', required: ['category'], properties: { category: { type: 'string' } } },
options: { category: [{ value: 'DYNAMIC_VALUE', label: 'Dynamic label', description: 'Dynamic description' }] },
defaults: { status: 'DYNAMIC_DEFAULT' }, serverGeneratedFields: ['id']
});
await expect(request).resolves.toEqual(expect.objectContaining({
blockProperty: 'biasAnnotations', maxItems: 3,
options: { category: [{ value: 'DYNAMIC_VALUE', label: 'Dynamic label', description: 'Dynamic description' }] }
}));
});
});

View File

@ -1,4 +1,4 @@
import { BlockType, FlowBlock } from "@models/flow";
import { BiasAnnotationOption, BiasAnnotationsDescriptor, BlockType, FlowBlock } from "@models/flow";
import { HttpClient, HttpParams } from "@angular/common/http";
import { inject } from "@angular/core";
import { environment } from "@environment";
@ -21,6 +21,12 @@ export class BlocksCallService extends BlocksCallServiceBase {
);
}
override retrieveBiasAnnotationsDescriptor(): Observable<BiasAnnotationsDescriptor> {
return this.http
.get<unknown>(`${environment.apiUrl}/blocks/bias-annotations/descriptor`)
.pipe(map((raw) => this.biasAnnotationsDescriptorFromApi(raw)));
}
override createEmptyBlock(blockType: string, context?: BlockDraftContext): Observable<FlowBlock> {
return this.getBlockTypesForCreate().pipe(
take(1),
@ -142,7 +148,41 @@ export class BlocksCallService extends BlocksCallServiceBase {
outputs: this.toPorts(value["outputs"], io.outputs),
specificConfiguration,
typeName,
nodeFamily: 'block'
nodeFamily: 'block',
biasAnnotations: Array.isArray(value["biasAnnotations"])
? value["biasAnnotations"] as FlowBlock["biasAnnotations"]
: []
};
}
private biasAnnotationsDescriptorFromApi(raw: unknown): BiasAnnotationsDescriptor {
const value = this.toRecord(raw);
const rawOptions = this.toRecord(value["options"]);
const options: Record<string, BiasAnnotationOption[]> = {};
for (const [field, entries] of Object.entries(rawOptions)) {
if (!Array.isArray(entries)) continue;
options[field] = entries
.map((entry) => this.toRecord(entry))
.filter((entry) => typeof entry["value"] === "string")
.map((entry) => ({
value: String(entry["value"]),
label: String(entry["label"] ?? entry["value"]),
description: typeof entry["description"] === "string" ? entry["description"] : undefined
}));
}
const maxItems = Number(value["maxItems"]);
return {
type: String(value["type"] ?? ""),
blockProperty: String(value["blockProperty"] ?? "biasAnnotations"),
multiple: value["multiple"] !== false,
maxItems: Number.isFinite(maxItems) && maxItems >= 0 ? maxItems : null,
schema: this.toRecord(value["schema"]),
options,
defaults: this.toRecord(value["defaults"]),
serverGeneratedFields: Array.isArray(value["serverGeneratedFields"])
? value["serverGeneratedFields"].map(String)
: []
};
}

View File

@ -1,6 +1,6 @@
import { computed, Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { BlockType, BlockTypeName, FlowBlock } from '@models/flow';
import { BiasAnnotationsDescriptor, BlockType, BlockTypeName, FlowBlock } from '@models/flow';
import { BlockDraftContext, BlocksCallServiceBase } from './block-call.base';
import { catchError, finalize, firstValueFrom, map, Observable, of, shareReplay, throwError } from 'rxjs';
@ -18,9 +18,26 @@ export class BlocksService {
private readonly pendingServerSyncCount = signal(0);
private _blockTypes = signal<BlockType[]>([]);
private readonly _biasAnnotationsDescriptor = signal<BiasAnnotationsDescriptor | null>(null);
private biasDescriptorPromise: Promise<BiasAnnotationsDescriptor> | null = null;
readonly hasPendingServerSync = computed(() => this.pendingServerSyncCount() > 0);
readonly blockTypes = this._blockTypes.asReadonly();
readonly catalogLoading = this._catalogLoading.asReadonly();
readonly biasAnnotationsDescriptor = this._biasAnnotationsDescriptor.asReadonly();
async getBiasAnnotationsDescriptor(force = false): Promise<BiasAnnotationsDescriptor> {
const cached = this._biasAnnotationsDescriptor();
if (cached && !force) return cached;
if (this.biasDescriptorPromise && !force) return this.biasDescriptorPromise;
this.biasDescriptorPromise = firstValueFrom(this.blocksCallService.retrieveBiasAnnotationsDescriptor())
.then((descriptor) => {
this._biasAnnotationsDescriptor.set(descriptor);
return descriptor;
})
.finally(() => { this.biasDescriptorPromise = null; });
return this.biasDescriptorPromise;
}
hasLoadedBlockTypes() {
return this._blockTypes().length > 0 || (!this.toInit && !this.loadingPromise);

View File

@ -0,0 +1,28 @@
:host { display: block; }
.bias-section { margin: 12px; padding: 12px; border: 1px solid #d8dee9; border-radius: 10px; background: #fff; color: #263244; }
.bias-header, .bias-actions, .bias-modal-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.bias-title { font-size: 13px; font-weight: 700; }
.bias-counter { color: #64748b; font-size: 11px; }
button { border: 1px solid #cbd5e1; border-radius: 6px; background: white; padding: 5px 8px; cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: .5; }
.bias-add, button.primary { color: white; border-color: #2563eb; background: #2563eb; }
.bias-empty { margin-top: 10px; color: #64748b; font-size: 12px; }
.bias-list { display: grid; gap: 8px; margin-top: 10px; }
.bias-card { border: 1px solid #e2e8f0; border-radius: 8px; padding: 9px; background: #f8fafc; }
.bias-badges { display: flex; flex-wrap: wrap; gap: 4px; }
.bias-badge { border-radius: 999px; padding: 2px 7px; background: #e2e8f0; font-size: 10px; font-weight: 650; }
.bias-badge.severity { background: #fee2e2; color: #991b1b; }
.bias-badge.status { background: #dbeafe; color: #1e40af; }
.bias-issue { margin: 7px 0; font-size: 12px; white-space: pre-wrap; }
.bias-actions { justify-content: flex-end; }
.bias-actions .danger { color: #b91c1c; }
.bias-error, label.invalid em { margin-top: 5px; color: #b91c1c; font-size: 11px; font-style: normal; }
.bias-modal-backdrop { position: fixed; inset: 0; z-index: 10020; display: grid; place-items: center; background: rgb(15 23 42 / 55%); }
.bias-modal { width: min(620px, 92vw); max-height: 86vh; overflow: auto; border-radius: 14px; background: white; padding: 20px; box-shadow: 0 20px 60px rgb(0 0 0 / 25%); }
.bias-modal h3 { margin: 0 0 14px; font-size: 18px; }
.bias-form { display: grid; gap: 12px; }
.bias-form label { display: grid; gap: 5px; font-size: 12px; font-weight: 650; }
.bias-form input, .bias-form select, .bias-form textarea { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 7px; padding: 8px; font: inherit; font-weight: 400; }
.bias-form label.invalid input, .bias-form label.invalid select, .bias-form label.invalid textarea { border-color: #dc2626; }
.bias-form small { color: #64748b; font-weight: 400; }
.bias-modal-actions { justify-content: flex-end; margin-top: 16px; }

View File

@ -0,0 +1,73 @@
@if (descriptor; as descriptor) {
<section class="bias-section" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<div class="bias-header">
<div>
<div class="bias-title">Bias annotations</div>
<div class="bias-counter">{{ annotations.length }}{{ descriptor.maxItems !== null ? ' / ' + descriptor.maxItems : '' }}</div>
</div>
@if (!readonly) {
<button type="button" class="bias-add" [disabled]="!canAdd" (click)="add($event)">Add bias annotation</button>
}
</div>
@if (listError(); as error) { <div class="bias-error">{{ error }}</div> }
@if (!annotations.length) {
<div class="bias-empty">No bias annotations.</div>
} @else {
<div class="bias-list">
@for (annotation of annotations; track annotation.id ?? $index; let index = $index) {
<article class="bias-card">
<div class="bias-badges">
<span class="bias-badge category">{{ optionLabel('category', annotation.category) }}</span>
<span class="bias-badge severity">{{ optionLabel('severity', annotation.severity) }}</span>
<span class="bias-badge status">{{ optionLabel('status', annotation.status) }}</span>
</div>
<div class="bias-issue">{{ annotation.issue }}</div>
@if (serverError(index); as error) { <div class="bias-error">{{ error }}</div> }
@for (field of fields; track field.key) {
@if (serverError(index, field.key); as error) { <div class="bias-error">{{ field.label }}: {{ error }}</div> }
}
@if (!readonly) {
<div class="bias-actions">
<button type="button" (click)="edit(index, $event)">Edit</button>
<button type="button" class="danger" (click)="remove(index, $event)">Remove</button>
</div>
}
</article>
}
</div>
}
</section>
@if (editorOpen) {
<div class="bias-modal-backdrop" (pointerdown)="$event.stopPropagation()" (click)="close($event)">
<form class="bias-modal" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()" (submit)="save($event)">
<h3>{{ editingIndex === null ? 'Add' : 'Edit' }} bias annotation</h3>
<div class="bias-form">
@for (field of fields; track field.key) {
<label [class.invalid]="clientErrors[field.key]">
<span>{{ field.label }}{{ field.required ? ' *' : '' }}</span>
@if (field.options.length) {
<select [(ngModel)]="draft[field.key]" [name]="field.key">
<option value="">Select {{ field.label.toLowerCase() }}</option>
@for (option of field.options; track option.value) { <option [value]="option.value">{{ option.label }}</option> }
</select>
} @else if (field.widget === 'textarea' || (field.maxLength ?? 0) > 500) {
<textarea [(ngModel)]="draft[field.key]" [name]="field.key" [maxlength]="field.maxLength ?? null" [placeholder]="field.placeholder ?? ''" rows="5"></textarea>
} @else {
<input type="text" [(ngModel)]="draft[field.key]" [name]="field.key" [maxlength]="field.maxLength ?? null" [placeholder]="field.placeholder ?? ''" />
}
@if (optionDescription(field); as description) { <small>{{ description }}</small> }
@if (field.maxLength) { <small>{{ valueLength(field.key) }} / {{ field.maxLength }}</small> }
@if (clientErrors[field.key]; as error) { <em>{{ error }}</em> }
</label>
}
</div>
<div class="bias-modal-actions">
<button type="button" (click)="close($event)">Cancel</button>
<button type="submit" class="primary">Save</button>
</div>
</form>
</div>
}
}

View File

@ -0,0 +1,96 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { BiasAnnotationsDescriptor } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { EditorStateHolder } from '@stores/flow-editor';
import { vi } from 'vitest';
import { BiasAnnotationsComponent } from './bias-annotations';
const descriptor: BiasAnnotationsDescriptor = {
type: 'BiasAnnotation', blockProperty: 'biasAnnotations', multiple: true, maxItems: 2,
schema: {
type: 'object', required: ['category', 'issue'], properties: {
id: { type: 'string', 'x-ui-order': 0 },
category: { type: 'string', 'x-ui-label': 'Category', 'x-ui-order': 1 },
issue: { type: 'string', maxLength: 5, 'x-ui-widget': 'textarea', 'x-ui-order': 2 },
status: { type: 'string', 'x-ui-order': 3 }
}
},
options: {
category: [{ value: 'DYNAMIC', label: 'Dynamic category', description: 'Loaded from the API' }],
status: [{ value: 'NEW', label: 'New status' }]
},
defaults: { status: 'NEW' }, serverGeneratedFields: ['id']
};
describe('BiasAnnotationsComponent', () => {
let fixture: ComponentFixture<BiasAnnotationsComponent>;
let component: BiasAnnotationsComponent;
const validationErrors = signal<any[]>([]);
beforeEach(async () => {
validationErrors.set([]);
await TestBed.configureTestingModule({
imports: [BiasAnnotationsComponent],
providers: [
{ provide: BlocksService, useValue: { biasAnnotationsDescriptor: signal(descriptor) } },
{ provide: EditorStateHolder, useValue: { flowValidationErrors: validationErrors } }
]
}).compileComponents();
fixture = TestBed.createComponent(BiasAnnotationsComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('blockId', 'block-1');
fixture.componentRef.setInput('annotations', []);
fixture.detectChanges();
});
it('renders API options and applies defaults without generating an id', () => {
(fixture.nativeElement.querySelector('.bias-add') as HTMLButtonElement).click();
fixture.detectChanges();
expect(component.draft).toEqual({ status: 'NEW' });
expect(component.fields.map((field) => field.key)).toEqual(['category', 'issue', 'status']);
expect(fixture.nativeElement.textContent).toContain('Dynamic category');
component.draft.category = 'DYNAMIC';
expect(component.optionDescription(component.fields[0])).toBe('Loaded from the API');
});
it('enforces required fields and schema maxLength, then adds and edits', () => {
const emitted = vi.fn();
component.annotationsChange.subscribe(emitted);
component.add();
component.save();
expect(component.clientErrors['category']).toContain('required');
component.draft = { category: 'DYNAMIC', issue: '123456', status: 'NEW' };
component.save();
expect(component.clientErrors['issue']).toContain('5');
component.draft.issue = 'valid';
component.save();
expect(emitted).toHaveBeenLastCalledWith([{ category: 'DYNAMIC', issue: 'valid', status: 'NEW' }]);
component.annotations = [{ id: 'server-id', category: 'DYNAMIC', issue: 'old' }];
component.edit(0);
component.draft.issue = 'new';
component.save();
expect(emitted).toHaveBeenLastCalledWith([{ id: 'server-id', category: 'DYNAMIC', issue: 'new' }]);
});
it('removes entries, respects maxItems and is read-only when finalized', () => {
const emitted = vi.fn();
component.annotationsChange.subscribe(emitted);
fixture.componentRef.setInput('annotations', [{ issue: 'one' }, { issue: 'two' }]);
expect(component.canAdd).toBe(false);
component.remove(0);
expect(emitted).toHaveBeenCalledWith([{ issue: 'two' }]);
fixture.componentRef.setInput('readonly', true);
component.remove(0);
expect(emitted).toHaveBeenCalledTimes(1);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('Add bias annotation');
});
it('maps backend validation paths to the matching annotation field', () => {
validationErrors.set([{ code: 'BIAS_CATEGORY_REQUIRED', id: 'block-1', field: 'biasAnnotations[0].category', message: 'Category required' }]);
expect(component.serverError(0, 'category')).toBe('Category required');
expect(component.serverError(1, 'category')).toBeNull();
});
});

View File

@ -0,0 +1,217 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BiasAnnotation, BiasAnnotationOption, BiasAnnotationsDescriptor, FlowValidationError } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { EditorStateHolder } from '@stores/flow-editor';
type BiasField = {
key: string;
label: string;
description?: string;
placeholder?: string;
widget?: string;
maxLength?: number;
required: boolean;
options: BiasAnnotationOption[];
};
const BIAS_ERROR_CODES = new Set([
'TOO_MANY_BIAS_ANNOTATIONS', 'NULL_BIAS_ANNOTATION', 'DUPLICATE_BIAS_ANNOTATION_ID',
'BIAS_CATEGORY_REQUIRED', 'BIAS_SEVERITY_REQUIRED', 'BIAS_ISSUE_REQUIRED', 'BIAS_FIELD_TOO_LONG'
]);
@Component({
selector: 'app-bias-annotations',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './bias-annotations.html',
styleUrl: './bias-annotations.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BiasAnnotationsComponent {
private readonly blocks = inject(BlocksService);
private readonly editorState = inject(EditorStateHolder);
@Input({ required: true }) blockId = '';
@Input() annotations: BiasAnnotation[] = [];
@Input() readonly = false;
@Output() annotationsChange = new EventEmitter<BiasAnnotation[]>();
editorOpen = false;
editingIndex: number | null = null;
draft: BiasAnnotation = {};
clientErrors: Record<string, string> = {};
get descriptor(): BiasAnnotationsDescriptor | null {
const descriptorSignal = (this.blocks as BlocksService & {
biasAnnotationsDescriptor?: () => BiasAnnotationsDescriptor | null
}).biasAnnotationsDescriptor;
return typeof descriptorSignal === 'function' ? descriptorSignal() : null;
}
get fields(): BiasField[] {
const descriptor = this.descriptor;
const descriptorSchema = descriptor?.schema ?? {};
const schema = descriptorSchema['type'] === 'array'
? this.record(descriptorSchema['items'])
: descriptorSchema;
const properties = this.record(schema['properties']);
const required = new Set(Array.isArray(schema['required']) ? schema['required'].map(String) : []);
const generated = new Set(descriptor?.serverGeneratedFields ?? []);
return Object.entries(properties)
.filter(([key]) => !generated.has(key))
.map(([key, raw]) => {
const field = this.record(raw);
const maxLength = Number(field['maxLength']);
return {
key,
label: String(field['x-ui-label'] ?? this.humanize(key)),
description: this.optionalString(field['x-ui-description']),
placeholder: this.optionalString(field['x-ui-placeholder']),
widget: this.optionalString(field['x-ui-widget']),
maxLength: Number.isFinite(maxLength) && maxLength >= 0 ? maxLength : undefined,
required: required.has(key),
options: descriptor?.options[key]?.length
? descriptor.options[key]
: (Array.isArray(field['enum'])
? field['enum'].map((value) => ({ value: String(value), label: String(value) }))
: [])
};
})
.sort((left, right) => {
const leftOrder = Number(this.record(properties[left.key])['x-ui-order']);
const rightOrder = Number(this.record(properties[right.key])['x-ui-order']);
return (Number.isFinite(leftOrder) ? leftOrder : 999) - (Number.isFinite(rightOrder) ? rightOrder : 999);
});
}
get canAdd(): boolean {
if (this.readonly || !this.descriptor) return false;
if (!this.descriptor.multiple && this.annotations.length >= 1) return false;
const max = this.descriptor.maxItems;
return max == null || this.annotations.length < max;
}
add(event?: Event) {
event?.stopPropagation();
if (!this.canAdd) return;
this.editingIndex = null;
this.draft = this.clone(this.descriptor?.defaults ?? {});
for (const generated of this.descriptor?.serverGeneratedFields ?? []) delete this.draft[generated];
this.clientErrors = {};
this.editorOpen = true;
}
edit(index: number, event?: Event) {
event?.stopPropagation();
if (this.readonly || index < 0 || index >= this.annotations.length) return;
this.editingIndex = index;
this.draft = this.clone(this.annotations[index]);
this.clientErrors = {};
this.editorOpen = true;
}
remove(index: number, event?: Event) {
event?.stopPropagation();
if (this.readonly || index < 0 || index >= this.annotations.length) return;
const next = [...this.annotations];
next.splice(index, 1);
this.annotationsChange.emit(next);
}
close(event?: Event) {
event?.stopPropagation();
this.editorOpen = false;
this.clientErrors = {};
}
save(event?: Event) {
event?.stopPropagation();
this.clientErrors = this.validateDraft();
if (Object.keys(this.clientErrors).length) return;
const next = [...this.annotations];
const clean = this.clone(this.draft);
for (const field of this.fields) {
if (clean[field.key] === '') delete clean[field.key];
}
if (this.editingIndex == null) next.push(clean);
else next[this.editingIndex] = clean;
this.annotationsChange.emit(next);
this.close();
}
optionLabel(field: string, value: unknown): string {
return this.descriptor?.options[field]?.find((option) => option.value === value)?.label ?? String(value ?? '—');
}
optionDescription(field: BiasField): string | null {
const value = this.draft[field.key];
return field.options.find((option) => option.value === value)?.description ?? field.description ?? null;
}
valueLength(field: string): number {
return String(this.draft[field] ?? '').length;
}
serverError(index: number, field?: string): string | null {
const errors = this.relevantServerErrors();
const expected = field ? new RegExp(`^biasAnnotations\\[${index}\\]\\.${this.escapeRegExp(field)}$`) : null;
const found = errors.find((error) => {
const path = String(error.field ?? '');
return expected ? expected.test(path) : path === `biasAnnotations[${index}]`;
});
return found?.message ?? null;
}
listError(): string | null {
const error = this.relevantServerErrors().find((candidate) =>
candidate.code === 'TOO_MANY_BIAS_ANNOTATIONS' || candidate.field === 'biasAnnotations'
);
return error?.message ?? null;
}
private relevantServerErrors(): FlowValidationError[] {
return this.editorState.flowValidationErrors().filter((error) => {
if (error.code && !BIAS_ERROR_CODES.has(error.code)) return false;
const related = error.relatedNodeIds ?? [];
return (!error.id || error.id === this.blockId || related.includes(this.blockId))
&& String(error.field ?? '').startsWith('biasAnnotations');
});
}
private validateDraft(): Record<string, string> {
const errors: Record<string, string> = {};
for (const field of this.fields) {
const value = this.draft[field.key];
if (field.required && (value == null || String(value).trim() === '')) {
errors[field.key] = `${field.label} is required.`;
} else if (field.maxLength != null && typeof value === 'string' && value.length > field.maxLength) {
errors[field.key] = `${field.label} must not exceed ${field.maxLength} characters.`;
}
}
return errors;
}
private record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
private optionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.length ? value : undefined;
}
private humanize(value: string): string {
return value.replace(/([A-Z])/g, ' $1').replace(/^./, (char) => char.toUpperCase()).trim();
}
private clone<T>(value: T): T {
return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
}
private escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
}

View File

@ -510,6 +510,12 @@
}
</div>
<app-bias-annotations
[blockId]="blockId ?? ''"
[annotations]="biasAnnotations"
[readonly]="isReadonly"
(annotationsChange)="updateBiasAnnotations($event)" />
@if (localEditorOpen) {
<div class="llm-modal-backdrop" (pointerdown)="$event.stopPropagation()" (click)="closeSimpleParamEditor($event)">
<div class="llm-modal" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">

View File

@ -4,6 +4,7 @@ import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialo
import { FieldRetriever } from '@services/retriever/field-retriever';
import { EditorStateHolder } from '@stores/flow-editor';
import { vi } from 'vitest';
import { of } from 'rxjs';
import { GenericNodeComponent } from './generic-node';
@ -25,6 +26,7 @@ describe('GenericNodeComponent', () => {
provide: EditorStateHolder,
useValue: {
currentFlow: vi.fn().mockReturnValue(null),
flowValidationErrors: vi.fn().mockReturnValue([]),
isBlockSelected: vi.fn().mockReturnValue(false),
isValidationNodeHighlighted: vi.fn().mockReturnValue(false),
updateData: vi.fn(),
@ -43,7 +45,9 @@ describe('GenericNodeComponent', () => {
provide: BlocksService,
useValue: {
peekBlockType: vi.fn().mockReturnValue(null),
getBlockType: vi.fn().mockResolvedValue(null)
getBlockType: vi.fn().mockResolvedValue(null),
biasAnnotationsDescriptor: vi.fn().mockReturnValue(null),
updateBlock: vi.fn()
}
}
]
@ -89,4 +93,26 @@ describe('GenericNodeComponent', () => {
expect(component.focusOpen).toBe(false);
expect(nodeData['__focusOpen']).toBe(false);
});
it('preserves id, position and bias annotations during block regeneration', async () => {
const blocks = TestBed.inject(BlocksService) as any;
const replacement = vi.fn().mockResolvedValue(undefined);
component.data.data = {
...component.data.data,
id: 'old-id', typeName: 'LLMBlock', position: { x: 10, y: 20 },
biasAnnotations: [{ id: 'bias-1', category: 'DYNAMIC', issue: 'keep me' }],
__needsServerCreate: true, replaceWithCreatedNode: replacement
};
blocks.updateBlock.mockReturnValue(of({
id: 'generated-id', name: 'Generated', typeName: 'LLMBlock', inputs: [], outputs: [],
specificConfiguration: {}, position: { x: 99, y: 99 }, biasAnnotations: []
}));
(component as any).maybeCreateBlockOnServer();
await fixture.whenStable();
expect(replacement).toHaveBeenCalledWith(expect.objectContaining({
id: 'old-id', position: { x: 10, y: 20 },
biasAnnotations: [{ id: 'bias-1', category: 'DYNAMIC', issue: 'keep me' }]
}));
});
});

View File

@ -1,8 +1,9 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, HostListener, inject, Input, OnDestroy } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, ElementRef, HostBinding, HostListener, inject, Input, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, normalizeFlowPortValueKinds } from '@models/flow';
import { BiasAnnotation, BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, normalizeFlowPortValueKinds } from '@models/flow';
import { BiasAnnotationsComponent } from '../../bias-annotations/bias-annotations';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import {
@ -121,7 +122,7 @@ type RenderedSocketPort = {
@Component({
selector: 'app-generic-node',
imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule],
imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule, BiasAnnotationsComponent],
templateUrl: './generic-node.html',
styleUrl: './generic-node.css',
host: {
@ -208,7 +209,22 @@ export class GenericNodeComponent implements OnDestroy {
private focusOriginalNextSibling: Node | null = null;
private pageScrollLocked = false;
constructor() {
effect(() => {
const descriptorSignal = (this.blocksService as BlocksService & {
biasAnnotationsDescriptor?: () => { blockProperty?: string } | null
}).biasAnnotationsDescriptor;
const property = typeof descriptorSignal === 'function' ? descriptorSignal()?.blockProperty : null;
if (this.data?.data && typeof property === 'string' && property.length) {
this.data.data.__biasAnnotationsProperty = property;
}
});
}
ngOnInit() {
if (this.data?.data) {
this.data.data.__biasAnnotationsProperty = this.biasAnnotationsProperty;
}
this.outputs = [];
this.inputs = [];
this.parameterFields = [];
@ -667,11 +683,25 @@ export class GenericNodeComponent implements OnDestroy {
return typeof typeName === 'string' && typeName.length > 0 ? typeName : 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;
}
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[] : [];
}
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 ensureBlockConfiguration(): Record<string, any> {
if (!this.data?.data) {
this.data.data = {};
@ -688,6 +718,14 @@ export class GenericNodeComponent implements OnDestroy {
this.editorState.updateData(this.cloneCurrentFlowWithNodeChanges(flow.data));
}
updateBiasAnnotations(annotations: BiasAnnotation[]) {
if (this.isReadonly || !this.data?.data) return;
this.data.data[this.biasAnnotationsProperty] = this.cloneFlowData(annotations);
this.data.data.__biasAnnotationsProperty = this.biasAnnotationsProperty;
this.markFlowDirty();
this.refreshView();
}
private async loadSchemaContext() {
if (this.schemaLoading) return;
const type = this.blockType;
@ -1851,6 +1889,9 @@ export class GenericNodeComponent implements OnDestroy {
block.name = configuredName || this.name || block.name;
block.typeName = this.blockType ?? block.typeName;
block.specificConfiguration = this.cloneFlowData(this.blockConfiguration ?? {});
(block as unknown as Record<string, unknown>)[this.biasAnnotationsProperty] = this.cloneFlowData(
Array.isArray(nodeData?.[this.biasAnnotationsProperty]) ? nodeData[this.biasAnnotationsProperty] : []
);
return nextFlowData;
}
@ -1878,11 +1919,17 @@ export class GenericNodeComponent implements OnDestroy {
).subscribe({
next: (createdBlock) => {
const current = (this.data?.data ?? {}) as Record<string, unknown>;
const annotationsProperty = this.biasAnnotationsProperty;
const replaceNode = current['replaceWithCreatedNode'];
if (typeof replaceNode === 'function') {
void replaceNode({
...createdBlock,
id: String(current['id'] ?? createdBlock.id),
position: (current['position'] as { x: number; y: number } | undefined) ?? createdBlock.position,
[annotationsProperty]: Array.isArray(current[annotationsProperty])
? this.cloneFlowData(current[annotationsProperty])
: [],
__biasAnnotationsProperty: annotationsProperty,
__focusOpen: current['__focusOpen'] === true
});
return;
@ -1891,7 +1938,12 @@ export class GenericNodeComponent implements OnDestroy {
this.data.data = {
...current,
...createdBlock,
id: String(current['id'] ?? createdBlock.id),
position: (current['position'] as { x: number; y: number } | undefined) ?? createdBlock.position,
[annotationsProperty]: Array.isArray(current[annotationsProperty])
? this.cloneFlowData(current[annotationsProperty])
: [],
__biasAnnotationsProperty: annotationsProperty,
__needsServerCreate: false,
__isCreatingOnServer: false,
__createdOnServer: true,

View File

@ -480,6 +480,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
inputs: currentNode.inputs,
outputs: currentNode.outputs,
specificConfiguration: currentNode.specificConfiguration,
biasAnnotations: currentNode.biasAnnotations,
typeName: currentNode.typeName,
userInteractive: currentNode['userInteractive'],
nodeFamily: currentNode.nodeFamily,
@ -491,6 +492,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
inputs: nextNode.inputs,
outputs: nextNode.outputs,
specificConfiguration: nextNode.specificConfiguration,
biasAnnotations: nextNode.biasAnnotations,
typeName: nextNode.typeName,
userInteractive: nextNode['userInteractive'],
nodeFamily: nextNode.nodeFamily,

View File

@ -25,7 +25,7 @@ describe('EditorStateHolder', () => {
let service: EditorStateHolder;
let confirmSpy: { open: ReturnType<typeof vi.fn> };
let authSpy: { loggedInUser: ReturnType<typeof vi.fn> };
let flowsServiceSpy: { getFlowValidation: ReturnType<typeof vi.fn> };
let flowsServiceSpy: { getFlowValidation: ReturnType<typeof vi.fn>; updateFlow: ReturnType<typeof vi.fn> };
beforeEach(() => {
confirmSpy = {
@ -35,7 +35,8 @@ describe('EditorStateHolder', () => {
loggedInUser: vi.fn().mockReturnValue({ username: 'testuser', email: null, role: 'USER' })
};
flowsServiceSpy = {
getFlowValidation: vi.fn()
getFlowValidation: vi.fn(),
updateFlow: vi.fn()
};
TestBed.configureTestingModule({
@ -155,6 +156,31 @@ describe('EditorStateHolder', () => {
});
});
it('saves annotations in the full flow payload and adopts server-generated ids', async () => {
const flow = makeFlow({
data: {
blocks: [{
id: 'block-1', name: 'Block', typeName: 'LLMBlock', inputs: [], outputs: [],
specificConfiguration: {}, biasAnnotations: [{ category: 'DYNAMIC', severity: 'HIGH', issue: 'Issue' }]
}],
containers: [], connections: [], dependencies: []
}
});
await service.openDocument(flow);
const saved = structuredClone(flow);
saved.data.blocks[0].biasAnnotations = [{
id: 'bias-server-1', category: 'DYNAMIC', severity: 'HIGH', issue: 'Issue'
}];
flowsServiceSpy.updateFlow.mockReturnValue(of(saved));
await new Promise<void>((resolve, reject) => service.save().subscribe({ next: () => resolve(), error: reject }));
expect(flowsServiceSpy.updateFlow).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ blocks: [expect.objectContaining({ biasAnnotations: [expect.objectContaining({ issue: 'Issue' })] })] })
}));
expect(service.currentFlow()?.data.blocks[0].biasAnnotations?.[0].id).toBe('bias-server-1');
});
describe('isCurrentFlowReadOnly', () => {
it('should return true for finalized flow', async () => {
await service.openDocument(makeFlow({ finalized: true }));

View File

@ -152,12 +152,17 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
const nodeIdToBlockId = new Map<string, string>();
const nodes: FlowNode[] = editor.getNodes().map((node) => {
const blockData = node.data;
const blockRecord = blockData as unknown as Record<string, unknown> | undefined;
const blockId = blockData?.id ?? node.id;
nodeIdToBlockId.set(node.id, blockId);
const inputs = cloneValue(blockData?.inputs ?? []);
const outputs = cloneValue(blockData?.outputs ?? []);
const biasAnnotationsProperty = typeof blockRecord?.['__biasAnnotationsProperty'] === 'string'
? String(blockRecord['__biasAnnotationsProperty'])
: 'biasAnnotations';
return {
id: blockId,
name: blockData?.name ?? node.label,
@ -165,6 +170,9 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
inputs,
outputs,
specificConfiguration: cloneValue(blockData?.specificConfiguration ?? {}),
...(blockData?.nodeFamily === 'container'
? {}
: { [biasAnnotationsProperty]: cloneValue(blockRecord?.[biasAnnotationsProperty] ?? []) }),
typeName: blockData?.typeName ?? "LLMBlock",
nodeFamily: blockData?.nodeFamily === 'container' ? 'container' : 'block'
};