From 285b74382a8a1f566639e04908d610fcc4ef7bec Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 11 May 2026 08:19:53 +0200 Subject: [PATCH] fix(iace): Initialize pipeline reads operational_states from metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Betriebszustand-UI saved states to metadata.operational_states but the initialize handler only read states from the parsed narrative text. Now merges both sources so the UI selection actually affects which patterns fire during initialization. Added integration E2E test that verifies: 2 states → fewer patterns, 9 states → more patterns. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../e2e/specs/iace-phase5.spec.ts | 83 +++++++++++++++++++ .../api/handlers/iace_handler_init.go | 48 ++++++++++- 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/admin-compliance/e2e/specs/iace-phase5.spec.ts b/admin-compliance/e2e/specs/iace-phase5.spec.ts index 36d0361..dfec32e 100644 --- a/admin-compliance/e2e/specs/iace-phase5.spec.ts +++ b/admin-compliance/e2e/specs/iace-phase5.spec.ts @@ -415,3 +415,86 @@ for (const project of PROJECTS) { }) }) } + +// --------------------------------------------------------------------------- +// 10. Integration: Operational States → Initialize → Hazards affected +// --------------------------------------------------------------------------- + +test.describe('Integration: Op. States affect initialization', () => { + test.setTimeout(120_000) + const API = 'https://macmini:8093/sdk/v1/iace' + const HEADERS = { 'Content-Type': 'application/json', 'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' } + // Use Cobot project — has limits_form data for initialization + const PROJECT_ID = PROJECTS[1].id + + test('saving states to metadata and re-initializing changes pattern count', async ({ request }) => { + // Step 1: Get current project metadata + const projRes = await request.get(`${API}/projects/${PROJECT_ID}`, { headers: HEADERS }) + expect(projRes.ok()).toBeTruthy() + const project = await projRes.json() + const existingMeta = project.metadata || {} + + // Step 2: Save operational states with only 2 states (restrictive) + const restrictiveStates = ['automatic_operation', 'emergency_stop'] + const putRes = await request.put(`${API}/projects/${PROJECT_ID}`, { + headers: HEADERS, + data: { metadata: { ...existingMeta, operational_states: restrictiveStates } }, + }) + expect(putRes.ok()).toBeTruthy() + + // Step 3: Delete existing hazards + mitigations so initialize creates fresh ones + const hazRes = await request.get(`${API}/projects/${PROJECT_ID}/hazards`, { headers: HEADERS }) + if (hazRes.ok()) { + const hazards = (await hazRes.json()).hazards || [] + for (const h of hazards) { + await request.delete(`${API}/projects/${PROJECT_ID}/hazards/${h.id}`, { headers: HEADERS }) + } + } + const mitRes = await request.get(`${API}/projects/${PROJECT_ID}/mitigations`, { headers: HEADERS }) + if (mitRes.ok()) { + const mits = (await mitRes.json()).mitigations || [] + for (const m of mits) { + await request.delete(`${API}/projects/${PROJECT_ID}/mitigations/${m.id}`, { headers: HEADERS }) + } + } + + // Step 4: Initialize with restrictive states + const initRes = await request.post(`${API}/projects/${PROJECT_ID}/initialize`, { headers: HEADERS }) + expect(initRes.ok()).toBeTruthy() + const initData = await initRes.json() + const restrictivePatterns = initData.steps?.find((s: { name: string }) => s.name === 'Patterns abgeglichen')?.count || 0 + + // Step 5: Now widen to all 9 states + const allStates = [ + 'startup', 'homing', 'automatic_operation', 'manual_operation', + 'teach_mode', 'maintenance', 'cleaning', 'emergency_stop', 'recovery_mode', + ] + await request.put(`${API}/projects/${PROJECT_ID}`, { + headers: HEADERS, + data: { metadata: { ...existingMeta, operational_states: allStates } }, + }) + + // Step 6: Delete hazards again and re-initialize + const hazRes2 = await request.get(`${API}/projects/${PROJECT_ID}/hazards`, { headers: HEADERS }) + if (hazRes2.ok()) { + const hazards = (await hazRes2.json()).hazards || [] + for (const h of hazards) { + await request.delete(`${API}/projects/${PROJECT_ID}/hazards/${h.id}`, { headers: HEADERS }) + } + } + + const initRes2 = await request.post(`${API}/projects/${PROJECT_ID}/initialize`, { headers: HEADERS }) + expect(initRes2.ok()).toBeTruthy() + const initData2 = await initRes2.json() + const widePatterns = initData2.steps?.find((s: { name: string }) => s.name === 'Patterns abgeglichen')?.count || 0 + + // More states should match more (or equal) patterns + expect(widePatterns).toBeGreaterThanOrEqual(restrictivePatterns) + + // Step 7: Restore original metadata + await request.put(`${API}/projects/${PROJECT_ID}`, { + headers: HEADERS, + data: { metadata: existingMeta }, + }) + }) +}) diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go index 6d695b7..259415b 100644 --- a/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go @@ -96,14 +96,18 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { energyIDs = append(energyIDs, e.SourceID) } + // Merge explicit operational_states from UI with parsed states from narrative + operationalStates := mergeStringSlices(parseResult.OperationalStates, extractOperationalStatesFromMetadata(project.Metadata)) + stateTransitions := parseResult.StateTransitions + engine := iace.NewPatternEngine() matchOutput := engine.Match(iace.MatchInput{ ComponentLibraryIDs: componentIDs, EnergySourceIDs: energyIDs, LifecyclePhases: parseResult.LifecyclePhases, CustomTags: parseResult.CustomTags, - OperationalStates: parseResult.OperationalStates, - StateTransitions: parseResult.StateTransitions, + OperationalStates: operationalStates, + StateTransitions: stateTransitions, HumanRoles: parseResult.Roles, }) steps = append(steps, InitStep{ @@ -386,6 +390,46 @@ func deriveComponentType(tags []string) iace.ComponentType { return iace.ComponentTypeMechanical } +// extractOperationalStatesFromMetadata reads the explicit operational_states +// selection that the user set via the Betriebszustand-UI. +func extractOperationalStatesFromMetadata(metadata json.RawMessage) []string { + if metadata == nil { + return nil + } + var meta map[string]json.RawMessage + if err := json.Unmarshal(metadata, &meta); err != nil { + return nil + } + raw, ok := meta["operational_states"] + if !ok { + return nil + } + var states []string + if err := json.Unmarshal(raw, &states); err != nil { + return nil + } + return states +} + +// mergeStringSlices merges two string slices, deduplicating entries. +func mergeStringSlices(a, b []string) []string { + seen := make(map[string]bool, len(a)+len(b)) + var result []string + for _, s := range a { + if !seen[s] { + seen[s] = true + result = append(result, s) + } + } + for _, s := range b { + if !seen[s] { + seen[s] = true + result = append(result, s) + } + } + return result +} + // findHazardForMeasureByCategory finds a matching hazard for a measure. func findHazardForMeasureByCategory(measureCat string, hazardsByCategory map[string]uuid.UUID) uuid.UUID { // Direct match