feat(ai-sdk): coverage blind-spot proposer (P2 slice 6, type 4)

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 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-25 10:03:10 +02:00
parent c13aa9183a
commit 4d225f73a8
3 changed files with 212 additions and 0 deletions
@@ -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()
}