diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index c3a06a0b..6376a32c 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -38,6 +38,12 @@ jobs: - name: Checkout uses: https://192.168.178.233/actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Validate CI workflow policy + run: bash scripts/check-ci-workflow-policy.sh + + - name: Validate recommendation contract gate + run: bash scripts/check-recommendation-contract.sh + - name: Lint GitHub Actions workflows uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12 diff --git a/.github/scripts/fop-local-ci.sh b/.github/scripts/fop-local-ci.sh index fb981916..a10e7d57 100755 --- a/.github/scripts/fop-local-ci.sh +++ b/.github/scripts/fop-local-ci.sh @@ -537,6 +537,7 @@ post_commit_status "pending" "fop local ${profile} running" run_direct "working tree whitespace" "git diff --check" run_direct "ui polish contract" "scripts/tests/test-ui-polish-contract.sh" +run_direct "recommendation contract gate" "bash scripts/check-recommendation-contract.sh" run_direct "legal-readiness wording contract" "scripts/tests/test-legal-readiness-wording.sh" run_direct "legal/module OpenAPI contract" "scripts/tests/test-legal-openapi-contract.sh" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index add9933d..807e7d53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,9 @@ jobs: - name: Validate CI workflow policy run: bash scripts/check-ci-workflow-policy.sh + - name: Validate recommendation contract gate + run: bash scripts/check-recommendation-contract.sh + - name: Validate Fly config run: python3 -c "import pathlib, tomllib; tomllib.loads(pathlib.Path('fly.toml').read_text())" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index c68e960a..1afc52ef 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -165,11 +165,11 @@ jobs: # Audit-mode: surface findings as informational; do NOT fail the gate yet. # Promote findings to errors once the workflow inventory has been triaged. continue-on-error: true - env: - ZIZMOR_VERSION: "1.24.1" permissions: contents: read # checkout source for the SAST scan security-events: write # upload Zizmor SARIF results to GitHub Security tab + env: + ZIZMOR_VERSION: "1.24.1" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: diff --git a/Makefile b/Makefile index e7822821..3660ebc8 100644 --- a/Makefile +++ b/Makefile @@ -146,6 +146,7 @@ script-tests: bash scripts/check-ci-workflow-policy.sh bash scripts/tests/test-drift-scripts.sh bash scripts/tests/test-ui-polish-contract.sh + bash scripts/check-recommendation-contract.sh bash scripts/tests/test-devcontainer-contract.sh bash scripts/tests/test-fop-local-ci-ergonomics.sh bash scripts/tests/test-fop-local-ci-failure-trap.sh diff --git a/app/Http/Controllers/Api/RecommendationController.php b/app/Http/Controllers/Api/RecommendationController.php index 74b50908..7bb64dd3 100644 --- a/app/Http/Controllers/Api/RecommendationController.php +++ b/app/Http/Controllers/Api/RecommendationController.php @@ -5,10 +5,17 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\AuditLog; use App\Models\Booking; use App\Models\ParkingLot; +use App\Models\Setting; +use App\Services\ModuleRegistry; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; class RecommendationController extends Controller { @@ -25,6 +32,9 @@ public function index(Request $request): JsonResponse { $user = $request->user(); $lotId = $request->query('lot_id'); + $engine = $this->recommendationEngineConfig(); + $weights = $engine['weights']; + $recommendationId = (string) Str::uuid(); // 1. Get user's booking history (completed/active/confirmed only) $bookings = Booking::where('user_id', $user->id) @@ -66,17 +76,17 @@ public function index(Request $request): JsonResponse $freq = $slotFrequency->get($slot->id, 0); $lotFreq = $lotFrequency->get($lot->id, 0); if ($freq > 0) { - $score += min($freq, 10) * 4.0; // max 40 points + $score += min($freq, 10) * ($weights['frequency'] / 10.0); $reasons[] = "Used {$freq} times before"; $badges[] = 'your_usual_spot'; } elseif ($lotFreq > 0) { - $score += min($lotFreq, 10) * 2.0; // max 20 points + $score += min($lotFreq, 10) * ($weights['preferred_lot'] / 10.0); $reasons[] = "In your preferred lot (used {$lotFreq} times)"; $badges[] = 'preferred_lot'; } // Availability component (30% weight) - $score += 30.0; + $score += $weights['availability']; $badges[] = 'available_now'; if (empty($reasons)) { $reasons[] = 'Available now'; @@ -84,35 +94,40 @@ public function index(Request $request): JsonResponse // Price component (20% weight — lower price = higher score) if ($maxPrice > 0) { - $priceScore = (1 - ($lotRate / $maxPrice)) * 20.0; + $priceScore = (1 - ($lotRate / $maxPrice)) * $weights['price']; $score += $priceScore; - if ($priceScore >= 15.0) { + if ($priceScore >= ($weights['price'] * 0.75)) { $badges[] = 'best_price'; + $reasons[] = 'Great price'; } } // Distance component (10% weight — lower slot number = closer) $slotNum = (int) $slot->slot_number; - $distanceScore = 10.0 / max($slotNum, 1); + $distanceScore = $weights['distance'] / max($slotNum, 1); $score += $distanceScore; - if ($distanceScore >= 5.0) { + if ($distanceScore >= ($weights['distance'] * 0.5)) { $badges[] = 'closest_entrance'; + $reasons[] = 'Near entrance'; } // Bonus for accessible slots if ($slot->is_accessible ?? false) { + $score += $weights['accessibility_bonus']; $badges[] = 'accessible'; + $reasons[] = 'Accessible'; } // Bonus for slot features $features = $slot->features ?? []; if (! empty($features)) { - $score += 5.0; + $score += $weights['feature_bonus']; $featureNames = implode(', ', array_map('ucfirst', $features)); $reasons[] = "Features: {$featureNames}"; } $candidates->push([ + 'recommendation_id' => $recommendationId, 'slot_id' => $slot->id, 'slot_number' => (int) $slot->slot_number, 'lot_id' => $lot->id, @@ -125,8 +140,19 @@ public function index(Request $request): JsonResponse } } - // Sort by score descending, take top 5 - $top = $candidates->sortByDesc('score')->take(5)->values(); + // Sort by local score first; fop_pipeline_v1 receives the full set and + // max_results is applied only after external ranking or fallback. + $rankedCandidates = $candidates->sortByDesc('score')->values(); + $adapter = $this->weightedV1AdapterStatus($engine); + if ($engine['algorithm'] === 'fop_pipeline_v1') { + [$rankedCandidates, $adapter] = $this->tryFopPipelineRecommendations( + $engine, + $recommendationId, + $rankedCandidates->all() + ); + } + $top = $rankedCandidates->take($engine['max_results'])->values(); + $this->auditRecommendationServed($request, $recommendationId, $engine, $adapter, $top->all()); return response()->json([ 'success' => true, @@ -141,24 +167,488 @@ public function index(Request $request): JsonResponse */ public function stats(): JsonResponse { - $totalBookings = Booking::count(); - $completedBookings = Booking::where('status', 'completed')->count(); - $acceptanceRate = $totalBookings > 0 ? round(($completedBookings / $totalBookings) * 100, 1) : 0; + $engine = $this->recommendationEngineConfig(); + $servedEvents = AuditLog::query() + ->where('action', 'recommendation_served') + ->orWhere('event_type', 'RecommendationServed') + ->get(); + $servedStats = $this->servedRecommendationStats($servedEvents); return response()->json([ 'success' => true, 'data' => [ - 'total_recommendations' => $totalBookings, - 'accepted' => $completedBookings, - 'acceptance_rate' => $acceptanceRate, - 'algorithm_weights' => [ - 'frequency' => 40, - 'availability' => 30, - 'price' => 20, - 'distance' => 10, + 'total_recommendations' => $servedStats['total_recommendation_batches'], + 'total_recommendations_served' => $servedStats['total_recommendations_served'], + 'accepted_recommendations' => null, + 'acceptance_rate' => null, + 'acceptance_metric_source' => 'not_tracked', + 'unique_users' => $servedStats['unique_users'], + 'avg_score' => $servedStats['avg_score'], + 'metrics_source' => 'audit_log.recommendation_served', + 'algorithm' => $engine['algorithm'], + 'algorithm_weights' => $engine['weights'], + 'algorithm_adapter' => $this->weightedV1AdapterStatus($engine), + 'legal_boundary' => [ + 'legal_review_required' => true, + 'attorney_review_status' => 'required_before_customer_wording', + 'execution_allowed' => false, + 'disclaimer' => 'fop legal output is reference-only drafting support; attorney review, citation verification, client authorization, and final legal judgment remain required before customer-facing profiling or legal wording ships.', ], + 'top_recommended_lots' => $servedStats['top_recommended_lots'], + ], + 'error' => null, + ]); + } + + /** + * Versioned default engine config. This preserves the legacy weighted_v1 + * scores while giving ParkHub a stable seam for fop-pipeline adoption. + * + * @return array{ + * algorithm: string, + * weights: array, + * max_results: int, + * explain: bool, + * profile_safe_mode: bool, + * pipeline: array{endpoint: ?string, pipeline_name: string, timeout_ms: int, fallback_enabled: bool} + * } + */ + private function recommendationEngineConfig(): array + { + $weights = (array) config('recommendations.weights', []); + $algorithm = (string) $this->moduleConfigValue( + 'algorithm', + config('recommendations.algorithm', 'weighted_v1'), + ); + if (! in_array($algorithm, ['weighted_v1', 'fop_pipeline_v1'], true)) { + $algorithm = 'weighted_v1'; + } + $pipeline = (array) config('recommendations.pipeline', []); + $pipelineEndpoint = $this->validatedPipelineEndpoint( + (string) $this->moduleConfigValue('pipeline_endpoint', $pipeline['endpoint'] ?? '') + ); + $pipelineName = (string) $this->moduleConfigValue( + 'pipeline_name', + $pipeline['pipeline_name'] ?? 'parkhub-recommendations' + ); + $pipelineName = trim($pipelineName) !== '' ? trim($pipelineName) : 'parkhub-recommendations'; + + return [ + 'algorithm' => $algorithm, + 'weights' => [ + 'frequency' => $this->boundedFloat( + $this->moduleConfigValue('weight_frequency', $weights['frequency'] ?? 40.0), + 0.0, + 100.0 + ), + 'preferred_lot' => $this->boundedFloat( + $this->moduleConfigValue('weight_preferred_lot', $weights['preferred_lot'] ?? 20.0), + 0.0, + 100.0 + ), + 'availability' => $this->boundedFloat( + $this->moduleConfigValue('weight_availability', $weights['availability'] ?? 30.0), + 0.0, + 100.0 + ), + 'price' => $this->boundedFloat( + $this->moduleConfigValue('weight_price', $weights['price'] ?? 20.0), + 0.0, + 100.0 + ), + 'distance' => $this->boundedFloat( + $this->moduleConfigValue('weight_distance', $weights['distance'] ?? 10.0), + 0.0, + 100.0 + ), + 'accessibility_bonus' => $this->boundedFloat( + $this->moduleConfigValue( + 'weight_accessibility_bonus', + $weights['accessibility_bonus'] ?? 0.0 + ), + 0.0, + 25.0 + ), + 'feature_bonus' => $this->boundedFloat( + $this->moduleConfigValue('weight_feature_bonus', $weights['feature_bonus'] ?? 2.0), + 0.0, + 25.0 + ), + ], + 'max_results' => max( + 1, + min(25, (int) $this->moduleConfigValue('max_results', config('recommendations.max_results', 5))) + ), + 'explain' => true, + 'profile_safe_mode' => true, + 'pipeline' => [ + 'endpoint' => $pipelineEndpoint, + 'pipeline_name' => $pipelineName, + 'timeout_ms' => max( + 100, + min(5000, (int) $this->moduleConfigValue('pipeline_timeout_ms', $pipeline['timeout_ms'] ?? 750)) + ), + 'fallback_enabled' => true, ], + ]; + } + + /** + * @param array $engine + * @return array{ + * requested_algorithm: string, + * effective_algorithm: string, + * attempted: bool, + * status: string, + * pipeline_name: ?string, + * endpoint_configured: bool, + * fallback_enabled: bool, + * error: ?string + * } + */ + private function weightedV1AdapterStatus(array $engine): array + { + return [ + 'requested_algorithm' => (string) $engine['algorithm'], + 'effective_algorithm' => 'weighted_v1', + 'attempted' => false, + 'status' => 'weighted_v1', + 'pipeline_name' => null, + 'endpoint_configured' => ! empty($engine['pipeline']['endpoint']), + 'fallback_enabled' => true, 'error' => null, + ]; + } + + /** + * @param array $engine + * @param array> $candidates + * @return array{0: Collection>, 1: array} + */ + private function tryFopPipelineRecommendations(array $engine, string $recommendationId, array $candidates): array + { + $endpoint = $engine['pipeline']['endpoint'] ?? null; + if (empty($endpoint)) { + return [ + collect($candidates), + $this->fallbackAdapterStatus( + $engine, + false, + 'fallback_not_configured', + 'fop_pipeline_v1 endpoint is not configured' + ), + ]; + } + + try { + $url = rtrim((string) $endpoint, '/').'/pipeline/' + .trim((string) $engine['pipeline']['pipeline_name'], '/').'/run'; + $timeoutSeconds = ((int) $engine['pipeline']['timeout_ms']) / 1000; + $response = Http::timeout($timeoutSeconds) + ->connectTimeout(min($timeoutSeconds, 1.0)) + ->post($url, [ + 'schema_version' => 'parkhub.recommendation.pipeline.v1', + 'recommendation_id' => $recommendationId, + 'algorithm' => 'fop_pipeline_v1', + 'fallback_algorithm' => 'weighted_v1', + 'weights' => $engine['weights'], + 'max_results' => $engine['max_results'], + 'explain' => $engine['explain'], + 'profile_safe_mode' => $engine['profile_safe_mode'], + 'candidates' => $candidates, + ]); + + if (! $response->successful()) { + throw new \RuntimeException('fop-pipeline returned HTTP '.$response->status()); + } + + $ranked = (array) data_get($response->json(), 'data.ranked', []); + $pipelineCandidates = $this->applyFopPipelineRankedResponse($candidates, $ranked); + if ($pipelineCandidates === []) { + throw new \RuntimeException('fop-pipeline response did not rank any known slots'); + } + + return [ + collect($pipelineCandidates), + [ + 'requested_algorithm' => 'fop_pipeline_v1', + 'effective_algorithm' => 'fop_pipeline_v1', + 'attempted' => true, + 'status' => 'succeeded', + 'pipeline_name' => (string) $engine['pipeline']['pipeline_name'], + 'endpoint_configured' => true, + 'fallback_enabled' => true, + 'error' => null, + ], + ]; + } catch (\Throwable $e) { + Log::warning('fop_pipeline_v1 recommendation attempt failed; falling back to weighted_v1', [ + 'recommendation_id' => $recommendationId, + 'error' => $e->getMessage(), + ]); + + return [ + collect($candidates), + $this->fallbackAdapterStatus($engine, true, 'fallback_error', $e->getMessage()), + ]; + } + } + + /** + * @param array> $candidates + * @param array> $ranked + * @return array> + */ + private function applyFopPipelineRankedResponse(array $candidates, array $ranked): array + { + $bySlotId = collect($candidates)->keyBy(fn (array $candidate) => (string) $candidate['slot_id']); + $out = []; + $seen = []; + foreach ($ranked as $item) { + $slotId = (string) ($item['slot_id'] ?? $item['id'] ?? ''); + if ($slotId === '' || ! $bySlotId->has($slotId)) { + throw new \RuntimeException("fop-pipeline ranked unknown slot_id '{$slotId}'"); + } + if (isset($seen[$slotId])) { + continue; + } + $candidate = $bySlotId->get($slotId); + if (array_key_exists('score', $item)) { + $candidate['score'] = round((float) $item['score'], 2); + } + if (isset($item['reasons']) && is_array($item['reasons'])) { + $candidate['reasons'] = array_values($item['reasons']); + } + if (isset($item['reason_badges']) && is_array($item['reason_badges'])) { + $candidate['reason_badges'] = array_values($item['reason_badges']); + } + $out[] = $candidate; + $seen[$slotId] = true; + } + + foreach ($candidates as $candidate) { + $slotId = (string) $candidate['slot_id']; + if (! isset($seen[$slotId])) { + $out[] = $candidate; + } + } + + return $out; + } + + /** + * @param array $engine + * @return array + */ + private function fallbackAdapterStatus(array $engine, bool $attempted, string $status, string $error): array + { + return [ + 'requested_algorithm' => (string) $engine['algorithm'], + 'effective_algorithm' => 'weighted_v1', + 'attempted' => $attempted, + 'status' => $status, + 'pipeline_name' => (string) $engine['pipeline']['pipeline_name'], + 'endpoint_configured' => ! empty($engine['pipeline']['endpoint']), + 'fallback_enabled' => true, + 'error' => $error, + ]; + } + + private function validatedPipelineEndpoint(string $endpoint): ?string + { + $endpoint = trim($endpoint); + if ($endpoint === '') { + return null; + } + + $parts = parse_url($endpoint); + $scheme = $parts['scheme'] ?? null; + $host = $parts['host'] ?? null; + if (! in_array($scheme, ['http', 'https'], true) || ! is_string($host)) { + Log::warning('recommendation pipeline_endpoint rejected as invalid URL', ['endpoint' => $endpoint]); + + return null; + } + + $allowed = in_array($host, ['localhost', '127.0.0.1', '::1', 'fop-pipeline'], true) + || str_ends_with($host, '.svc') + || str_ends_with($host, '.svc.cluster.local') + || str_ends_with($host, '.test'); + if (! $allowed) { + Log::warning('recommendation pipeline_endpoint rejected by local/cluster allowlist', ['endpoint' => $endpoint]); + + return null; + } + + return $endpoint; + } + + /** + * @param Collection $servedEvents + * @return array{ + * total_recommendation_batches: int, + * total_recommendations_served: int, + * unique_users: int, + * avg_score: ?float, + * top_recommended_lots: list + * } + */ + private function servedRecommendationStats(Collection $servedEvents): array + { + $scores = []; + $lotCounts = []; + $userIds = []; + $servedCandidates = 0; + + foreach ($servedEvents as $event) { + if ($event->user_id !== null) { + $userIds[(string) $event->user_id] = true; + } + + $candidates = (array) data_get($event->details, 'candidates', []); + $servedCandidates += count($candidates); + foreach ($candidates as $candidate) { + if (! is_array($candidate)) { + continue; + } + + if (isset($candidate['score']) && is_numeric($candidate['score'])) { + $scores[] = (float) $candidate['score']; + } + + $lotId = (string) ($candidate['lot_id'] ?? ''); + if ($lotId !== '') { + $lotCounts[$lotId] = ($lotCounts[$lotId] ?? 0) + 1; + } + } + } + + arsort($lotCounts); + $topLotIds = array_slice(array_keys($lotCounts), 0, 5); + $lotNames = ParkingLot::query() + ->whereIn('id', $topLotIds) + ->pluck('name', 'id') + ->all(); + $topLots = collect($topLotIds) + ->map(fn (string $lotId): array => [ + 'lot_id' => $lotId, + 'lot_name' => isset($lotNames[$lotId]) ? (string) $lotNames[$lotId] : null, + 'count' => (int) $lotCounts[$lotId], + ]) + ->values() + ->all(); + + return [ + 'total_recommendation_batches' => $servedEvents->count(), + 'total_recommendations_served' => $servedCandidates, + 'unique_users' => count($userIds), + 'avg_score' => $scores === [] ? null : round(array_sum($scores) / count($scores), 2), + 'top_recommended_lots' => $topLots, + ]; + } + + /** + * @param array{ + * algorithm: string, + * weights: array, + * max_results: int, + * explain: bool, + * profile_safe_mode: bool, + * pipeline: array + * } $engine + * @param array $adapter + * @param array> $recommendations + */ + private function auditRecommendationServed( + Request $request, + string $recommendationId, + array $engine, + array $adapter, + array $recommendations + ): void { + $actor = $request->user(); + $username = $actor === null ? null : ($actor->email ?: $actor->username); + $candidates = array_map(static fn (array $rec): array => [ + 'slot_id' => (string) ($rec['slot_id'] ?? ''), + 'lot_id' => (string) ($rec['lot_id'] ?? ''), + 'score' => (float) ($rec['score'] ?? 0.0), + 'reason_badges' => array_values((array) ($rec['reason_badges'] ?? [])), + 'reasons' => array_values((array) ($rec['reasons'] ?? [])), + ], $recommendations); + + AuditLog::log([ + 'user_id' => $actor?->id, + 'username' => $username, + 'action' => 'recommendation_served', + 'event_type' => 'RecommendationServed', + 'target_type' => 'recommendation', + 'target_id' => $recommendationId, + 'ip_address' => $request->ip(), + 'details' => [ + 'recommendation_id' => $recommendationId, + 'algorithm' => $engine['algorithm'], + 'config_hash' => $this->recommendationConfigHash($engine), + 'weights_hash' => $this->recommendationWeightsHash($engine['weights']), + 'adapter' => $adapter, + 'profile_safe_mode' => $engine['profile_safe_mode'], + 'explain' => $engine['explain'], + 'candidate_ids' => array_column($candidates, 'slot_id'), + 'candidates' => $candidates, + 'legal_boundary' => [ + 'legal_review_required' => true, + 'attorney_review_status' => 'required_before_customer_wording', + 'execution_allowed' => false, + ], + ], ]); } + + /** + * @param array{ + * algorithm: string, + * weights: array, + * max_results: int, + * explain: bool, + * profile_safe_mode: bool, + * pipeline: array + * } $engine + */ + private function recommendationConfigHash(array $engine): string + { + return hash('sha256', json_encode([ + 'algorithm' => $engine['algorithm'], + 'weights' => $engine['weights'], + 'max_results' => $engine['max_results'], + 'explain' => $engine['explain'], + 'profile_safe_mode' => $engine['profile_safe_mode'], + 'pipeline' => $engine['pipeline'], + ], JSON_THROW_ON_ERROR)); + } + + /** + * @param array $weights + */ + private function recommendationWeightsHash(array $weights): string + { + return hash('sha256', json_encode($weights, JSON_THROW_ON_ERROR)); + } + + private function moduleConfigValue(string $key, mixed $default): mixed + { + $raw = Setting::get(ModuleRegistry::configSettingKey('recommendations', $key)); + if ($raw === null) { + return $default; + } + + $decoded = is_string($raw) ? json_decode($raw, true) : $raw; + + return json_last_error() === JSON_ERROR_NONE ? $decoded : $raw; + } + + private function boundedFloat(mixed $value, float $min, float $max): float + { + $number = is_numeric($value) ? (float) $value : $min; + + return max($min, min($max, $number)); + } } diff --git a/app/Services/ModuleRegistry.php b/app/Services/ModuleRegistry.php index d9dae855..b4e5e7ee 100644 --- a/app/Services/ModuleRegistry.php +++ b/app/Services/ModuleRegistry.php @@ -897,10 +897,139 @@ final class ModuleRegistry [ 'name' => 'recommendations', 'category' => 'Experimental', - 'description' => 'Slot recommendations based on user history.', - 'config_keys' => [], + 'description' => 'Configurable weighted slot recommendation engine.', + 'config_keys' => [ + 'algorithm', + 'pipeline_endpoint', + 'pipeline_name', + 'pipeline_timeout_ms', + 'pipeline_fallback_enabled', + 'weight_frequency', + 'weight_preferred_lot', + 'weight_availability', + 'weight_price', + 'weight_distance', + 'weight_accessibility_bonus', + 'weight_feature_bonus', + 'max_results', + 'explain', + 'profile_safe_mode', + ], 'ui_route' => null, 'depends_on' => [], + 'config_schema' => [ + 'type' => 'object', + 'properties' => [ + 'algorithm' => [ + 'type' => 'string', + 'enum' => ['weighted_v1', 'fop_pipeline_v1'], + 'description' => 'Versioned scoring strategy. weighted_v1 is the rollback default; fop_pipeline_v1 calls the configured fop-pipeline HTTP adapter and falls back to weighted_v1 on any error.', + ], + 'pipeline_endpoint' => [ + 'type' => 'string', + 'description' => 'Optional local or cluster fop-pipeline base URL, for example http://fop-pipeline.fop-agents.svc:9310. External hosts are rejected by the adapter allowlist.', + ], + 'pipeline_name' => [ + 'type' => 'string', + 'minLength' => 1, + 'description' => 'fop-pipeline pipeline name used by POST /pipeline/{name}/run.', + ], + 'pipeline_timeout_ms' => [ + 'type' => 'integer', + 'minimum' => 100, + 'maximum' => 5000, + 'description' => 'Adapter timeout in milliseconds before falling back to weighted_v1.', + ], + 'pipeline_fallback_enabled' => [ + 'type' => 'boolean', + 'const' => true, + 'description' => 'Fail-closed guardrail. weighted_v1 fallback stays mandatory until fop_pipeline_v1 is production-certified.', + ], + 'weight_frequency' => [ + 'type' => 'number', + 'minimum' => 0, + 'maximum' => 100, + ], + 'weight_preferred_lot' => [ + 'type' => 'number', + 'minimum' => 0, + 'maximum' => 100, + ], + 'weight_availability' => [ + 'type' => 'number', + 'minimum' => 0, + 'maximum' => 100, + ], + 'weight_price' => [ + 'type' => 'number', + 'minimum' => 0, + 'maximum' => 100, + ], + 'weight_distance' => [ + 'type' => 'number', + 'minimum' => 0, + 'maximum' => 100, + ], + 'weight_accessibility_bonus' => [ + 'type' => 'number', + 'minimum' => 0, + 'maximum' => 25, + 'description' => 'Optional extra points for facility-designated accessible slots only. Must not use inferred disability, health, or other sensitive user attributes; keep at 0 until tenant DPIA/privacy review and user-facing notice approve it.', + ], + 'weight_feature_bonus' => [ + 'type' => 'number', + 'minimum' => 0, + 'maximum' => 25, + ], + 'max_results' => [ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 25, + ], + 'explain' => [ + 'type' => 'boolean', + 'const' => true, + 'description' => 'Fail-closed guardrail. Reason strings and badges must stay enabled before legal/privacy review approves disabling them.', + ], + 'profile_safe_mode' => [ + 'type' => 'boolean', + 'const' => true, + 'description' => 'Fail-closed privacy guardrail. Sensitive personal attributes are blocked from scoring inputs; this must stay enabled before legal/privacy review approves disabling it.', + ], + ], + 'required' => [ + 'algorithm', + 'weight_frequency', + 'weight_preferred_lot', + 'weight_availability', + 'weight_price', + 'weight_distance', + 'weight_accessibility_bonus', + 'weight_feature_bonus', + 'max_results', + 'explain', + 'profile_safe_mode', + ], + 'allOf' => [ + [ + 'if' => [ + 'properties' => [ + 'algorithm' => ['const' => 'fop_pipeline_v1'], + ], + 'required' => ['algorithm'], + ], + 'then' => [ + 'required' => [ + 'pipeline_endpoint', + 'pipeline_name', + 'pipeline_timeout_ms', + 'pipeline_fallback_enabled', + ], + ], + ], + ], + 'additionalProperties' => false, + ], ], [ 'name' => 'operating_hours', diff --git a/config/recommendations.php b/config/recommendations.php new file mode 100644 index 00000000..5e33fbdc --- /dev/null +++ b/config/recommendations.php @@ -0,0 +1,39 @@ + env('RECOMMENDATION_ALGORITHM', 'weighted_v1'), + + 'weights' => [ + 'frequency' => (float) env('RECOMMENDATION_WEIGHT_FREQUENCY', 40.0), + 'preferred_lot' => (float) env('RECOMMENDATION_WEIGHT_PREFERRED_LOT', 20.0), + 'availability' => (float) env('RECOMMENDATION_WEIGHT_AVAILABILITY', 30.0), + 'price' => (float) env('RECOMMENDATION_WEIGHT_PRICE', 20.0), + 'distance' => (float) env('RECOMMENDATION_WEIGHT_DISTANCE', 10.0), + 'accessibility_bonus' => (float) env('RECOMMENDATION_WEIGHT_ACCESSIBILITY_BONUS', 0.0), + 'feature_bonus' => (float) env('RECOMMENDATION_WEIGHT_FEATURE_BONUS', 2.0), + ], + + 'max_results' => (int) env('RECOMMENDATION_MAX_RESULTS', 5), + 'explain' => true, + 'profile_safe_mode' => true, + + 'pipeline' => [ + 'endpoint' => env('RECOMMENDATION_PIPELINE_ENDPOINT'), + 'pipeline_name' => env('RECOMMENDATION_PIPELINE_NAME', 'parkhub-recommendations'), + 'timeout_ms' => (int) env('RECOMMENDATION_PIPELINE_TIMEOUT_MS', 750), + 'fallback_enabled' => true, + ], +]; diff --git a/docs/openapi/php.json b/docs/openapi/php.json index 8020c46e..a016924e 100644 --- a/docs/openapi/php.json +++ b/docs/openapi/php.json @@ -34264,60 +34264,181 @@ "properties": { "data": { "properties": { + "acceptance_metric_source": { + "const": "not_tracked", + "type": "string" + }, "acceptance_rate": { - "anyOf": [ - { - "type": "number" + "type": "null" + }, + "accepted_recommendations": { + "type": "null" + }, + "algorithm": { + "const": "weighted_v1", + "type": "string" + }, + "algorithm_adapter": { + "properties": { + "attempted": { + "type": "boolean" }, - { - "enum": [ - 0 - ], - "type": "integer" + "effective_algorithm": { + "const": "weighted_v1", + "type": "string" + }, + "endpoint_configured": { + "type": "boolean" + }, + "error": { + "type": "null" + }, + "fallback_enabled": { + "type": "boolean" + }, + "pipeline_name": { + "type": "null" + }, + "requested_algorithm": { + "type": "string" + }, + "status": { + "const": "weighted_v1", + "type": "string" } - ] - }, - "accepted": { - "minimum": 0, - "type": "integer" + }, + "required": [ + "requested_algorithm", + "effective_algorithm", + "attempted", + "status", + "pipeline_name", + "endpoint_configured", + "fallback_enabled", + "error" + ], + "type": "object" }, "algorithm_weights": { "properties": { + "accessibility_bonus": { + "type": "number" + }, "availability": { - "const": 30, - "type": "integer" + "type": "number" }, "distance": { - "const": 10, - "type": "integer" + "type": "number" + }, + "feature_bonus": { + "type": "number" }, "frequency": { - "const": 40, - "type": "integer" + "type": "number" + }, + "preferred_lot": { + "type": "number" }, "price": { - "const": 20, - "type": "integer" + "type": "number" } }, "required": [ "frequency", + "preferred_lot", "availability", "price", - "distance" + "distance", + "accessibility_bonus", + "feature_bonus" ], "type": "object" }, + "avg_score": { + "type": [ + "number", + "null" + ] + }, + "legal_boundary": { + "properties": { + "attorney_review_status": { + "const": "required_before_customer_wording", + "type": "string" + }, + "disclaimer": { + "const": "fop legal output is reference-only drafting support; attorney review, citation verification, client authorization, and final legal judgment remain required before customer-facing profiling or legal wording ships.", + "type": "string" + }, + "execution_allowed": { + "type": "boolean" + }, + "legal_review_required": { + "type": "boolean" + } + }, + "required": [ + "legal_review_required", + "attorney_review_status", + "execution_allowed", + "disclaimer" + ], + "type": "object" + }, + "metrics_source": { + "const": "audit_log.recommendation_served", + "type": "string" + }, + "top_recommended_lots": { + "items": { + "properties": { + "count": { + "type": "integer" + }, + "lot_id": { + "type": "string" + }, + "lot_name": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "lot_id", + "lot_name", + "count" + ], + "type": "object" + }, + "type": "array" + }, "total_recommendations": { "minimum": 0, "type": "integer" + }, + "total_recommendations_served": { + "type": "integer" + }, + "unique_users": { + "type": "integer" } }, "required": [ "total_recommendations", - "accepted", + "total_recommendations_served", + "accepted_recommendations", "acceptance_rate", - "algorithm_weights" + "acceptance_metric_source", + "unique_users", + "avg_score", + "metrics_source", + "algorithm", + "algorithm_weights", + "algorithm_adapter", + "legal_boundary", + "top_recommended_lots" ], "type": "object" }, diff --git a/docs/recommendation-engine-contract.md b/docs/recommendation-engine-contract.md new file mode 100644 index 00000000..ffb152be --- /dev/null +++ b/docs/recommendation-engine-contract.md @@ -0,0 +1,176 @@ +# ParkHub Recommendation Engine Contract + +Status: T-6318 SP1-SP5 draft, PHP side + +## Purpose + +ParkHub recommendations now have an explicit `weighted_v1` contract. The first +slice codifies the shared deterministic scoring behavior and moves the weights +behind `config/recommendations.php` so PHP can later consume the shared +`fop-pipeline` recommender without another controller-local scoring fork. + +## Stable Algorithm + +`weighted_v1` is the deterministic rollback algorithm. `fop_pipeline_v1` is the +adapter algorithm for the external fop-pipeline service and must fall back to +`weighted_v1` on every missing endpoint, timeout, non-2xx response, invalid +response, or unknown slot ID. + +Default weights: + +| Key | Default | Meaning | +| --- | ---: | --- | +| `weight_frequency` | 40 | Maximum points for repeatedly using the same slot. | +| `weight_preferred_lot` | 20 | Maximum points for using the same lot when the exact slot has no history. | +| `weight_availability` | 30 | Points for an available slot. | +| `weight_price` | 20 | Maximum points for lower-priced lots. | +| `weight_distance` | 10 | Maximum points for slots near the entrance. | +| `weight_accessibility_bonus` | 0 | Optional extra points for facility-designated accessible slots. | +| `weight_feature_bonus` | 2 | Tiebreaker points for slot feature metadata. | +| `max_results` | 5 | Maximum results returned by the endpoint. | +| `pipeline_endpoint` | empty | Optional local/cluster fop-pipeline base URL. External hosts are rejected. | +| `pipeline_name` | `parkhub-recommendations` | Pipeline name used by `POST /pipeline/{name}/run`. | +| `pipeline_timeout_ms` | 750 | Total/connect timeout before fallback. | +| `pipeline_fallback_enabled` | true | Fail-closed: fallback to `weighted_v1` is mandatory until certification. | +| `explain` | true | Fail-closed: reasons and badges remain enabled until legal/privacy review approves disabling them. | +| `profile_safe_mode` | true | Fail-closed privacy guardrail for current and future scoring inputs. | + +Formula notes: + +- `frequency`: `min(slot_usage_count, 10) / 10 * weight_frequency`. +- `preferred_lot`: only applies when the exact slot has no usage history: + `min(lot_usage_count, 10) / 10 * weight_preferred_lot`. +- `availability`: every available, unbooked slot gets `weight_availability`. +- `price`: normalize within the candidate lot set: + `(1 - lot_hourly_rate / max_candidate_hourly_rate) * weight_price`, clamped at + zero for outlier rates; missing rates are treated as `0`. +- `distance`: `weight_distance / max(slot_number, 1)`. +- `accessibility_bonus` and `feature_bonus`: additive opt-in tiebreakers. + `is_accessible` and `features` are facility attributes only. They must never + be inferred from user disability, health, or other sensitive personal + attributes; `accessibility_bonus` stays `0` unless tenant DPIA/privacy review + and user-facing notice approve changing it. + +Changing `weighted_v1` semantics is not allowed. Any ML or tenant-specific +strategy must be introduced as a new algorithm version and must pass parity +fixtures against `weighted_v1` before rollout. + +## Config Boundary + +The PHP module registry exposes a JSON Schema for `recommendations`. Runtime +settings written by the module config editor under +`module.recommendations.config.*` override the defaults in +`config/recommendations.php`. The stats endpoint exposes the active algorithm +and weights for operator audit. The `explain` and `profile_safe_mode` settings +are reserved, fail-closed fields: attempts to set them to `false` are rejected by +schema and ignored by runtime loading. + +`fop_pipeline_v1` uses the fop-pipeline JSON/HTTP boundary: +`POST {pipeline_endpoint}/pipeline/{pipeline_name}/run`. ParkHub sends the +candidate slots, weights, `profile_safe_mode`, explanation requirement, and +`fallback_algorithm=weighted_v1`. The adapter only accepts local, `.test`, or +Kubernetes service hosts by default and records whether the pipeline was +attempted, succeeded, or fell back. + +The response continues to include reasons and badges. Shared parity fixtures +live under `docs/recommendation-engine-fixtures/` and are the contract for PHP, +Rust, and any future fop-pipeline adapter. `profile_safe_mode` stays enabled by +default and is reserved as the privacy gate for the future fop-pipeline adapter. +The stats endpoint also emits a machine-readable legal boundary: +`legal_review_required=true`, `attorney_review_status=required_before_customer_wording`, +and `execution_allowed=false` for generated/public profiling or legal wording. + +Every served recommendation batch includes a `recommendation_id` and writes a +best-effort `RecommendationServed` audit event. The event stores the algorithm, +SHA-256 config hash, SHA-256 weights hash, `profile_safe_mode`, `explain`, +adapter status, candidate slot IDs, scores, reason badges, reasons, and the legal +boundary. This is the trace key for later acceptance/rejection linkage and audit +export. + +## Compliance Boundary + +This is engineering compliance, not legal advice. For German/EU/international +use, the recommendation surface must keep: + +- data minimization: no sensitive categories, location history beyond parking + usage, or unrelated profile attributes in the score inputs; +- explainability: every score must keep a reason or badge that can be audited; +- operator control: weight changes must be authenticated, audited, and reversible; +- security evidence: SBOM/provenance/vulnerability handling remains part of the + ParkHub CI/CD baseline before business rollout; +- legal review: public ToS/privacy/profiling wording must go through `fop legal` + plus attorney review before being treated as customer-ready. + +`fop legal catalog` currently marks the local Claude-for-Legal catalog as +reference-only with attorney review and human signoff required, and execution +disabled. ParkHub mirrors that boundary in recommendation stats so operators can +see that compliance support is present but not a substitute for counsel. + +2026 compliance posture gates before business rollout: + +- SBOM, provenance, image digest, and VEX/vulnerability evidence attached to the + ParkHub Rust/PHP release artifacts; +- documented vulnerability disclosure and security update process; +- audit evidence retention for module config changes and served + `RecommendationServed` decisions; +- CRA/NIS2/AI Act/GDPR milestone tracking in the fop task board before + customer-facing profiling language ships. + +Relevant current public references: + +- European Commission, GDPR data protection by design and by default: + https://commission.europa.eu/law/law-topic/data-protection/rules-business-and-organisations/obligations/what-does-data-protection-design-and-default-mean_en +- European Commission, Cyber Resilience Act: + https://digital-strategy.ec.europa.eu/en/policies/cyber-resilience-act +- European Commission, CRA summary: + https://digital-strategy.ec.europa.eu/en/policies/cra-summary +- European Commission, NIS2 Directive overview: + https://digital-strategy.ec.europa.eu/en/policies/nis2-directive +- European Commission, AI Act transparency guidance: + https://digital-strategy.ec.europa.eu/en/faqs/guidelines-and-code-practice-transparent-ai-systems +- BSI IT-Grundschutz-Kompendium: + https://www.bsi.bund.de/DE/Themen/Unternehmen-und-Organisationen/Standards-und-Zertifizierung/IT-Grundschutz/IT-Grundschutz-Kompendium/it-grundschutz-kompendium_node.html + +## Legal Review Packet + +`fop legal` can draft the supporting documents, but the generated text is not a +shipping approval. Treat the commands below as review inputs only: + +```bash +NO_COLOR=true fop legal privacy "ParkHub" +NO_COLOR=true fop legal tos "ParkHub" +``` + +Before enabling `fop_pipeline_v1` for any customer tenant, the rollout packet +must contain: + +1. product counsel approval for the privacy-policy and ToS wording that names + recommendation logic, parking-history use, explanation output, and opt-out or + operator override behavior; +2. a tenant data-processing note that confirms the legal basis for parking + history, lot/slot metadata, and recommendation audit retention; +3. a DPIA or explicit DPIA-not-required decision before changing + `weight_accessibility_bonus` above `0` or adding any tenant-specific + behavioral/personalization input; +4. an Art. 30/records-of-processing update for the + `RecommendationServed` audit event, including retention and export paths; +5. a security release packet with SBOM, provenance, image digest, + vulnerability/VEX status, dependency license review, and incident/update + process evidence; +6. an operational acceptance record showing local/cluster-only + `pipeline_endpoint` allowlisting, timeout/fallback behavior, health checks, + and a tested rollback to `weighted_v1`. + +For personal or local evaluation, keep `weighted_v1` and the default +`execution_allowed=false` legal boundary. For business/customer operation, +do not present generated recommendation or legal text as approved until the +packet above is complete and signed off. + +## Next Slice + +1. Keep the shared JSON fixture wired into PHP and Rust tests whenever + recommendation scoring changes. +2. Add runtime certification/health gates for `fop_pipeline_v1` before enabling + it outside local/cluster controlled endpoints. +3. Keep `weighted_v1` as the rollback default until CI proves parity and the + legal/privacy review has accepted the customer-facing wording. diff --git a/docs/recommendation-engine-fixtures/weighted_v1.basic.json b/docs/recommendation-engine-fixtures/weighted_v1.basic.json new file mode 100644 index 00000000..0db3efc9 --- /dev/null +++ b/docs/recommendation-engine-fixtures/weighted_v1.basic.json @@ -0,0 +1,80 @@ +{ + "schema_version": "parkhub.recommendation.fixture.v1", + "algorithm": "weighted_v1", + "weights": { + "frequency": 40, + "preferred_lot": 20, + "availability": 30, + "price": 20, + "distance": 10, + "accessibility_bonus": 0, + "feature_bonus": 2 + }, + "max_results": 5, + "price_normalization": { + "max_candidate_hourly_rate": 8 + }, + "history": { + "slot_usage": { + "slot-usual": 3 + }, + "lot_usage": { + "lot-a": 3 + } + }, + "candidate_lots": [ + { + "id": "lot-a", + "hourly_rate": 2, + "slots": [ + { + "id": "slot-usual", + "slot_number": 1, + "status": "available", + "is_accessible": true, + "features": ["covered"] + }, + { + "id": "slot-preferred-lot", + "slot_number": 5, + "status": "available", + "is_accessible": false, + "features": [] + } + ] + }, + { + "id": "lot-b", + "hourly_rate": 8, + "slots": [ + { + "id": "slot-standard", + "slot_number": 2, + "status": "available", + "is_accessible": false, + "features": [] + } + ] + } + ], + "expected_ranked_slots": [ + { + "slot_id": "slot-usual", + "score": 69, + "badges": ["your_usual_spot", "available_now", "best_price", "closest_entrance", "accessible"], + "reasons": ["Used 3 times before", "Great price", "Near entrance", "Accessible", "Features: Covered"] + }, + { + "slot_id": "slot-preferred-lot", + "score": 53, + "badges": ["preferred_lot", "available_now", "best_price"], + "reasons": ["In your preferred lot (used 3 times)", "Great price"] + }, + { + "slot_id": "slot-standard", + "score": 35, + "badges": ["available_now", "closest_entrance"], + "reasons": ["Available now", "Near entrance"] + } + ] +} diff --git a/routes/modules/bookings.php b/routes/modules/bookings.php index 8a548182..f34832bc 100644 --- a/routes/modules/bookings.php +++ b/routes/modules/bookings.php @@ -20,7 +20,8 @@ // Literal paths MUST come before the /{id} catch-all, otherwise // Laravel matches /bookings/guest against /bookings/{id} and calls // BookingController@show with id='guest' which returns a 404. - Route::get('/bookings/recommendations', [RecommendationController::class, 'index']); + Route::get('/bookings/recommendations', [RecommendationController::class, 'index']) + ->middleware('module:recommendations'); Route::get('/bookings/guest', [GuestBookingController::class, 'listGuestBookings']); Route::post('/bookings/guest', [GuestBookingController::class, 'guestBooking']); Route::delete('/bookings/guest/{id}', [GuestBookingController::class, 'deleteGuestBooking']); diff --git a/routes/modules/recommendations.php b/routes/modules/recommendations.php index ac239697..c685adba 100644 --- a/routes/modules/recommendations.php +++ b/routes/modules/recommendations.php @@ -8,6 +8,6 @@ use App\Http\Controllers\Api\RecommendationController; use Illuminate\Support\Facades\Route; -Route::middleware(['module:recommendations', 'auth:sanctum', 'throttle:api'])->group(function () { +Route::middleware(['module:recommendations', 'auth:sanctum', 'throttle:api', 'admin'])->group(function () { Route::get('/recommendations/stats', [RecommendationController::class, 'stats']); }); diff --git a/scripts/check-recommendation-contract.sh b/scripts/check-recommendation-contract.sh new file mode 100644 index 00000000..62309e37 --- /dev/null +++ b/scripts/check-recommendation-contract.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +fixture="docs/recommendation-engine-fixtures/weighted_v1.basic.json" +expected_fixture_sha="fe8ffc6a8cdb645f48ded1bebcaf3f48eb4d8576c95520a75378e2f4394b4bfa" + +require_file() { + local path="$1" + if [[ ! -f "$path" ]]; then + echo "ERROR: missing $path" >&2 + exit 1 + fi +} + +require_grep() { + local pattern="$1" + shift + if ! grep -R -n --fixed-strings "$pattern" "$@" >/dev/null; then + echo "ERROR: missing recommendation contract pattern: $pattern" >&2 + echo " in: $*" >&2 + exit 1 + fi +} + +require_grep_each() { + local pattern="$1" + shift + local path + for path in "$@"; do + require_grep "$pattern" "$path" + done +} + +sha256_file() { + local path="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{print $1}' + else + echo "ERROR: neither sha256sum nor shasum is available" >&2 + exit 1 + fi +} + +require_file "$fixture" +actual_fixture_sha="$(sha256_file "$fixture")" +if [[ "$actual_fixture_sha" != "$expected_fixture_sha" ]]; then + echo "ERROR: $fixture hash drifted: $actual_fixture_sha" >&2 + echo " expected: $expected_fixture_sha" >&2 + echo " Update both PHP/Rust fixtures, tests, and this gate together." >&2 + exit 1 +fi + +require_grep '"algorithm": "weighted_v1"' "$fixture" +require_grep '"slot_id": "slot-usual"' "$fixture" +require_grep '"score": 69' "$fixture" + +require_grep_each 'fop_pipeline_v1' \ + app/Http/Controllers/Api/RecommendationController.php \ + app/Services/ModuleRegistry.php \ + tests/Feature/RecommendationExtendedTest.php \ + docs/recommendation-engine-contract.md +require_grep "'algorithm' => env('RECOMMENDATION_ALGORITHM', 'weighted_v1')" config/recommendations.php +require_grep 'fallback_algorithm=weighted_v1' docs/recommendation-engine-contract.md +require_grep "'fallback_algorithm' => 'weighted_v1'" app/Http/Controllers/Api/RecommendationController.php +require_grep "data_get(\$request->data(), 'fallback_algorithm') === 'weighted_v1'" tests/Feature/RecommendationExtendedTest.php +require_grep_each 'RecommendationServed' app/Http/Controllers/Api/RecommendationController.php docs/recommendation-engine-contract.md +require_grep "'adapter' =>" app/Http/Controllers/Api/RecommendationController.php +require_grep "'event_type' => 'RecommendationServed'" app/Http/Controllers/Api/RecommendationController.php +require_grep 'pipeline_endpoint rejected' app/Http/Controllers/Api/RecommendationController.php +require_grep 'test_fop_pipeline_v1_success_reorders_known_candidates' tests/Feature/RecommendationExtendedTest.php +require_grep 'test_fop_pipeline_v1_falls_back_when_endpoint_missing' tests/Feature/RecommendationExtendedTest.php +require_grep 'test_fop_pipeline_v1_rejects_external_endpoint_and_falls_back' tests/Feature/RecommendationExtendedTest.php +require_grep "str_ends_with(\$host, '.svc')" app/Http/Controllers/Api/RecommendationController.php +require_grep "str_ends_with(\$host, '.svc.cluster.local')" app/Http/Controllers/Api/RecommendationController.php +require_grep "str_ends_with(\$host, '.test')" app/Http/Controllers/Api/RecommendationController.php +require_grep 'https://example.com' tests/Feature/RecommendationExtendedTest.php +require_grep "'enum' => ['weighted_v1', 'fop_pipeline_v1']" app/Services/ModuleRegistry.php +require_grep "'execution_allowed' => false" app/Http/Controllers/Api/RecommendationController.php +require_grep "['legal_boundary']['execution_allowed']" tests/Feature/RecommendationExtendedTest.php +require_grep 'execution_allowed=false' docs/recommendation-engine-contract.md + +echo "ParkHub PHP recommendation contract gate OK." diff --git a/tests/Feature/RecommendationExtendedTest.php b/tests/Feature/RecommendationExtendedTest.php index ad0ccced..822f5248 100644 --- a/tests/Feature/RecommendationExtendedTest.php +++ b/tests/Feature/RecommendationExtendedTest.php @@ -2,11 +2,15 @@ namespace Tests\Feature; +use App\Models\AuditLog; use App\Models\Booking; use App\Models\ParkingLot; use App\Models\ParkingSlot; +use App\Models\Setting; use App\Models\User; +use App\Services\ModuleRegistry; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; use Tests\TestCase; class RecommendationExtendedTest extends TestCase @@ -30,10 +34,46 @@ public function test_recommendations_include_reason_badges(): void $response->assertOk(); $data = $response->json('data'); $this->assertNotEmpty($data); + $this->assertArrayHasKey('recommendation_id', $data[0]); $this->assertArrayHasKey('reason_badges', $data[0]); $this->assertContains('available_now', $data[0]['reason_badges']); } + public function test_recommendations_emit_served_audit_log(): void + { + $user = User::factory()->create(); + $lot = ParkingLot::create([ + 'name' => 'Audit Lot', + 'total_slots' => 1, + 'available_slots' => 1, + 'status' => 'open', + 'hourly_rate' => 4.0, + ]); + $slot = ParkingSlot::create(['lot_id' => $lot->id, 'slot_number' => '1', 'status' => 'available']); + + $response = $this->actingAs($user)->getJson('/api/v1/bookings/recommendations'); + + $response->assertOk(); + $recommendationId = $response->json('data.0.recommendation_id'); + $this->assertNotEmpty($recommendationId); + + $entry = AuditLog::query()->where('action', 'recommendation_served')->first(); + $this->assertNotNull($entry); + $this->assertSame('RecommendationServed', $entry->event_type); + $this->assertSame('recommendation', $entry->target_type); + $this->assertSame($recommendationId, $entry->target_id); + $this->assertSame($recommendationId, $entry->details['recommendation_id']); + $this->assertSame('weighted_v1', $entry->details['algorithm']); + $this->assertSame('weighted_v1', $entry->details['adapter']['effective_algorithm']); + $this->assertFalse($entry->details['adapter']['attempted']); + $this->assertSame([$slot->id], $entry->details['candidate_ids']); + $this->assertTrue($entry->details['profile_safe_mode']); + $this->assertTrue($entry->details['explain']); + $this->assertFalse($entry->details['legal_boundary']['execution_allowed']); + $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $entry->details['config_hash']); + $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $entry->details['weights_hash']); + } + public function test_recommendations_weighted_scoring(): void { $user = User::factory()->create(); @@ -71,6 +111,240 @@ public function test_recommendations_weighted_scoring(): void $this->assertContains('your_usual_spot', $data[0]['reason_badges']); } + public function test_weighted_v1_fixture_matches_contract(): void + { + $fixture = json_decode( + (string) file_get_contents(base_path('docs/recommendation-engine-fixtures/weighted_v1.basic.json')), + true, + 512, + JSON_THROW_ON_ERROR + ); + $this->assertSame('weighted_v1', $fixture['algorithm']); + + $user = User::factory()->create(); + $lots = []; + $slots = []; + $slotLotKeys = []; + + foreach ($fixture['candidate_lots'] as $fixtureLot) { + $lot = ParkingLot::create([ + 'name' => $fixtureLot['id'], + 'total_slots' => count($fixtureLot['slots']), + 'available_slots' => count($fixtureLot['slots']), + 'status' => 'open', + 'hourly_rate' => $fixtureLot['hourly_rate'], + ]); + $lots[$fixtureLot['id']] = $lot; + + foreach ($fixtureLot['slots'] as $fixtureSlot) { + $slots[$fixtureSlot['id']] = ParkingSlot::create([ + 'lot_id' => $lot->id, + 'slot_number' => (string) $fixtureSlot['slot_number'], + 'status' => $fixtureSlot['status'], + 'is_accessible' => $fixtureSlot['is_accessible'], + 'features' => $fixtureSlot['features'], + ]); + $slotLotKeys[$fixtureSlot['id']] = $fixtureLot['id']; + } + } + + foreach ($fixture['history']['slot_usage'] as $slotKey => $usage) { + $slot = $slots[$slotKey]; + $lot = $lots[$slotLotKeys[$slotKey]]; + for ($i = 0; $i < $usage; $i++) { + Booking::create([ + 'user_id' => $user->id, + 'lot_id' => $lot->id, + 'slot_id' => $slot->id, + 'lot_name' => $lot->name, + 'slot_number' => $slot->slot_number, + 'start_time' => now()->subDays($i + 1), + 'end_time' => now()->subDays($i + 1)->addHours(2), + 'status' => 'completed', + ]); + } + } + + $slotKeysById = collect($slots)->mapWithKeys(fn ($slot, string $key) => [$slot->id => $key]); + + $response = $this->actingAs($user)->getJson('/api/v1/bookings/recommendations'); + + $response->assertOk(); + $actual = collect($response->json('data'))->map(fn (array $item) => [ + 'slot_id' => $slotKeysById[$item['slot_id']], + 'score' => round((float) $item['score'], 2), + 'badges' => $item['reason_badges'], + 'reasons' => $item['reasons'], + ])->values()->all(); + $expected = collect($fixture['expected_ranked_slots'])->map(fn (array $item) => [ + 'slot_id' => $item['slot_id'], + 'score' => (float) $item['score'], + 'badges' => $item['badges'], + 'reasons' => $item['reasons'], + ])->all(); + + $this->assertSame($expected, $actual); + } + + public function test_fop_pipeline_v1_success_reorders_known_candidates(): void + { + $user = User::factory()->create(); + $lot = ParkingLot::create([ + 'name' => 'Pipeline Lot', + 'total_slots' => 2, + 'available_slots' => 2, + 'status' => 'open', + 'hourly_rate' => 2.0, + ]); + $slot1 = ParkingSlot::create(['lot_id' => $lot->id, 'slot_number' => '1', 'status' => 'available']); + $slot2 = ParkingSlot::create(['lot_id' => $lot->id, 'slot_number' => '2', 'status' => 'available']); + ParkingSlot::create(['lot_id' => $lot->id, 'slot_number' => '3', 'status' => 'available']); + Setting::set(ModuleRegistry::configSettingKey('recommendations', 'algorithm'), json_encode('fop_pipeline_v1')); + Setting::set(ModuleRegistry::configSettingKey('recommendations', 'pipeline_endpoint'), json_encode('http://fop-pipeline.test:9310')); + Setting::set(ModuleRegistry::configSettingKey('recommendations', 'pipeline_name'), json_encode('parkhub-recommendations')); + Setting::set(ModuleRegistry::configSettingKey('recommendations', 'max_results'), json_encode(1)); + + Http::fake([ + 'http://fop-pipeline.test:9310/*' => Http::response([ + 'ok' => true, + 'data' => [ + 'ranked' => [ + [ + 'slot_id' => $slot2->id, + 'score' => 99.5, + 'reasons' => ['Pipeline selected'], + 'reason_badges' => ['available_now'], + ], + [ + 'slot_id' => $slot1->id, + 'score' => 10, + 'reasons' => ['Pipeline fallback rank'], + 'reason_badges' => ['available_now'], + ], + ], + ], + ], 200), + ]); + + $response = $this->actingAs($user)->getJson('/api/v1/bookings/recommendations'); + + $response->assertOk() + ->assertJsonPath('data.0.slot_id', $slot2->id) + ->assertJsonPath('data.0.score', 99.5) + ->assertJsonPath('data.0.reasons.0', 'Pipeline selected') + ->assertJsonCount(1, 'data'); + Http::assertSent(fn ($request) => str_contains($request->url(), '/pipeline/parkhub-recommendations/run') + && data_get($request->data(), 'algorithm') === 'fop_pipeline_v1' + && data_get($request->data(), 'fallback_algorithm') === 'weighted_v1' + && data_get($request->data(), 'profile_safe_mode') === true + && data_get($request->data(), 'max_results') === 1 + && count((array) data_get($request->data(), 'candidates')) === 3); + + $entry = AuditLog::query()->where('action', 'recommendation_served')->first(); + $this->assertSame('fop_pipeline_v1', $entry->details['algorithm']); + $this->assertSame('fop_pipeline_v1', $entry->details['adapter']['effective_algorithm']); + $this->assertSame('succeeded', $entry->details['adapter']['status']); + $this->assertTrue($entry->details['adapter']['attempted']); + } + + public function test_fop_pipeline_v1_unknown_ranked_slot_falls_back_to_weighted_v1(): void + { + $user = User::factory()->create(); + $lot = ParkingLot::create([ + 'name' => 'Unknown Slot Pipeline Lot', + 'total_slots' => 2, + 'available_slots' => 2, + 'status' => 'open', + 'hourly_rate' => 2.0, + ]); + $slot1 = ParkingSlot::create(['lot_id' => $lot->id, 'slot_number' => '1', 'status' => 'available']); + $slot2 = ParkingSlot::create(['lot_id' => $lot->id, 'slot_number' => '2', 'status' => 'available']); + Setting::set(ModuleRegistry::configSettingKey('recommendations', 'algorithm'), json_encode('fop_pipeline_v1')); + Setting::set(ModuleRegistry::configSettingKey('recommendations', 'pipeline_endpoint'), json_encode('http://fop-pipeline.test:9310')); + Setting::set(ModuleRegistry::configSettingKey('recommendations', 'pipeline_name'), json_encode('parkhub-recommendations')); + + Http::fake([ + 'http://fop-pipeline.test:9310/*' => Http::response([ + 'ok' => true, + 'data' => [ + 'ranked' => [ + ['slot_id' => 'slot-from-another-candidate-set', 'score' => 100], + ['slot_id' => $slot2->id, 'score' => 99], + ], + ], + ], 200), + ]); + + $response = $this->actingAs($user)->getJson('/api/v1/bookings/recommendations'); + + $response->assertOk() + ->assertJsonPath('data.0.slot_id', $slot1->id); + + $entry = AuditLog::query()->where('action', 'recommendation_served')->first(); + $this->assertSame('fop_pipeline_v1', $entry->details['algorithm']); + $this->assertSame('weighted_v1', $entry->details['adapter']['effective_algorithm']); + $this->assertSame('fallback_error', $entry->details['adapter']['status']); + $this->assertTrue($entry->details['adapter']['attempted']); + $this->assertStringContainsString('unknown slot_id', $entry->details['adapter']['error']); + } + + public function test_fop_pipeline_v1_falls_back_when_endpoint_missing(): void + { + $user = User::factory()->create(); + $lot = ParkingLot::create([ + 'name' => 'Fallback Lot', + 'total_slots' => 1, + 'available_slots' => 1, + 'status' => 'open', + 'hourly_rate' => 2.0, + ]); + $slot = ParkingSlot::create(['lot_id' => $lot->id, 'slot_number' => '1', 'status' => 'available']); + Setting::set(ModuleRegistry::configSettingKey('recommendations', 'algorithm'), json_encode('fop_pipeline_v1')); + + Http::fake(); + + $response = $this->actingAs($user)->getJson('/api/v1/bookings/recommendations'); + + $response->assertOk() + ->assertJsonPath('data.0.slot_id', $slot->id); + Http::assertNothingSent(); + + $entry = AuditLog::query()->where('action', 'recommendation_served')->first(); + $this->assertSame('fop_pipeline_v1', $entry->details['algorithm']); + $this->assertSame('weighted_v1', $entry->details['adapter']['effective_algorithm']); + $this->assertSame('fallback_not_configured', $entry->details['adapter']['status']); + $this->assertFalse($entry->details['adapter']['attempted']); + } + + public function test_fop_pipeline_v1_rejects_external_endpoint_and_falls_back(): void + { + $user = User::factory()->create(); + $lot = ParkingLot::create([ + 'name' => 'External Endpoint Lot', + 'total_slots' => 1, + 'available_slots' => 1, + 'status' => 'open', + 'hourly_rate' => 2.0, + ]); + $slot = ParkingSlot::create(['lot_id' => $lot->id, 'slot_number' => '1', 'status' => 'available']); + Setting::set(ModuleRegistry::configSettingKey('recommendations', 'algorithm'), json_encode('fop_pipeline_v1')); + Setting::set(ModuleRegistry::configSettingKey('recommendations', 'pipeline_endpoint'), json_encode('https://example.com/pipeline')); + + Http::fake(); + + $response = $this->actingAs($user)->getJson('/api/v1/bookings/recommendations'); + + $response->assertOk() + ->assertJsonPath('data.0.slot_id', $slot->id); + Http::assertNothingSent(); + + $entry = AuditLog::query()->where('action', 'recommendation_served')->first(); + $this->assertSame('fop_pipeline_v1', $entry->details['algorithm']); + $this->assertSame('weighted_v1', $entry->details['adapter']['effective_algorithm']); + $this->assertSame('fallback_not_configured', $entry->details['adapter']['status']); + $this->assertFalse($entry->details['adapter']['endpoint_configured']); + } + public function test_recommendations_stats_endpoint(): void { $admin = User::factory()->admin()->create(); @@ -79,13 +353,60 @@ public function test_recommendations_stats_endpoint(): void $response->assertOk() ->assertJsonPath('success', true) + ->assertJsonPath('data.total_recommendations', 0) + ->assertJsonPath('data.total_recommendations_served', 0) + ->assertJsonPath('data.accepted_recommendations', null) + ->assertJsonPath('data.acceptance_rate', null) + ->assertJsonPath('data.acceptance_metric_source', 'not_tracked') + ->assertJsonPath('data.unique_users', 0) + ->assertJsonPath('data.avg_score', null) + ->assertJsonPath('data.metrics_source', 'audit_log.recommendation_served') ->assertJsonPath('data.algorithm_weights.frequency', 40) ->assertJsonPath('data.algorithm_weights.availability', 30) ->assertJsonPath('data.algorithm_weights.price', 20) - ->assertJsonPath('data.algorithm_weights.distance', 10); + ->assertJsonPath('data.algorithm_weights.distance', 10) + ->assertJsonPath('data.algorithm_weights.feature_bonus', 2) + ->assertJsonPath('data.algorithm_adapter.effective_algorithm', 'weighted_v1') + ->assertJsonPath('data.algorithm_adapter.fallback_enabled', true) + ->assertJsonPath('data.legal_boundary.legal_review_required', true) + ->assertJsonPath('data.legal_boundary.attorney_review_status', 'required_before_customer_wording') + ->assertJsonPath('data.legal_boundary.execution_allowed', false) + ->assertJsonCount(0, 'data.top_recommended_lots'); } - public function test_recommendations_stats_with_bookings(): void + public function test_recommendations_stats_requires_admin(): void + { + $user = User::factory()->create(); + + $this->actingAs($user)->getJson('/api/v1/recommendations/stats')->assertForbidden(); + } + + public function test_recommendations_stats_reflect_configured_engine_weights(): void + { + Setting::set( + ModuleRegistry::configSettingKey('recommendations', 'weight_frequency'), + json_encode(55.0), + ); + Setting::set( + ModuleRegistry::configSettingKey('recommendations', 'weight_preferred_lot'), + json_encode(15.0), + ); + Setting::set( + ModuleRegistry::configSettingKey('recommendations', 'profile_safe_mode'), + json_encode(true), + ); + + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->getJson('/api/v1/recommendations/stats') + ->assertOk() + ->assertJsonPath('data.algorithm', 'weighted_v1') + ->assertJsonPath('data.algorithm_weights.frequency', 55) + ->assertJsonPath('data.algorithm_weights.preferred_lot', 15); + } + + public function test_recommendations_stats_uses_served_audit_logs_not_bookings(): void { $user = User::factory()->create(); $lot = ParkingLot::create([ @@ -117,13 +438,48 @@ public function test_recommendations_stats_with_bookings(): void 'end_time' => now()->addHours(2), 'status' => 'active', ]); + AuditLog::log([ + 'user_id' => $user->id, + 'username' => $user->email, + 'action' => 'recommendation_served', + 'event_type' => 'RecommendationServed', + 'target_type' => 'recommendation', + 'target_id' => 'rec-1', + 'details' => [ + 'candidates' => [ + ['slot_id' => $slot->id, 'lot_id' => $lot->id, 'score' => 80.0], + ['slot_id' => 'other-slot', 'lot_id' => 'missing-lot', 'score' => 70.0], + ], + ], + ]); + AuditLog::log([ + 'user_id' => $user->id, + 'username' => $user->email, + 'action' => 'recommendation_served', + 'event_type' => 'RecommendationServed', + 'target_type' => 'recommendation', + 'target_id' => 'rec-2', + 'details' => [ + 'candidates' => [ + ['slot_id' => $slot->id, 'lot_id' => $lot->id, 'score' => 90.0], + ], + ], + ]); - $response = $this->actingAs($user)->getJson('/api/v1/recommendations/stats'); + $admin = User::factory()->admin()->create(); + + $response = $this->actingAs($admin)->getJson('/api/v1/recommendations/stats'); $response->assertOk() ->assertJsonPath('data.total_recommendations', 2) - ->assertJsonPath('data.accepted', 1) - ->assertJsonPath('data.acceptance_rate', 50); + ->assertJsonPath('data.total_recommendations_served', 3) + ->assertJsonPath('data.accepted_recommendations', null) + ->assertJsonPath('data.acceptance_rate', null) + ->assertJsonPath('data.unique_users', 1) + ->assertJsonPath('data.avg_score', 80) + ->assertJsonPath('data.top_recommended_lots.0.lot_id', $lot->id) + ->assertJsonPath('data.top_recommended_lots.0.lot_name', 'Stats Lot') + ->assertJsonPath('data.top_recommended_lots.0.count', 2); } public function test_recommendations_price_scoring(): void @@ -203,6 +559,7 @@ public function test_disabled_recommendations_module_returns_404(): void $user = User::factory()->create(); + $this->actingAs($user)->getJson('/api/v1/bookings/recommendations')->assertNotFound(); $this->actingAs($user)->getJson('/api/v1/recommendations/stats')->assertNotFound(); } } diff --git a/tests/Unit/Services/Modules/ModuleConfigurationServiceTest.php b/tests/Unit/Services/Modules/ModuleConfigurationServiceTest.php index a2831cfa..e34e6b0f 100644 --- a/tests/Unit/Services/Modules/ModuleConfigurationServiceTest.php +++ b/tests/Unit/Services/Modules/ModuleConfigurationServiceTest.php @@ -50,6 +50,26 @@ private function widgetsSchema(): array return $schema; } + /** + * @return array + */ + private function recommendationWeightedValues(): array + { + return [ + 'algorithm' => 'weighted_v1', + 'weight_frequency' => 40, + 'weight_preferred_lot' => 20, + 'weight_availability' => 30, + 'weight_price' => 20, + 'weight_distance' => 10, + 'weight_accessibility_bonus' => 0, + 'weight_feature_bonus' => 2, + 'max_results' => 5, + 'explain' => true, + 'profile_safe_mode' => true, + ]; + } + public function test_toggle_runtime_state_unknown_module_returns_not_found_without_writing(): void { $result = $this->service()->toggleRuntimeState('does-not-exist', true, $this->actor()); @@ -103,6 +123,45 @@ public function test_update_config_schema_violation_returns_formatted_details(): $this->assertSame(0, AuditLog::query()->where('action', 'module_config_updated')->count()); } + public function test_recommendations_weighted_config_does_not_require_pipeline_settings(): void + { + $schema = ModuleRegistry::configSchema('recommendations'); + $this->assertNotNull($schema, "'recommendations' module lost its config_schema — fixture drift."); + + $result = $this->service()->updateConfig( + 'recommendations', + $schema, + $this->recommendationWeightedValues(), + $this->actor(), + ); + + $this->assertTrue($result->isOk()); + $this->assertSame( + '"weighted_v1"', + Setting::get(ModuleRegistry::configSettingKey('recommendations', 'algorithm')), + ); + } + + public function test_recommendations_fop_pipeline_config_requires_pipeline_settings(): void + { + $schema = ModuleRegistry::configSchema('recommendations'); + $this->assertNotNull($schema, "'recommendations' module lost its config_schema — fixture drift."); + + $values = $this->recommendationWeightedValues(); + $values['algorithm'] = 'fop_pipeline_v1'; + + $result = $this->service()->updateConfig( + 'recommendations', + $schema, + $values, + $this->actor(), + ); + + $this->assertSame(ModuleConfigurationStatus::ValidationFailed, $result->status); + $this->assertIsArray($result->details); + $this->assertNotEmpty($result->details); + } + public function test_update_config_persists_each_key_and_writes_audit_log(): void { $actor = $this->actor();