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