generated from coulomb/repo-seed
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>
129 lines
5.7 KiB
Haskell
129 lines
5.7 KiB
Haskell
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 }
|