feat(task-execution): resolve ${{...}} placeholders in human interaction text
question/actionDescription arrive with literal ${{name}} / ${{global.name}}
placeholders since no block type ever persists a "resolved" prompt. Resolves
them client-side in a single pass (not sequential replaces, to avoid
re-resolving a value that itself contains ${{...}}), then renders each
value as its own expandable card or collapsed accordion instead of
concatenating long/array values inline into the surrounding text.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b1e1a08b13
commit
1e5a6e78ca
|
|
@ -41,6 +41,8 @@ export type HumanInteractionDialogInput = {
|
|||
decisionOptions?: HumanDecisionOption[];
|
||||
rationaleRequired?: boolean;
|
||||
rationaleLabel?: string;
|
||||
/** Flat substitution map for `${{name}}` / `${{global.name}}` / `${{vars.name}}` placeholders in `question`/`actionDescription`. */
|
||||
templateValues?: Record<string, unknown>;
|
||||
onSubmit?: (value: HumanInteractionDialogResult) => void;
|
||||
};
|
||||
|
||||
|
|
@ -72,6 +74,7 @@ export type HumanInteractionDialogState = {
|
|||
decisionOptions: HumanDecisionOption[];
|
||||
rationaleRequired: boolean;
|
||||
rationaleLabel: string;
|
||||
templateValues: Record<string, unknown>;
|
||||
onSubmit: ((value: HumanInteractionDialogResult) => void) | null;
|
||||
resolve: (value: HumanInteractionDialogResult | null) => void;
|
||||
};
|
||||
|
|
@ -108,6 +111,7 @@ export class HumanInteractionDialogService {
|
|||
decisionOptions: input.decisionOptions ?? [],
|
||||
rationaleRequired: input.rationaleRequired === true,
|
||||
rationaleLabel: input.rationaleLabel ?? 'Rationale',
|
||||
templateValues: input.templateValues ?? {},
|
||||
onSubmit: input.onSubmit ?? null,
|
||||
resolve
|
||||
});
|
||||
|
|
@ -145,6 +149,7 @@ export class HumanInteractionDialogService {
|
|||
decisionOptions: input.decisionOptions ?? state.decisionOptions,
|
||||
rationaleRequired: input.rationaleRequired ?? state.rationaleRequired,
|
||||
rationaleLabel: input.rationaleLabel ?? state.rationaleLabel,
|
||||
templateValues: input.templateValues ?? state.templateValues,
|
||||
onSubmit: input.onSubmit !== undefined ? input.onSubmit : state.onSubmit
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
<div class="grid gap-4">
|
||||
<div class="text-base font-semibold text-slate-900">{{ state.question || 'Select an option' }}</div>
|
||||
@if (state.question) {
|
||||
<div class="text-base font-semibold text-slate-900">
|
||||
<app-template-placeholder-text [text]="state.question" [values]="state.templateValues"></app-template-placeholder-text>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="text-base font-semibold text-slate-900">Select an option</div>
|
||||
}
|
||||
|
||||
@if (state.runtimeInputs.length) {
|
||||
<fieldset class="rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ function state(overrides: Partial<HumanInteractionDialogState> = {}): HumanInter
|
|||
],
|
||||
rationaleRequired: true,
|
||||
rationaleLabel: 'Evidence-based rationale',
|
||||
templateValues: {},
|
||||
onSubmit: null,
|
||||
resolve: () => undefined,
|
||||
...overrides
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ import {
|
|||
HumanInteractionDialogResult,
|
||||
HumanInteractionDialogState
|
||||
} from '@services/dialogs/human-interaction-dialog';
|
||||
import { TemplatePlaceholderTextComponent } from '@shared/template-placeholder-text/template-placeholder-text';
|
||||
|
||||
@Component({
|
||||
selector: 'app-human-decision-interaction',
|
||||
standalone: true,
|
||||
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
|
||||
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, TemplatePlaceholderTextComponent],
|
||||
templateUrl: './human-decision-interaction.html',
|
||||
styleUrl: './human-decision-interaction.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
<div class="grid gap-4">
|
||||
<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">Instructions</legend>
|
||||
<div class="whitespace-pre-wrap break-words text-sm text-slate-800">
|
||||
{{ state.actionDescription || '-' }}
|
||||
</div>
|
||||
@if (state.actionDescription) {
|
||||
<app-template-placeholder-text [text]="state.actionDescription" [values]="state.templateValues"></app-template-placeholder-text>
|
||||
} @else {
|
||||
<div class="text-sm text-slate-800">-</div>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
@if (state.runtimeInputs.length) {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ import { MatButtonModule } from '@angular/material/button';
|
|||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { HumanInteractionDialogState } from '@services/dialogs/human-interaction-dialog';
|
||||
import { TemplatePlaceholderTextComponent } from '@shared/template-placeholder-text/template-placeholder-text';
|
||||
|
||||
@Component({
|
||||
selector: 'app-human-text-interaction',
|
||||
standalone: true,
|
||||
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
|
||||
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, TemplatePlaceholderTextComponent],
|
||||
templateUrl: './human-text-interaction.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
|
|
|
|||
|
|
@ -245,6 +245,30 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it('builds a template substitution map from step inputs, global inputs and execution variables', () => {
|
||||
configureInteraction('human-decision');
|
||||
component.data.data.specificConfiguration = {
|
||||
...component.data.data.specificConfiguration,
|
||||
__executionInputs: { candidateProfile: 'Jane Doe' },
|
||||
__globalInputs: { cvs: ['cv-1', 'cv-2'] },
|
||||
__executionVariables: { retryCount: 2 },
|
||||
__executionName: 'Ranking run'
|
||||
};
|
||||
const dialog = TestBed.inject(HumanInteractionDialogService) as any;
|
||||
|
||||
component.openInteractionModal();
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledWith(expect.objectContaining({
|
||||
templateValues: {
|
||||
candidateProfile: 'Jane Doe',
|
||||
'global.cvs': ['cv-1', 'cv-2'],
|
||||
'vars.retryCount': 2,
|
||||
'context.executionId': 'execution-1',
|
||||
'context.executionName': 'Ranking run'
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
it('submits rationale first and the technical choice last', () => {
|
||||
configureInteraction('human-decision');
|
||||
const dialog = TestBed.inject(HumanInteractionDialogService) as any;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
HumanInteractionRuntimeInput
|
||||
} from '@services/dialogs/human-interaction-dialog';
|
||||
import { TaskExecutionsService } from '@services/task-executions/task-executions';
|
||||
import { buildTemplateSubstitutions } from '@shared/template-placeholder-text/template-placeholder';
|
||||
import { BiasImpactExperimentDialogService } from '@services/dialogs/bias-impact-experiment-dialog';
|
||||
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
|
||||
import { BiasAnnotationsComponent } from '@shared/bias-annotations/bias-annotations';
|
||||
|
|
@ -659,6 +660,29 @@ export class TaskStepNodeComponent {
|
|||
return '';
|
||||
}
|
||||
|
||||
private globalInputsValue(): Record<string, unknown> {
|
||||
const value = this.blockConfiguration?.['__globalInputs'];
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
private executionVariablesValue(): Record<string, unknown> {
|
||||
const value = this.blockConfiguration?.['__executionVariables'];
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
private executionName(): string | null {
|
||||
const value = this.blockConfiguration?.['__executionName'];
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
private templateSubstitutions(): Record<string, unknown> {
|
||||
const inputs = (this.blockConfiguration?.['__executionInputs'] ?? {}) as Record<string, unknown>;
|
||||
return buildTemplateSubstitutions(inputs, this.globalInputsValue(), this.executionVariablesValue(), {
|
||||
executionId: this.executionId(),
|
||||
executionName: this.executionName()
|
||||
});
|
||||
}
|
||||
|
||||
private currentInputValue(): string {
|
||||
const inputKey = this.inputs[0]?.key ?? 'input';
|
||||
return this.executionInputTooltip(inputKey) ?? 'not ready yet';
|
||||
|
|
@ -918,7 +942,8 @@ export class TaskStepNodeComponent {
|
|||
question: this.decisionQuestion(),
|
||||
decisionOptions: this.decisionOptions(),
|
||||
rationaleRequired: this.blockConfiguration?.['rationaleRequired'] === true,
|
||||
rationaleLabel: this.decisionRationaleLabel()
|
||||
rationaleLabel: this.decisionRationaleLabel(),
|
||||
templateValues: this.templateSubstitutions()
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -339,6 +339,9 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
const contextWarnings = this.execution()?.context.warnings ?? {};
|
||||
const waitingSteps = this.execution()?.context.waitingSteps ?? [];
|
||||
const activeAnnotationIdsByNode = this.execution()?.biasExecutionContext?.activeAnnotationIdsByNode ?? {};
|
||||
const globalInputsValue = this.execution()?.context.globalInputs ?? {};
|
||||
const executionVariablesValue = this.execution()?.context.executionVariables ?? {};
|
||||
const executionName = this.execution()?.name ?? null;
|
||||
const steps = this.stepsArray();
|
||||
const execution = this.execution();
|
||||
const sourceFlow = execution?.flowSnapshot ?? this.sourceFlowData();
|
||||
|
|
@ -394,7 +397,10 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
__executionWarnings: getExecutionWarnings(step.id, contextWarnings),
|
||||
__stepResultData: step.result ?? null,
|
||||
__executionPartialResult: this.execution()?.context.partialResult ?? null,
|
||||
__biasActiveAnnotationIds: activeAnnotationIdsByNode[step.id] ?? []
|
||||
__biasActiveAnnotationIds: activeAnnotationIdsByNode[step.id] ?? [],
|
||||
__globalInputs: globalInputsValue,
|
||||
__executionVariables: executionVariablesValue,
|
||||
__executionName: executionName
|
||||
},
|
||||
position: stepNode.position ?? {
|
||||
x: 120 + (index % 3) * 340,
|
||||
|
|
@ -441,7 +447,10 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
__executionWarnings: [],
|
||||
__stepResultData: null,
|
||||
__executionPartialResult: this.execution()?.context.partialResult ?? null,
|
||||
__biasActiveAnnotationIds: activeAnnotationIdsByNode[sourceNode.id] ?? []
|
||||
__biasActiveAnnotationIds: activeAnnotationIdsByNode[sourceNode.id] ?? [],
|
||||
__globalInputs: globalInputsValue,
|
||||
__executionVariables: executionVariablesValue,
|
||||
__executionName: executionName
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
:host {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tpl-chevron {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
transition: transform 0.12s ease;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.tpl-chevron-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tpl-link {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
@for (segment of segments(); track $index; let segIndex = $index) {
|
||||
@if (segment.kind === 'text') {
|
||||
@if (segment.text.trim().length) {
|
||||
<p class="tpl-text whitespace-pre-wrap break-words text-sm text-slate-800">{{ segment.text }}</p>
|
||||
}
|
||||
} @else if (!segment.multiple) {
|
||||
<div class="tpl-value-card rounded-lg border border-slate-200 bg-white p-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ segmentLabel(segment.name) }}</div>
|
||||
@if (hasValue(segment.value)) {
|
||||
<div class="mt-1 whitespace-pre-wrap break-words font-mono text-sm font-normal text-slate-800">{{ isSingleExpanded(segIndex) ? stringifyValue(segment.value) : truncate(stringifyValue(segment.value)) }}</div>
|
||||
@if (isLong(stringifyValue(segment.value))) {
|
||||
<button type="button" class="tpl-link mt-1 text-xs font-semibold text-blue-600 hover:underline" (click)="toggleSingle(segIndex)">{{ isSingleExpanded(segIndex) ? 'Show less' : 'Show all' }}</button>
|
||||
}
|
||||
} @else {
|
||||
<div class="mt-1 text-sm font-normal italic text-slate-400">No data available</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tpl-array rounded-lg border border-slate-200 bg-white p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ segmentLabel(segment.name) }} ({{ itemsFor(segment.value).length }})</span>
|
||||
@if (itemsFor(segment.value).length > 1) {
|
||||
<button
|
||||
type="button"
|
||||
class="tpl-link text-xs font-semibold text-blue-600 hover:underline"
|
||||
(click)="toggleAllItems(segIndex, itemsFor(segment.value).length, $event)">{{ allItemsExpanded(segIndex, itemsFor(segment.value).length) ? 'Collapse all' : 'Expand all' }}</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!itemsFor(segment.value).length) {
|
||||
<div class="mt-1 text-sm font-normal italic text-slate-400">No data available</div>
|
||||
} @else {
|
||||
<div class="mt-2 grid gap-1">
|
||||
@for (item of itemsFor(segment.value); track $index; let itemIndex = $index) {
|
||||
<div class="tpl-array-item rounded-md border border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
class="tpl-array-item-header flex w-full items-center gap-2 px-2 py-1.5 text-left text-sm font-normal text-slate-700 hover:bg-slate-50"
|
||||
(click)="toggleItem(segIndex, itemIndex)">
|
||||
<span class="tpl-chevron" [class.tpl-chevron-open]="isItemExpanded(segIndex, itemIndex)" aria-hidden="true">›</span>
|
||||
<span class="tpl-array-item-label">{{ segmentLabel(segment.name) }} {{ itemIndex + 1 }} of {{ itemsFor(segment.value).length }}</span>
|
||||
</button>
|
||||
@if (isItemExpanded(segIndex, itemIndex)) {
|
||||
<div class="border-t border-slate-100 px-2 py-2">
|
||||
<div class="whitespace-pre-wrap break-words font-mono text-sm font-normal text-slate-800">{{ isItemFullyExpanded(segIndex, itemIndex) ? stringifyValue(item) : truncate(stringifyValue(item)) }}</div>
|
||||
@if (isLong(stringifyValue(item))) {
|
||||
<button type="button" class="tpl-link mt-1 text-xs font-semibold text-blue-600 hover:underline" (click)="toggleItemFull(segIndex, itemIndex, $event)">{{ isItemFullyExpanded(segIndex, itemIndex) ? 'Show less' : 'Show all' }}</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { TemplatePlaceholderTextComponent } from './template-placeholder-text';
|
||||
|
||||
function textContent(fixture: ComponentFixture<TemplatePlaceholderTextComponent>, selector: string): string[] {
|
||||
return Array.from(fixture.nativeElement.querySelectorAll(selector) as NodeListOf<Element>)
|
||||
.map((el) => el.textContent?.trim() ?? '');
|
||||
}
|
||||
|
||||
describe('TemplatePlaceholderTextComponent', () => {
|
||||
let fixture: ComponentFixture<TemplatePlaceholderTextComponent>;
|
||||
let component: TemplatePlaceholderTextComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TemplatePlaceholderTextComponent]
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(TemplatePlaceholderTextComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('never shows the literal ${{...}} placeholder syntax', () => {
|
||||
fixture.componentRef.setInput('text', 'Profile:\n${{candidateProfile}}\nReview it.');
|
||||
fixture.componentRef.setInput('values', { candidateProfile: 'Jane Doe' });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).not.toContain('${{');
|
||||
expect(textContent(fixture, '.tpl-text').join(' ')).toContain('Profile:');
|
||||
expect(textContent(fixture, '.tpl-value-card')[0]).toContain('Jane Doe');
|
||||
});
|
||||
|
||||
it('truncates a long single value with an expand toggle, not shown inline in full', () => {
|
||||
const longValue = 'x'.repeat(250);
|
||||
fixture.componentRef.setInput('text', '${{notes}}');
|
||||
fixture.componentRef.setInput('values', { notes: longValue });
|
||||
fixture.detectChanges();
|
||||
|
||||
const body = fixture.nativeElement.querySelector('.tpl-value-card div.mt-1') as HTMLElement;
|
||||
expect(body.textContent?.length).toBeLessThan(longValue.length);
|
||||
expect(body.textContent).toContain('…');
|
||||
|
||||
const button = fixture.nativeElement.querySelector('.tpl-value-card button') as HTMLButtonElement;
|
||||
expect(button.textContent?.trim()).toBe('Show all');
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const expandedBody = fixture.nativeElement.querySelector('.tpl-value-card div.mt-1') as HTMLElement;
|
||||
expect(expandedBody.textContent).toBe(longValue);
|
||||
});
|
||||
|
||||
it('shows an explicit empty-state instead of a blank hole for a missing value', () => {
|
||||
fixture.componentRef.setInput('text', '${{global.cvs}}');
|
||||
fixture.componentRef.setInput('values', {});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('No data available');
|
||||
});
|
||||
|
||||
it('renders a multiple placeholder as a collapsed-by-default accordion, one item per array entry', () => {
|
||||
fixture.componentRef.setInput('text', 'CVs:\n${{global.cvs[]}}');
|
||||
fixture.componentRef.setInput('values', { 'global.cvs': ['cv one', 'cv two', 'cv three'] });
|
||||
fixture.detectChanges();
|
||||
|
||||
const headers = textContent(fixture, '.tpl-array-item-label');
|
||||
expect(headers).toEqual(['Cvs 1 of 3', 'Cvs 2 of 3', 'Cvs 3 of 3']);
|
||||
expect(fixture.nativeElement.querySelectorAll('.tpl-array-item .border-t').length).toBe(0);
|
||||
|
||||
(fixture.nativeElement.querySelectorAll('.tpl-array-item-header')[0] as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelectorAll('.tpl-array-item .border-t').length).toBe(1);
|
||||
});
|
||||
|
||||
it('expands and collapses every array item at once', () => {
|
||||
fixture.componentRef.setInput('text', '${{items[]}}');
|
||||
fixture.componentRef.setInput('values', { items: ['a', 'b', 'c'] });
|
||||
fixture.detectChanges();
|
||||
|
||||
const expandAllButton = fixture.nativeElement.querySelector('.tpl-array button.tpl-link') as HTMLButtonElement;
|
||||
expect(expandAllButton.textContent?.trim()).toBe('Expand all');
|
||||
expandAllButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelectorAll('.tpl-array-item .border-t').length).toBe(3);
|
||||
expect(expandAllButton.textContent?.trim()).toBe('Collapse all');
|
||||
|
||||
expandAllButton.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelectorAll('.tpl-array-item .border-t').length).toBe(0);
|
||||
});
|
||||
|
||||
it('treats an empty array the same as a missing value', () => {
|
||||
fixture.componentRef.setInput('text', '${{global.cvs[]}}');
|
||||
fixture.componentRef.setInput('values', { 'global.cvs': [] });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('No data available');
|
||||
expect(fixture.nativeElement.querySelectorAll('.tpl-array-item').length).toBe(0);
|
||||
});
|
||||
|
||||
it('does not render an empty paragraph between two adjacent placeholders', () => {
|
||||
fixture.componentRef.setInput('text', '${{a}}${{b}}');
|
||||
fixture.componentRef.setInput('values', { a: '1', b: '2' });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelectorAll('.tpl-text').length).toBe(0);
|
||||
expect(fixture.nativeElement.querySelectorAll('.tpl-value-card').length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core';
|
||||
import { pathToLabel } from '@shared/nodes/node-utility';
|
||||
import { resolveTemplateSegments, TemplatePlaceholderSegment } from './template-placeholder';
|
||||
|
||||
const TRUNCATE_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* Renders question/actionDescription text with its `${{...}}` placeholders resolved
|
||||
* to actual values, without concatenating long/array values inline: each resolved
|
||||
* value becomes its own expandable card (single value) or collapsed-by-default
|
||||
* accordion (array value), never a giant text blob.
|
||||
* See docs/human-block-placeholder-resolution-frontend-integration-2026-07-24.md.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-template-placeholder-text',
|
||||
imports: [CommonModule],
|
||||
templateUrl: './template-placeholder-text.html',
|
||||
styleUrl: './template-placeholder-text.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class TemplatePlaceholderTextComponent {
|
||||
readonly text = input<string | null | undefined>('');
|
||||
readonly values = input<Record<string, unknown>>({});
|
||||
|
||||
private readonly expandedSingleValues = signal<Set<number>>(new Set());
|
||||
private readonly expandedItems = signal<Set<string>>(new Set());
|
||||
private readonly fullyExpandedItems = signal<Set<string>>(new Set());
|
||||
|
||||
readonly segments = computed<TemplatePlaceholderSegment[]>(() =>
|
||||
resolveTemplateSegments(this.text(), this.values())
|
||||
);
|
||||
|
||||
segmentLabel(name: string): string {
|
||||
const lastPart = name.includes('.') ? name.split('.').pop() ?? name : name;
|
||||
return pathToLabel(lastPart);
|
||||
}
|
||||
|
||||
hasValue(value: unknown): boolean {
|
||||
if (value == null) return false;
|
||||
if (typeof value === 'string') return value.trim().length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
stringifyValue(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
itemsFor(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
isLong(text: string): boolean {
|
||||
return text.length > TRUNCATE_LENGTH;
|
||||
}
|
||||
|
||||
truncate(text: string): string {
|
||||
return this.isLong(text) ? `${text.slice(0, TRUNCATE_LENGTH)}…` : text;
|
||||
}
|
||||
|
||||
isSingleExpanded(segIndex: number): boolean {
|
||||
return this.expandedSingleValues().has(segIndex);
|
||||
}
|
||||
|
||||
toggleSingle(segIndex: number) {
|
||||
this.expandedSingleValues.update((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(segIndex)) {
|
||||
next.delete(segIndex);
|
||||
} else {
|
||||
next.add(segIndex);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
isItemExpanded(segIndex: number, itemIndex: number): boolean {
|
||||
return this.expandedItems().has(this.itemKey(segIndex, itemIndex));
|
||||
}
|
||||
|
||||
toggleItem(segIndex: number, itemIndex: number) {
|
||||
const key = this.itemKey(segIndex, itemIndex);
|
||||
this.expandedItems.update((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
isItemFullyExpanded(segIndex: number, itemIndex: number): boolean {
|
||||
return this.fullyExpandedItems().has(this.itemKey(segIndex, itemIndex));
|
||||
}
|
||||
|
||||
toggleItemFull(segIndex: number, itemIndex: number, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
const key = this.itemKey(segIndex, itemIndex);
|
||||
this.fullyExpandedItems.update((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
allItemsExpanded(segIndex: number, itemCount: number): boolean {
|
||||
if (!itemCount) return false;
|
||||
const expanded = this.expandedItems();
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
if (!expanded.has(this.itemKey(segIndex, i))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
toggleAllItems(segIndex: number, itemCount: number, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
const shouldExpand = !this.allItemsExpanded(segIndex, itemCount);
|
||||
this.expandedItems.update((current) => {
|
||||
const next = new Set(current);
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
const key = this.itemKey(segIndex, i);
|
||||
if (shouldExpand) {
|
||||
next.add(key);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private itemKey(segIndex: number, itemIndex: number): string {
|
||||
return `${segIndex}:${itemIndex}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { buildTemplateSubstitutions, resolveTemplateSegments } from './template-placeholder';
|
||||
|
||||
describe('buildTemplateSubstitutions', () => {
|
||||
it('prefixes global inputs and execution variables but not plain step inputs', () => {
|
||||
const values = buildTemplateSubstitutions(
|
||||
{ candidateProfile: 'Jane Doe' },
|
||||
{ cvs: ['cv-1', 'cv-2'] },
|
||||
{ retryCount: 2 },
|
||||
{ executionId: 'exec-1', executionName: 'Ranking run' }
|
||||
);
|
||||
|
||||
expect(values).toEqual({
|
||||
candidateProfile: 'Jane Doe',
|
||||
'global.cvs': ['cv-1', 'cv-2'],
|
||||
'vars.retryCount': 2,
|
||||
'context.executionId': 'exec-1',
|
||||
'context.executionName': 'Ranking run'
|
||||
});
|
||||
});
|
||||
|
||||
it('omits context keys when execution metadata is missing', () => {
|
||||
const values = buildTemplateSubstitutions(null, null, null);
|
||||
expect(values).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTemplateSegments', () => {
|
||||
it('returns no segments for empty or missing text', () => {
|
||||
expect(resolveTemplateSegments('', {})).toEqual([]);
|
||||
expect(resolveTemplateSegments(null, {})).toEqual([]);
|
||||
expect(resolveTemplateSegments(undefined, {})).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a single text segment when there are no placeholders', () => {
|
||||
expect(resolveTemplateSegments('Plain instructions.', {})).toEqual([
|
||||
{ kind: 'text', text: 'Plain instructions.', name: '', multiple: false, value: undefined }
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits surrounding text from a single non-array placeholder', () => {
|
||||
const segments = resolveTemplateSegments(
|
||||
'Profile:\n${{candidateProfile}}\nReview it.',
|
||||
{ candidateProfile: 'Jane Doe, 5 years experience' }
|
||||
);
|
||||
|
||||
expect(segments).toEqual([
|
||||
{ kind: 'text', text: 'Profile:\n', name: '', multiple: false, value: undefined },
|
||||
{ kind: 'value', text: '', name: 'candidateProfile', multiple: false, value: 'Jane Doe, 5 years experience' },
|
||||
{ kind: 'text', text: '\nReview it.', name: '', multiple: false, value: undefined }
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips the [] marker and marks the segment as multiple', () => {
|
||||
const segments = resolveTemplateSegments('CVs:\n${{global.cvs[]}}', { 'global.cvs': ['cv-1', 'cv-2', 'cv-3'] });
|
||||
|
||||
expect(segments[1]).toEqual({
|
||||
kind: 'value',
|
||||
text: '',
|
||||
name: 'global.cvs',
|
||||
multiple: true,
|
||||
value: ['cv-1', 'cv-2', 'cv-3']
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a global-prefixed placeholder against the global. key', () => {
|
||||
const segments = resolveTemplateSegments('${{global.cvs}}', { 'global.cvs': 'resolved' });
|
||||
expect(segments[0].value).toBe('resolved');
|
||||
});
|
||||
|
||||
it('leaves value undefined when the placeholder has no matching substitution', () => {
|
||||
const segments = resolveTemplateSegments('${{missing}}', {});
|
||||
expect(segments[0]).toEqual({ kind: 'value', text: '', name: 'missing', multiple: false, value: undefined });
|
||||
});
|
||||
|
||||
it('does not re-resolve a value that itself contains a placeholder-like string', () => {
|
||||
const segments = resolveTemplateSegments(
|
||||
'${{candidateProfile}}',
|
||||
{ candidateProfile: 'Pasted CV mentioning ${{something}} literally' }
|
||||
);
|
||||
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segments[0].value).toBe('Pasted CV mentioning ${{something}} literally');
|
||||
});
|
||||
|
||||
it('resolves multiple placeholders in one pass, preserving order', () => {
|
||||
const segments = resolveTemplateSegments(
|
||||
'Hello ${{name}}, your score is ${{score}}.',
|
||||
{ name: 'Jane', score: 87 }
|
||||
);
|
||||
|
||||
expect(segments.map((segment) => (segment.kind === 'value' ? segment.value : segment.text))).toEqual([
|
||||
'Hello ',
|
||||
'Jane',
|
||||
', your score is ',
|
||||
87,
|
||||
'.'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Human-facing text on HumanDecisionBlock.question / HumanInteractionBlock.actionDescription
|
||||
* can contain `${{name}}` / `${{name[]}}` placeholders. The API never resolves them (no
|
||||
* block type persists or returns a "resolved" prompt - see
|
||||
* docs/human-block-placeholder-resolution-frontend-integration-2026-07-24.md), so
|
||||
* resolution happens client-side, right before display.
|
||||
*/
|
||||
export type TemplatePlaceholderSegment = {
|
||||
kind: 'text' | 'value';
|
||||
text: string;
|
||||
name: string;
|
||||
multiple: boolean;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export type TemplateContextMeta = {
|
||||
executionId?: string | null;
|
||||
executionName?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the flat substitution map from the three possible value sources, keyed exactly
|
||||
* as placeholders reference them in text (global/vars prefixed, plain step inputs, not).
|
||||
*/
|
||||
export function buildTemplateSubstitutions(
|
||||
inputs: Record<string, unknown> | null | undefined,
|
||||
globalInputs: Record<string, unknown> | null | undefined,
|
||||
executionVariables: Record<string, unknown> | null | undefined,
|
||||
context?: TemplateContextMeta
|
||||
): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
|
||||
for (const [name, value] of Object.entries(inputs ?? {})) {
|
||||
values[name] = value;
|
||||
}
|
||||
for (const [name, value] of Object.entries(globalInputs ?? {})) {
|
||||
values[`global.${name}`] = value;
|
||||
}
|
||||
for (const [name, value] of Object.entries(executionVariables ?? {})) {
|
||||
values[`vars.${name}`] = value;
|
||||
}
|
||||
if (context?.executionId) {
|
||||
values['context.executionId'] = context.executionId;
|
||||
}
|
||||
if (context?.executionName) {
|
||||
values['context.executionName'] = context.executionName;
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
const PLACEHOLDER_PATTERN = /\$\{\{(.*?)\}\}/g;
|
||||
|
||||
/**
|
||||
* Splits text into alternating text/value segments in a single pass (not sequential
|
||||
* replaces), so a resolved value that itself contains `${{...}}` (e.g. a pasted CV) is
|
||||
* never resolved a second time.
|
||||
*/
|
||||
export function resolveTemplateSegments(
|
||||
text: string | null | undefined,
|
||||
values: Record<string, unknown>
|
||||
): TemplatePlaceholderSegment[] {
|
||||
if (!text) return [];
|
||||
|
||||
const segments: TemplatePlaceholderSegment[] = [];
|
||||
const pattern = new RegExp(PLACEHOLDER_PATTERN.source, PLACEHOLDER_PATTERN.flags);
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ kind: 'text', text: text.slice(lastIndex, match.index), name: '', multiple: false, value: undefined });
|
||||
}
|
||||
|
||||
const raw = match[1].trim();
|
||||
const multiple = raw.endsWith('[]');
|
||||
const name = multiple ? raw.slice(0, -2).trim() : raw;
|
||||
segments.push({
|
||||
kind: 'value',
|
||||
text: '',
|
||||
name,
|
||||
multiple,
|
||||
value: Object.prototype.hasOwnProperty.call(values, name) ? values[name] : undefined
|
||||
});
|
||||
|
||||
lastIndex = pattern.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ kind: 'text', text: text.slice(lastIndex), name: '', multiple: false, value: undefined });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
Loading…
Reference in New Issue