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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user