Merge branch feature/human-evaluation-node into main
This commit is contained in:
commit
def50c229b
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue