diff --git a/src/app/layouts/flow-editor/flow-editor.ts b/src/app/layouts/flow-editor/flow-editor.ts index f9264c6..2100702 100644 --- a/src/app/layouts/flow-editor/flow-editor.ts +++ b/src/app/layouts/flow-editor/flow-editor.ts @@ -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; diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index 8128db1..79cc17b 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -86,8 +86,38 @@ export type FlowNodeBase = { nodeFamily?: NodeFamily; }; +export type BiasAnnotation = Record & { + 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; + options: Record; + defaults: Record; + serverGeneratedFields: string[]; +}; + export type FlowBlock = FlowNodeBase & { nodeFamily?: 'block'; + biasAnnotations?: BiasAnnotation[]; }; export type FlowContainer = FlowNodeBase & { diff --git a/src/app/models/nodes.ts b/src/app/models/nodes.ts index 82a440c..8107b6c 100644 --- a/src/app/models/nodes.ts +++ b/src/app/models/nodes.ts @@ -17,6 +17,7 @@ export type HFNodeData = FlowNode & { __containerValidationErrors?: unknown[]; __containerAssignmentError?: string | null; __containerAssigning?: boolean; + __biasAnnotationsProperty?: string; }; export type HFNode = ClassicPreset.Node & { diff --git a/src/app/services/blocks/block-call.base.ts b/src/app/services/blocks/block-call.base.ts index 90a0335..65b090d 100644 --- a/src/app/services/blocks/block-call.base.ts +++ b/src/app/services/blocks/block-call.base.ts @@ -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; + abstract retrieveBiasAnnotationsDescriptor(): Observable; + abstract createEmptyBlock(blockType: BlockTypeName, context?: BlockDraftContext) : Observable; abstract updateBlock(blockId : string, configuration : any, context?: BlockDraftContext) : Observable; diff --git a/src/app/services/blocks/blocks-call.fake.ts b/src/app/services/blocks/blocks-call.fake.ts index 464a4f8..90b8fba 100644 --- a/src/app/services/blocks/blocks-call.fake.ts +++ b/src/app/services/blocks/blocks-call.fake.ts @@ -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 { + 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 { const descriptor = this.blockTypes.find((b) => b.type === blockType); const typeName = descriptor?.type ?? blockType ?? "LLMBlock"; diff --git a/src/app/services/blocks/blocks-call.spec.ts b/src/app/services/blocks/blocks-call.spec.ts index fbb9f0a..b3747ed 100644 --- a/src/app/services/blocks/blocks-call.spec.ts +++ b/src/app/services/blocks/blocks-call.spec.ts @@ -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' }] } + })); + }); }); diff --git a/src/app/services/blocks/blocks-call.ts b/src/app/services/blocks/blocks-call.ts index 0545255..256cde3 100644 --- a/src/app/services/blocks/blocks-call.ts +++ b/src/app/services/blocks/blocks-call.ts @@ -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 { + return this.http + .get(`${environment.apiUrl}/blocks/bias-annotations/descriptor`) + .pipe(map((raw) => this.biasAnnotationsDescriptorFromApi(raw))); + } + override createEmptyBlock(blockType: string, context?: BlockDraftContext): Observable { 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 = {}; + 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) + : [] }; } diff --git a/src/app/services/blocks/blocks.ts b/src/app/services/blocks/blocks.ts index 92c38ee..853185c 100644 --- a/src/app/services/blocks/blocks.ts +++ b/src/app/services/blocks/blocks.ts @@ -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([]); + private readonly _biasAnnotationsDescriptor = signal(null); + private biasDescriptorPromise: Promise | 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 { + 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); diff --git a/src/app/shared/bias-annotations/bias-annotations.css b/src/app/shared/bias-annotations/bias-annotations.css new file mode 100644 index 0000000..41ef8c6 --- /dev/null +++ b/src/app/shared/bias-annotations/bias-annotations.css @@ -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; } diff --git a/src/app/shared/bias-annotations/bias-annotations.html b/src/app/shared/bias-annotations/bias-annotations.html new file mode 100644 index 0000000..53ec493 --- /dev/null +++ b/src/app/shared/bias-annotations/bias-annotations.html @@ -0,0 +1,73 @@ +@if (descriptor; as descriptor) { +
+
+
+
Bias annotations
+
{{ annotations.length }}{{ descriptor.maxItems !== null ? ' / ' + descriptor.maxItems : '' }}
+
+ @if (!readonly) { + + } +
+ + @if (listError(); as error) {
{{ error }}
} + @if (!annotations.length) { +
No bias annotations.
+ } @else { +
+ @for (annotation of annotations; track annotation.id ?? $index; let index = $index) { +
+
+ {{ optionLabel('category', annotation.category) }} + {{ optionLabel('severity', annotation.severity) }} + {{ optionLabel('status', annotation.status) }} +
+
{{ annotation.issue }}
+ @if (serverError(index); as error) {
{{ error }}
} + @for (field of fields; track field.key) { + @if (serverError(index, field.key); as error) {
{{ field.label }}: {{ error }}
} + } + @if (!readonly) { +
+ + +
+ } +
+ } +
+ } +
+ +@if (editorOpen) { +
+
+

{{ editingIndex === null ? 'Add' : 'Edit' }} bias annotation

+
+ @for (field of fields; track field.key) { + + } +
+
+ + +
+
+
+} +} diff --git a/src/app/shared/bias-annotations/bias-annotations.spec.ts b/src/app/shared/bias-annotations/bias-annotations.spec.ts new file mode 100644 index 0000000..048ed9a --- /dev/null +++ b/src/app/shared/bias-annotations/bias-annotations.spec.ts @@ -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; + let component: BiasAnnotationsComponent; + const validationErrors = signal([]); + + 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(); + }); +}); diff --git a/src/app/shared/bias-annotations/bias-annotations.ts b/src/app/shared/bias-annotations/bias-annotations.ts new file mode 100644 index 0000000..2174d3f --- /dev/null +++ b/src/app/shared/bias-annotations/bias-annotations.ts @@ -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(); + + editorOpen = false; + editingIndex: number | null = null; + draft: BiasAnnotation = {}; + clientErrors: Record = {}; + + 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 { + const errors: Record = {}; + 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 { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + } + + 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(value: T): T { + return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value)); + } + + private escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } +} diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index 5e8c92f..ee71bb4 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -510,6 +510,12 @@ } + + @if (localEditorOpen) {
diff --git a/src/app/shared/nodes/generic-node/generic-node.spec.ts b/src/app/shared/nodes/generic-node/generic-node.spec.ts index e2c472a..b931856 100644 --- a/src/app/shared/nodes/generic-node/generic-node.spec.ts +++ b/src/app/shared/nodes/generic-node/generic-node.spec.ts @@ -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' }] + })); + }); }); diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 3b8257f..5aadee3 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -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 | 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 { 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)[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; + 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, diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts index a996031..94c5bf4 100644 --- a/src/app/shared/rete-editor/rete-editor.ts +++ b/src/app/shared/rete-editor/rete-editor.ts @@ -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, diff --git a/src/app/stores/flow-editor.spec.ts b/src/app/stores/flow-editor.spec.ts index 7d69be9..20e1394 100644 --- a/src/app/stores/flow-editor.spec.ts +++ b/src/app/stores/flow-editor.spec.ts @@ -25,7 +25,7 @@ describe('EditorStateHolder', () => { let service: EditorStateHolder; let confirmSpy: { open: ReturnType }; let authSpy: { loggedInUser: ReturnType }; - let flowsServiceSpy: { getFlowValidation: ReturnType }; + let flowsServiceSpy: { getFlowValidation: ReturnType; updateFlow: ReturnType }; 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((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 })); diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 95dfe9f..b2a1886 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -152,12 +152,17 @@ export function exportGraph(editor: NodeEditor) { const nodeIdToBlockId = new Map(); const nodes: FlowNode[] = editor.getNodes().map((node) => { const blockData = node.data; + const blockRecord = blockData as unknown as Record | 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) { inputs, outputs, specificConfiguration: cloneValue(blockData?.specificConfiguration ?? {}), + ...(blockData?.nodeFamily === 'container' + ? {} + : { [biasAnnotationsProperty]: cloneValue(blockRecord?.[biasAnnotationsProperty] ?? []) }), typeName: blockData?.typeName ?? "LLMBlock", nodeFamily: blockData?.nodeFamily === 'container' ? 'container' : 'block' };