feat(P5): IHF Phase 5 complete — agent-assisted distillation
Some checks failed
Test / test (push) Has been cancelled

Adds bounded AI support to the IHF governance loop. All AI outputs are
attributed (model_ref), reviewable (AgentReviewRecord), and reversible.
No autonomous decisions; no silent requirement promotion.

- T01: Schema — agent_proposals, agent_review_records,
  confidence_annotations (migration 1743379200)
- T02: AgentProposalsController (index/show/accept/reject, idempotent
  review guard), global nav "Agent" link
- T03: SummarizeClusterAction — Claude API cluster summary on widget show
- T04: DraftRequirementAction — AI requirement draft; acceptance creates
  RequirementCandidate (human-gated)
- T05: DetectDuplicatesAction — duplicate_flag proposal on candidate show
- T06: DetectPolicySensitivityAction — policy_flag with
  ConfidenceAnnotations per concern scope
- T07: ProposeImplementationAction — impl_proposal from decision show
- T08: AgentAuditDashboardAction — autoRefresh; KPI row, unreviewed queue,
  recent proposals, attribution log matrix
- T09: integration tests, SCOPE.md updated, phase5-summary.md, flake.nix
  adds http-conduit/aeson/string-conversions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 15:54:33 +00:00
parent 1862bb295a
commit 2605c1c977
23 changed files with 1284 additions and 21 deletions

View File

@@ -942,3 +942,151 @@ main = do
let improvedCount = length (filter (\s -> s.signalType == "improved") signals)
improvedCount `shouldBe` 1
deleteRecord hub
-- Phase 5: Agent-Assisted Distillation
describe "AgentProposal" do
it "creates and fetches a proposal with all fields" do
hub <- newRecord @Hub |> set #name "P5Hub" |> createRecord
widget <- newRecord @Widget
|> set #name "p5widget" |> set #widgetType "button"
|> set #hubId hub.id |> set #status "active"
|> createRecord
proposal <- newRecord @AgentProposal
|> set #proposalType "summary"
|> set #sourceWidgetId (Just widget.id)
|> set #content "AI summary text"
|> set #modelRef "claude-sonnet-4-6"
|> set #status "pending"
|> createRecord
proposal.proposalType `shouldBe` "summary"
proposal.modelRef `shouldBe` "claude-sonnet-4-6"
proposal.status `shouldBe` "pending"
proposal.confidence `shouldBe` Nothing
deleteRecord hub
it "accept changes proposal status to accepted and creates review record" do
hub <- newRecord @Hub |> set #name "P5AccHub" |> createRecord
proposal <- newRecord @AgentProposal
|> set #proposalType "summary"
|> set #content "test"
|> set #modelRef "claude-sonnet-4-6"
|> set #status "pending"
|> createRecord
proposal' <- proposal |> set #status "accepted" |> updateRecord
_review <- newRecord @AgentReviewRecord
|> set #proposalId proposal.id
|> set #decision "accepted"
|> createRecord
proposal'.status `shouldBe` "accepted"
reviews <- query @AgentReviewRecord
|> filterWhere (#proposalId, proposal.id) |> fetch
length reviews `shouldBe` 1
(head reviews).decision `shouldBe` "accepted"
deleteRecord hub
it "reject changes proposal status to rejected and creates review record" do
hub <- newRecord @Hub |> set #name "P5RejHub" |> createRecord
proposal <- newRecord @AgentProposal
|> set #proposalType "policy_flag"
|> set #content "{}"
|> set #modelRef "claude-sonnet-4-6"
|> set #status "pending"
|> createRecord
proposal' <- proposal |> set #status "rejected" |> updateRecord
_review <- newRecord @AgentReviewRecord
|> set #proposalId proposal.id
|> set #decision "rejected"
|> createRecord
proposal'.status `shouldBe` "rejected"
reviews <- query @AgentReviewRecord
|> filterWhere (#proposalId, proposal.id) |> fetch
(head reviews).decision `shouldBe` "rejected"
deleteRecord hub
it "review record is idempotent (UNIQUE constraint on proposal_id)" do
hub <- newRecord @Hub |> set #name "P5IdemHub" |> createRecord
proposal <- newRecord @AgentProposal
|> set #proposalType "summary"
|> set #content "c"
|> set #modelRef "claude-sonnet-4-6"
|> set #status "pending"
|> createRecord
newRecord @AgentReviewRecord
|> set #proposalId proposal.id
|> set #decision "accepted"
|> createRecord
result <- try (
newRecord @AgentReviewRecord
|> set #proposalId proposal.id
|> set #decision "accepted"
|> createRecord
) :: IO (Either SomeException AgentReviewRecord)
isLeft result `shouldBe` True
deleteRecord hub
describe "ConfidenceAnnotation" do
it "creates and links to proposal" do
hub <- newRecord @Hub |> set #name "P5CaHub" |> createRecord
proposal <- newRecord @AgentProposal
|> set #proposalType "policy_flag"
|> set #content "{}"
|> set #modelRef "claude-sonnet-4-6"
|> set #status "pending"
|> set #confidence (Just 0.9)
|> createRecord
_ca <- newRecord @ConfidenceAnnotation
|> set #proposalId proposal.id
|> set #dimension "policy_alignment"
|> set #score 0.9
|> set #explanation (Just "High regulatory risk")
|> createRecord
cas <- query @ConfidenceAnnotation
|> filterWhere (#proposalId, proposal.id) |> fetch
length cas `shouldBe` 1
(head cas).dimension `shouldBe` "policy_alignment"
deleteRecord hub
describe "Duplicate detection proposal" do
it "creates duplicate_flag proposal and handles empty duplicates array" do
hub <- newRecord @Hub |> set #name "P5DupHub" |> createRecord
widget <- newRecord @Widget
|> set #name "dupwidget" |> set #widgetType "form"
|> set #hubId hub.id |> set #status "active"
|> createRecord
candidate <- newRecord @RequirementCandidate
|> set #title "Slow form" |> set #description "Form is slow"
|> set #sourceWidgetId widget.id |> set #category "friction"
|> set #status "open" |> createRecord
proposal <- newRecord @AgentProposal
|> set #proposalType "duplicate_flag"
|> set #sourceCandidateId (Just candidate.id)
|> set #content "{\"duplicates\": []}"
|> set #modelRef "claude-sonnet-4-6"
|> set #status "pending"
|> createRecord
proposal.proposalType `shouldBe` "duplicate_flag"
proposal.content `shouldBe` "{\"duplicates\": []}"
deleteRecord hub
describe "Agent audit dashboard data" do
it "fetches proposal counts correctly" do
hub <- newRecord @Hub |> set #name "P5AuditHub" |> createRecord
p1 <- newRecord @AgentProposal
|> set #proposalType "summary" |> set #content "s"
|> set #modelRef "claude-sonnet-4-6" |> set #status "pending"
|> createRecord
p2 <- newRecord @AgentProposal
|> set #proposalType "policy_flag" |> set #content "p"
|> set #modelRef "claude-sonnet-4-6" |> set #status "accepted"
|> createRecord
_r <- newRecord @AgentReviewRecord
|> set #proposalId p2.id |> set #decision "accepted"
|> createRecord
allProposals <- query @AgentProposal |> fetch
allReviews <- query @AgentReviewRecord |> fetch
let pending = filter (\p -> p.status == "pending") allProposals
accepted = filter (\r -> r.decision == "accepted") allReviews
length pending `shouldBe` 1
length accepted `shouldBe` 1
deleteRecord hub