Files
inter-hub/Application/Helper/FrictionScore.hs
Bernd Worsch f1978c3888 fix(WP-0014): pre-flight compilation fixes, Tailwind pipeline, and admin seed
A2 — Compilation fixes:
- Remove inline FK constraints from Schema.sql; IHP schema compiler cannot
  parse them. Add 1744329600-restore-fk-constraints.sql migration to restore
  referential integrity at the DB level.
- Rename `#label` → `#label_` throughout to avoid clash with Haskell built-in.
- Fix `hub.id == hid` UUID comparisons to use `toUUID hub.id`.
- Replace non-existent `setStatus`/`respondJson` calls with
  `renderJsonWithStatusCode` throughout Api controllers.
- Fix qualified package import for `cryptohash-sha256` in Auth.hs.
- Add `CanSelect (Text, Text)` instance in Helper.View.
- Refactor HSX inline lambdas to named helper functions in 100+ views
  (GHC cannot infer types for anonymous functions inside quasi-quoted HSX).
- Fix missing imports (IHP.QueryBuilder, IHP.Fetch, Web.Routes, Only, etc.)
  across helpers and controllers.
- Remove duplicate `diffUTCTime` definition in BottleneckDetector.
- Change `createEventForHub` return type from `IO ResponseReceived` to `IO ()`.
- Seed type-registry vocabulary via 1744502400-seed-type-registries.sql
  (moved from Schema.sql where IHP does not execute INSERT statements).

A3 — Tailwind build pipeline:
- Add `tailwindcss` to flake.nix native packages.
- Uncomment `tailwind.exec` process in devenv shell config.
- Add tailwind/tailwind.config.js (scans Web/View/**/*.hs).
- Add tailwind/app.css with @tailwind directives.

A4 — Admin user seed:
- Add 1744416000-seed-admin-user.sql: inserts admin@inter-hub.local
  with bcrypt-hashed password admin1234! (cost 10).
- Add .env.example documenting all required environment variables
  and default admin credentials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 09:55:12 +00:00

103 lines
4.1 KiB
Haskell

module Application.Helper.FrictionScore where
import IHP.Prelude
import IHP.ModelSupport
import IHP.QueryBuilder
import IHP.Fetch
import Generated.Types
import Web.Routes ()
import Database.PostgreSQL.Simple (Only(..))
import Data.Time.Clock (addUTCTime, getCurrentTime)
import qualified Data.Aeson as A
import qualified Data.HashMap.Strict as H
-- | Friction score formula (documented):
--
-- score = min 100 $
-- annotationCount * 5
-- + errorEventCount * 10
-- + (if regressionFlag then 20 else 0)
-- + staleCandidateCount * 8
--
-- Inputs are computed from the widget's related records.
computeFrictionScore
:: (?modelContext :: ModelContext)
=> Id Widget
-> [Annotation] -- all annotations for this widget
-> [InteractionEvent] -- all events for this widget
-> Bool -- True if widget is in regression
-> [RequirementCandidate] -- all candidates for this widget
-> IO FrictionScore
computeFrictionScore wid annotations events isRegressed candidates = do
now <- getCurrentTime
let thirtyDaysAgo = addUTCTime (negate $ 30 * 86400) now
annCount = length annotations
errCount = length (filter (\e -> e.eventType == "errored") events)
staleCount = length (filter (\c -> c.status == "open" && c.createdAt < thirtyDaysAgo) candidates)
rawScore = annCount * 5 + errCount * 10 + (if isRegressed then 20 else 0) + staleCount * 8
finalScore = min 100 rawScore
-- Upsert: update if row exists, insert otherwise
existingRows <- sqlQuery
"SELECT * FROM friction_scores WHERE widget_id = ? LIMIT 1"
(Only wid)
case (existingRows :: [FrictionScore]) of
(existing : _) -> do
existing
|> set #score finalScore
|> set #annotationCount annCount
|> set #errorEventCount errCount
|> set #regressionFlag isRegressed
|> set #staleCandidateCount staleCount
|> set #lastComputedAt now
|> updateRecord
[] -> do
newRecord @FrictionScore
|> set #widgetId wid
|> set #score finalScore
|> set #annotationCount annCount
|> set #errorEventCount errCount
|> set #regressionFlag isRegressed
|> set #staleCandidateCount staleCount
|> set #lastComputedAt now
|> createRecord
-- | Score band for Tailwind colour coding.
scoreBand :: Int -> Text
scoreBand s
| s < 20 = "bg-green-100 text-green-800"
| s < 40 = "bg-yellow-100 text-yellow-800"
| s < 60 = "bg-orange-100 text-orange-800"
| otherwise = "bg-red-100 text-red-800"
-- | Read per-hub AdaptiveThresholdConfig and apply weight_overrides
-- to friction component scores before summing. Falls back to global
-- defaults when no config exists for the hub.
-- weight_overrides keys: "annotation", "error", "regression", "stale"
applyAdaptiveWeights ::
(?modelContext :: ModelContext) =>
Id Hub ->
Int -> -- annotationCount
Int -> -- errorEventCount
Bool -> -- regressionFlag
Int -> -- staleCandidateCount
IO Int
applyAdaptiveWeights hubId annCount errCount isRegressed staleCount = do
mConfig <- query @AdaptiveThresholdConfig
|> filterWhere (#hubId, hubId)
|> fetchOneOrNothing
let overrides = maybe mempty (.weightOverrides) mConfig
w k def = case overrides of
A.Object o -> case H.lookup k o of
Just (A.Number n) -> round (n * fromIntegral def) :: Int
_ -> def
_ -> def
annW = w "annotation" 5
errW = w "error" 10
regW = w "regression" 20
staleW = w "stale" 8
raw = annCount * annW
+ errCount * errW
+ (if isRegressed then regW else 0)
+ staleCount * staleW
pure (min 100 raw)