Assistant de veille candidature : ingère une offre d'emploi, la compare à un profil via un agent multi-étapes, et note la qualité de ses propres réponses. Construit pour trier de vraies candidatures avant septembre 2026, et pour couvrir trois briques absentes du reste du portfolio : recherche vectorielle, orchestration d'agents, évaluation de LLM.
Le reste des projets IA du profil (chatbot en production, assistant RAG
interne, copilote de vente) utilise du full-text PostgreSQL et des appels
LLM enchaînés simplement — jamais d'embeddings/recherche vectorielle, jamais
de graphe d'agent explicite, jamais d'évaluation automatisée. Radar comble
ces trois manques avec un cas d'usage réel plutôt qu'un exercice isolé. Le
détail du raisonnement est dans CLAUDE.md.
Offre brute
│
▼
┌─────────────────┐
│ extractMetadata │ extraction structurée (function calling Mistral)
└────────┬─────────┘
│
├──► embedding (mistral-embed) ──► pgvector (recherche par similarité)
│
▼
┌─────────────────────────── agent LangGraph ───────────────────────────┐
│ extractRequirements → scoreMatch → [score ≥ seuil ?] → draftPitch │
│ (retry 1x) (breakdown │ non → fin │
│ par critère) │ oui │
│ ▼ │
│ accroche personnalisée │
└─────────────────────────────────────────────────────────────────────┘
│
▼
Postgres (offers, matches, profile_snapshots)
│
▼
eval/run.ts : dataset annoté + LLM-as-judge → score de régression
TypeScript, Express, PostgreSQL + pgvector (Supabase), LangGraph.js,
Mistral (mistral-embed + mistral-small), Zod.
npm install
cp .env.example .env # remplir MISTRAL_API_KEY (clé personnelle) et DATABASE_URL
psql "$DATABASE_URL" -f db/schema.sql # tables + extension pgvector + index
npm run seed # ingère et note les 18 offres du dataset (appels Mistral réels)
npm run dev # API sur :3001Dashboard (dans un second terminal, l'API doit tourner) :
cd web && npm install && npm run dev # http://localhost:3000Le reste en ligne de commande :
npm run ingest -- --text "texte de l'offre..."
npm run search -- "stage IA RAG Paris"
npm run match -- <offer-id>
npm run eval # fait tourner l'agent sur le dataset annotéSur Supabase, prendre la chaîne de connexion Session pooler : la
connexion directe (db.*.supabase.co) ne résout qu'en IPv6 et n'est pas
joignable depuis tous les réseaux.
$ npm run eval
eval-01: score=95 attendu=[70,95] OK
eval-02: score=23 attendu=[0,30] OK
...
eval-13: score=43 attendu=[60,90] HORS FOURCHETTE
Precision du score (dans la fourchette attendue) : 72% (13/18)
Fidelite factuelle moyenne des accroches (juge LLM) : 5.00/5
72 % sur 18 offres, contre 83 % quand le dataset n'en comptait que 6 : un jeu plus large et plus varié fait ressortir des faiblesses qu'un petit échantillon masquait. C'est l'intérêt d'avoir un eval versionné plutôt qu'une impression — les écarts restants sont documentés en limites connues.
- Recherche vectorielle (
src/offers/search.ts) : embedding de requête, distance cosinus pgvector, index HNSW. - Agent multi-étapes observable (
src/agent/) : état typé partagé entre nœuds (state.ts), un fichier par nœud, retry localisé sur l'extraction, aiguillage conditionnel pour éviter un appel LLM inutile. - Anti-hallucination : le prompt de rédaction d'accroche interdit
explicitement d'inventer un fait absent du profil (
draftPitch.ts). - Évaluation versionnée (
src/eval/) : dataset annoté à la main, comparaison à une fourchette attendue (pas une valeur exacte — un LLM n'est pas déterministe), LLM-as-judge sur la fidélité factuelle, résultats horodatés pour comparer avant/après un changement de prompt. - Traçabilité : chaque match est lié à un
profile_snapshotversionné et à unagent_logdétaillé par nœud (durée, statut, retry). - Coût et latence par étape (
src/mistral/pricing.ts, tablellm_calls) : chaque appel LLM est enregistré avec ses tokens, son coût estimé et sa durée, y compris hors du graphe (ingestion, recherche, éval). Le dashboard en tire un coût cumulé et un p95 par nœud.
db/schema.sql fait référence : 4 tables (offers, profile_snapshots,
matches, llm_calls), l'extension pgvector, l'index HNSW et les policies
RLS. Le coût est stocké au moment de l'appel plutôt que recalculé à la
lecture — sinon un changement de tarif réécrirait silencieusement
l'historique.
- Ingestion manuelle (coller le texte), pas de scraping automatique.
- LLM-as-judge partage les biais du modèle qu'il évalue — le dataset annoté à la main reste la référence, jamais l'eval automatique seule.
- Outil personnel, pas d'authentification multi-utilisateur.
- Le scoring reste sévère sur les offres de pilotage/gestion de projet
(
eval-05,eval-13) : sans stack technique à comparer, il sous-évalue une expérience métier pourtant pertinente. Corrigé partiellement, pas entièrement — c'est le principal chantier de calibration restant. error_countpar nœud n'est fiable que pourextractRequirements, seul nœud à gérer un retry. Une erreur dansscoreMatchoudraftPitcharrête le graphe sans être comptée.- Les prix Mistral sont figés dans
pricing.ts(relevés le 2026-07-28) : le coût affiché est un estimé, la facturation réelle fait foi.