generated from coulomb/repo-seed
feat(P5): IHF Phase 5 complete — agent-assisted distillation
Some checks failed
Test / test (push) Has been cancelled
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:
128
Web/Controller/AgentProposals.hs
Normal file
128
Web/Controller/AgentProposals.hs
Normal 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 }
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user