From 4d225f73a8dc1bbf81d8ddd7159c13edefb91c19 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 25 Jun 2026 10:03:10 +0200 Subject: [PATCH] feat(ai-sdk): coverage blind-spot proposer (P2 slice 6, type 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the proposer's four types. - FindCoverageGaps (proposer_coverage.go): deterministic — which EN ISO 12100 hazard groups A-G did the engine leave with zero hazards for this machine? An empty group is a structural blind-spot signal (the machine may truly lack it, or a pattern/GT case is missing). Useful with no model at all. - ProposeMissingHazards + BuildCoveragePrompt: optional LLM expansion of each gap into specific expected-but-missing hazards a safety assessor would name (propose-only, reuses LLMCompleter, degrades to nil on any error). - Wired into iace-audit propose -> audit-reports/coverage.{md,json}. On the dishwasher: D. Pneumatik (truly absent — nothing invented), E. Laerm (borderline), F. Ergonomie (a genuine gap: manual loading the engine did not produce). P3 (pin an accepted proposal into a GT case) remains as a human-in-the- loop follow-up. Co-Authored-By: Claude Opus 4.7 --- ai-compliance-sdk/cmd/iace-audit/propose.go | 10 ++ .../internal/iace/proposer_coverage.go | 143 ++++++++++++++++++ .../internal/iace/proposer_coverage_test.go | 59 ++++++++ 3 files changed, 212 insertions(+) create mode 100644 ai-compliance-sdk/internal/iace/proposer_coverage.go create mode 100644 ai-compliance-sdk/internal/iace/proposer_coverage_test.go diff --git a/ai-compliance-sdk/cmd/iace-audit/propose.go b/ai-compliance-sdk/cmd/iace-audit/propose.go index 75452b0f..2c3e03f9 100644 --- a/ai-compliance-sdk/cmd/iace-audit/propose.go +++ b/ai-compliance-sdk/cmd/iace-audit/propose.go @@ -103,6 +103,15 @@ func cmdPropose(args []string) { writeText("audit-reports/vocab.md", renderVocabQueue(in.MachineType, vgaps)) writeJSON("audit-reports/vocab.json", vgaps) + // Type 4: coverage blind-spots (empty ISO 12100 groups A-G) + LLM expansion. + gaps := iace.FindCoverageGaps(hazards) + var missing []iace.MissingHazard + if lj, ok := judge.(iace.LLMJudge); ok { + missing = iace.ProposeMissingHazards(ctx, lj.Completer, in.MachineType, in.Narrative, hazards, gaps) + } + writeText("audit-reports/coverage.md", iace.RenderCoverageQueue(in.MachineType, gaps, missing)) + writeJSON("audit-reports/coverage.json", gaps) + printSummary("Method P — Dedup Proposer ("+judge.Name()+")", map[string]int{ "fired_patterns": len(fired), "candidates": len(candidates), @@ -110,6 +119,7 @@ func cmdPropose(args []string) { "gt_blocked": blocked, "framing_flags": len(framing), "vocab_gaps": len(vgaps), + "coverage_gaps": len(gaps), }) if gt == nil { fmt.Fprintln(os.Stderr, "note: no ground truth provided — GT wall NOT applied (candidates not recall-screened)") diff --git a/ai-compliance-sdk/internal/iace/proposer_coverage.go b/ai-compliance-sdk/internal/iace/proposer_coverage.go new file mode 100644 index 00000000..836c2165 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/proposer_coverage.go @@ -0,0 +1,143 @@ +package iace + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// Coverage blind-spot proposer (P2 slice 6, type 4). DEV-TIME, propose-only. +// +// Deterministic skeleton: which EN ISO 12100 hazard groups (A-G, the classic CE +// groups; H-J are control/CRA and routinely routed elsewhere) did the engine +// leave with ZERO hazards for this machine? An empty group is a structural +// blind-spot signal — the machine may genuinely lack that hazard, or a pattern +// may be missing. The LLM then expands each gap into specific expected-but-missing +// hazards a safety assessor would name, for a human to confirm into a new pattern +// or GT case. The gaps alone are useful without any model. + +type isoGroup struct { + Key string + Label string + Cats []string +} + +var iso12100Groups = []isoGroup{ + {"mechanical", "A. Mechanisch", []string{"mechanical_hazard", "mechanical", "maintenance_hazard"}}, + {"electrical", "B. Elektrisch", []string{"electrical_hazard", "electrical", "emc_hazard"}}, + {"thermal", "C. Thermisch", []string{"thermal_hazard", "thermal", "high_temperature", "fire_explosion"}}, + {"pneumatic_hydraulic", "D. Pneumatik/Hydraulik", []string{"pneumatic_hydraulic"}}, + {"noise_vibration", "E. Laerm/Vibration", []string{"noise_hazard", "noise_vibration", "vibration_hazard"}}, + {"ergonomic", "F. Ergonomie", []string{"ergonomic_hazard", "ergonomic"}}, + {"material", "G. Stoffe/Umwelt", []string{"material_environmental", "chemical_risk", "radiation_hazard"}}, +} + +// CoverageGap is an ISO 12100 hazard group with no engine hazard. +type CoverageGap struct { + Group string `json:"group"` + Key string `json:"key"` + Note string `json:"note"` +} + +// FindCoverageGaps returns the A-G hazard groups that produced zero hazards. +func FindCoverageGaps(hazards []Hazard) []CoverageGap { + present := make(map[string]bool, len(hazards)) + for _, h := range hazards { + present[h.Category] = true + } + var gaps []CoverageGap + for _, g := range iso12100Groups { + covered := false + for _, c := range g.Cats { + if present[c] { + covered = true + break + } + } + if !covered { + gaps = append(gaps, CoverageGap{ + Group: g.Label, Key: g.Key, + Note: "no engine hazard in this ISO 12100 group — verify the machine truly lacks it, or a pattern is missing", + }) + } + } + return gaps +} + +// MissingHazard is an LLM-proposed hazard a safety assessor would expect. +type MissingHazard struct { + Group string `json:"group"` + Hazard string `json:"hazard"` + Why string `json:"why"` +} + +// ProposeMissingHazards asks the LLM to expand the empty groups into specific +// expected hazards. Returns nil without a completer or on any error — propose-only, +// never breaks the run. +func ProposeMissingHazards(ctx context.Context, completer LLMCompleter, machineClass, narrative string, produced []Hazard, gaps []CoverageGap) []MissingHazard { + if completer == nil || len(gaps) == 0 { + return nil + } + system, user := BuildCoveragePrompt(machineClass, narrative, produced, gaps) + raw, err := completer.Complete(ctx, system, user) + if err != nil { + return nil + } + return parseMissingHazards(raw) +} + +// BuildCoveragePrompt frames the "what is missing?" question for the LLM. +func BuildCoveragePrompt(machineClass, narrative string, produced []Hazard, gaps []CoverageGap) (system, user string) { + system = "Du bist Sachverstaendiger fuer Maschinensicherheit nach EN ISO 12100. " + + "Dir werden eine Maschine, die bereits erkannten Gefaehrdungen und Gefaehrdungsgruppen OHNE Eintrag genannt. " + + "Nenne nur Gefaehrdungen, die ein Sachverstaendiger fuer DIESE Maschine ERWARTET, die aber FEHLEN. " + + "Erfinde nichts Maschinenfremdes. Antworte AUSSCHLIESSLICH als JSON-Array: " + + `[{"group":"...","hazard":"...","why":"..."}].` + + var have []string + seen := map[string]bool{} + for _, h := range produced { + if h.Category != "" && !seen[h.Category] { + seen[h.Category] = true + have = append(have, h.Category) + } + } + var empty []string + for _, g := range gaps { + empty = append(empty, g.Group) + } + user = fmt.Sprintf("Maschinenklasse: %s\n\nBeschreibung:\n%s\n\nBereits erkannte Kategorien: %s\n\nGruppen OHNE Eintrag (Fokus): %s\n\nWelche erwarteten Gefaehrdungen fehlen?", + machineClass, narrative, strings.Join(have, ", "), strings.Join(empty, ", ")) + return system, user +} + +func parseMissingHazards(raw string) []MissingHazard { + start, end := strings.Index(raw, "["), strings.LastIndex(raw, "]") + if start < 0 || end <= start { + return nil + } + var out []MissingHazard + if err := json.Unmarshal([]byte(raw[start:end+1]), &out); err != nil { + return nil + } + return out +} + +// RenderCoverageQueue renders the deterministic gaps plus any LLM-proposed missing +// hazards as a markdown review queue. +func RenderCoverageQueue(machine string, gaps []CoverageGap, missing []MissingHazard) string { + var b strings.Builder + fmt.Fprintf(&b, "# Coverage blind-spot queue — %s\n\n", machine) + fmt.Fprintf(&b, "%d ISO 12100 group(s) (A-G) have no engine hazard. Propose-only — a human confirms whether the machine truly lacks it or a pattern/GT case is missing.\n\n", len(gaps)) + for _, g := range gaps { + fmt.Fprintf(&b, "- **%s** — %s\n", g.Group, g.Note) + } + if len(missing) > 0 { + fmt.Fprintf(&b, "\n## LLM-proposed expected-but-missing hazards (%d)\n\n", len(missing)) + for i, m := range missing { + fmt.Fprintf(&b, "%d. [%s] %s\n - why: %s\n", i+1, m.Group, m.Hazard, m.Why) + } + } + return b.String() +} diff --git a/ai-compliance-sdk/internal/iace/proposer_coverage_test.go b/ai-compliance-sdk/internal/iace/proposer_coverage_test.go new file mode 100644 index 00000000..7e442d1c --- /dev/null +++ b/ai-compliance-sdk/internal/iace/proposer_coverage_test.go @@ -0,0 +1,59 @@ +package iace + +import ( + "context" + "strings" + "testing" +) + +func TestFindCoverageGaps(t *testing.T) { + hazards := []Hazard{ + {Category: "mechanical_hazard"}, + {Category: "thermal_hazard"}, + {Category: "electrical_hazard"}, + {Category: "material_environmental"}, + } + gapKeys := map[string]bool{} + for _, g := range FindCoverageGaps(hazards) { + gapKeys[g.Key] = true + } + for _, want := range []string{"pneumatic_hydraulic", "noise_vibration", "ergonomic"} { + if !gapKeys[want] { + t.Errorf("expected gap %s", want) + } + } + for _, notWant := range []string{"mechanical", "thermal", "electrical", "material"} { + if gapKeys[notWant] { + t.Errorf("did not expect gap %s (covered)", notWant) + } + } +} + +func TestBuildCoveragePrompt_ContainsContext(t *testing.T) { + produced := []Hazard{{Category: "thermal_hazard"}} + gaps := []CoverageGap{{Group: "F. Ergonomie", Key: "ergonomic"}} + system, user := BuildCoveragePrompt("Geschirrspuelmaschine", "Eine Spuelmaschine mit Tank.", produced, gaps) + if !strings.Contains(system, "EN ISO 12100") || !strings.Contains(system, "JSON") { + t.Errorf("system prompt missing framing") + } + for _, want := range []string{"Geschirrspuelmaschine", "thermal_hazard", "F. Ergonomie", "Spuelmaschine mit Tank"} { + if !strings.Contains(user, want) { + t.Errorf("user prompt missing %q", want) + } + } +} + +func TestProposeMissingHazards_ParsesAndDegrades(t *testing.T) { + gaps := []CoverageGap{{Group: "F. Ergonomie", Key: "ergonomic"}} + c := fakeCompleter{out: `Hier: [{"group":"F. Ergonomie","hazard":"Heben schwerer Koerbe","why":"manuelles Beladen"}] fertig`} + got := ProposeMissingHazards(context.Background(), c, "x", "n", nil, gaps) + if len(got) != 1 || got[0].Hazard != "Heben schwerer Koerbe" { + t.Fatalf("parse: got %+v", got) + } + if ProposeMissingHazards(context.Background(), nil, "x", "n", nil, gaps) != nil { + t.Errorf("nil completer must return nil") + } + if ProposeMissingHazards(context.Background(), fakeCompleter{err: context.DeadlineExceeded}, "x", "n", nil, gaps) != nil { + t.Errorf("error must return nil") + } +}