Part of #645 (approval-outcome prediction epic — read it first for the big picture).
Goal
The serving path: when a query lands in review, load the org's trained model, extract features, score, and persist the prediction row. Event-driven, fail-safe — a prediction failure must never affect workflow state.
Depends on: core.api services, the feature extractor, and the trainer/model classes.
Steps
ai/api/ApprovalPredictionService.java — interface: void predictForQuery(UUID queryRequestId) (plus void trainAll() used by the scheduled job in the next sub-issue). Implementation ai/internal/DefaultApprovalPredictionService.java:
- Guards, in order (each persists a
skipped=true row with a distinct skipped_reason string): feature disabled → DISABLED; no model row / serving=false → MODEL_NOT_SERVING (covers cold start and the below-AUC case).
- Happy path: load persisted AI analysis + cost estimate + query attributes + rate counts (all via
core.api lookups), extract features, model.predict(...), persist via ApprovalPredictionPersistenceService, publish ApprovalPredictionCompletedEvent.
- Any unexpected exception → catch (specific types where possible), log ERROR, persist a
failed=true sentinel row with error_message. Mirror how the AF-624 estimate path and the AI-analysis path handle failure sentinels.
ai/internal/ApprovalPredictionListener.java — two @ApplicationModuleListener methods:
on(QueryReadyForReviewEvent event) → predictForQuery(...). This event is published by workflow/internal/QueryReviewStateMachine exactly when a query enters PENDING_REVIEW and never for auto-approved/rejected/break-glass paths — which is precisely the population we want.
on(QueryEstimateCompletedEvent event) → the estimate is computed in parallel and may arrive after review-readiness. If a prediction row exists whose feature snapshot recorded estimate_missing=true and the query is still PENDING_REVIEW, recompute and replace the row (this is the only update path). Otherwise no-op.
- Register nothing in routing/workflow decision paths — the prediction is advisory-only by construction (see epic).
Acceptance criteria
DefaultApprovalPredictionServiceTest (Mockito, no Spring): disabled → skipped(DISABLED); no serving model → skipped(MODEL_NOT_SERVING); happy path persists probability + publishes event; lookup throwing → failed sentinel, no exception propagates.
ApprovalPredictionListenerTest: ready-for-review triggers prediction; estimate-completed recomputes only in the estimate_missing + still-pending case (3 no-op branches tested).
- Integration test
ApprovalPredictionIntegrationTest (Testcontainers postgres:18-alpine, model on BehaviorAnomalyDetectionIntegrationTest): seed ≥ 50 decided queries → train (call trainAll() directly) → submit a query to PENDING_REVIEW → assert an approval_predictions row with a probability exists.
mvn test -Dtest=ApplicationModulesTest passes (ai → core via api only).
Part of #645 (approval-outcome prediction epic — read it first for the big picture).
Goal
The serving path: when a query lands in review, load the org's trained model, extract features, score, and persist the prediction row. Event-driven, fail-safe — a prediction failure must never affect workflow state.
Depends on: core.api services, the feature extractor, and the trainer/model classes.
Steps
ai/api/ApprovalPredictionService.java— interface:void predictForQuery(UUID queryRequestId)(plusvoid trainAll()used by the scheduled job in the next sub-issue). Implementationai/internal/DefaultApprovalPredictionService.java:skipped=truerow with a distinctskipped_reasonstring): feature disabled →DISABLED; no model row /serving=false→MODEL_NOT_SERVING(covers cold start and the below-AUC case).core.apilookups), extract features,model.predict(...), persist viaApprovalPredictionPersistenceService, publishApprovalPredictionCompletedEvent.failed=truesentinel row witherror_message. Mirror how the AF-624 estimate path and the AI-analysis path handle failure sentinels.ai/internal/ApprovalPredictionListener.java— two@ApplicationModuleListenermethods:on(QueryReadyForReviewEvent event)→predictForQuery(...). This event is published byworkflow/internal/QueryReviewStateMachineexactly when a query entersPENDING_REVIEWand never for auto-approved/rejected/break-glass paths — which is precisely the population we want.on(QueryEstimateCompletedEvent event)→ the estimate is computed in parallel and may arrive after review-readiness. If a prediction row exists whose feature snapshot recordedestimate_missing=trueand the query is stillPENDING_REVIEW, recompute and replace the row (this is the only update path). Otherwise no-op.Acceptance criteria
DefaultApprovalPredictionServiceTest(Mockito, no Spring): disabled → skipped(DISABLED); no serving model → skipped(MODEL_NOT_SERVING); happy path persists probability + publishes event; lookup throwing → failed sentinel, no exception propagates.ApprovalPredictionListenerTest: ready-for-review triggers prediction; estimate-completed recomputes only in theestimate_missing+ still-pending case (3 no-op branches tested).ApprovalPredictionIntegrationTest(Testcontainerspostgres:18-alpine, model onBehaviorAnomalyDetectionIntegrationTest): seed ≥ 50 decided queries → train (calltrainAll()directly) → submit a query to PENDING_REVIEW → assert anapproval_predictionsrow with a probability exists.mvn test -Dtest=ApplicationModulesTestpasses (ai → core via api only).