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

@@ -0,0 +1,128 @@
module Web.Controller.AgentProposals where
import Web.Types
import Web.View.AgentProposals.Index
import Web.View.AgentProposals.Show
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (decode)
import Data.ByteString.Lazy (fromStrict)
import Data.Text.Encoding (encodeUtf8)
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as HashMap
instance Controller AgentProposalsController where
beforeAction = ensureIsUser
action AgentProposalsAction = do
mTypeFilter <- paramOrNothing @Text "proposal_type"
mStatusFilter <- paramOrNothing @Text "status"
proposals <- case (mTypeFilter, mStatusFilter) of
(Nothing, Nothing) ->
query @AgentProposal |> orderByDesc #createdAt |> fetch
(Just t, Nothing) ->
query @AgentProposal
|> filterWhere (#proposalType, t)
|> orderByDesc #createdAt |> fetch
(Nothing, Just s) ->
query @AgentProposal
|> filterWhere (#status, s)
|> orderByDesc #createdAt |> fetch
(Just t, Just s) ->
query @AgentProposal
|> filterWhere (#proposalType, t)
|> filterWhere (#status, s)
|> orderByDesc #createdAt |> fetch
widgets <- query @Widget |> fetch
render IndexView { proposals, widgets, mTypeFilter, mStatusFilter }
action ShowAgentProposalAction { agentProposalId } = do
proposal <- fetch agentProposalId
mWidget <- case proposal.sourceWidgetId of
Nothing -> pure Nothing
Just wid -> fetchOneOrNothing wid
mCandidate <- case proposal.sourceCandidateId of
Nothing -> pure Nothing
Just cid -> fetchOneOrNothing cid
mDecision <- case proposal.sourceDecisionId of
Nothing -> pure Nothing
Just did -> fetchOneOrNothing did
mReview <- query @AgentReviewRecord
|> filterWhere (#proposalId, agentProposalId)
|> fetchOneOrNothing
confidences <- query @ConfidenceAnnotation
|> filterWhere (#proposalId, agentProposalId)
|> orderByAsc #createdAt
|> fetch
users <- query @User |> fetch
render ShowView
{ proposal, mWidget, mCandidate, mDecision
, mReview, confidences, users }
action AcceptProposalAction { agentProposalId } = do
proposal <- fetch agentProposalId
mExisting <- query @AgentReviewRecord
|> filterWhere (#proposalId, agentProposalId)
|> fetchOneOrNothing
case mExisting of
Just _ -> do
setSuccessMessage "Already reviewed"
redirectTo ShowAgentProposalAction { agentProposalId }
Nothing -> do
mUser <- currentUserOrNothing
let reviewerId = fmap (.id) mUser
proposal
|> set #status "accepted"
|> updateRecord
notes <- paramOrNothing @Text "notes"
newRecord @AgentReviewRecord
|> set #proposalId agentProposalId
|> set #reviewerId (fmap (Id . unId) reviewerId)
|> set #decision "accepted"
|> set #notes notes
|> createRecord
-- T04: if requirement_draft, promote to RequirementCandidate
when (proposal.proposalType == "requirement_draft") do
let mParsed = decode (fromStrict (encodeUtf8 proposal.content))
:: Maybe (HashMap Text Text)
case mParsed of
Just m -> do
let title = fromMaybe "AI Draft" (HashMap.lookup "title" m)
desc = fromMaybe "" (HashMap.lookup "description" m)
newRecord @RequirementCandidate
|> set #title title
|> set #description desc
|> set #sourceWidgetId proposal.sourceWidgetId
|> set #category "friction"
|> set #status "open"
|> createRecord
setSuccessMessage "Requirement candidate created from AI draft"
Nothing ->
setSuccessMessage "Proposal accepted (could not parse JSON for candidate)"
redirectTo ShowAgentProposalAction { agentProposalId }
action RejectProposalAction { agentProposalId } = do
proposal <- fetch agentProposalId
mExisting <- query @AgentReviewRecord
|> filterWhere (#proposalId, agentProposalId)
|> fetchOneOrNothing
case mExisting of
Just _ -> do
setSuccessMessage "Already reviewed"
redirectTo ShowAgentProposalAction { agentProposalId }
Nothing -> do
mUser <- currentUserOrNothing
let reviewerId = fmap (.id) mUser
proposal
|> set #status "rejected"
|> updateRecord
notes <- paramOrNothing @Text "notes"
newRecord @AgentReviewRecord
|> set #proposalId agentProposalId
|> set #reviewerId (fmap (Id . unId) reviewerId)
|> set #decision "rejected"
|> set #notes notes
|> createRecord
setSuccessMessage "Proposal rejected"
redirectTo ShowAgentProposalAction { agentProposalId }

View File

@@ -8,6 +8,8 @@ import Web.View.DecisionRecords.Edit
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Application.Helper.Controller (callClaudeApi)
import Data.List (intercalate)
validOutcomes :: [Text]
validOutcomes = ["accepted", "rejected", "deferred", "split", "merged", "reframed"]
@@ -175,3 +177,38 @@ instance Controller DecisionRecordsController where
deleteRecord ref
setSuccessMessage "Implementation reference removed"
redirectTo ShowDecisionRecordAction { decisionRecordId }
-- T07: Propose implementation paths via Claude API
action ProposeImplementationAction { decisionRecordId } = do
record <- fetch decisionRecordId
implRefs <- query @ImplementationChangeReference
|> filterWhere (#decisionId, decisionRecordId)
|> fetch
mRequirement <- case record.requirementId of
Nothing -> pure Nothing
Just rid -> fetchOneOrNothing rid
let implLines = map (\r -> r.system <> ": " <> r.workItemRef) implRefs
reqDesc = maybe "" (.description) mRequirement
userMsg = "Decision: " <> record.title
<> "\nRationale: " <> record.rationale
<> "\nOutcome: " <> record.outcome
<> "\nRequirement: " <> reqDesc
<> "\nExisting impl refs: " <> intercalate ", " implLines
result <- liftIO $ callClaudeApi
"You are a traceability-aware implementation analyst. Propose 1\x20133 concrete implementation paths for this decision. Each path should include a work_item_ref (e.g. PROJ-123), a system (github|linear|jira), and a rationale. Respond with JSON: {\"proposals\": [{\"work_item_ref\": \"...\", \"system\": \"...\", \"rationale\": \"...\"}]}."
userMsg
600
case result of
Left err -> do
setErrorMessage ("Implementation proposal failed: " <> err)
redirectTo ShowDecisionRecordAction { decisionRecordId }
Right content -> do
newRecord @AgentProposal
|> set #proposalType "impl_proposal"
|> set #sourceDecisionId (Just decisionRecordId)
|> set #content content
|> set #modelRef "claude-sonnet-4-6"
|> set #status "pending"
|> createRecord
setSuccessMessage "Implementation proposal created"
redirectTo ShowDecisionRecordAction { decisionRecordId }

View File

@@ -8,6 +8,7 @@ import Web.View.Hubs.Edit
import Web.View.Hubs.TriageDashboard
import Web.View.Hubs.GovernanceDashboard
import Web.View.Hubs.AntifragilityDashboard
import Web.View.Hubs.AgentAuditDashboard
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
@@ -219,3 +220,11 @@ instance Controller HubsController where
, regressionWidgetIds
, recurrenceLeaderboard
}
action AgentAuditDashboardAction { hubId } = autoRefresh do
hub <- fetch hubId
proposals <- query @AgentProposal
|> orderByDesc #createdAt
|> fetch
reviews <- query @AgentReviewRecord |> fetch
widgets <- query @Widget |> fetch
render AgentAuditDashboardView { hub, proposals, reviews, widgets }

View File

@@ -8,6 +8,17 @@ import Web.View.RequirementCandidates.Edit
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Application.Helper.Controller (callClaudeApi)
import Data.List (intercalate)
import Data.Aeson (decode, Value(..), Array)
import Data.Aeson.Lens (key, _String)
import Control.Lens ((^?))
import Data.ByteString.Lazy (fromStrict)
import Data.Text.Encoding (encodeUtf8)
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as HashMap
import qualified Data.Vector as Vector
import Control.Monad (forM_)
validStatuses :: [Text]
validStatuses = ["open", "in_review", "accepted", "rejected", "deferred"]
@@ -233,3 +244,86 @@ instance Controller RequirementCandidatesController where
|> createRecord
setSuccessMessage "Decision record created"
redirectTo ShowDecisionRecordAction { decisionRecordId = dr.id }
-- T05: Detect duplicate candidates via Claude API
action DetectDuplicatesAction { requirementCandidateId } = do
target <- fetch requirementCandidateId
others <- query @RequirementCandidate
|> fetch
let otherLines = map (\c -> show c.id <> " | " <> c.title <> ": " <> c.description)
(filter (\c -> c.id /= requirementCandidateId) others)
targetLine = "TARGET: " <> target.title <> ": " <> target.description
userMsg = targetLine <> "\n\nEXISTING:\n" <> intercalate "\n" otherLines
result <- liftIO $ callClaudeApi
"You are a deduplication assistant. Given a target candidate and a list of existing candidates, identify likely duplicates. Respond with JSON: {\"duplicates\": [{\"id\": \"uuid\", \"reason\": \"...\"}]}."
userMsg
500
case result of
Left err -> do
setErrorMessage ("Duplicate detection failed: " <> err)
redirectTo ShowRequirementCandidateAction { requirementCandidateId }
Right content -> do
newRecord @AgentProposal
|> set #proposalType "duplicate_flag"
|> set #sourceCandidateId (Just requirementCandidateId)
|> set #content content
|> set #modelRef "claude-sonnet-4-6"
|> set #status "pending"
|> createRecord
setSuccessMessage "Duplicate detection proposal created"
redirectTo ShowRequirementCandidateAction { requirementCandidateId }
-- T06: Detect policy sensitivity via Claude API
action DetectPolicySensitivityAction { requirementCandidateId } = do
candidate <- fetch requirementCandidateId
mWidget <- case candidate.sourceWidgetId of
Nothing -> pure Nothing
Just wid -> fetchOneOrNothing wid
let policyCtx = maybe "unknown" (.policyScope) mWidget
userMsg = "Title: " <> candidate.title
<> "\nDescription: " <> candidate.description
<> "\nPolicy scope context: " <> policyCtx
result <- liftIO $ callClaudeApi
"You are a policy compliance assistant. Analyse this requirement candidate for potential policy concerns. Valid scopes: internal, external, regulatory, contractual, architectural. Respond with JSON: {\"concerns\": [{\"scope\": \"...\", \"note\": \"...\"}], \"severity\": \"low|medium|high\"}."
userMsg
500
case result of
Left err -> do
setErrorMessage ("Policy check failed: " <> err)
redirectTo ShowRequirementCandidateAction { requirementCandidateId }
Right content -> do
let confidenceScore = extractSeverityScore content
proposal <- newRecord @AgentProposal
|> set #proposalType "policy_flag"
|> set #sourceCandidateId (Just requirementCandidateId)
|> set #content content
|> set #modelRef "claude-sonnet-4-6"
|> set #confidence (Just confidenceScore)
|> set #status "pending"
|> createRecord
-- Create one ConfidenceAnnotation per concern scope
let mParsed = decode (fromStrict (encodeUtf8 content))
:: Maybe (HashMap Text Value)
case mParsed >>= HashMap.lookup "concerns" of
Just (Array concerns) ->
forM_ (Vector.toList concerns) \concern ->
case (concern ^? key "scope" . _String
,concern ^? key "note" . _String) of
(Just scope, noteM) ->
newRecord @ConfidenceAnnotation
|> set #proposalId proposal.id
|> set #dimension scope
|> set #score confidenceScore
|> set #explanation noteM
|> createRecord
_ -> pure ()
_ -> pure ()
setSuccessMessage "Policy check proposal created"
redirectTo ShowRequirementCandidateAction { requirementCandidateId }
-- Map severity string to numeric confidence
extractSeverityScore :: Text -> Double
extractSeverityScore content
| "\"high\"" `isInfixOf` content = 0.9
| "\"medium\"" `isInfixOf` content = 0.6
| otherwise = 0.3

View File

@@ -9,7 +9,8 @@ import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (toJSON, object, (.=))
import Application.Helper.Controller (isInRegression, widgetCycleCounts)
import Application.Helper.Controller (isInRegression, widgetCycleCounts, callClaudeApi)
import Data.List (intercalate)
instance Controller WidgetsController where
beforeAction = ensureIsUser
@@ -121,3 +122,65 @@ instance Controller WidgetsController where
|> createRecord
setSuccessMessage "Widget updated"
redirectTo ShowWidgetAction { widgetId = widget.id }
-- T03: Summarize feedback cluster via Claude API
action SummarizeClusterAction { widgetId } = do
annotations <- query @Annotation
|> filterWhere (#widgetId, widgetId)
|> orderByDesc #createdAt
|> limit 20
|> fetch
threads <- query @AnnotationThread
|> filterWhere (#widgetId, widgetId)
|> orderByDesc #createdAt
|> limit 20
|> fetch
let annLines = map (\a -> "[" <> a.category <> "/" <> a.severity <> "] " <> a.body) annotations
threadLines = map (\t -> "[thread] " <> t.title <> ": " <> fromMaybe "" t.description) threads
userMsg = intercalate "\n" (annLines <> threadLines)
result <- liftIO $ callClaudeApi
"You are a distillation assistant for a governed interaction hub. Summarize the following user feedback cluster into a concise, actionable summary (2\x20134 sentences). Be factual and neutral."
userMsg
300
case result of
Left err -> do
setErrorMessage ("AI summarization failed: " <> err)
redirectTo ShowWidgetAction { widgetId }
Right content -> do
newRecord @AgentProposal
|> set #proposalType "summary"
|> set #sourceWidgetId (Just widgetId)
|> set #content content
|> set #modelRef "claude-sonnet-4-6"
|> set #status "pending"
|> createRecord
setSuccessMessage "Summary proposal created"
redirectTo ShowWidgetAction { widgetId }
-- T04: Draft a requirement candidate via Claude API
action DraftRequirementAction { widgetId } = do
annotations <- query @Annotation
|> filterWhere (#widgetId, widgetId)
|> orderByDesc #createdAt
|> limit 20
|> fetch
let annLines = map (\a -> "[" <> a.category <> "/" <> a.severity <> "] " <> a.body) annotations
userMsg = intercalate "\n" annLines
result <- liftIO $ callClaudeApi
"You are a requirements analyst. Given these friction annotations, draft a single structured requirement candidate. Respond with JSON: {\"title\": \"...\", \"description\": \"...\"}."
userMsg
400
case result of
Left err -> do
setErrorMessage ("AI draft failed: " <> err)
redirectTo ShowWidgetAction { widgetId }
Right content -> do
newRecord @AgentProposal
|> set #proposalType "requirement_draft"
|> set #sourceWidgetId (Just widgetId)
|> set #content content
|> set #modelRef "claude-sonnet-4-6"
|> set #status "pending"
|> createRecord
setSuccessMessage "Requirement draft proposal created"
redirectTo ShowWidgetAction { widgetId }