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:
@@ -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)")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user