generated from coulomb/repo-seed
feat(WP-0013): IHF Phase 12 — Platform Memory and Continuous Learning
Closes the long-range feedback loop: outcome signals now enrich the full
traceability chain and feed back into routing, triage, and AI proposals.
Schema (T01):
- outcome_correlations (CHECK correlation_type)
- pattern_performance_records
- adaptive_threshold_configs
- institutional_knowledge_entries (GIN tsvector FTS)
- learning_insights (CHECK insight_type)
- ALTER TABLE decision_records + requirement_candidates: outcome_summary JSONB
- AFTER INSERT trigger trg_enrich_lineage on outcome_signals
- contracts/core/ updated (outcome-summary-columns-v1, append-only addendum)
Correlation engine (T02):
- Application/Helper/CorrelationEngine.hs: pure annotation→outcome SQL
- Web/Controller/OutcomeCorrelations.hs: ComputeCorrelationsAction + index
Pattern performance (T03):
- Web/Controller/PatternPerformance.hs: ComputePatternPerformanceAction
Adaptive thresholds (T04):
- Web/Controller/AdaptiveThresholds.hs: CalibrateThresholdsAction
- Application/Helper/FrictionScore.hs: applyAdaptiveWeights
Institutional knowledge (T05):
- DistilDecisionAction in DecisionRecords controller
- Web/Controller/InstitutionalKnowledge.hs: QueryKnowledgeBaseAction
Lineage enrichment (T06):
- Web/Controller/LineageEnrichment.hs: EnrichLineageAction (batch backfill)
- enrich_lineage_on_outcome_batch() PL/pgSQL helper in migration
Learning dashboard (T07):
- Web/Controller/LearningDashboard.hs: 5-panel autoRefresh view
- "Learning" nav link in FrontController
API v2 learning endpoints (T08):
- GET /api/v2/outcome-correlations, /pattern-performance, /knowledge-base/{id}
- OpenAPI schemas: OutcomeCorrelation, PatternPerformanceRecord, InstitutionalKnowledgeEntry
GAAF scorecard + docs (T09):
- Core 3.8→3.9, Functional 3.6→3.8, overall 3.61→3.68
- CLAUDE.md: IHF v0.2 complete, no active workplan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
86
Web/Controller/AdaptiveThresholds.hs
Normal file
86
Web/Controller/AdaptiveThresholds.hs
Normal file
@@ -0,0 +1,86 @@
|
||||
module Web.Controller.AdaptiveThresholds where
|
||||
|
||||
-- IHF Phase 12 — Platform Memory (IHUB-WP-0013 T04)
|
||||
|
||||
import Web.Controller.Prelude
|
||||
import Web.View.AdaptiveThresholds.Index
|
||||
import IHP.ModelSupport (sqlQuery)
|
||||
import Database.PostgreSQL.Simple (Only(..))
|
||||
|
||||
instance Controller AdaptiveThresholdsController where
|
||||
beforeAction = ensureIsUser
|
||||
|
||||
action AdaptiveThresholdsAction = do
|
||||
hubs <- query @Hub |> orderByAsc #name |> fetch
|
||||
configs <- query @AdaptiveThresholdConfig |> fetch
|
||||
insights <- query @LearningInsight
|
||||
|> filterWhere (#insightType, "threshold_calibration")
|
||||
|> orderByDesc #computedAt
|
||||
|> limit 10
|
||||
|> fetch
|
||||
render IndexView { hubs, configs, insights }
|
||||
|
||||
action CalibrateThresholdsAction { hubIdForThreshold } = do
|
||||
let hubId = hubIdForThreshold
|
||||
-- Step 1: find weak-predictor categories (score < 0.3)
|
||||
weakCats <- sqlQuery
|
||||
"SELECT annotation_category FROM outcome_correlations \
|
||||
\ WHERE hub_id = ? AND correlation_score < 0.3"
|
||||
[hubId]
|
||||
:: IO [Only Text]
|
||||
|
||||
-- Step 2: compute bottleneck threshold override = mean friction score
|
||||
-- for widgets with at least one negative outcome signal
|
||||
[Only mBottleneckOverride] <- sqlQuery
|
||||
"SELECT AVG(fs.score) \
|
||||
\ FROM friction_scores fs \
|
||||
\ JOIN widgets w ON w.id = fs.widget_id \
|
||||
\ WHERE w.hub_id = ? \
|
||||
\ AND EXISTS ( \
|
||||
\ SELECT 1 FROM outcome_signals os \
|
||||
\ JOIN deployment_records dep ON dep.id = os.deployment_id \
|
||||
\ JOIN decision_records dr ON dr.id = dep.decision_id \
|
||||
\ JOIN requirements r ON r.id = dr.requirement_id \
|
||||
\ JOIN requirement_candidates rc ON rc.id = r.candidate_id \
|
||||
\ WHERE rc.source_widget_id = w.id \
|
||||
\ AND os.signal_type NOT IN ('success','adoption','satisfaction') \
|
||||
\ )"
|
||||
[hubId]
|
||||
:: IO [Only (Maybe Double)]
|
||||
|
||||
now <- getCurrentTime
|
||||
let weakNote = "Weak predictor categories (score < 0.3): "
|
||||
<> intercalate ", " (map fromOnly weakCats)
|
||||
|
||||
-- Step 3: upsert AdaptiveThresholdConfig
|
||||
existing <- query @AdaptiveThresholdConfig
|
||||
|> filterWhere (#hubId, hubId)
|
||||
|> fetchOneOrNothing
|
||||
case existing of
|
||||
Just cfg ->
|
||||
cfg
|
||||
|> set #bottleneckThresholdOverride mBottleneckOverride
|
||||
|> set #calibrationDate now
|
||||
|> set #notes (Just weakNote)
|
||||
|> updateRecord
|
||||
Nothing ->
|
||||
newRecord @AdaptiveThresholdConfig
|
||||
|> set #hubId hubId
|
||||
|> set #weightOverrides (A.Object mempty)
|
||||
|> set #bottleneckThresholdOverride mBottleneckOverride
|
||||
|> set #calibrationDate now
|
||||
|> set #notes (Just weakNote)
|
||||
|> createRecord
|
||||
|
||||
-- Step 4: write LearningInsight
|
||||
newRecord @LearningInsight
|
||||
|> set #hubId hubId
|
||||
|> set #insightType "threshold_calibration"
|
||||
|> set #title "Adaptive threshold calibration completed"
|
||||
|> set #body ("Calibrated friction thresholds. " <> weakNote
|
||||
<> maybe "" (\b -> " Bottleneck override: " <> show b) mBottleneckOverride)
|
||||
|> set #evidenceLinks (A.toJSON ([] :: [A.Value]))
|
||||
|> createRecord
|
||||
|
||||
setSuccessMessage "Threshold calibration complete"
|
||||
redirectTo AdaptiveThresholdsAction
|
||||
106
Web/Controller/Api/V2/Learning.hs
Normal file
106
Web/Controller/Api/V2/Learning.hs
Normal file
@@ -0,0 +1,106 @@
|
||||
module Web.Controller.Api.V2.Learning where
|
||||
|
||||
-- IHF Phase 12 — Platform Memory (IHUB-WP-0013 T08)
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (object, (.=))
|
||||
import Web.Controller.Api.V2.Auth (requireApiConsumer, paginatedResponse, getPageParams)
|
||||
import IHP.ModelSupport (sqlQuery)
|
||||
|
||||
instance Controller ApiV2LearningController where
|
||||
|
||||
action ApiV2IndexOutcomeCorrelationsAction = do
|
||||
_consumer <- requireApiConsumer
|
||||
mHubId <- paramOrNothing @(Id Hub) "hub_id"
|
||||
mCat <- paramOrNothing @Text "category"
|
||||
(page, perPage) <- getPageParams
|
||||
let off = (page - 1) * perPage
|
||||
baseQuery <- pure $ query @OutcomeCorrelation
|
||||
filtered <- pure $ case mHubId of
|
||||
Nothing -> baseQuery
|
||||
Just hid -> baseQuery |> filterWhere (#hubId, hid)
|
||||
filteredCat <- pure $ case mCat of
|
||||
Nothing -> filtered
|
||||
Just cat -> filtered |> filterWhere (#annotationCategory, cat)
|
||||
total <- filteredCat |> fetchCount
|
||||
rows <- filteredCat |> orderByDesc #correlationScore |> limit perPage |> offset off |> fetch
|
||||
renderJson $ paginatedResponse (map correlationToJson rows) page perPage total
|
||||
|
||||
action ApiV2IndexPatternPerformanceAction = do
|
||||
_consumer <- requireApiConsumer
|
||||
(page, perPage) <- getPageParams
|
||||
let off = (page - 1) * perPage
|
||||
total <- query @PatternPerformanceRecord |> fetchCount
|
||||
rows <- query @PatternPerformanceRecord
|
||||
|> orderByAsc #outcomeRank
|
||||
|> limit perPage
|
||||
|> offset off
|
||||
|> fetch
|
||||
renderJson $ paginatedResponse (map patternPerfToJson rows) page perPage total
|
||||
|
||||
action ApiV2IndexKnowledgeBaseAction = do
|
||||
_consumer <- requireApiConsumer
|
||||
mQ <- paramOrNothing @Text "q"
|
||||
(page, perPage) <- getPageParams
|
||||
let off = (page - 1) * perPage
|
||||
rows <- case mQ of
|
||||
Nothing -> query @InstitutionalKnowledgeEntry
|
||||
|> orderByDesc #createdAt
|
||||
|> limit perPage
|
||||
|> offset off
|
||||
|> fetch
|
||||
Just q -> sqlQuery
|
||||
"SELECT * FROM institutional_knowledge_entries \
|
||||
\ WHERE summary_tsv @@ plainto_tsquery('english', ?) \
|
||||
\ ORDER BY ts_rank(summary_tsv, plainto_tsquery('english', ?)) DESC \
|
||||
\ LIMIT ? OFFSET ?"
|
||||
(q, q, perPage, off)
|
||||
renderJson (map knowledgeToJson rows)
|
||||
|
||||
action ApiV2ShowKnowledgeBaseAction { knowledgeEntryId } = do
|
||||
_consumer <- requireApiConsumer
|
||||
entry <- fetch knowledgeEntryId
|
||||
renderJson (knowledgeToJson entry)
|
||||
|
||||
correlationToJson :: OutcomeCorrelation -> Value
|
||||
correlationToJson c = object
|
||||
[ "id" .= c.id
|
||||
, "hubId" .= c.hubId
|
||||
, "annotationCategory" .= c.annotationCategory
|
||||
, "correlationType" .= c.correlationType
|
||||
, "correlationScore" .= c.correlationScore
|
||||
, "sampleCount" .= c.sampleCount
|
||||
, "computedAt" .= c.computedAt
|
||||
]
|
||||
|
||||
patternPerfToJson :: PatternPerformanceRecord -> Value
|
||||
patternPerfToJson r =
|
||||
let positiveRate = if r.totalOutcomeCount > 0
|
||||
then fromIntegral r.positiveOutcomeCount / fromIntegral r.totalOutcomeCount :: Double
|
||||
else 0.0
|
||||
in object
|
||||
[ "id" .= r.id
|
||||
, "widgetPatternId" .= r.widgetPatternId
|
||||
, "hubId" .= r.hubId
|
||||
, "adoptionCount" .= r.adoptionCount
|
||||
, "positiveOutcomeCount" .= r.positiveOutcomeCount
|
||||
, "totalOutcomeCount" .= r.totalOutcomeCount
|
||||
, "positiveOutcomeRate" .= positiveRate
|
||||
, "meanOutcomeValue" .= r.meanOutcomeValue
|
||||
, "outcomeRank" .= r.outcomeRank
|
||||
, "calibratedAt" .= r.calibratedAt
|
||||
]
|
||||
|
||||
knowledgeToJson :: InstitutionalKnowledgeEntry -> Value
|
||||
knowledgeToJson e = object
|
||||
[ "id" .= e.id
|
||||
, "hubId" .= e.hubId
|
||||
, "decisionRecordId" .= e.decisionRecordId
|
||||
, "summary" .= e.summary
|
||||
, "tags" .= e.tags
|
||||
, "createdAt" .= e.createdAt
|
||||
, "updatedAt" .= e.updatedAt
|
||||
]
|
||||
@@ -90,6 +90,9 @@ buildOpenApiSpec = do
|
||||
, "DecisionRecord" .= drSchema
|
||||
, "DeploymentRecord" .= depSchema
|
||||
, "OutcomeSignal" .= sigSchema
|
||||
, "OutcomeCorrelation" .= outcomeCorrelationSchema
|
||||
, "PatternPerformanceRecord" .= patternPerformanceSchema
|
||||
, "InstitutionalKnowledgeEntry" .= institutionalKnowledgeSchema
|
||||
]
|
||||
, "securitySchemes" .= object
|
||||
[ "BearerAuth" .= object
|
||||
@@ -367,6 +370,51 @@ sigSchema = object
|
||||
]
|
||||
]
|
||||
|
||||
outcomeCorrelationSchema :: Value
|
||||
outcomeCorrelationSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "hubId" .= uuidProp
|
||||
, "annotationCategory" .= strProp
|
||||
, "correlationType" .= strProp
|
||||
, "correlationScore" .= object ["type" .= ("number" :: Text)]
|
||||
, "sampleCount" .= object ["type" .= ("integer" :: Text)]
|
||||
, "computedAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
patternPerformanceSchema :: Value
|
||||
patternPerformanceSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "widgetPatternId" .= uuidProp
|
||||
, "hubId" .= uuidProp
|
||||
, "adoptionCount" .= object ["type" .= ("integer" :: Text)]
|
||||
, "positiveOutcomeCount" .= object ["type" .= ("integer" :: Text)]
|
||||
, "totalOutcomeCount" .= object ["type" .= ("integer" :: Text)]
|
||||
, "positiveOutcomeRate" .= object ["type" .= ("number" :: Text)]
|
||||
, "meanOutcomeValue" .= object ["type" .= ("number" :: Text)]
|
||||
, "outcomeRank" .= object ["type" .= ("integer" :: Text)]
|
||||
, "calibratedAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
institutionalKnowledgeSchema :: Value
|
||||
institutionalKnowledgeSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "hubId" .= uuidProp
|
||||
, "decisionRecordId" .= uuidProp
|
||||
, "summary" .= strProp
|
||||
, "tags" .= object ["type" .= ("array" :: Text)]
|
||||
, "createdAt" .= dtProp
|
||||
, "updatedAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
uuidProp :: Value
|
||||
uuidProp = object ["type" .= ("string" :: Text), "format" .= ("uuid" :: Text)]
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import IHP.ControllerPrelude
|
||||
import Application.Helper.AgentBridge (callAgent, checkGovernancePolicy, bridgeErrorMessage)
|
||||
import Application.Helper.ModelRouter (resolveAgent)
|
||||
import Data.List (intercalate)
|
||||
import IHP.ModelSupport (sqlQuery)
|
||||
import qualified Data.Aeson as A
|
||||
|
||||
validOutcomes :: [Text]
|
||||
validOutcomes = ["accepted", "rejected", "deferred", "split", "merged", "reframed"]
|
||||
@@ -242,3 +244,60 @@ instance Controller DecisionRecordsController where
|
||||
|> createRecord
|
||||
setSuccessMessage "Implementation proposal created"
|
||||
redirectTo ShowDecisionRecordAction { decisionRecordId }
|
||||
|
||||
-- T05 / Phase 12: Distil decision into institutional knowledge entry
|
||||
action DistilDecisionAction { decisionRecordId } = do
|
||||
record <- fetch decisionRecordId
|
||||
outcomes <- sqlQuery
|
||||
"SELECT os.signal_type, os.value FROM outcome_signals os \
|
||||
\ JOIN deployment_records dep ON dep.id = os.deployment_id \
|
||||
\ WHERE dep.decision_id = ?"
|
||||
[decisionRecordId]
|
||||
:: IO [(Text, Maybe Double)]
|
||||
let signalText = intercalate ", " $
|
||||
map (\(st, mv) -> st <> maybe "" (\v -> "=" <> show v) mv) outcomes
|
||||
prompt = "Distil this decision into a 2-3 sentence institutional knowledge entry. "
|
||||
<> "Include the outcome data.\n\nDecision: " <> record.title
|
||||
<> "\nRationale: " <> record.rationale
|
||||
<> "\nOutcome: " <> record.outcome
|
||||
<> "\nSignals: " <> signalText
|
||||
-- Resolve hub from requirement chain
|
||||
mHubId <- case record.requirementId of
|
||||
Nothing -> pure Nothing
|
||||
Just rid -> do
|
||||
mReq <- fetchOneOrNothing rid
|
||||
pure $ case mReq >>= (.sourceWidgetId) of
|
||||
Nothing -> Nothing
|
||||
Just _ -> Nothing -- hub resolution via widget lookup below
|
||||
mHubIdResolved <- case record.requirementId of
|
||||
Nothing -> pure Nothing
|
||||
Just rid -> do
|
||||
mReq <- fetchOneOrNothing rid
|
||||
case mReq >>= (.sourceWidgetId) of
|
||||
Nothing -> pure Nothing
|
||||
Just wid -> fmap (.hubId) <$> fetchOneOrNothing @Widget wid
|
||||
case mHubIdResolved of
|
||||
Nothing -> do
|
||||
setErrorMessage "Cannot resolve hub — ensure decision has a linked requirement with a source widget"
|
||||
redirectTo ShowDecisionRecordAction { decisionRecordId }
|
||||
Just hubId -> do
|
||||
mAgent <- resolveAgent hubId "synthesis"
|
||||
case mAgent of
|
||||
Nothing -> do
|
||||
setErrorMessage "No routing policy for 'synthesis' task type"
|
||||
redirectTo ShowDecisionRecordAction { decisionRecordId }
|
||||
Just agent -> do
|
||||
result <- liftIO $ callAgent agent prompt
|
||||
case result of
|
||||
Left err -> do
|
||||
setErrorMessage ("Distillation failed: " <> bridgeErrorMessage err)
|
||||
redirectTo ShowDecisionRecordAction { decisionRecordId }
|
||||
Right resp -> do
|
||||
newRecord @InstitutionalKnowledgeEntry
|
||||
|> set #hubId hubId
|
||||
|> set #decisionRecordId (Just decisionRecordId)
|
||||
|> set #summary resp.content
|
||||
|> set #tags (A.toJSON ["decision" :: Text])
|
||||
|> createRecord
|
||||
setSuccessMessage "Knowledge entry created"
|
||||
redirectTo ShowDecisionRecordAction { decisionRecordId }
|
||||
|
||||
49
Web/Controller/InstitutionalKnowledge.hs
Normal file
49
Web/Controller/InstitutionalKnowledge.hs
Normal file
@@ -0,0 +1,49 @@
|
||||
module Web.Controller.InstitutionalKnowledge where
|
||||
|
||||
-- IHF Phase 12 — Platform Memory (IHUB-WP-0013 T05)
|
||||
|
||||
import Web.Controller.Prelude
|
||||
import Web.View.InstitutionalKnowledge.Index
|
||||
import Web.View.InstitutionalKnowledge.Show
|
||||
import IHP.ModelSupport (sqlQuery)
|
||||
|
||||
instance Controller InstitutionalKnowledgeController where
|
||||
beforeAction = ensureIsUser
|
||||
|
||||
action InstitutionalKnowledgeAction = do
|
||||
entries <- query @InstitutionalKnowledgeEntry
|
||||
|> orderByDesc #createdAt
|
||||
|> limit 50
|
||||
|> fetch
|
||||
hubs <- query @Hub |> fetch
|
||||
render IndexView { entries, hubs, mQuery = Nothing }
|
||||
|
||||
action ShowInstitutionalKnowledgeAction { knowledgeEntryId } = do
|
||||
entry <- fetch knowledgeEntryId
|
||||
hub <- fetch entry.hubId
|
||||
mDecision <- case entry.decisionRecordId of
|
||||
Nothing -> pure Nothing
|
||||
Just did -> fetchOneOrNothing did
|
||||
render ShowView { entry, hub, mDecision }
|
||||
|
||||
action QueryKnowledgeBaseAction = do
|
||||
q <- param @Text "q"
|
||||
mHubStr <- paramOrNothing @Text "hubId"
|
||||
hubs <- query @Hub |> fetch
|
||||
entries <- case mHubStr of
|
||||
Nothing ->
|
||||
sqlQuery
|
||||
"SELECT * FROM institutional_knowledge_entries \
|
||||
\ WHERE summary_tsv @@ plainto_tsquery('english', ?) \
|
||||
\ ORDER BY ts_rank(summary_tsv, plainto_tsquery('english', ?)) DESC \
|
||||
\ LIMIT 20"
|
||||
(q, q)
|
||||
Just hid ->
|
||||
sqlQuery
|
||||
"SELECT * FROM institutional_knowledge_entries \
|
||||
\ WHERE hub_id = ? \
|
||||
\ AND summary_tsv @@ plainto_tsquery('english', ?) \
|
||||
\ ORDER BY ts_rank(summary_tsv, plainto_tsquery('english', ?)) DESC \
|
||||
\ LIMIT 20"
|
||||
(hid, q, q)
|
||||
render IndexView { entries, hubs, mQuery = Just q }
|
||||
32
Web/Controller/LearningDashboard.hs
Normal file
32
Web/Controller/LearningDashboard.hs
Normal file
@@ -0,0 +1,32 @@
|
||||
module Web.Controller.LearningDashboard where
|
||||
|
||||
-- IHF Phase 12 — Platform Memory (IHUB-WP-0013 T07)
|
||||
|
||||
import Web.Controller.Prelude
|
||||
import Web.View.LearningDashboard.Show
|
||||
|
||||
instance Controller LearningDashboardController where
|
||||
beforeAction = ensureIsUser
|
||||
|
||||
action LearningDashboardAction = do
|
||||
autoRefresh
|
||||
topCorrelations <- query @OutcomeCorrelation
|
||||
|> orderByDesc #correlationScore
|
||||
|> limit 10
|
||||
|> fetch
|
||||
patternRankings <- query @PatternPerformanceRecord
|
||||
|> orderByAsc #outcomeRank
|
||||
|> limit 10
|
||||
|> fetch
|
||||
hubs <- query @Hub |> orderByAsc #name |> fetch
|
||||
configs <- query @AdaptiveThresholdConfig |> fetch
|
||||
let thresholdStatus = map (\h -> (h, find (\c -> c.hubId == h.id) configs)) hubs
|
||||
recentInsights <- query @LearningInsight
|
||||
|> orderByDesc #computedAt
|
||||
|> limit 10
|
||||
|> fetch
|
||||
knowledgeHighlights <- query @InstitutionalKnowledgeEntry
|
||||
|> orderByDesc #createdAt
|
||||
|> limit 5
|
||||
|> fetch
|
||||
render ShowView { topCorrelations, patternRankings, thresholdStatus, recentInsights, knowledgeHighlights }
|
||||
42
Web/Controller/LineageEnrichment.hs
Normal file
42
Web/Controller/LineageEnrichment.hs
Normal file
@@ -0,0 +1,42 @@
|
||||
module Web.Controller.LineageEnrichment where
|
||||
|
||||
-- IHF Phase 12 — Platform Memory (IHUB-WP-0013 T06)
|
||||
-- The AFTER INSERT trigger trg_enrich_lineage handles real-time enrichment.
|
||||
-- This controller provides on-demand batch backfill for existing records.
|
||||
|
||||
import Web.Controller.Prelude
|
||||
import Web.View.LineageEnrichment.Index
|
||||
import IHP.ModelSupport (sqlQuery)
|
||||
import Database.PostgreSQL.Simple (Only(..))
|
||||
|
||||
instance Controller LineageEnrichmentController where
|
||||
beforeAction = ensureIsUser
|
||||
|
||||
action LineageEnrichmentAction = do
|
||||
hubs <- query @Hub |> orderByAsc #name |> fetch
|
||||
-- Count unenriched decisions per hub
|
||||
counts <- sqlQuery
|
||||
"SELECT dr.hub_id, COUNT(*) FILTER (WHERE dr.outcome_summary IS NULL)::int AS unenriched \
|
||||
\ FROM decision_records dr \
|
||||
\ GROUP BY dr.hub_id"
|
||||
()
|
||||
:: IO [(Id Hub, Int)]
|
||||
render IndexView { hubs, counts }
|
||||
|
||||
action EnrichLineageAction { hubIdForLineage } = do
|
||||
let hubId = hubIdForLineage
|
||||
-- Batch-call the trigger logic via a PL/pgSQL function for all
|
||||
-- outcome_signals in this hub that haven't yet enriched their chain.
|
||||
[Only enriched] <- sqlQuery
|
||||
"SELECT COUNT(*) FROM ( \
|
||||
\ SELECT enrich_lineage_on_outcome_batch(os.id) \
|
||||
\ FROM outcome_signals os \
|
||||
\ JOIN deployment_records dep ON dep.id = os.deployment_id \
|
||||
\ JOIN decision_records dr ON dr.id = dep.decision_id \
|
||||
\ WHERE dr.hub_id = ? \
|
||||
\ AND dr.outcome_summary IS NULL \
|
||||
\ ) sub"
|
||||
[hubId]
|
||||
:: IO [Only Int]
|
||||
setSuccessMessage ("Lineage enriched for " <> show enriched <> " signals")
|
||||
redirectTo LineageEnrichmentAction
|
||||
58
Web/Controller/OutcomeCorrelations.hs
Normal file
58
Web/Controller/OutcomeCorrelations.hs
Normal file
@@ -0,0 +1,58 @@
|
||||
module Web.Controller.OutcomeCorrelations where
|
||||
|
||||
-- IHF Phase 12 — Platform Memory (IHUB-WP-0013 T02)
|
||||
|
||||
import Web.Controller.Prelude
|
||||
import Web.View.OutcomeCorrelations.Index
|
||||
import Application.Helper.CorrelationEngine (computeAnnotationCorrelations)
|
||||
import Data.Aeson ((.=), object)
|
||||
|
||||
instance Controller OutcomeCorrelationsController where
|
||||
beforeAction = ensureIsUser
|
||||
|
||||
action OutcomeCorrelationsAction = do
|
||||
mHubFilter <- paramOrNothing @(Id Hub) "hubId"
|
||||
correlations <- case mHubFilter of
|
||||
Nothing -> query @OutcomeCorrelation
|
||||
|> orderByDesc #correlationScore
|
||||
|> fetch
|
||||
Just hid -> query @OutcomeCorrelation
|
||||
|> filterWhere (#hubId, hid)
|
||||
|> orderByDesc #correlationScore
|
||||
|> fetch
|
||||
hubs <- query @Hub |> orderByAsc #name |> fetch
|
||||
render IndexView { correlations, hubs, mHubFilter }
|
||||
|
||||
action ComputeCorrelationsAction { hubId } = do
|
||||
rows <- liftIO $ computeAnnotationCorrelations hubId
|
||||
now <- getCurrentTime
|
||||
-- Upsert: delete existing rows for this hub then insert fresh
|
||||
deleteWhere @OutcomeCorrelation (#hubId, hubId)
|
||||
forM_ rows \(category, score, sampleCount) ->
|
||||
newRecord @OutcomeCorrelation
|
||||
|> set #hubId hubId
|
||||
|> set #annotationCategory category
|
||||
|> set #correlationType "annotation_predictor"
|
||||
|> set #correlationScore score
|
||||
|> set #sampleCount sampleCount
|
||||
|> set #computedAt now
|
||||
|> createRecord
|
||||
|
||||
-- Generate LearningInsight for top-scoring category
|
||||
case rows of
|
||||
((topCat, topScore, _) : _) | topScore >= 0.4 ->
|
||||
newRecord @LearningInsight
|
||||
|> set #hubId hubId
|
||||
|> set #insightType "annotation_predictor"
|
||||
|> set #title ("Strong predictor: annotation category '" <> topCat <> "'")
|
||||
|> set #body ("Annotation category '" <> topCat <> "' shows a correlation score of "
|
||||
<> show topScore <> " with positive outcomes. Consider weighting this category "
|
||||
<> "higher in triage and routing decisions.")
|
||||
|> set #evidenceLinks (A.toJSON
|
||||
[object ["type" .= ("outcome_correlation" :: Text), "category" .= topCat]])
|
||||
|> createRecord
|
||||
>> pure ()
|
||||
_ -> pure ()
|
||||
|
||||
setSuccessMessage ("Correlations computed: " <> show (length rows) <> " categories")
|
||||
redirectTo OutcomeCorrelationsAction
|
||||
66
Web/Controller/PatternPerformance.hs
Normal file
66
Web/Controller/PatternPerformance.hs
Normal file
@@ -0,0 +1,66 @@
|
||||
module Web.Controller.PatternPerformance where
|
||||
|
||||
-- IHF Phase 12 — Platform Memory (IHUB-WP-0013 T03)
|
||||
|
||||
import Web.Controller.Prelude
|
||||
import Web.View.PatternPerformance.Index
|
||||
import IHP.ModelSupport (sqlQuery)
|
||||
|
||||
instance Controller PatternPerformanceController where
|
||||
beforeAction = ensureIsUser
|
||||
|
||||
action PatternPerformanceAction = do
|
||||
records <- query @PatternPerformanceRecord
|
||||
|> orderByAsc #outcomeRank
|
||||
|> fetch
|
||||
hubs <- query @Hub |> orderByAsc #name |> fetch
|
||||
render IndexView { records, hubs }
|
||||
|
||||
action ComputePatternPerformanceAction { hubIdForPerformance } = do
|
||||
let hubId = hubIdForPerformance
|
||||
rows <- sqlQuery
|
||||
"SELECT \
|
||||
\ wp.id AS pattern_id, \
|
||||
\ COUNT(DISTINCT pa.id)::int AS adoption_count, \
|
||||
\ COUNT(os.id)::int AS total_outcome_count, \
|
||||
\ COUNT(os.id) FILTER ( \
|
||||
\ WHERE os.signal_type IN ('success','adoption','satisfaction') \
|
||||
\ )::int AS positive_outcome_count, \
|
||||
\ AVG(os.value) AS mean_outcome_value \
|
||||
\ FROM widget_patterns wp \
|
||||
\ JOIN pattern_adoptions pa ON pa.widget_pattern_id = wp.id \
|
||||
\ JOIN widgets w ON w.hub_id = pa.adopting_hub_id \
|
||||
\ AND w.widget_type = wp.widget_type \
|
||||
\ JOIN deployment_records dep ON dep.id IN ( \
|
||||
\ SELECT dep2.id FROM deployment_records dep2 \
|
||||
\ JOIN decision_records dr2 ON dr2.id = dep2.decision_id \
|
||||
\ JOIN requirements r2 ON r2.id = dr2.requirement_id \
|
||||
\ JOIN requirement_candidates rc2 ON rc2.id = r2.candidate_id \
|
||||
\ WHERE rc2.source_widget_id = w.id \
|
||||
\ ) \
|
||||
\ JOIN outcome_signals os ON os.deployment_id = dep.id \
|
||||
\ WHERE pa.adopting_hub_id = ? \
|
||||
\ GROUP BY wp.id"
|
||||
[hubId]
|
||||
:: IO [(Id WidgetPattern, Int, Int, Int, Maybe Double)]
|
||||
|
||||
now <- getCurrentTime
|
||||
-- Delete existing records for this hub then insert fresh
|
||||
deleteWhere @PatternPerformanceRecord (#hubId, hubId)
|
||||
-- Insert with rank computation
|
||||
let sorted = sortBy (\(_, _, _, pos1, _) (_, _, _, pos2, _) -> compare pos2 pos1) rows
|
||||
ranked = zip [1..] sorted
|
||||
forM_ ranked \(rank, (patId, adoptions, total, positive, meanVal)) ->
|
||||
newRecord @PatternPerformanceRecord
|
||||
|> set #widgetPatternId patId
|
||||
|> set #hubId hubId
|
||||
|> set #adoptionCount adoptions
|
||||
|> set #positiveOutcomeCount positive
|
||||
|> set #totalOutcomeCount total
|
||||
|> set #meanOutcomeValue meanVal
|
||||
|> set #outcomeRank (Just rank)
|
||||
|> set #calibratedAt now
|
||||
|> createRecord
|
||||
|
||||
setSuccessMessage ("Pattern performance computed: " <> show (length rows) <> " patterns")
|
||||
redirectTo PatternPerformanceAction
|
||||
@@ -61,6 +61,14 @@ import Web.Controller.ModelRoutingPolicies ()
|
||||
import Web.Controller.AgentDelegations ()
|
||||
import Web.Controller.CollectiveProposals ()
|
||||
import Web.Controller.AiGovernancePolicies ()
|
||||
-- Phase 12 — Platform Memory and Continuous Learning (IHUB-WP-0013)
|
||||
import Web.Controller.OutcomeCorrelations ()
|
||||
import Web.Controller.PatternPerformance ()
|
||||
import Web.Controller.AdaptiveThresholds ()
|
||||
import Web.Controller.InstitutionalKnowledge ()
|
||||
import Web.Controller.LineageEnrichment ()
|
||||
import Web.Controller.LearningDashboard ()
|
||||
import Web.Controller.Api.V2.Learning ()
|
||||
import Web.Controller.Sessions ()
|
||||
|
||||
instance FrontController WebApplication where
|
||||
@@ -119,6 +127,14 @@ instance FrontController WebApplication where
|
||||
, parseRoute @AgentDelegationsController
|
||||
, parseRoute @CollectiveProposalsController
|
||||
, parseRoute @AiGovernancePoliciesController
|
||||
-- Phase 12 — Platform Memory and Continuous Learning (IHUB-WP-0013)
|
||||
, parseRoute @OutcomeCorrelationsController
|
||||
, parseRoute @PatternPerformanceController
|
||||
, parseRoute @AdaptiveThresholdsController
|
||||
, parseRoute @InstitutionalKnowledgeController
|
||||
, parseRoute @LineageEnrichmentController
|
||||
, parseRoute @LearningDashboardController
|
||||
, parseRoute @ApiV2LearningController
|
||||
]
|
||||
|
||||
instance InitControllerContext WebApplication where
|
||||
@@ -173,6 +189,7 @@ defaultLayout inner = [hsx|
|
||||
<a href={ModelRoutingPoliciesAction} class="text-sm text-gray-600 hover:text-gray-900">Routing</a>
|
||||
<a href={CollectiveProposalsAction} class="text-sm text-gray-600 hover:text-gray-900">Collective</a>
|
||||
<a href={AiGovernancePoliciesAction} class="text-sm text-gray-600 hover:text-gray-900">AI Gov</a>
|
||||
<a href={LearningDashboardAction} class="text-sm text-gray-600 hover:text-gray-900">Learning</a>
|
||||
<div class="ml-auto">
|
||||
<a href={DeleteSessionAction} class="text-sm text-gray-500 hover:text-gray-700">Sign out</a>
|
||||
</div>
|
||||
|
||||
@@ -268,5 +268,35 @@ instance AutoRoute AgentDelegationsController
|
||||
instance AutoRoute CollectiveProposalsController
|
||||
instance AutoRoute AiGovernancePoliciesController
|
||||
|
||||
-- Phase 12 — Platform Memory and Continuous Learning (IHUB-WP-0013)
|
||||
instance AutoRoute OutcomeCorrelationsController
|
||||
instance AutoRoute PatternPerformanceController
|
||||
instance AutoRoute AdaptiveThresholdsController
|
||||
instance AutoRoute InstitutionalKnowledgeController
|
||||
instance AutoRoute LineageEnrichmentController
|
||||
instance AutoRoute LearningDashboardController
|
||||
|
||||
instance CanRoute ApiV2LearningController where
|
||||
parseRoute' = do
|
||||
_ <- string "/api/v2"
|
||||
choice
|
||||
[ do _ <- string "/outcome-correlations"; endOfInput
|
||||
pure ApiV2IndexOutcomeCorrelationsAction
|
||||
, do _ <- string "/pattern-performance"; endOfInput
|
||||
pure ApiV2IndexPatternPerformanceAction
|
||||
, do _ <- string "/knowledge-base"
|
||||
choice
|
||||
[ do endOfInput; pure ApiV2IndexKnowledgeBaseAction
|
||||
, do _ <- string "/"; eid <- parseUUID; endOfInput
|
||||
pure ApiV2ShowKnowledgeBaseAction { knowledgeEntryId = Id eid }
|
||||
]
|
||||
]
|
||||
|
||||
instance HasPath ApiV2LearningController where
|
||||
pathTo ApiV2IndexOutcomeCorrelationsAction = "/api/v2/outcome-correlations"
|
||||
pathTo ApiV2IndexPatternPerformanceAction = "/api/v2/pattern-performance"
|
||||
pathTo ApiV2IndexKnowledgeBaseAction = "/api/v2/knowledge-base"
|
||||
pathTo ApiV2ShowKnowledgeBaseAction { knowledgeEntryId } = "/api/v2/knowledge-base/" <> show knowledgeEntryId
|
||||
|
||||
-- Sessions
|
||||
instance AutoRoute SessionsController
|
||||
|
||||
39
Web/Types.hs
39
Web/Types.hs
@@ -424,6 +424,45 @@ data AiGovernancePoliciesController
|
||||
| ToggleAiGovernancePolicyAction { aiGovernancePolicyId :: !(Id AiGovernancePolicy) }
|
||||
deriving (Eq, Show, Data)
|
||||
|
||||
-- Phase 12 — Platform Memory and Continuous Learning
|
||||
|
||||
data OutcomeCorrelationsController
|
||||
= OutcomeCorrelationsAction
|
||||
| ComputeCorrelationsAction { hubId :: !(Id Hub) }
|
||||
deriving (Eq, Show, Data)
|
||||
|
||||
data PatternPerformanceController
|
||||
= PatternPerformanceAction
|
||||
| ComputePatternPerformanceAction { hubIdForPerformance :: !(Id Hub) }
|
||||
deriving (Eq, Show, Data)
|
||||
|
||||
data AdaptiveThresholdsController
|
||||
= AdaptiveThresholdsAction
|
||||
| CalibrateThresholdsAction { hubIdForThreshold :: !(Id Hub) }
|
||||
deriving (Eq, Show, Data)
|
||||
|
||||
data InstitutionalKnowledgeController
|
||||
= InstitutionalKnowledgeAction
|
||||
| ShowInstitutionalKnowledgeAction { knowledgeEntryId :: !(Id InstitutionalKnowledgeEntry) }
|
||||
| QueryKnowledgeBaseAction
|
||||
deriving (Eq, Show, Data)
|
||||
|
||||
data LineageEnrichmentController
|
||||
= LineageEnrichmentAction
|
||||
| EnrichLineageAction { hubIdForLineage :: !(Id Hub) }
|
||||
deriving (Eq, Show, Data)
|
||||
|
||||
data LearningDashboardController
|
||||
= LearningDashboardAction
|
||||
deriving (Eq, Show, Data)
|
||||
|
||||
data ApiV2LearningController
|
||||
= ApiV2IndexOutcomeCorrelationsAction
|
||||
| ApiV2IndexPatternPerformanceAction
|
||||
| ApiV2IndexKnowledgeBaseAction
|
||||
| ApiV2ShowKnowledgeBaseAction { knowledgeEntryId :: !(Id InstitutionalKnowledgeEntry) }
|
||||
deriving (Eq, Show, Data)
|
||||
|
||||
data SessionsController
|
||||
= NewSessionAction
|
||||
| CreateSessionAction
|
||||
|
||||
62
Web/View/AdaptiveThresholds/Index.hs
Normal file
62
Web/View/AdaptiveThresholds/Index.hs
Normal file
@@ -0,0 +1,62 @@
|
||||
module Web.View.AdaptiveThresholds.Index where
|
||||
|
||||
import Web.View.Prelude
|
||||
import Data.Time (diffUTCTime)
|
||||
|
||||
data IndexView = IndexView
|
||||
{ hubs :: ![Hub]
|
||||
, configs :: ![AdaptiveThresholdConfig]
|
||||
, insights :: ![LearningInsight]
|
||||
}
|
||||
|
||||
instance View IndexView where
|
||||
html IndexView { .. } = [hsx|
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Adaptive Thresholds</h1>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 mb-8">
|
||||
{forM_ hubs (renderHubCard configs)}
|
||||
</div>
|
||||
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-3">Recent Calibration Insights</h2>
|
||||
<div class="space-y-3">
|
||||
{forM_ insights renderInsight}
|
||||
</div>
|
||||
</div>
|
||||
|]
|
||||
where
|
||||
renderHubCard cs h =
|
||||
let mCfg = find (\c -> c.hubId == h.id) cs
|
||||
in [hsx|
|
||||
<div class="bg-white shadow rounded-lg p-5">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900">{h.name}</h3>
|
||||
{case mCfg of
|
||||
Nothing -> [hsx|<p class="text-sm text-gray-400 mt-1">Not calibrated</p>|]
|
||||
Just cfg -> [hsx|
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
Last calibrated: {show cfg.calibrationDate}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">{maybe "" id cfg.notes}</p>
|
||||
|]}
|
||||
</div>
|
||||
<form method="POST" action={CalibrateThresholdsAction { hubIdForThreshold = h.id }}>
|
||||
{csrfTokenTag}
|
||||
<button type="submit"
|
||||
class="px-3 py-1.5 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700">
|
||||
Calibrate
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|]
|
||||
renderInsight i = [hsx|
|
||||
<div class="bg-white shadow rounded-sm p-4 border-l-4 border-indigo-400">
|
||||
<p class="text-sm font-medium text-gray-800">{i.title}</p>
|
||||
<p class="text-sm text-gray-600 mt-1">{i.body}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{show i.computedAt}</p>
|
||||
</div>
|
||||
|]
|
||||
58
Web/View/InstitutionalKnowledge/Index.hs
Normal file
58
Web/View/InstitutionalKnowledge/Index.hs
Normal file
@@ -0,0 +1,58 @@
|
||||
module Web.View.InstitutionalKnowledge.Index where
|
||||
|
||||
import Web.View.Prelude
|
||||
|
||||
data IndexView = IndexView
|
||||
{ entries :: ![InstitutionalKnowledgeEntry]
|
||||
, hubs :: ![Hub]
|
||||
, mQuery :: !(Maybe Text)
|
||||
}
|
||||
|
||||
instance View IndexView where
|
||||
html IndexView { .. } = [hsx|
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Institutional Knowledge Base</h1>
|
||||
</div>
|
||||
|
||||
<form method="GET" action={QueryKnowledgeBaseAction} class="mb-6 flex gap-3 items-end">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Search</label>
|
||||
<input type="text" name="q"
|
||||
value={fromMaybe "" mQuery}
|
||||
placeholder="Search knowledge entries…"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Hub</label>
|
||||
<select name="hubId" class="border border-gray-300 rounded-md px-3 py-2 text-sm">
|
||||
<option value="">All hubs</option>
|
||||
{forM_ hubs \h -> [hsx|<option value={show h.id}>{h.name}</option>|]}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{if null entries
|
||||
then [hsx|<p class="text-gray-500 text-sm">No entries found.</p>|]
|
||||
else [hsx|
|
||||
<div class="space-y-4">
|
||||
{forM_ entries renderEntry}
|
||||
</div>
|
||||
|]}
|
||||
</div>
|
||||
|]
|
||||
where
|
||||
renderEntry e = [hsx|
|
||||
<div class="bg-white shadow rounded-lg p-5">
|
||||
<div class="flex justify-between items-start">
|
||||
<p class="text-sm text-gray-800 leading-relaxed flex-1 mr-4">{e.summary}</p>
|
||||
<a href={ShowInstitutionalKnowledgeAction { knowledgeEntryId = e.id }}
|
||||
class="text-sm text-blue-600 hover:underline whitespace-nowrap">View</a>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-2">{show e.createdAt}</p>
|
||||
</div>
|
||||
|]
|
||||
39
Web/View/InstitutionalKnowledge/Show.hs
Normal file
39
Web/View/InstitutionalKnowledge/Show.hs
Normal file
@@ -0,0 +1,39 @@
|
||||
module Web.View.InstitutionalKnowledge.Show where
|
||||
|
||||
import Web.View.Prelude
|
||||
|
||||
data ShowView = ShowView
|
||||
{ entry :: !InstitutionalKnowledgeEntry
|
||||
, hub :: !Hub
|
||||
, mDecision :: !(Maybe DecisionRecord)
|
||||
}
|
||||
|
||||
instance View ShowView where
|
||||
html ShowView { .. } = [hsx|
|
||||
<div class="p-6 max-w-3xl">
|
||||
<div class="mb-4">
|
||||
<a href={InstitutionalKnowledgeAction} class="text-sm text-blue-600 hover:underline">
|
||||
← Knowledge Base
|
||||
</a>
|
||||
</div>
|
||||
<div class="bg-white shadow rounded-lg p-6">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">{hub.name}</span>
|
||||
<span class="text-xs text-gray-400">{show entry.createdAt}</span>
|
||||
</div>
|
||||
<p class="text-gray-800 leading-relaxed text-sm">{entry.summary}</p>
|
||||
{case mDecision of
|
||||
Nothing -> mempty
|
||||
Just dr -> [hsx|
|
||||
<div class="mt-5 border-t pt-4">
|
||||
<p class="text-xs font-medium text-gray-500 uppercase mb-2">Source Decision</p>
|
||||
<a href={ShowDecisionRecordAction { decisionRecordId = dr.id }}
|
||||
class="text-sm text-blue-600 hover:underline">
|
||||
{dr.title}
|
||||
</a>
|
||||
<span class="ml-2 text-xs text-gray-500">({dr.outcome})</span>
|
||||
</div>
|
||||
|]}
|
||||
</div>
|
||||
</div>
|
||||
|]
|
||||
151
Web/View/LearningDashboard/Show.hs
Normal file
151
Web/View/LearningDashboard/Show.hs
Normal file
@@ -0,0 +1,151 @@
|
||||
module Web.View.LearningDashboard.Show where
|
||||
|
||||
import Web.View.Prelude
|
||||
import Data.Time (diffUTCTime, getCurrentTime, nominalDay)
|
||||
|
||||
data ShowView = ShowView
|
||||
{ topCorrelations :: ![OutcomeCorrelation]
|
||||
, patternRankings :: ![PatternPerformanceRecord]
|
||||
, thresholdStatus :: ![(Hub, Maybe AdaptiveThresholdConfig)]
|
||||
, recentInsights :: ![LearningInsight]
|
||||
, knowledgeHighlights :: ![InstitutionalKnowledgeEntry]
|
||||
}
|
||||
|
||||
instance View ShowView where
|
||||
html ShowView { .. } = [hsx|
|
||||
<div class="p-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">Learning Dashboard</h1>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
|
||||
{-- Panel 1: Top annotation predictors --}
|
||||
<div class="bg-white shadow rounded-lg p-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4">Top Annotation Predictors</h2>
|
||||
{if null topCorrelations
|
||||
then [hsx|<p class="text-sm text-gray-400">No data — run correlation analysis per hub.</p>|]
|
||||
else [hsx|
|
||||
<div class="space-y-2">
|
||||
{forM_ topCorrelations renderCorrelation}
|
||||
</div>
|
||||
|]}
|
||||
</div>
|
||||
|
||||
{-- Panel 2: Pattern performance ranking --}
|
||||
<div class="bg-white shadow rounded-lg p-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4">Pattern Performance Ranking</h2>
|
||||
{if null patternRankings
|
||||
then [hsx|<p class="text-sm text-gray-400">No data — run pattern performance per hub.</p>|]
|
||||
else [hsx|
|
||||
<div class="space-y-2">
|
||||
{forM_ patternRankings renderPatternRank}
|
||||
</div>
|
||||
|]}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{-- Panel 3: Adaptive threshold status --}
|
||||
<div class="bg-white shadow rounded-lg p-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4">Adaptive Threshold Status</h2>
|
||||
<div class="space-y-2">
|
||||
{forM_ thresholdStatus renderThresholdStatus}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{-- Panel 4: Recent learning insights --}
|
||||
<div class="bg-white shadow rounded-lg p-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4">Recent Insights</h2>
|
||||
{if null recentInsights
|
||||
then [hsx|<p class="text-sm text-gray-400">No insights yet.</p>|]
|
||||
else [hsx|
|
||||
<div class="space-y-3">
|
||||
{forM_ recentInsights renderInsight}
|
||||
</div>
|
||||
|]}
|
||||
</div>
|
||||
|
||||
{-- Panel 5: Knowledge base highlights --}
|
||||
<div class="bg-white shadow rounded-lg p-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4">Knowledge Highlights</h2>
|
||||
{if null knowledgeHighlights
|
||||
then [hsx|<p class="text-sm text-gray-400">No entries yet — distil a decision.</p>|]
|
||||
else [hsx|
|
||||
<div class="space-y-3">
|
||||
{forM_ knowledgeHighlights renderKnowledge}
|
||||
</div>
|
||||
|]}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|]
|
||||
where
|
||||
renderCorrelation c = [hsx|
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-700 w-32 truncate">{c.annotationCategory}</span>
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div class={barColor c.correlationScore}
|
||||
style={"width:" <> show (round (c.correlationScore * 100) :: Int) <> "%"}></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">{show (round (c.correlationScore * 100) :: Int)}%</span>
|
||||
</div>
|
||||
|]
|
||||
|
||||
barColor s
|
||||
| s >= 0.7 = "h-2 rounded-full bg-green-500" :: Text
|
||||
| s >= 0.4 = "h-2 rounded-full bg-yellow-500"
|
||||
| otherwise = "h-2 rounded-full bg-red-400"
|
||||
|
||||
renderPatternRank r =
|
||||
let rate = if r.totalOutcomeCount > 0
|
||||
then fromIntegral r.positiveOutcomeCount / fromIntegral r.totalOutcomeCount :: Double
|
||||
else 0.0
|
||||
in [hsx|
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-400 w-5">{maybe "-" show r.outcomeRank}</span>
|
||||
<a href={ShowWidgetPatternAction { widgetPatternId = r.widgetPatternId }}
|
||||
class="text-sm text-blue-600 hover:underline truncate flex-1">
|
||||
{show r.widgetPatternId}
|
||||
</a>
|
||||
<span class="text-xs text-gray-500">{show (round (rate * 100) :: Int)}%</span>
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderThresholdStatus (h, mCfg) =
|
||||
let driftClass = case mCfg of
|
||||
Nothing -> "text-red-500" :: Text
|
||||
Just cfg -> "text-green-600"
|
||||
label = case mCfg of
|
||||
Nothing -> "Not calibrated"
|
||||
Just cfg -> "Calibrated"
|
||||
in [hsx|
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-700">{h.name}</span>
|
||||
<span class={driftClass}>{label}</span>
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderInsight i = [hsx|
|
||||
<div class="border-l-4 border-indigo-400 pl-3">
|
||||
<p class="text-xs font-medium text-gray-700">{i.title}</p>
|
||||
<p class="text-xs text-gray-400">{insightTypeBadge i.insightType}</p>
|
||||
</div>
|
||||
|]
|
||||
|
||||
insightTypeBadge t = case t of
|
||||
"annotation_predictor" -> "Annotation predictor" :: Text
|
||||
"threshold_calibration" -> "Threshold calibration"
|
||||
"pattern_ranking" -> "Pattern ranking"
|
||||
"routing_improvement" -> "Routing improvement"
|
||||
_ -> t
|
||||
|
||||
renderKnowledge e = [hsx|
|
||||
<div>
|
||||
<a href={ShowInstitutionalKnowledgeAction { knowledgeEntryId = e.id }}
|
||||
class="text-sm text-blue-600 hover:underline">
|
||||
{take 80 e.summary <> if length e.summary > 80 then "…" else ""}
|
||||
</a>
|
||||
</div>
|
||||
|]
|
||||
47
Web/View/LineageEnrichment/Index.hs
Normal file
47
Web/View/LineageEnrichment/Index.hs
Normal file
@@ -0,0 +1,47 @@
|
||||
module Web.View.LineageEnrichment.Index where
|
||||
|
||||
import Web.View.Prelude
|
||||
|
||||
data IndexView = IndexView
|
||||
{ hubs :: ![Hub]
|
||||
, counts :: ![(Id Hub, Int)]
|
||||
}
|
||||
|
||||
instance View IndexView where
|
||||
html IndexView { .. } = [hsx|
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Lineage Enrichment</h1>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-6">
|
||||
The <code>trg_enrich_lineage</code> trigger automatically enriches
|
||||
<code>decision_records.outcome_summary</code> and
|
||||
<code>requirement_candidates.outcome_summary</code> on every new
|
||||
outcome signal. Use the buttons below to backfill existing records.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{forM_ hubs (renderHubCard counts)}
|
||||
</div>
|
||||
</div>
|
||||
|]
|
||||
where
|
||||
renderHubCard cs h =
|
||||
let unenriched = maybe 0 snd (find (\(hid, _) -> hid == h.id) cs)
|
||||
in [hsx|
|
||||
<div class="bg-white shadow rounded-lg p-5 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900">{h.name}</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{show unenriched} decision(s) pending enrichment
|
||||
</p>
|
||||
</div>
|
||||
<form method="POST" action={EnrichLineageAction { hubIdForLineage = h.id }}>
|
||||
{csrfTokenTag}
|
||||
<button type="submit"
|
||||
class="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700"
|
||||
disabled={unenriched == 0}>
|
||||
{if unenriched == 0 then "Up to date" else "Enrich Now"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|]
|
||||
71
Web/View/OutcomeCorrelations/Index.hs
Normal file
71
Web/View/OutcomeCorrelations/Index.hs
Normal file
@@ -0,0 +1,71 @@
|
||||
module Web.View.OutcomeCorrelations.Index where
|
||||
|
||||
import Web.View.Prelude
|
||||
|
||||
data IndexView = IndexView
|
||||
{ correlations :: ![OutcomeCorrelation]
|
||||
, hubs :: ![Hub]
|
||||
, mHubFilter :: !(Maybe (Id Hub))
|
||||
}
|
||||
|
||||
instance View IndexView where
|
||||
html IndexView { .. } = [hsx|
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Outcome Correlations</h1>
|
||||
</div>
|
||||
|
||||
<form method="GET" class="mb-6 flex gap-4 items-end">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Filter by Hub</label>
|
||||
<select name="hubId" class="border border-gray-300 rounded-md px-3 py-2 text-sm">
|
||||
<option value="">All hubs</option>
|
||||
{forM_ hubs \h -> [hsx|
|
||||
<option value={show h.id}
|
||||
selected={mHubFilter == Just h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
|]}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-gray-600 text-white rounded-md text-sm hover:bg-gray-700">
|
||||
Filter
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Category</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Score</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Samples</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Computed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{forM_ correlations renderRow}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|]
|
||||
where
|
||||
renderRow c = [hsx|
|
||||
<tr>
|
||||
<td class="px-6 py-4 text-sm font-medium text-gray-900">{c.annotationCategory}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">{c.correlationType}</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<span class={scoreClass c.correlationScore}>
|
||||
{show (round (c.correlationScore * 100) :: Int)}%
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">{show c.sampleCount}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">{show c.computedAt}</td>
|
||||
</tr>
|
||||
|]
|
||||
scoreClass s
|
||||
| s >= 0.7 = "inline-block px-2 py-1 text-xs font-semibold bg-green-100 text-green-800 rounded" :: Text
|
||||
| s >= 0.4 = "inline-block px-2 py-1 text-xs font-semibold bg-yellow-100 text-yellow-800 rounded"
|
||||
| otherwise = "inline-block px-2 py-1 text-xs font-semibold bg-red-100 text-red-800 rounded"
|
||||
64
Web/View/PatternPerformance/Index.hs
Normal file
64
Web/View/PatternPerformance/Index.hs
Normal file
@@ -0,0 +1,64 @@
|
||||
module Web.View.PatternPerformance.Index where
|
||||
|
||||
import Web.View.Prelude
|
||||
|
||||
data IndexView = IndexView
|
||||
{ records :: ![PatternPerformanceRecord]
|
||||
, hubs :: ![Hub]
|
||||
}
|
||||
|
||||
instance View IndexView where
|
||||
html IndexView { .. } = [hsx|
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Pattern Performance</h1>
|
||||
</div>
|
||||
<div class="mb-4 flex gap-2 flex-wrap">
|
||||
{forM_ hubs \h -> [hsx|
|
||||
<form method="POST" action={ComputePatternPerformanceAction { hubIdForPerformance = h.id }} class="inline">
|
||||
{csrfTokenTag}
|
||||
<button type="submit"
|
||||
class="px-3 py-1.5 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700">
|
||||
Recompute for {h.name}
|
||||
</button>
|
||||
</form>
|
||||
|]}
|
||||
</div>
|
||||
<div class="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Rank</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Pattern</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Adoptions</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Positive / Total</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Positive Rate</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Mean Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{forM_ records renderRow}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|]
|
||||
where
|
||||
renderRow r =
|
||||
let rate = if r.totalOutcomeCount > 0
|
||||
then fromIntegral r.positiveOutcomeCount / fromIntegral r.totalOutcomeCount :: Double
|
||||
else 0.0
|
||||
rankLabel = maybe "-" show r.outcomeRank
|
||||
in [hsx|
|
||||
<tr>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">{rankLabel}</td>
|
||||
<td class="px-6 py-4 text-sm font-medium text-gray-900">
|
||||
<a href={ShowWidgetPatternAction { widgetPatternId = r.widgetPatternId }}
|
||||
class="text-blue-600 hover:underline">{show r.widgetPatternId}</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">{show r.adoptionCount}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">{show r.positiveOutcomeCount}/{show r.totalOutcomeCount}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">{show (round (rate * 100) :: Int)}%</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">{maybe "-" show r.meanOutcomeValue}</td>
|
||||
</tr>
|
||||
|]
|
||||
Reference in New Issue
Block a user