Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions apps/app/server/services/matching.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Pipeline THI-124 §2 (matching honnête) + §3 (garde-fou de provenance).
*
* Règle architecturale : le LLM reçoit comme SOURCE DE CONTENU uniquement les
* données réelles du profil (`ProfileDTO`). L'offre analysée sert seulement de
* consignes de tri/reformulation. Chaque élément produit doit porter la
* `provenance` d'un élément réel du profil.
*
* §3 — garde-fou déterministe HORS LLM : avant de retourner le CV, on vérifie
* par le code (`assertValidCv`) que chaque élément référence un id réel du
* profil. Un item inventé (provenance absente ou inconnue) ⇒ rejet. L'invention
* est donc impossible par construction, indépendamment de ce que produit le LLM.
*/
import {
assertValidCv,
type AnalyzedOffer,
type ProfileDTO,
type ProfileItemId,
type RenderableCv,
} from '@cvo/shared'
import { anthropicComplete, type LlmComplete } from '../utils/anthropic'

const SYSTEM = `Tu es un moteur de mise en forme de CV honnête.
RÈGLES ABSOLUES :
- Le CONTENU vient EXCLUSIVEMENT du profil candidat fourni. N'invente JAMAIS une compétence, une expérience ou une réalisation absente du profil.
- L'offre sert uniquement à TRIER et REFORMULER : réordonne les sections/éléments par pertinence et reformule les libellés, sans rien ajouter.
- Chaque élément produit DOIT porter "provenance.profileItemId" = l'id EXACT de l'élément profil source. Mets "reformulated": true si tu as reformulé le libellé.
- N'utilise QUE les id présents dans le profil. Aucun id inventé.`

/** Provenance attendue sur chaque nœud du CV. */
const PROVENANCE_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
profileItemId: { type: 'string' },
reformulated: { type: 'boolean' },
},
required: ['profileItemId', 'reformulated'],
}

/** JSON Schema imposé à la sortie du matching (forme de `RenderableCv`). */
const RENDERABLE_CV_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
locale: { type: 'string', enum: ['fr'] },
header: {
type: 'object',
additionalProperties: false,
properties: {
fullName: { type: 'string' },
headline: { type: 'string' },
contacts: { type: 'array', items: { type: 'object' } },
provenance: PROVENANCE_SCHEMA,
},
required: ['fullName', 'headline', 'contacts', 'provenance'],
},
sections: { type: 'array', items: { type: 'object' } },
},
required: ['locale', 'header', 'sections'],
} as const

export interface MatchDeps {
complete: LlmComplete
}

/** Ids réels du profil : header + chaque expérience / compétence / formation. */
export function collectProfileItemIds(profile: ProfileDTO): Set<ProfileItemId> {
const ids = new Set<ProfileItemId>([profile.id])
for (const e of profile.experiences) ids.add(e.id)
for (const s of profile.skills) ids.add(s.id)
for (const ed of profile.education) ids.add(ed.id)
return ids
}

/**
* Produit un CV structuré priorisé pour l'offre, puis le passe au garde-fou
* déterministe. Lève `ProvenanceError` si le LLM a produit un item non sourcé.
*/
export async function matchProfileToOffer(
profile: ProfileDTO,
offer: AnalyzedOffer,
deps: MatchDeps = { complete: anthropicComplete },
): Promise<RenderableCv> {
const user = JSON.stringify({
instructionsDeTri: offer,
profilReel: profile,
})

const cv = (await deps.complete({
system: SYSTEM,
user,
schema: RENDERABLE_CV_SCHEMA as unknown as Record<string, unknown>,
costLabel: 'matching',
effort: 'medium',
maxTokens: 8192,
})) as RenderableCv

// §3 — garde-fou déterministe : rejette tout élément non sourcé (hors LLM).
assertValidCv(cv, collectProfileItemIds(profile))
return cv
}
47 changes: 47 additions & 0 deletions apps/app/server/services/offer-analysis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Pipeline THI-124 §1 — Analyse de l'offre collée.
*
* Entrée : texte brut de l'offre. Sortie : `AnalyzedOffer` structurée (titre,
* compétences requises, mots-clés, séniorité). Cette sortie ne sert que de
* consignes de tri au moteur de matching — jamais de source de contenu CV.
*/
import { ANALYZED_OFFER_SCHEMA, type AnalyzedOffer } from '@cvo/shared'
import { anthropicComplete, LlmError, type LlmComplete } from '../utils/anthropic'

const SYSTEM = `Tu analyses une offre d'emploi et tu en extrais un résumé structuré.
Reste STRICTEMENT fidèle au texte de l'offre : n'invente aucune compétence ni mot-clé absent.
Normalise l'intitulé du poste. Déduis la séniorité (junior|mid|senior|lead) seulement si l'offre la précise, sinon null.`

/** Borne de garde : on n'envoie pas un texte d'offre démesuré au modèle. */
const MAX_OFFER_CHARS = 20_000

export interface AnalyzeOfferDeps {
complete: LlmComplete
}

/**
* Analyse une offre collée. `deps.complete` est injecté (réel en prod, faux en test).
*/
export async function analyzeOffer(
rawOffer: string,
deps: AnalyzeOfferDeps = { complete: anthropicComplete },
): Promise<AnalyzedOffer> {
const text = rawOffer.trim()
if (!text) throw new LlmError('Offre vide : rien à analyser')

const result = (await deps.complete({
system: SYSTEM,
user: text.slice(0, MAX_OFFER_CHARS),
schema: ANALYZED_OFFER_SCHEMA as unknown as Record<string, unknown>,
costLabel: 'offer-analysis',
effort: 'low',
maxTokens: 1024,
})) as AnalyzedOffer

return {
title: result.title,
requiredSkills: result.requiredSkills ?? [],
keywords: result.keywords ?? [],
seniority: result.seniority ?? null,
}
}
116 changes: 116 additions & 0 deletions apps/app/server/utils/anthropic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Client Claude (API Messages) pour le moteur THI-124.
*
* Sortie structurée imposée via `output_config.format` (JSON Schema) : le modèle
* répond un JSON conforme, parsé puis revalidé en aval.
*
* ⚠️ RGPD : on logge le COÛT (modèle, tokens, $ estimé) mais JAMAIS le contenu
* (offre, profil, CV) — aucune donnée personnelle ne transite par les logs.
*
* L'appel réseau est isolé derrière le type `LlmComplete` : les services moteur
* en dépendent par injection, donc les tests tournent sans réseau ni clé API.
*/

/** Modèle par défaut du moteur (DoD THI-124). */
export const DEFAULT_MODEL = 'claude-sonnet-4-6' as const

/** Tarifs USD / million de tokens (entrée, sortie) — pour le log de coût. */
const PRICING: Record<string, { in: number; out: number }> = {
'claude-sonnet-4-6': { in: 3, out: 15 },
'claude-haiku-4-5': { in: 1, out: 5 },
'claude-opus-4-8': { in: 5, out: 25 },
}

export interface LlmRequest {
/** Consignes système (rôle/tâche). Pas de contenu CV au-delà du strict nécessaire. */
system: string
/** Message utilisateur : données structurées (profil réel + offre analysée). */
user: string
/** JSON Schema imposé à la sortie. */
schema: Record<string, unknown>
/** Étiquette de coût pour le log (ex. `offer-analysis`). JAMAIS de contenu. */
costLabel: string
/** Override modèle (défaut `DEFAULT_MODEL`). */
model?: string
/** Budget de sortie. */
maxTokens?: number
/** Effort de raisonnement (Sonnet 4.6 / Opus uniquement). */
effort?: 'low' | 'medium' | 'high'
}

/** Contrat d'appel LLM injectable — réimplémenté par un faux dans les tests. */
export type LlmComplete = (req: LlmRequest) => Promise<unknown>

/** Erreur d'appel LLM (réseau, refus, sortie non parsable). */
export class LlmError extends Error {
constructor(message: string) {
super(message)
this.name = 'LlmError'
}
}

/** Log de coût RGPD-safe : modèle + tokens + $ estimé, sans aucun contenu. */
function logCost(label: string, model: string, inTok: number, outTok: number): void {
const p = PRICING[model] ?? { in: 0, out: 0 }
const costUsd = (inTok * p.in + outTok * p.out) / 1_000_000
console.info(
JSON.stringify({
event: 'llm_cost',
label,
model,
inputTokens: inTok,
outputTokens: outTok,
costUsd: Number(costUsd.toFixed(6)),
}),
)
}

/**
* Implémentation réelle : appelle l'API Messages d'Anthropic via `fetch`.
* Lit la clé dans `ANTHROPIC_API_KEY`. Retourne l'objet JSON parsé (non typé —
* la validation de forme est faite par l'appelant / le garde-fou).
*/
export const anthropicComplete: LlmComplete = async (req) => {
const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey) throw new LlmError('ANTHROPIC_API_KEY manquante')
const model = req.model ?? DEFAULT_MODEL

const body: Record<string, unknown> = {
model,
max_tokens: req.maxTokens ?? 4096,
system: req.system,
messages: [{ role: 'user', content: req.user }],
thinking: { type: 'disabled' },
output_config: {
format: { type: 'json_schema', schema: req.schema },
...(req.effort ? { effort: req.effort } : {}),
},
}

const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify(body),
})
if (!res.ok) throw new LlmError(`Claude API ${res.status}`)

const data = (await res.json()) as {
stop_reason?: string
content?: { type: string; text?: string }[]
usage?: { input_tokens?: number; output_tokens?: number }
}
logCost(req.costLabel, model, data.usage?.input_tokens ?? 0, data.usage?.output_tokens ?? 0)

if (data.stop_reason === 'refusal') throw new LlmError('Réponse refusée par le modèle')
const text = data.content?.find((b) => b.type === 'text')?.text
if (!text) throw new LlmError('Réponse LLM vide')
try {
return JSON.parse(text) as unknown
} catch {
throw new LlmError('Sortie LLM non parsable en JSON')
}
}
116 changes: 116 additions & 0 deletions apps/app/test/matching.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect } from 'vitest'
import {
ProvenanceError,
type AnalyzedOffer,
type ProfileDTO,
type RenderableCv,
} from '@cvo/shared'
import { matchProfileToOffer, collectProfileItemIds } from '../server/services/matching'
import { analyzeOffer } from '../server/services/offer-analysis'
import type { LlmComplete } from '../server/utils/anthropic'

/** Profil réel minimal : 1 expérience + 1 compétence. Ids = source de vérité. */
const PROFILE: ProfileDTO = {
id: 'prof-1',
headline: 'Dev full-stack',
summary: 'Quatre ans de TypeScript.',
experiences: [
{
id: 'exp-1',
title: 'Développeuse',
company: 'ACME',
startDate: '2021-01-01',
endDate: null,
description: 'Vue + Node',
skillsUsed: ['TypeScript'],
orderIndex: 0,
},
],
skills: [{ id: 'skill-ts', label: 'TypeScript', level: 'ADVANCED', years: 4, orderIndex: 0 }],
education: [],
}

const OFFER: AnalyzedOffer = {
title: 'Développeur Front-end',
requiredSkills: ['TypeScript', 'Kubernetes'],
keywords: ['SaaS'],
seniority: 'mid',
}

/** Fabrique un faux client LLM qui renvoie toujours `cv` (aucun réseau). */
const fakeLlm =
(cv: RenderableCv): LlmComplete =>
async () =>
cv

/** CV entièrement sourcé : toutes les provenances pointent vers des ids réels. */
const SOURCED_CV: RenderableCv = {
locale: 'fr',
header: {
fullName: 'Camille',
headline: 'Dev',
contacts: [],
provenance: { profileItemId: 'prof-1', reformulated: true },
},
sections: [
{
kind: 'skills',
title: 'Compétences',
entries: [
{
id: 'cv-s1',
label: 'TypeScript',
provenance: { profileItemId: 'skill-ts', reformulated: false },
},
],
},
],
}

describe('collectProfileItemIds', () => {
it('rassemble le header + expériences + compétences + formations', () => {
expect(collectProfileItemIds(PROFILE)).toEqual(new Set(['prof-1', 'exp-1', 'skill-ts']))
})
})

describe('matchProfileToOffer — garde-fou de provenance (THI-124 §3)', () => {
it('accepte un CV dont chaque élément est sourcé', async () => {
const cv = await matchProfileToOffer(PROFILE, OFFER, { complete: fakeLlm(SOURCED_CV) })
expect(cv.sections).toHaveLength(1)
})

it('REJETTE un CV où le LLM a inventé une compétence (provenance absente)', async () => {
const invented: RenderableCv = structuredClone(SOURCED_CV)
const skills = invented.sections.find((s) => s.kind === 'skills')!
// Item halluciné : « Kubernetes » est dans l'offre mais PAS dans le profil.
skills.entries.push({ id: 'cv-sx', label: 'Kubernetes' } as never)

await expect(
matchProfileToOffer(PROFILE, OFFER, { complete: fakeLlm(invented) }),
).rejects.toBeInstanceOf(ProvenanceError)
})

it('REJETTE un item dont la provenance pointe vers un id absent du profil', async () => {
const invented: RenderableCv = structuredClone(SOURCED_CV)
const skills = invented.sections.find((s) => s.kind === 'skills')!
skills.entries[0].provenance = { profileItemId: 'skill-fantome', reformulated: false }

await expect(
matchProfileToOffer(PROFILE, OFFER, { complete: fakeLlm(invented) }),
).rejects.toBeInstanceOf(ProvenanceError)
})
})

describe('analyzeOffer — étape 1', () => {
it('retourne le résumé structuré renvoyé par le LLM', async () => {
const complete: LlmComplete = async () => OFFER
const result = await analyzeOffer('Nous recherchons un développeur front-end…', { complete })
expect(result.title).toBe('Développeur Front-end')
expect(result.requiredSkills).toContain('Kubernetes')
})

it('rejette une offre vide', async () => {
const complete: LlmComplete = async () => OFFER
await expect(analyzeOffer(' ', { complete })).rejects.toThrow()
})
})
Loading