CV Parser
→ ParsedCV
Extrait les compétences, années d'expérience, réalisations et langues dans un ParsedCV typé.
Colle un CV et une offre. Les recruteurs obtiennent un verdict de fit et un outreach qui vaut le coup d'être envoyé. Les candidats obtiennent un brief sur l'entreprise et une préparation d'entretien sur mesure.
Google ADK v2 vient de sortir son API workflow basée sur un graphe. Je voulais la pousser de bout en bout sur un cas d'usage réel, dans l'esprit de LangGraph.
Upload un CV et une description de poste. Un pipeline d'agents fait l'analyse complète et produit une sortie adaptée au côté de la boucle de recrutement qui pose la question, recruteur ou candidat.
Le graphe encode une séparation délibérée entre control flow déterministe et génération LLM. Routage et synchronisation restent en pure Python ; seule la génération franchit une frontière LLM, et chaque sortie est contrainte par un schema Pydantic avant de quitter le node.
root_agent = Workflow(
name="career_copilot",
edges=[
(
"START",
(cv_parser_agent, jd_parser_agent), # parallel
parse_join,
mode_router,
{
"RECRUITER": fit_analyzer_agent,
"CANDIDATE": (research_agent, cv_optimizer_agent),
},
),
(
fit_analyzer_agent,
verdict_router,
{"OUTREACH": outreach_writer_agent, "GAP": gap_explainer_agent},
),
(
(research_agent, cv_optimizer_agent),
candidate_join,
interview_prep_agent,
),
],
)Huit agents LLM, deux routers FunctionNode, deux JoinNodes. Chaque agent tourne sur OpenAI gpt-5.4-mini via LiteLlm avec un reasoning effort low par défaut. L'Outreach Writer monte à medium pour générer des messages plus précis. La sortie de chaque agent est un schema Pydantic appliqué par output_schema d'ADK, sauf le Research Agent qui utilise les outils Tavily et valide son JSON à la frontière API.
→ ParsedCV
Extrait les compétences, années d'expérience, réalisations et langues dans un ParsedCV typé.
→ ParsedJD
Extrait le titre, l'entreprise, les compétences requises et préférées, la séniorité et les indices d'agence dans un ParsedJD.
→ FitVerdict
Compare ParsedCV à ParsedJD et émet un FitVerdict avec confiance calibrée, strengths matchées et gaps.
→ OutreachDraft
Écrit un message LinkedIn qui cite une réalisation concrète du CV. Reasoning effort medium.
→ GapReport
En cas de no_fit, explique les gaps en clair et suggère des postes adjacents pertinents.
→ CompanyIntelligence
Appelle Tavily search et extract via MCP pour récolter de l'intelligence sur l'entreprise : levée, culture, Glassdoor.
→ CVOptimizationBundle
Suggère des améliorations ciblées du CV pour mieux matcher l'offre, sans mentir sur l'expérience du candidat.
→ InterviewPrepBundle
Construit un pack de préparation d'entretien : questions probables, talking points, questions à poser en retour.
Le graphe agentique ci-dessus ne ship que s'il est exposé via une API stateless et atteignable depuis une UI. Deux couches propres font ça sans laisser fuir la complexité dans le graphe.
# Discriminated union: clients get one of three typed response
# shapes, picked by the requested mode.
AnalyzeResponse = Union[
RecruiterFitResponse, # recruiter mode, fit / borderline
RecruiterNoFitResponse, # recruiter mode, no_fit
CandidateResponse, # candidate mode
]
@router.post("/v1/analyze", response_model=AnalyzeResponse)
async def analyze(request: AnalyzeRequest) -> AnalyzeResponse:
"""Run the agent graph against a CV + JD pair, return the typed result."""
# ADK Workflow graph runs end-to-end. Parallel branches actually
# execute in parallel thanks to the async runner.
state = await run_agent(root_agent, initial_state)
# Pydantic schemas at every node boundary guarantee state is typed
# where it matters; we just pick the right response builder.
return (
_build_recruiter_response(state)
if request.mode == "recruiter"
else _build_candidate_response(state)
)Chaque requête, tracée de bout en bout avec Langfuse.
Un système LLM qu'on ne peut pas tracer est un système qu'on ne peut pas réparer. Chaque appel /v1/analyze ouvre une observation parente dans Langfuse. Les appels sub-agents et tool calls s'imbriquent dessous comme des spans enfants, avec inputs, outputs, latence et token counts capturés. Mode, modèle, version et tailles d'inputs sont propagés en attributs de trace pour un filtrage rapide dans l'UI.
@router.post("/analyze", response_model=AnalyzeResponse)
async def analyze(request: AnalyzeRequest) -> AnalyzeResponse:
langfuse = get_client()
with langfuse.start_as_current_observation(
name=f"analyze.{request.mode}",
as_type="agent",
input={"mode": request.mode, "cv_text": ..., "jd_text": ...},
) as span, propagate_attributes(
trace_name=f"career-copilot.analyze.{request.mode}",
tags=["analyze", request.mode],
metadata={
"mode": request.mode,
"model": PRIMARY_MODEL,
"version": VERSION,
"cv_chars": str(len(request.cv_text)),
"jd_chars": str(len(request.jd_text)),
},
):
state = await run_agent(root_agent, initial_state)
response = _build_response(state)
span.update(output=response.model_dump())
return responseLe handler /v1/analyze ouvre une observation parente typée 'agent'. ADK lance chaque sub-agent et tool call comme un span imbriqué dessous. Inputs, outputs, latence et token counts capturés automatiquement.
propagate_attributes injecte mode, modèle, version, cv_chars et jd_chars sur la trace active. Filtre par mode en deux clics, compare les distributions de confiance entre versions de modèle, repère les régressions tôt.
Les erreurs d'agent remontent en 502 avec full traceback dans les logs. Les incohérences de state workflow (FitVerdict manquant, OutreachDraft manquant) remontent en 500 avec un détail actionnable. Rien n'est silencieusement avalé.
Colle un CV, colle une offre, choisis un mode. Verdict et draft en quelques secondes.