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

@@ -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