feat(ai-sdk): runnable iace-audit propose CLI + live LLM wiring (P2 slice 3)
Makes the offline proposer runnable end-to-end.
- BuildProposerInput (proposer_input.go): non-test engine->hazards path. The
PatternMatch->Hazard converter is lifted out of the GT test files into
production scope so both the tests and the CLI share one pipeline.
- iace-audit propose <narrative.json> [<ground-truth.json>]: detect candidates ->
GT-screen survivors (when a ground truth is given) -> judge (HeuristicJudge by
default, LLMJudge over ollama when IACE_PROPOSE_LLM=1) -> write the human-review
queue to audit-reports/proposals.{md,json}. Propose-only.
Smoke run on a dishwasher narrative: 32 fired -> 3 candidates -> queue with a
confident duplicate, a confident distinct, and one punted to the LLM judge; GT
wall recall-safe. Live qwen is opt-in via env; the heuristic default keeps the
tool runnable (and CI deterministic) without a model. Proposal types 2-4
(foreign-framing gates, vocab->tag, coverage blind spots) remain for slice 4.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,8 @@ func main() {
|
||||
cmdEcho(os.Args[2:])
|
||||
case "hierarchy":
|
||||
cmdHierarchy(os.Args[2:])
|
||||
case "propose":
|
||||
cmdPropose(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
@@ -41,7 +43,7 @@ func main() {
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "Usage: iace-audit <reachability|consistency|vocabulary|echo|hierarchy> [args]")
|
||||
fmt.Fprintln(os.Stderr, "Usage: iace-audit <reachability|consistency|vocabulary|echo|hierarchy|propose> [args]")
|
||||
}
|
||||
|
||||
func cmdReachability(_ []string) {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
)
|
||||
|
||||
type narrativeInput struct {
|
||||
MachineType string `json:"machine_type"`
|
||||
Narrative string `json:"narrative"`
|
||||
MachineTypes []string `json:"machine_types,omitempty"`
|
||||
}
|
||||
|
||||
// cmdPropose — Method P: offline dedup-candidate proposer.
|
||||
//
|
||||
// iace-audit propose <narrative.json> [<ground-truth.json>]
|
||||
//
|
||||
// Detect near-duplicate patterns, screen survivors against a ground truth (if
|
||||
// given), judge them (heuristic by default, LLM when enabled), and write the
|
||||
// human-review queue to audit-reports/proposals.{md,json}. Propose-only — it
|
||||
// writes a report and never mutates the pattern library.
|
||||
//
|
||||
// Env:
|
||||
//
|
||||
// IACE_PROPOSE_THRESHOLD candidate score threshold (default 0.30)
|
||||
// IACE_PROPOSE_LLM=1 use the offline LLM judge instead of the heuristic
|
||||
// OLLAMA_URL ollama base URL (default http://localhost:11434)
|
||||
// SELF_HOSTED_LLM_MODEL model name (default qwen2.5:32b-instruct)
|
||||
func cmdPropose(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "propose: usage: iace-audit propose <narrative.json> [<ground-truth.json>]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
var in narrativeInput
|
||||
must(readJSONFile(args[0], &in))
|
||||
if in.Narrative == "" {
|
||||
fmt.Fprintln(os.Stderr, "propose: narrative is empty")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
var gt *iace.GroundTruth
|
||||
if len(args) >= 2 {
|
||||
var g iace.GroundTruth
|
||||
must(readJSONFile(args[1], &g))
|
||||
gt = &g
|
||||
}
|
||||
|
||||
threshold := envFloat("IACE_PROPOSE_THRESHOLD", 0.30)
|
||||
hazards, mits, fired := iace.BuildProposerInput(in.Narrative, in.MachineType, in.MachineTypes)
|
||||
candidates := iace.FindDedupCandidates(fired, threshold)
|
||||
|
||||
byID := make(map[string]iace.PatternMatch, len(fired))
|
||||
for _, pm := range fired {
|
||||
byID[pm.PatternID] = pm
|
||||
}
|
||||
|
||||
judge := selectJudge(in.MachineType)
|
||||
ctx := context.Background()
|
||||
|
||||
var proposals []iace.JudgedProposal
|
||||
blocked := 0
|
||||
for _, c := range candidates {
|
||||
var sr iace.ScreenResult
|
||||
if gt != nil {
|
||||
sr = iace.ScreenSupersession(gt, hazards, mits, c.KeepHazardName, c.DropName)
|
||||
if sr.RecallAfter < sr.RecallBefore || sr.DistinctGT {
|
||||
blocked++
|
||||
continue
|
||||
}
|
||||
}
|
||||
v, conf, rat := judge.Judge(ctx, c, byID[c.KeepPattern], byID[c.DropPattern])
|
||||
proposals = append(proposals, iace.JudgedProposal{
|
||||
Candidate: c, Screen: sr, Verdict: v, Confidence: conf, Rationale: rat, Judge: judge.Name(),
|
||||
})
|
||||
}
|
||||
|
||||
writeText("audit-reports/proposals.md", iace.RenderProposalQueue(in.MachineType, proposals))
|
||||
writeJSON("audit-reports/proposals.json", proposals)
|
||||
|
||||
printSummary("Method P — Dedup Proposer ("+judge.Name()+")", map[string]int{
|
||||
"fired_patterns": len(fired),
|
||||
"candidates": len(candidates),
|
||||
"in_queue": len(proposals),
|
||||
"gt_blocked": blocked,
|
||||
})
|
||||
if gt == nil {
|
||||
fmt.Fprintln(os.Stderr, "note: no ground truth provided — GT wall NOT applied (candidates not recall-screened)")
|
||||
}
|
||||
}
|
||||
|
||||
func selectJudge(machineClass string) iace.CandidateJudge {
|
||||
if os.Getenv("IACE_PROPOSE_LLM") != "1" {
|
||||
return iace.HeuristicJudge{}
|
||||
}
|
||||
base := envStr("OLLAMA_URL", "http://localhost:11434")
|
||||
model := envStr("SELF_HOSTED_LLM_MODEL", "qwen2.5:32b-instruct")
|
||||
reg := llm.NewProviderRegistry("ollama", "")
|
||||
reg.Register(llm.NewOllamaAdapter(base, model))
|
||||
fmt.Printf("using LLM judge (ollama %s, model %s)\n", base, model)
|
||||
return iace.LLMJudge{Completer: iace.NewRegistryCompleter(reg, model), MachineClass: machineClass}
|
||||
}
|
||||
|
||||
func readJSONFile(path string, v any) error {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, v)
|
||||
}
|
||||
|
||||
func writeText(path, content string) {
|
||||
_ = os.MkdirAll("audit-reports", 0o755)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warn: could not write", path, err)
|
||||
return
|
||||
}
|
||||
fmt.Println("→ wrote", path)
|
||||
}
|
||||
|
||||
func envStr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envFloat(key string, def float64) float64 {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
Reference in New Issue
Block a user