Render the evaluation form, and submit a judgement as one act
The interaction dialog knew three shapes: a chat, a free-text answer and a decision. A HumanEvaluation node needs a fourth - the target to open, the steps to follow, and one control per criterion - so it gets its own component beside the others rather than another branch inside one. The verdict goes to the server whole. Sent a criterion at a time, a failure partway through would leave the step holding a judgement nobody made. While a node hides its reference verdict, that input is dropped from the inputs the form shows: hiding it in the component alone would leave it sitting in the panel above. Asking to see it is a deliberate, recorded act, and it does not come back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b1497ab3c4
commit
974589aeee
|
|
@ -16,6 +16,13 @@ export type HumanDecisionOption = {
|
||||||
label: string;
|
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 = {
|
export type HumanInteractionRuntimeInput = {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -45,6 +52,21 @@ export type HumanInteractionDialogInput = {
|
||||||
decisionOptions?: HumanDecisionOption[];
|
decisionOptions?: HumanDecisionOption[];
|
||||||
rationaleRequired?: boolean;
|
rationaleRequired?: boolean;
|
||||||
rationaleLabel?: string;
|
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`. */
|
/** Flat substitution map for `${{name}}` / `${{global.name}}` / `${{vars.name}}` placeholders in `question`/`actionDescription`. */
|
||||||
templateValues?: Record<string, unknown>;
|
templateValues?: Record<string, unknown>;
|
||||||
onSubmit?: (value: HumanInteractionDialogResult) => void;
|
onSubmit?: (value: HumanInteractionDialogResult) => void;
|
||||||
|
|
@ -52,7 +74,8 @@ export type HumanInteractionDialogInput = {
|
||||||
|
|
||||||
export type HumanInteractionDialogResult =
|
export type HumanInteractionDialogResult =
|
||||||
| { mode: 'message' | 'complete'; value: string }
|
| { 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 = {
|
export type HumanInteractionDialogState = {
|
||||||
executionId: string | null;
|
executionId: string | null;
|
||||||
|
|
@ -78,6 +101,16 @@ export type HumanInteractionDialogState = {
|
||||||
decisionOptions: HumanDecisionOption[];
|
decisionOptions: HumanDecisionOption[];
|
||||||
rationaleRequired: boolean;
|
rationaleRequired: boolean;
|
||||||
rationaleLabel: string;
|
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>;
|
templateValues: Record<string, unknown>;
|
||||||
onSubmit: ((value: HumanInteractionDialogResult) => void) | null;
|
onSubmit: ((value: HumanInteractionDialogResult) => void) | null;
|
||||||
resolve: (value: HumanInteractionDialogResult | null) => void;
|
resolve: (value: HumanInteractionDialogResult | null) => void;
|
||||||
|
|
@ -115,6 +148,16 @@ export class HumanInteractionDialogService {
|
||||||
decisionOptions: input.decisionOptions ?? [],
|
decisionOptions: input.decisionOptions ?? [],
|
||||||
rationaleRequired: input.rationaleRequired === true,
|
rationaleRequired: input.rationaleRequired === true,
|
||||||
rationaleLabel: input.rationaleLabel ?? 'Rationale',
|
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 ?? {},
|
templateValues: input.templateValues ?? {},
|
||||||
onSubmit: input.onSubmit ?? null,
|
onSubmit: input.onSubmit ?? null,
|
||||||
resolve
|
resolve
|
||||||
|
|
@ -153,6 +196,16 @@ export class HumanInteractionDialogService {
|
||||||
decisionOptions: input.decisionOptions ?? state.decisionOptions,
|
decisionOptions: input.decisionOptions ?? state.decisionOptions,
|
||||||
rationaleRequired: input.rationaleRequired ?? state.rationaleRequired,
|
rationaleRequired: input.rationaleRequired ?? state.rationaleRequired,
|
||||||
rationaleLabel: input.rationaleLabel ?? state.rationaleLabel,
|
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,
|
templateValues: input.templateValues ?? state.templateValues,
|
||||||
onSubmit: input.onSubmit !== undefined ? input.onSubmit : state.onSubmit
|
onSubmit: input.onSubmit !== undefined ? input.onSubmit : state.onSubmit
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,26 @@ export abstract class TaskExecutionsCallServiceBase {
|
||||||
fieldName: string,
|
fieldName: string,
|
||||||
value: string
|
value: string
|
||||||
): Observable<TaskExecution>;
|
): 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(
|
abstract provideAuthorization(
|
||||||
executionId: string,
|
executionId: string,
|
||||||
key: string,
|
key: string,
|
||||||
|
|
|
||||||
|
|
@ -1005,6 +1005,46 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
|
||||||
return of(execution);
|
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(
|
override provideAuthorization(
|
||||||
executionId: string,
|
executionId: string,
|
||||||
key: string,
|
key: string,
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,34 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
||||||
}).pipe(map((raw) => this.mapExecution(raw)));
|
}).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(
|
override provideAuthorization(
|
||||||
executionId: string,
|
executionId: string,
|
||||||
key: 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) {
|
provideAuthorization(executionId: string, key: string, value: string) {
|
||||||
return this.withRefreshAndErrorHandling(
|
return this.withRefreshAndErrorHandling(
|
||||||
this.taskExecutionsCallService.provideAuthorization(executionId, key, value).pipe(
|
this.taskExecutionsCallService.provideAuthorization(executionId, key, value).pipe(
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,16 @@ function state(overrides: Partial<HumanInteractionDialogState> = {}): HumanInter
|
||||||
],
|
],
|
||||||
rationaleRequired: true,
|
rationaleRequired: true,
|
||||||
rationaleLabel: 'Evidence-based rationale',
|
rationaleLabel: 'Evidence-based rationale',
|
||||||
|
evaluationTarget: '',
|
||||||
|
taskScript: [],
|
||||||
|
criteria: [],
|
||||||
|
evidenceRequired: false,
|
||||||
|
evidenceCount: 0,
|
||||||
|
referenceLabel: null,
|
||||||
|
referenceValue: null,
|
||||||
|
referenceHidden: false,
|
||||||
|
onRevealReference: null,
|
||||||
|
onAttachEvidence: null,
|
||||||
templateValues: {},
|
templateValues: {},
|
||||||
onSubmit: null,
|
onSubmit: null,
|
||||||
resolve: () => undefined,
|
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.
|
Select one option and document the rationale for the decision.
|
||||||
} @else if (currentState.kind === 'single-response') {
|
} @else if (currentState.kind === 'single-response') {
|
||||||
Provide the requested response to complete this activity.
|
Provide the requested response to complete this activity.
|
||||||
|
} @else if (currentState.kind === 'evaluation-form') {
|
||||||
|
Exercise the target, then score every criterion.
|
||||||
} @else {
|
} @else {
|
||||||
This interaction type is not supported.
|
This interaction type is not supported.
|
||||||
}
|
}
|
||||||
|
|
@ -113,6 +115,12 @@
|
||||||
[state]="currentState"
|
[state]="currentState"
|
||||||
(submitResponse)="submitTextResponse($event)" />
|
(submitResponse)="submitTextResponse($event)" />
|
||||||
</div>
|
</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 {
|
} @else {
|
||||||
<div class="flex-1 px-5 py-8 text-center text-sm text-slate-600">
|
<div class="flex-1 px-5 py-8 text-center text-sm text-slate-600">
|
||||||
Unsupported interaction contract: {{ currentState.kind }}
|
Unsupported interaction contract: {{ currentState.kind }}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
HumanInteractionDialogService
|
HumanInteractionDialogService
|
||||||
} from '@services/dialogs/human-interaction-dialog';
|
} from '@services/dialogs/human-interaction-dialog';
|
||||||
import { HumanDecisionInteractionComponent } from '@shared/human-decision-interaction/human-decision-interaction';
|
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';
|
import { HumanTextInteractionComponent } from '@shared/human-text-interaction/human-text-interaction';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|
@ -23,6 +24,7 @@ import { HumanTextInteractionComponent } from '@shared/human-text-interaction/hu
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
HumanDecisionInteractionComponent,
|
HumanDecisionInteractionComponent,
|
||||||
|
HumanEvaluationInteractionComponent,
|
||||||
HumanTextInteractionComponent
|
HumanTextInteractionComponent
|
||||||
],
|
],
|
||||||
templateUrl: './human-interaction-dialog.html',
|
templateUrl: './human-interaction-dialog.html',
|
||||||
|
|
@ -136,6 +138,10 @@ export class HumanInteractionDialogHostComponent {
|
||||||
this.dialog.submit(result);
|
this.dialog.submit(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
submitHumanEvaluation(result: Extract<HumanInteractionDialogResult, { mode: 'evaluation' }>) {
|
||||||
|
this.dialog.submit(result);
|
||||||
|
}
|
||||||
|
|
||||||
canSendEditedOutput(): boolean {
|
canSendEditedOutput(): boolean {
|
||||||
const state = this.state();
|
const state = this.state();
|
||||||
if (state?.isSubmitting || state?.isRunning) return false;
|
if (state?.isSubmitting || state?.isRunning) return false;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
HumanDecisionOption,
|
HumanDecisionOption,
|
||||||
HumanInteractionDialogResult,
|
HumanInteractionDialogResult,
|
||||||
HumanInteractionDialogService,
|
HumanInteractionDialogService,
|
||||||
|
EvaluationCriterionView,
|
||||||
HumanInteractionRuntimeInput
|
HumanInteractionRuntimeInput
|
||||||
} from '@services/dialogs/human-interaction-dialog';
|
} from '@services/dialogs/human-interaction-dialog';
|
||||||
import { TaskExecutionsService } from '@services/task-executions/task-executions';
|
import { TaskExecutionsService } from '@services/task-executions/task-executions';
|
||||||
|
|
@ -131,6 +132,9 @@ export class TaskStepNodeComponent {
|
||||||
name = 'Step';
|
name = 'Step';
|
||||||
mainContentFields: MainContentView[] = [];
|
mainContentFields: MainContentView[] = [];
|
||||||
interactionSubmitting = false;
|
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;
|
schemaReady = false;
|
||||||
biasCapabilities: BiasCapabilities | null = null;
|
biasCapabilities: BiasCapabilities | null = null;
|
||||||
|
|
||||||
|
|
@ -981,7 +985,6 @@ export class TaskStepNodeComponent {
|
||||||
kind: contract.kind,
|
kind: contract.kind,
|
||||||
actionDescription: this.actionDescriptionValue(),
|
actionDescription: this.actionDescriptionValue(),
|
||||||
currentInput: this.currentInputValue(),
|
currentInput: this.currentInputValue(),
|
||||||
runtimeInputs: this.interactionRuntimeInputs(),
|
|
||||||
history: this.chatHistory(contract),
|
history: this.chatHistory(contract),
|
||||||
latestResponse: this.latestInteractionResponse(contract),
|
latestResponse: this.latestInteractionResponse(contract),
|
||||||
historyField: contract.historyField,
|
historyField: contract.historyField,
|
||||||
|
|
@ -998,10 +1001,109 @@ export class TaskStepNodeComponent {
|
||||||
decisionOptions: this.decisionOptions(),
|
decisionOptions: this.decisionOptions(),
|
||||||
rationaleRequired: this.blockConfiguration?.['rationaleRequired'] === true,
|
rationaleRequired: this.blockConfiguration?.['rationaleRequired'] === true,
|
||||||
rationaleLabel: this.decisionRationaleLabel(),
|
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()
|
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(
|
private submitInteractionResult(
|
||||||
executionId: string,
|
executionId: string,
|
||||||
executionNodeId: string,
|
executionNodeId: string,
|
||||||
|
|
@ -1018,6 +1120,10 @@ export class TaskStepNodeComponent {
|
||||||
this.submitHumanDecision(executionId, executionNodeId, contract, result);
|
this.submitHumanDecision(executionId, executionNodeId, contract, result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result.mode === 'evaluation') {
|
||||||
|
this.submitHumanEvaluation(executionId, executionNodeId, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const interactionFieldName = result.mode === 'message'
|
const interactionFieldName = result.mode === 'message'
|
||||||
? contract.messageField
|
? 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(
|
private submitHumanDecision(
|
||||||
executionId: string,
|
executionId: string,
|
||||||
executionNodeId: string,
|
executionNodeId: string,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue