Refactoring AI candidate analysis: UI improvements, data normalization, provider management and real-time score clamping

This commit is contained in:
jeremy bayse
2026-04-21 06:41:37 +02:00
parent abfe01190b
commit 2216de1a02
19 changed files with 1793 additions and 1402 deletions

View File

@@ -23,8 +23,8 @@ class AIAnalysisController extends Controller
}
// Restriction: Une analyse tous les 7 jours maximum par candidat
// Le super_admin peut outrepasser cette restriction via le paramètre 'force'
$shouldCheckRestriction = !($request->force && auth()->user()->isSuperAdmin());
// Tout admin peut outrepasser cette restriction s'il utilise l'option 'force'
$shouldCheckRestriction = !$request->input('force', false);
if ($shouldCheckRestriction && $candidate->ai_analysis && isset($candidate->ai_analysis['analyzed_at'])) {
$lastAnalysis = Carbon::parse($candidate->ai_analysis['analyzed_at']);

View File

@@ -149,7 +149,7 @@ class AttemptController extends Controller
$this->authorizeAdmin();
$request->validate([
'score' => 'required|numeric|min:0'
'score' => 'required|numeric|min:0|max:' . $answer->question->points
]);
$answer->update(['score' => $request->score]);

View File

@@ -103,12 +103,12 @@ class CandidateController extends Controller
'jobPositions' => \App\Models\JobPosition::all(),
'ai_config' => [
'default' => env('AI_DEFAULT_PROVIDER', 'ollama'),
'enabled_providers' => array_filter([
'ollama' => true, // Toujours dispo car local ou simulé
'providers' => array_keys(array_filter([
'ollama' => true,
'openai' => !empty(env('OPENAI_API_KEY')),
'anthropic' => !empty(env('ANTHROPIC_API_KEY')),
'gemini' => !empty(env('GEMINI_API_KEY')),
], function($v) { return $v; })
])),
]
];

View File

@@ -151,7 +151,19 @@ class AIAnalysisService
if (isset($data['score_global']) && !isset($data['match_score'])) {
$normalized['match_score'] = $data['score_global'];
}
if (isset($data['score']) && !isset($data['match_score'])) {
$normalized['match_score'] = $data['score'];
}
if (isset($data['points_forts']) && !isset($data['strengths'])) {
$normalized['strengths'] = $data['points_forts'];
}
if (isset($data['points_faibles']) && !isset($data['gaps'])) {
$normalized['gaps'] = $data['points_faibles'];
}
if (isset($data['recommandation']) && !isset($data['verdict'])) {
$normalized['verdict'] = $data['recommandation'];
}
@@ -159,14 +171,63 @@ class AIAnalysisService
if (isset($data['synthese']) && !isset($data['summary'])) {
$normalized['summary'] = $data['synthese'];
}
// List-specific normalization (handle list of objects or strings)
$cleanList = function($list) {
if (!is_array($list)) return [];
return array_map(function($item) {
if (is_array($item)) {
$type = $item['type'] ?? $item['title'] ?? $item['category'] ?? null;
$desc = $item['description'] ?? $item['value'] ?? $item['content'] ?? null;
if ($type && $desc) return "{$type} : {$desc}";
if ($desc) return $desc;
if ($type) return $type;
return json_encode($item);
}
return (string) $item;
}, $list);
};
if (isset($normalized['strengths'])) {
$normalized['strengths'] = $cleanList($normalized['strengths']);
}
if (isset($data['points_vigilance']) && !isset($data['gaps'])) {
// Handle if points_vigilance is a list of objects (as in user's prompt)
if (is_array($data['points_vigilance']) && isset($data['points_vigilance'][0]) && is_array($data['points_vigilance'][0])) {
$normalized['gaps'] = array_map(fn($i) => ($i['type'] ?? '') . ': ' . ($i['description'] ?? ''), $data['points_vigilance']);
} else {
$normalized['gaps'] = $data['points_vigilance'];
if (isset($normalized['gaps'])) {
$normalized['gaps'] = $cleanList($normalized['gaps']);
}
if (isset($normalized['elements_bloquants'])) {
$normalized['elements_bloquants'] = $cleanList($normalized['elements_bloquants']);
}
// Ensure match_score is a numeric value and handle common AI formatting quirks
if (isset($normalized['match_score'])) {
$scoreValue = $normalized['match_score'];
if (is_string($scoreValue)) {
// If AI returns something like "18/20", take the first part
if (str_contains($scoreValue, '/')) {
$scoreValue = explode('/', $scoreValue)[0];
}
// Convert comma to dot for European decimals
$scoreValue = str_replace(',', '.', $scoreValue);
// Keep only digits and the first decimal point
$scoreValue = preg_replace('/[^0-9.]/', '', $scoreValue);
}
$num = (float)$scoreValue;
// If the AI returned a ratio beneath 1 (e.g. 0.85 for 85%), scale it up
if ($num > 0 && $num < 1.1 && !is_int($normalized['match_score'])) {
// But be careful: a score of "1" might honestly be 1/100
// but 0.95 is almost certainly a ratio.
if ($num < 1 || str_contains((string)$normalized['match_score'], '.')) {
$num *= 100;
}
}
// Cap at 100
$normalized['match_score'] = (int) min(100, round($num));
}
// Ensure default keys exist even if empty
@@ -175,6 +236,9 @@ class AIAnalysisService
$normalized['verdict'] = $normalized['verdict'] ?? "Indéterminé";
$normalized['strengths'] = $normalized['strengths'] ?? [];
$normalized['gaps'] = $normalized['gaps'] ?? [];
$normalized['scores_detailles'] = $normalized['scores_detailles'] ?? null;
$normalized['elements_bloquants'] = $normalized['elements_bloquants'] ?? [];
$normalized['questions_entretien_suggerees'] = $normalized['questions_entretien_suggerees'] ?? [];
return $normalized;
}