feat(flow-editor): support bias annotations
This commit is contained in:
parent
a258064eb5
commit
0d60da1874
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 & {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export type HFNodeData = FlowNode & {
|
|||
__containerValidationErrors?: unknown[];
|
||||
__containerAssignmentError?: string | null;
|
||||
__containerAssigning?: boolean;
|
||||
__biasAnnotationsProperty?: string;
|
||||
};
|
||||
|
||||
export type HFNode = ClassicPreset.Node & {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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' }] }
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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, '\\$&');
|
||||
}
|
||||
}
|
||||
|
|
@ -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()">
|
||||
|
|
|
|||
|
|
@ -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' }]
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue