Merge branch feature/human-evaluation-node into main

This commit is contained in:
Lucio Lelii 2026-09-22 16:08:33 +02:00
commit def50c229b
15 changed files with 793 additions and 2 deletions

View File

@ -16,6 +16,13 @@ export type HumanDecisionOption = {
label: string;
};
/** One thing a tester is asked to judge, as the evaluation form renders it. */
export type EvaluationCriterionView = {
name: string;
description: string;
scale: 'PASS_FAIL' | 'SCORE_1_5';
};
export type HumanInteractionRuntimeInput = {
name: string;
value: string;
@ -45,6 +52,21 @@ export type HumanInteractionDialogInput = {
decisionOptions?: HumanDecisionOption[];
rationaleRequired?: boolean;
rationaleLabel?: string;
/** Evaluation form: what to open, the script to follow, and what to score. */
evaluationTarget?: string;
taskScript?: string[];
criteria?: EvaluationCriterionView[];
evidenceRequired?: boolean;
evidenceCount?: number;
/**
* A reference verdict the node is keeping out of sight until this evaluation is submitted. The
* point is not secrecy - it is that a judgement made after reading it is a different measurement.
*/
referenceLabel?: string | null;
referenceValue?: string | null;
referenceHidden?: boolean;
onRevealReference?: () => void;
onAttachEvidence?: (files: File[]) => void;
/** Flat substitution map for `${{name}}` / `${{global.name}}` / `${{vars.name}}` placeholders in `question`/`actionDescription`. */
templateValues?: Record<string, unknown>;
onSubmit?: (value: HumanInteractionDialogResult) => void;
@ -52,7 +74,8 @@ export type HumanInteractionDialogInput = {
export type HumanInteractionDialogResult =
| { mode: 'message' | 'complete'; value: string }
| { mode: 'decision'; choice: string; rationale: string };
| { mode: 'decision'; choice: string; rationale: string }
| { mode: 'evaluation'; verdict: Record<string, string>; notes: string };
export type HumanInteractionDialogState = {
executionId: string | null;
@ -78,6 +101,16 @@ export type HumanInteractionDialogState = {
decisionOptions: HumanDecisionOption[];
rationaleRequired: boolean;
rationaleLabel: string;
evaluationTarget: string;
taskScript: string[];
criteria: EvaluationCriterionView[];
evidenceRequired: boolean;
evidenceCount: number;
referenceLabel: string | null;
referenceValue: string | null;
referenceHidden: boolean;
onRevealReference: (() => void) | null;
onAttachEvidence: ((files: File[]) => void) | null;
templateValues: Record<string, unknown>;
onSubmit: ((value: HumanInteractionDialogResult) => void) | null;
resolve: (value: HumanInteractionDialogResult | null) => void;
@ -115,6 +148,16 @@ export class HumanInteractionDialogService {
decisionOptions: input.decisionOptions ?? [],
rationaleRequired: input.rationaleRequired === true,
rationaleLabel: input.rationaleLabel ?? 'Rationale',
evaluationTarget: input.evaluationTarget ?? '',
taskScript: input.taskScript ?? [],
criteria: input.criteria ?? [],
evidenceRequired: input.evidenceRequired === true,
evidenceCount: input.evidenceCount ?? 0,
referenceLabel: input.referenceLabel ?? null,
referenceValue: input.referenceValue ?? null,
referenceHidden: input.referenceHidden === true,
onRevealReference: input.onRevealReference ?? null,
onAttachEvidence: input.onAttachEvidence ?? null,
templateValues: input.templateValues ?? {},
onSubmit: input.onSubmit ?? null,
resolve
@ -153,6 +196,16 @@ export class HumanInteractionDialogService {
decisionOptions: input.decisionOptions ?? state.decisionOptions,
rationaleRequired: input.rationaleRequired ?? state.rationaleRequired,
rationaleLabel: input.rationaleLabel ?? state.rationaleLabel,
evaluationTarget: input.evaluationTarget ?? state.evaluationTarget,
taskScript: input.taskScript ?? state.taskScript,
criteria: input.criteria ?? state.criteria,
evidenceRequired: input.evidenceRequired ?? state.evidenceRequired,
evidenceCount: input.evidenceCount ?? state.evidenceCount,
referenceLabel: input.referenceLabel !== undefined ? input.referenceLabel : state.referenceLabel,
referenceValue: input.referenceValue !== undefined ? input.referenceValue : state.referenceValue,
referenceHidden: input.referenceHidden ?? state.referenceHidden,
onRevealReference: input.onRevealReference ?? state.onRevealReference,
onAttachEvidence: input.onAttachEvidence ?? state.onAttachEvidence,
templateValues: input.templateValues ?? state.templateValues,
onSubmit: input.onSubmit !== undefined ? input.onSubmit : state.onSubmit
});

View File

@ -102,6 +102,26 @@ export abstract class TaskExecutionsCallServiceBase {
fieldName: string,
value: string
): Observable<TaskExecution>;
/**
* Submits a human evaluation whole - every criterion and the notes in one call - because the
* fields are one judgement, and half of one recorded on the step is worse than none.
*/
abstract submitEvaluation(
executionId: string,
nodeId: string,
verdict: Record<string, string>,
notes: string
): Observable<TaskExecution>;
abstract attachEvaluationEvidence(
executionId: string,
nodeId: string,
files: File[]
): Observable<TaskExecution>;
/** Asks to see the reference verdict; the execution records that it was seen before judging. */
abstract revealEvaluationReference(
executionId: string,
nodeId: string
): Observable<TaskExecution>;
abstract provideAuthorization(
executionId: string,
key: string,

View File

@ -1005,6 +1005,46 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
return of(execution);
}
override submitEvaluation(
executionId: string,
nodeId: string,
verdict: Record<string, string>,
notes: string
): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
const step = execution.context.steps[nodeId];
execution.context.result[`${nodeId}:verdict`] = verdict;
execution.context.result[`${nodeId}:notes`] = notes;
execution.context.waitingSteps = execution.context.waitingSteps.filter((stepId) => stepId !== nodeId);
if (step) step.status = 'COMPLETED';
execution.context.status = execution.context.waitingSteps.length ? 'WAITING' : 'RUNNING';
return of(execution);
}
override attachEvaluationEvidence(
executionId: string,
nodeId: string,
files: File[]
): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
const key = `${nodeId}:evidence`;
const existing = execution.context.partialResult?.[key];
execution.context.partialResult = {
...(execution.context.partialResult ?? {}),
[key]: [...(Array.isArray(existing) ? existing : []), ...files.map((file) => file.name)]
};
return of(execution);
}
override revealEvaluationReference(executionId: string, nodeId: string): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
execution.context.partialResult = {
...(execution.context.partialResult ?? {}),
[`${nodeId}:__referenceRevealed`]: true
};
return of(execution);
}
override provideAuthorization(
executionId: string,
key: string,

View File

@ -273,6 +273,34 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
}).pipe(map((raw) => this.mapExecution(raw)));
}
override submitEvaluation(
executionId: string,
nodeId: string,
verdict: Record<string, string>,
notes: string
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/evaluation`;
return this.http.put<unknown>(url, { verdict, notes }).pipe(map((raw) => this.mapExecution(raw)));
}
override attachEvaluationEvidence(
executionId: string,
nodeId: string,
files: File[]
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/evaluation/evidence`;
const formData = new FormData();
for (const file of files) {
formData.append('files', file);
}
return this.http.put<unknown>(url, formData).pipe(map((raw) => this.mapExecution(raw)));
}
override revealEvaluationReference(executionId: string, nodeId: string): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/evaluation/reveal`;
return this.http.put<unknown>(url, {}).pipe(map((raw) => this.mapExecution(raw)));
}
override provideAuthorization(
executionId: string,
key: string,

View File

@ -360,6 +360,46 @@ export class TaskExecutionsService {
);
}
submitEvaluation(executionId: string, nodeId: string, verdict: Record<string, string>, notes: string) {
const execution = this._taskExecutions().find((item) => item.id === executionId);
if (execution?.interactionSimulationEnabled === true) {
return throwError(() => new Error('Manual interaction is disabled for simulated executions.'));
}
return this.withRefreshAndErrorHandling(
this.taskExecutionsCallService.submitEvaluation(executionId, nodeId, verdict, notes).pipe(
tap((updatedExecution) => {
if (updatedExecution.executionKind === 'SUBFLOW') {
this.cacheFollowedExecution(updatedExecution);
} else {
this.replaceExecution(updatedExecution);
}
})
),
'Submit evaluation failed'
);
}
attachEvaluationEvidence(executionId: string, nodeId: string, files: File[]) {
return this.withRefreshAndErrorHandling(
this.taskExecutionsCallService.attachEvaluationEvidence(executionId, nodeId, files).pipe(
tap((execution) => this.replaceExecution(execution))
),
'Attach evaluation evidence failed',
false
);
}
revealEvaluationReference(executionId: string, nodeId: string) {
return this.withRefreshAndErrorHandling(
this.taskExecutionsCallService.revealEvaluationReference(executionId, nodeId).pipe(
tap((execution) => this.replaceExecution(execution))
),
'Reveal reference verdict failed',
false
);
}
provideAuthorization(executionId: string, key: string, value: string) {
return this.withRefreshAndErrorHandling(
this.taskExecutionsCallService.provideAuthorization(executionId, key, value).pipe(

View File

@ -34,6 +34,16 @@ function state(overrides: Partial<HumanInteractionDialogState> = {}): HumanInter
],
rationaleRequired: true,
rationaleLabel: 'Evidence-based rationale',
evaluationTarget: '',
taskScript: [],
criteria: [],
evidenceRequired: false,
evidenceCount: 0,
referenceLabel: null,
referenceValue: null,
referenceHidden: false,
onRevealReference: null,
onAttachEvidence: null,
templateValues: {},
onSubmit: null,
resolve: () => undefined,

View File

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
* SPDX-License-Identifier: AGPL-3.0-or-later
* Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
*/
/* Wide enough to hit without aiming, since a tester scores several of these in a row. */
.evaluation-score {
min-width: 56px;
padding: 7px 14px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
color: #1e293b;
font-size: 0.875rem;
cursor: pointer;
}
.evaluation-score:hover:not(:disabled) {
border-color: #64748b;
background: #f8fafc;
}
.evaluation-score-selected {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
font-weight: 600;
}
.evaluation-score:disabled {
cursor: not-allowed;
opacity: 0.65;
}

View File

@ -0,0 +1,137 @@
<!--
SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
SPDX-License-Identifier: AGPL-3.0-or-later
Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
-->
<div class="grid gap-4">
@if (state.evaluationTarget) {
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">What to evaluate</div>
<div class="mt-1 text-base font-semibold text-slate-900">
<app-template-placeholder-text
[text]="state.evaluationTarget"
[values]="state.templateValues"></app-template-placeholder-text>
</div>
</div>
}
@if (state.taskScript.length) {
<fieldset class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<legend class="px-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Steps to perform</legend>
<ol class="ml-4 list-decimal text-sm text-slate-800">
@for (step of state.taskScript; track $index) {
<li class="py-0.5">{{ step }}</li>
}
</ol>
</fieldset>
}
@if (state.referenceLabel) {
<fieldset class="rounded-lg border border-amber-200 bg-amber-50 p-3">
<legend class="px-1 text-xs font-semibold uppercase tracking-wide text-amber-700">Reference verdict</legend>
@if (state.referenceHidden) {
<div class="grid gap-2">
<p class="text-sm text-amber-800">
Hidden until you submit, so your judgement is your own. Opening it is recorded with the result.
</p>
<div>
<button type="button" mat-stroked-button [disabled]="busy()" (click)="revealReference($event)">
Show it anyway
</button>
</div>
</div>
} @else {
<div class="whitespace-pre-wrap break-words font-mono text-sm text-amber-900">{{ state.referenceValue || '-' }}</div>
}
</fieldset>
}
@if (state.runtimeInputs.length) {
<fieldset class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<legend class="px-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Runtime inputs</legend>
<div class="grid gap-3">
@for (input of state.runtimeInputs; track input.name) {
<div>
<div class="text-xs font-semibold text-slate-500">{{ input.name }}</div>
<div class="mt-1 whitespace-pre-wrap break-words font-mono text-sm text-slate-800">{{ input.value || '-' }}</div>
</div>
}
</div>
</fieldset>
}
<fieldset class="grid gap-3">
<legend class="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Your verdict</legend>
@for (criterion of state.criteria; track criterion.name) {
<div class="rounded-lg border border-slate-200 p-3">
<div class="text-sm font-semibold text-slate-900">{{ criterion.name }}</div>
@if (criterion.description) {
<div class="mt-0.5 text-sm text-slate-600">{{ criterion.description }}</div>
}
<div class="mt-2 flex flex-wrap gap-2">
@for (value of valuesFor(criterion.scale); track value) {
<button
type="button"
class="evaluation-score"
[class.evaluation-score-selected]="isScored(criterion.name, value)"
[disabled]="busy()"
(click)="score(criterion.name, value)">
{{ value }}
</button>
}
</div>
</div>
}
</fieldset>
@if (!state.criteria.length) {
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
This evaluation defines no criteria, so there is nothing to score.
</div>
}
<fieldset class="rounded-lg border border-slate-200 p-3">
<legend class="px-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Evidence{{ state.evidenceRequired ? ' *' : '' }}
</legend>
<div class="flex flex-wrap items-center gap-3">
<input type="file" multiple [disabled]="busy()" (change)="attachEvidence($event)" />
<span class="text-sm text-slate-600">
{{ state.evidenceCount }} attached
</span>
</div>
@if (state.evidenceRequired && !state.evidenceCount) {
<p class="mt-2 text-sm text-slate-600">This evaluation needs at least one attachment.</p>
}
</fieldset>
@if (state.submitError) {
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ state.submitError }}
</div>
}
<mat-form-field appearance="outline">
<mat-label>Notes</mat-label>
<textarea
matInput
class="min-h-24"
rows="4"
[(ngModel)]="notes"
[disabled]="busy()"
[attr.data-autofocus]="'true'">
</textarea>
</mat-form-field>
<div class="flex items-center justify-between gap-3">
@if (missingCriteria().length) {
<span class="text-sm text-slate-600">Still to score: {{ missingCriteria().join(', ') }}</span>
} @else {
<span></span>
}
<button type="button" mat-flat-button [disabled]="!canSubmit()" (click)="submit($event)">
@if (state.isSubmitting) { Sending... } @else { Submit evaluation }
</button>
</div>
</div>

View File

@ -0,0 +1,158 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HumanInteractionDialogState } from '@services/dialogs/human-interaction-dialog';
import { vi } from 'vitest';
import { HumanEvaluationInteractionComponent } from './human-evaluation-interaction';
function state(overrides: Partial<HumanInteractionDialogState> = {}): HumanInteractionDialogState {
return {
executionId: 'execution-1',
nodeId: 'evaluation-1',
title: 'Evaluation',
kind: 'evaluation-form',
actionDescription: '',
currentInput: '',
runtimeInputs: [{ name: 'deployUrl', value: 'https://staging.example' }],
history: [],
latestResponse: '',
historyField: null,
responseField: 'verdict',
messageField: 'notes',
completionField: 'verdict',
pendingUserMessage: null,
awaitingAssistantResponse: false,
assistantResponseBaseline: '',
isRunning: false,
isSubmitting: false,
submitError: null,
question: '',
decisionOptions: [],
rationaleRequired: false,
rationaleLabel: 'Rationale',
evaluationTarget: 'Open ${{deployUrl}}',
taskScript: ['Sign in', 'Create an order'],
criteria: [
{ name: 'works', description: 'Does the order go through?', scale: 'PASS_FAIL' },
{ name: 'clarity', description: 'How clear are the errors?', scale: 'SCORE_1_5' }
],
evidenceRequired: false,
evidenceCount: 0,
referenceLabel: null,
referenceValue: null,
referenceHidden: false,
onRevealReference: null,
onAttachEvidence: null,
templateValues: {},
onSubmit: null,
resolve: () => undefined,
...overrides
};
}
describe('HumanEvaluationInteractionComponent', () => {
let fixture: ComponentFixture<HumanEvaluationInteractionComponent>;
let component: HumanEvaluationInteractionComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HumanEvaluationInteractionComponent]
}).compileComponents();
fixture = TestBed.createComponent(HumanEvaluationInteractionComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('state', state());
fixture.detectChanges();
});
it('will not submit until every criterion has a verdict', () => {
// A half-scored form is not a smaller judgement, it is an unfinished one.
expect(component.canSubmit()).toBe(false);
component.score('works', 'pass');
expect(component.canSubmit()).toBe(false);
component.score('clarity', '4');
expect(component.canSubmit()).toBe(true);
});
it('offers each criterion the values its own scale allows', () => {
expect(component.valuesFor('PASS_FAIL')).toEqual(['pass', 'fail']);
expect(component.valuesFor('SCORE_1_5')).toEqual(['1', '2', '3', '4', '5']);
});
it('emits one verdict per criterion, keyed by name', () => {
const emitted: unknown[] = [];
component.submitEvaluation.subscribe((value) => emitted.push(value));
component.score('works', 'fail');
component.score('clarity', '2');
component.notes = ' Checkout throws on empty cart ';
component.submit();
expect(emitted).toEqual([{
mode: 'evaluation',
verdict: { works: 'fail', clarity: '2' },
notes: 'Checkout throws on empty cart'
}]);
});
it('names what is still unscored, rather than only refusing', () => {
component.score('works', 'pass');
expect(component.missingCriteria()).toEqual(['clarity']);
});
it('refuses a verdict with no attachment when the node requires evidence', () => {
fixture.componentRef.setInput('state', state({ evidenceRequired: true }));
fixture.detectChanges();
component.score('works', 'pass');
component.score('clarity', '3');
expect(component.canSubmit()).toBe(false);
fixture.componentRef.setInput('state', state({ evidenceRequired: true, evidenceCount: 1 }));
fixture.detectChanges();
expect(component.canSubmit()).toBe(true);
});
it('keeps the reference verdict out of sight until it is asked for', () => {
const onRevealReference = vi.fn();
fixture.componentRef.setInput('state', state({
referenceLabel: 'agentReport',
referenceValue: null,
referenceHidden: true,
onRevealReference
}));
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('All good, shipped');
component.revealReference();
expect(onRevealReference).toHaveBeenCalledOnce();
});
it('shows the reference once revealed', () => {
fixture.componentRef.setInput('state', state({
referenceLabel: 'agentReport',
referenceValue: 'All good, shipped',
referenceHidden: false
}));
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('All good, shipped');
});
it('keeps the scores already given when an error updates the dialog state', () => {
component.score('works', 'pass');
fixture.componentRef.setInput('state', state({ submitError: 'Network error' }));
fixture.detectChanges();
expect(component.isScored('works', 'pass')).toBe(true);
expect(fixture.nativeElement.textContent).toContain('Network error');
});
it('scores nothing while the step is submitting', () => {
fixture.componentRef.setInput('state', state({ isSubmitting: true }));
fixture.detectChanges();
component.score('works', 'pass');
expect(component.isScored('works', 'pass')).toBe(false);
});
});

View File

@ -0,0 +1,97 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import {
HumanInteractionDialogResult,
HumanInteractionDialogState
} from '@services/dialogs/human-interaction-dialog';
import { TemplatePlaceholderTextComponent } from '@shared/template-placeholder-text/template-placeholder-text';
/** The scores a PASS_FAIL and a SCORE_1_5 criterion offer, in the order they are shown. */
const SCALE_VALUES: Record<string, string[]> = {
PASS_FAIL: ['pass', 'fail'],
SCORE_1_5: ['1', '2', '3', '4', '5']
};
@Component({
selector: 'app-human-evaluation-interaction',
standalone: true,
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, TemplatePlaceholderTextComponent],
templateUrl: './human-evaluation-interaction.html',
styleUrl: './human-evaluation-interaction.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HumanEvaluationInteractionComponent {
@Input({ required: true }) state!: HumanInteractionDialogState;
@Output() submitEvaluation = new EventEmitter<Extract<HumanInteractionDialogResult, { mode: 'evaluation' }>>();
verdict: Record<string, string> = {};
notes = '';
valuesFor(scale: string): string[] {
return SCALE_VALUES[scale] ?? SCALE_VALUES['PASS_FAIL'];
}
score(criterion: string, value: string) {
if (this.busy()) return;
this.verdict = { ...this.verdict, [criterion]: value };
}
isScored(criterion: string, value: string): boolean {
return this.verdict[criterion] === value;
}
busy(): boolean {
return this.state.isSubmitting || this.state.isRunning;
}
/**
* Revealing is a one-way act on purpose: the node records that this judgement was made after
* seeing the other one, and taking it back would make that record a lie.
*/
revealReference(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.busy()) return;
this.state.onRevealReference?.();
}
attachEvidence(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
if (files.length) {
this.state.onAttachEvidence?.(files);
}
input.value = '';
}
missingCriteria(): string[] {
return this.state.criteria
.filter((criterion) => !this.verdict[criterion.name])
.map((criterion) => criterion.name);
}
canSubmit(): boolean {
if (this.busy()) return false;
if (!this.state.criteria.length) return false;
if (this.missingCriteria().length) return false;
return !this.state.evidenceRequired || this.state.evidenceCount > 0;
}
submit(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!this.canSubmit()) return;
this.submitEvaluation.emit({
mode: 'evaluation',
verdict: { ...this.verdict },
notes: this.notes.trim()
});
}
}

View File

@ -19,6 +19,8 @@
Select one option and document the rationale for the decision.
} @else if (currentState.kind === 'single-response') {
Provide the requested response to complete this activity.
} @else if (currentState.kind === 'evaluation-form') {
Exercise the target, then score every criterion.
} @else {
This interaction type is not supported.
}
@ -113,6 +115,12 @@
[state]="currentState"
(submitResponse)="submitTextResponse($event)" />
</div>
} @else if (currentState.kind === 'evaluation-form') {
<div class="flex-1 overflow-y-auto px-5 py-4">
<app-human-evaluation-interaction
[state]="currentState"
(submitEvaluation)="submitHumanEvaluation($event)" />
</div>
} @else {
<div class="flex-1 px-5 py-8 text-center text-sm text-slate-600">
Unsupported interaction contract: {{ currentState.kind }}

View File

@ -12,6 +12,7 @@ import {
HumanInteractionDialogService
} from '@services/dialogs/human-interaction-dialog';
import { HumanDecisionInteractionComponent } from '@shared/human-decision-interaction/human-decision-interaction';
import { HumanEvaluationInteractionComponent } from '@shared/human-evaluation-interaction/human-evaluation-interaction';
import { HumanTextInteractionComponent } from '@shared/human-text-interaction/human-text-interaction';
@Component({
@ -23,6 +24,7 @@ import { HumanTextInteractionComponent } from '@shared/human-text-interaction/hu
MatFormFieldModule,
MatInputModule,
HumanDecisionInteractionComponent,
HumanEvaluationInteractionComponent,
HumanTextInteractionComponent
],
templateUrl: './human-interaction-dialog.html',
@ -136,6 +138,10 @@ export class HumanInteractionDialogHostComponent {
this.dialog.submit(result);
}
submitHumanEvaluation(result: Extract<HumanInteractionDialogResult, { mode: 'evaluation' }>) {
this.dialog.submit(result);
}
canSendEditedOutput(): boolean {
const state = this.state();
if (state?.isSubmitting || state?.isRunning) return false;

View File

@ -1372,4 +1372,15 @@ describe('GenericNodeComponent', () => {
expect(summary(definition, { source: 'INPUT', name: 'planDoc', multiple: true }, 0))
.toBe('INPUT · planDoc');
});
it('reads back a plain-value row as its value, not as its position', () => {
// A task script is a list of strings: "Item 1 / Item 2" hid the very text being configured.
const definition = { path: 'taskScript', label: 'Steps', itemSchema: { type: 'string' }, uniqueBy: null } as any;
const summary = (component as any).toArrayItemSummary.bind(component);
expect(summary(definition, 'Sign in with the test account', 0)).toBe('Sign in with the test account');
expect(summary(definition, ' Create an order ', 1)).toBe('Create an order');
// An empty string identifies nothing, so the position is all that is left to say.
expect(summary(definition, ' ', 2)).toBe('Item 3');
});
});

View File

@ -1956,6 +1956,11 @@ export class GenericNodeComponent implements OnDestroy {
}
private toArrayItemSummary(definition: ArrayFieldDefinition, item: unknown, index: number) {
// An array of plain values - a task script, a list of names - is its own summary. Reading back
// "Item 1 / Item 2" where the row holds "Sign in" tells the reader nothing they can act on.
if (typeof item === 'string') return item.trim() || `Item ${index + 1}`;
if (typeof item === 'number' || typeof item === 'boolean') return String(item);
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return `Item ${index + 1}`;
}

View File

@ -17,6 +17,7 @@ import {
HumanDecisionOption,
HumanInteractionDialogResult,
HumanInteractionDialogService,
EvaluationCriterionView,
HumanInteractionRuntimeInput
} from '@services/dialogs/human-interaction-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
@ -131,6 +132,9 @@ export class TaskStepNodeComponent {
name = 'Step';
mainContentFields: MainContentView[] = [];
interactionSubmitting = false;
/** Per dialog session: the step holds the truth, these only drive what the form shows. */
private evaluationEvidenceCount = 0;
private evaluationReferenceRevealed = false;
schemaReady = false;
biasCapabilities: BiasCapabilities | null = null;
@ -981,7 +985,6 @@ export class TaskStepNodeComponent {
kind: contract.kind,
actionDescription: this.actionDescriptionValue(),
currentInput: this.currentInputValue(),
runtimeInputs: this.interactionRuntimeInputs(),
history: this.chatHistory(contract),
latestResponse: this.latestInteractionResponse(contract),
historyField: contract.historyField,
@ -998,10 +1001,109 @@ export class TaskStepNodeComponent {
decisionOptions: this.decisionOptions(),
rationaleRequired: this.blockConfiguration?.['rationaleRequired'] === true,
rationaleLabel: this.decisionRationaleLabel(),
evaluationTarget: this.evaluationTarget(),
taskScript: this.evaluationTaskScript(),
criteria: this.evaluationCriteria(),
evidenceRequired: this.blockConfiguration?.['evidenceRequired'] === true,
evidenceCount: 0,
referenceLabel: this.evaluationReferenceInput(),
referenceValue: this.evaluationReferenceValue(),
referenceHidden: this.isEvaluationReferenceHidden(),
runtimeInputs: this.evaluationVisibleRuntimeInputs(),
onRevealReference: () => this.revealEvaluationReference(executionId, executionNodeId),
onAttachEvidence: (files: File[]) => this.attachEvaluationEvidence(executionId, executionNodeId, files),
templateValues: this.templateSubstitutions()
};
}
/** What the node says to open, unresolved: the dialog renders its placeholders itself. */
private evaluationTarget(): string {
const target = this.blockConfiguration?.['target'];
return typeof target === 'string' ? target : '';
}
private evaluationTaskScript(): string[] {
const script = this.blockConfiguration?.['taskScript'];
if (!Array.isArray(script)) return [];
return script.filter((step): step is string => typeof step === 'string' && step.trim().length > 0);
}
private evaluationCriteria(): EvaluationCriterionView[] {
const criteria = this.blockConfiguration?.['criteria'];
if (!Array.isArray(criteria)) return [];
return criteria.flatMap((criterion) => {
if (!criterion || typeof criterion !== 'object' || Array.isArray(criterion)) return [];
const record = criterion as Record<string, unknown>;
const name = typeof record['name'] === 'string' ? record['name'].trim() : '';
if (!name) return [];
const description = typeof record['description'] === 'string' ? record['description'].trim() : '';
const scale = record['scale'] === 'SCORE_1_5' ? 'SCORE_1_5' as const : 'PASS_FAIL' as const;
return [{ name, description, scale }];
});
}
private evaluationReferenceInput(): string | null {
const reference = this.blockConfiguration?.['referenceInput'];
return typeof reference === 'string' && reference.trim().length > 0 ? reference.trim() : null;
}
/**
* Hidden while the node asks for a blind judgement and nobody has asked to see it yet. The
* hiding is what makes the recorded flag mean something, so it is decided here rather than left
* to the form.
*/
private isEvaluationReferenceHidden(): boolean {
if (this.blockConfiguration?.['blindUntilSubmitted'] !== true) return false;
return !this.evaluationReferenceRevealed;
}
private evaluationReferenceValue(): string | null {
const reference = this.evaluationReferenceInput();
if (!reference || this.isEvaluationReferenceHidden()) return null;
return this.executionInputTooltip(reference) ?? null;
}
/** The reference is pulled out of the ordinary inputs while it is meant to stay unseen. */
private evaluationVisibleRuntimeInputs(): HumanInteractionRuntimeInput[] {
const inputs = this.interactionRuntimeInputs();
const reference = this.evaluationReferenceInput();
if (!reference || !this.isEvaluationReferenceHidden()) return inputs;
return inputs.filter((input) => input.name !== reference);
}
private revealEvaluationReference(executionId: string, executionNodeId: string) {
this.taskExecutionsService.revealEvaluationReference(executionId, executionNodeId).subscribe({
next: () => {
this.evaluationReferenceRevealed = true;
this.humanInteractionDialog.update({
referenceHidden: false,
referenceValue: this.executionInputTooltip(this.evaluationReferenceInput() ?? '') ?? '',
runtimeInputs: this.interactionRuntimeInputs()
});
},
error: (error) => {
this.humanInteractionDialog.update({ submitError: 'Failed to reveal the reference verdict.' });
console.error('Reveal reference verdict failed', error);
}
});
}
private attachEvaluationEvidence(executionId: string, executionNodeId: string, files: File[]) {
this.taskExecutionsService.attachEvaluationEvidence(executionId, executionNodeId, files).subscribe({
next: () => {
this.evaluationEvidenceCount += files.length;
this.humanInteractionDialog.update({
evidenceCount: this.evaluationEvidenceCount,
submitError: null
});
},
error: (error) => {
this.humanInteractionDialog.update({ submitError: 'Failed to attach the evidence.' });
console.error('Attach evaluation evidence failed', error);
}
});
}
private submitInteractionResult(
executionId: string,
executionNodeId: string,
@ -1018,6 +1120,10 @@ export class TaskStepNodeComponent {
this.submitHumanDecision(executionId, executionNodeId, contract, result);
return;
}
if (result.mode === 'evaluation') {
this.submitHumanEvaluation(executionId, executionNodeId, result);
return;
}
const interactionFieldName = result.mode === 'message'
? contract.messageField
@ -1060,6 +1166,44 @@ export class TaskStepNodeComponent {
});
}
private submitHumanEvaluation(
executionId: string,
executionNodeId: string,
result: Extract<HumanInteractionDialogResult, { mode: 'evaluation' }>
) {
const scored = this.evaluationCriteria().filter((criterion) => !result.verdict[criterion.name]);
if (scored.length) {
this.humanInteractionDialog.update({
submitError: `Score every criterion first: ${scored.map((criterion) => criterion.name).join(', ')}.`
});
return;
}
this.interactionSubmitting = true;
this.humanInteractionDialog.update({ isSubmitting: true, submitError: null });
this.taskExecutionsService.submitEvaluation(
executionId,
executionNodeId,
result.verdict,
result.notes
).subscribe({
next: () => {
this.interactionSubmitting = false;
this.evaluationEvidenceCount = 0;
this.evaluationReferenceRevealed = false;
this.humanInteractionDialog.close(result);
},
error: (error) => {
this.interactionSubmitting = false;
this.humanInteractionDialog.update({
isSubmitting: false,
submitError: 'Failed to submit the evaluation.'
});
console.error('Submit evaluation failed', error);
}
});
}
private submitHumanDecision(
executionId: string,
executionNodeId: string,