From 1e5a6e78ca2673f37c6f23659f0f8a96aa7e7871 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Fri, 24 Jul 2026 21:07:54 +0200 Subject: [PATCH] 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 --- .../dialogs/human-interaction-dialog.ts | 5 + .../human-decision-interaction.html | 8 +- .../human-decision-interaction.spec.ts | 1 + .../human-decision-interaction.ts | 3 +- .../human-text-interaction.html | 8 +- .../human-text-interaction.ts | 3 +- .../task-step-node/task-step-node.spec.ts | 24 +++ .../nodes/task-step-node/task-step-node.ts | 27 +++- .../task-execution-viewer.ts | 13 +- .../template-placeholder-text.css | 23 +++ .../template-placeholder-text.html | 57 +++++++ .../template-placeholder-text.spec.ts | 108 +++++++++++++ .../template-placeholder-text.ts | 149 ++++++++++++++++++ .../template-placeholder.spec.ts | 99 ++++++++++++ .../template-placeholder.ts | 94 +++++++++++ 15 files changed, 613 insertions(+), 9 deletions(-) create mode 100644 src/app/shared/template-placeholder-text/template-placeholder-text.css create mode 100644 src/app/shared/template-placeholder-text/template-placeholder-text.html create mode 100644 src/app/shared/template-placeholder-text/template-placeholder-text.spec.ts create mode 100644 src/app/shared/template-placeholder-text/template-placeholder-text.ts create mode 100644 src/app/shared/template-placeholder-text/template-placeholder.spec.ts create mode 100644 src/app/shared/template-placeholder-text/template-placeholder.ts diff --git a/src/app/services/dialogs/human-interaction-dialog.ts b/src/app/services/dialogs/human-interaction-dialog.ts index 5a433e1..7e20aae 100644 --- a/src/app/services/dialogs/human-interaction-dialog.ts +++ b/src/app/services/dialogs/human-interaction-dialog.ts @@ -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; onSubmit?: (value: HumanInteractionDialogResult) => void; }; @@ -72,6 +74,7 @@ export type HumanInteractionDialogState = { decisionOptions: HumanDecisionOption[]; rationaleRequired: boolean; rationaleLabel: string; + templateValues: Record; 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 }); } diff --git a/src/app/shared/human-decision-interaction/human-decision-interaction.html b/src/app/shared/human-decision-interaction/human-decision-interaction.html index 3d72f35..a6c87bb 100644 --- a/src/app/shared/human-decision-interaction/human-decision-interaction.html +++ b/src/app/shared/human-decision-interaction/human-decision-interaction.html @@ -1,5 +1,11 @@
-
{{ state.question || 'Select an option' }}
+ @if (state.question) { +
+ +
+ } @else { +
Select an option
+ } @if (state.runtimeInputs.length) {
diff --git a/src/app/shared/human-decision-interaction/human-decision-interaction.spec.ts b/src/app/shared/human-decision-interaction/human-decision-interaction.spec.ts index 3bc02f9..9724c13 100644 --- a/src/app/shared/human-decision-interaction/human-decision-interaction.spec.ts +++ b/src/app/shared/human-decision-interaction/human-decision-interaction.spec.ts @@ -30,6 +30,7 @@ function state(overrides: Partial = {}): HumanInter ], rationaleRequired: true, rationaleLabel: 'Evidence-based rationale', + templateValues: {}, onSubmit: null, resolve: () => undefined, ...overrides diff --git a/src/app/shared/human-decision-interaction/human-decision-interaction.ts b/src/app/shared/human-decision-interaction/human-decision-interaction.ts index b6a16ed..6feb1c1 100644 --- a/src/app/shared/human-decision-interaction/human-decision-interaction.ts +++ b/src/app/shared/human-decision-interaction/human-decision-interaction.ts @@ -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 diff --git a/src/app/shared/human-text-interaction/human-text-interaction.html b/src/app/shared/human-text-interaction/human-text-interaction.html index 6118d14..e822da4 100644 --- a/src/app/shared/human-text-interaction/human-text-interaction.html +++ b/src/app/shared/human-text-interaction/human-text-interaction.html @@ -1,9 +1,11 @@
Instructions -
- {{ state.actionDescription || '-' }} -
+ @if (state.actionDescription) { + + } @else { +
-
+ }
@if (state.runtimeInputs.length) { diff --git a/src/app/shared/human-text-interaction/human-text-interaction.ts b/src/app/shared/human-text-interaction/human-text-interaction.ts index bbf697b..fb8f998 100644 --- a/src/app/shared/human-text-interaction/human-text-interaction.ts +++ b/src/app/shared/human-text-interaction/human-text-interaction.ts @@ -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 }) diff --git a/src/app/shared/nodes/task-step-node/task-step-node.spec.ts b/src/app/shared/nodes/task-step-node/task-step-node.spec.ts index 825df6b..b5a2aca 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.spec.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.spec.ts @@ -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; diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index 9f8af73..bfc3f56 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -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 { + const value = this.blockConfiguration?.['__globalInputs']; + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + } + + private executionVariablesValue(): Record { + const value = this.blockConfiguration?.['__executionVariables']; + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + } + + private executionName(): string | null { + const value = this.blockConfiguration?.['__executionName']; + return typeof value === 'string' && value.trim().length > 0 ? value : null; + } + + private templateSubstitutions(): Record { + const inputs = (this.blockConfiguration?.['__executionInputs'] ?? {}) as Record; + 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() }; } diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index c31787d..12dd263 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -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 } }; diff --git a/src/app/shared/template-placeholder-text/template-placeholder-text.css b/src/app/shared/template-placeholder-text/template-placeholder-text.css new file mode 100644 index 0000000..12327b0 --- /dev/null +++ b/src/app/shared/template-placeholder-text/template-placeholder-text.css @@ -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; +} diff --git a/src/app/shared/template-placeholder-text/template-placeholder-text.html b/src/app/shared/template-placeholder-text/template-placeholder-text.html new file mode 100644 index 0000000..9c6cbf7 --- /dev/null +++ b/src/app/shared/template-placeholder-text/template-placeholder-text.html @@ -0,0 +1,57 @@ +@for (segment of segments(); track $index; let segIndex = $index) { +@if (segment.kind === 'text') { +@if (segment.text.trim().length) { +

{{ segment.text }}

+} +} @else if (!segment.multiple) { +
+
{{ segmentLabel(segment.name) }}
+ @if (hasValue(segment.value)) { +
{{ isSingleExpanded(segIndex) ? stringifyValue(segment.value) : truncate(stringifyValue(segment.value)) }}
+ @if (isLong(stringifyValue(segment.value))) { + + } + } @else { +
No data available
+ } +
+} @else { +
+
+ {{ segmentLabel(segment.name) }} ({{ itemsFor(segment.value).length }}) + @if (itemsFor(segment.value).length > 1) { + + } +
+ + @if (!itemsFor(segment.value).length) { +
No data available
+ } @else { +
+ @for (item of itemsFor(segment.value); track $index; let itemIndex = $index) { +
+ + @if (isItemExpanded(segIndex, itemIndex)) { +
+
{{ isItemFullyExpanded(segIndex, itemIndex) ? stringifyValue(item) : truncate(stringifyValue(item)) }}
+ @if (isLong(stringifyValue(item))) { + + } +
+ } +
+ } +
+ } +
+} +} diff --git a/src/app/shared/template-placeholder-text/template-placeholder-text.spec.ts b/src/app/shared/template-placeholder-text/template-placeholder-text.spec.ts new file mode 100644 index 0000000..79afabc --- /dev/null +++ b/src/app/shared/template-placeholder-text/template-placeholder-text.spec.ts @@ -0,0 +1,108 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TemplatePlaceholderTextComponent } from './template-placeholder-text'; + +function textContent(fixture: ComponentFixture, selector: string): string[] { + return Array.from(fixture.nativeElement.querySelectorAll(selector) as NodeListOf) + .map((el) => el.textContent?.trim() ?? ''); +} + +describe('TemplatePlaceholderTextComponent', () => { + let fixture: ComponentFixture; + 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); + }); +}); diff --git a/src/app/shared/template-placeholder-text/template-placeholder-text.ts b/src/app/shared/template-placeholder-text/template-placeholder-text.ts new file mode 100644 index 0000000..f407ea4 --- /dev/null +++ b/src/app/shared/template-placeholder-text/template-placeholder-text.ts @@ -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(''); + readonly values = input>({}); + + private readonly expandedSingleValues = signal>(new Set()); + private readonly expandedItems = signal>(new Set()); + private readonly fullyExpandedItems = signal>(new Set()); + + readonly segments = computed(() => + 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}`; + } +} diff --git a/src/app/shared/template-placeholder-text/template-placeholder.spec.ts b/src/app/shared/template-placeholder-text/template-placeholder.spec.ts new file mode 100644 index 0000000..318ca8f --- /dev/null +++ b/src/app/shared/template-placeholder-text/template-placeholder.spec.ts @@ -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, + '.' + ]); + }); +}); diff --git a/src/app/shared/template-placeholder-text/template-placeholder.ts b/src/app/shared/template-placeholder-text/template-placeholder.ts new file mode 100644 index 0000000..072751c --- /dev/null +++ b/src/app/shared/template-placeholder-text/template-placeholder.ts @@ -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 | null | undefined, + globalInputs: Record | null | undefined, + executionVariables: Record | null | undefined, + context?: TemplateContextMeta +): Record { + const values: Record = {}; + + 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 +): 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; +}