package main import ( "context" "encoding/json" "fmt" "os" "strconv" "strings" "github.com/breakpilot/ai-compliance-sdk/internal/iace" "github.com/breakpilot/ai-compliance-sdk/internal/iace/audit" "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 [] // // 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 []") 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) // Type 2: foreign-framing candidates (zone terms with no narrative echo). framing := iace.FindFramingCandidates(fired, in.Narrative, envFloat("IACE_FRAMING_MIN_ORPHAN", 0.6)) writeText("audit-reports/framing.md", iace.RenderFramingQueue(in.MachineType, framing)) writeJSON("audit-reports/framing.json", framing) // Type 3: vocab->tag proposals (unknown narrative tokens that pattern text // names as a whole word, with a dominant shared required tag). vocab := audit.RunVocabulary(map[string]any{"narrative": in.Narrative}) var vgaps []audit.DictionarySuggestion for _, s := range vocab.SuggestedDictionaryEntries { if len(s.SuggestedTags) > 0 { vgaps = append(vgaps, s) } } 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), "in_queue": len(proposals), "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)") } } 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 } func renderVocabQueue(machine string, entries []audit.DictionarySuggestion) string { var b strings.Builder fmt.Fprintf(&b, "# Vocab→tag review queue — %s\n\n", machine) fmt.Fprintf(&b, "%d unknown token(s) appear in pattern text but map to no dictionary tag. Propose-only — a human (or the LLM) confirms the tag, then adds a keyword_dictionary entry and pins a GT case.\n\n", len(entries)) for i, s := range entries { tag := "" if len(s.SuggestedTags) > 0 { tag = s.SuggestedTags[0] } fmt.Fprintf(&b, "## %d. \"%s\" → suggested tag(s): %s\n", i+1, s.Token, strings.Join(s.SuggestedTags, ", ")) fmt.Fprintf(&b, "- named by %d pattern(s): %s\n", len(s.PatternIDs), strings.Join(s.PatternIDs, ", ")) fmt.Fprintf(&b, "- suggested action: add keyword_dictionary entry {%q → %s} so narratives mentioning it trigger those patterns; human confirms\n\n", s.Token, tag) } return b.String() }