diff --git a/apps/app/server/services/matching.ts b/apps/app/server/services/matching.ts new file mode 100644 index 0000000..3f9a1ac --- /dev/null +++ b/apps/app/server/services/matching.ts @@ -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 { + const ids = new Set([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 { + const user = JSON.stringify({ + instructionsDeTri: offer, + profilReel: profile, + }) + + const cv = (await deps.complete({ + system: SYSTEM, + user, + schema: RENDERABLE_CV_SCHEMA as unknown as Record, + 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 +} diff --git a/apps/app/server/services/offer-analysis.ts b/apps/app/server/services/offer-analysis.ts new file mode 100644 index 0000000..ec8b820 --- /dev/null +++ b/apps/app/server/services/offer-analysis.ts @@ -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 { + 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, + costLabel: 'offer-analysis', + effort: 'low', + maxTokens: 1024, + })) as AnalyzedOffer + + return { + title: result.title, + requiredSkills: result.requiredSkills ?? [], + keywords: result.keywords ?? [], + seniority: result.seniority ?? null, + } +} diff --git a/apps/app/server/utils/anthropic.ts b/apps/app/server/utils/anthropic.ts new file mode 100644 index 0000000..3649dd2 --- /dev/null +++ b/apps/app/server/utils/anthropic.ts @@ -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 = { + '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 + /** É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 + +/** 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 = { + 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') + } +} diff --git a/apps/app/test/matching.spec.ts b/apps/app/test/matching.spec.ts new file mode 100644 index 0000000..0dd597b --- /dev/null +++ b/apps/app/test/matching.spec.ts @@ -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() + }) +}) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9e09068..22ddffe 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,8 +4,6 @@ * - Profil candidat (THI-123) → ./profile. */ -export * from './profile' - export type HealthState = 'ok' | 'degraded' export type DependencyState = 'up' | 'down' @@ -23,9 +21,15 @@ export interface HealthStatus { export const HEALTH_PATH = '/api/health' as const +// Profil candidat — SEULE source de contenu du moteur (THI-123). +export * from './profile' + // Contrat du CV structuré + garde-fou de provenance (THI-124 ↔ THI-125). export * from './cv' export * from './provenance' // Metering & quotas (THI-126) — mesure d'usage + base billing freemium. export * from './usage' + +// Analyse d'offre — consignes de tri/reformulation du moteur (THI-124). +export * from './offer' diff --git a/packages/shared/src/offer.ts b/packages/shared/src/offer.ts new file mode 100644 index 0000000..05ad869 --- /dev/null +++ b/packages/shared/src/offer.ts @@ -0,0 +1,47 @@ +/** + * Contrat de l'analyse d'offre (THI-124, pipeline §1). Architecture §4a. + * + * L'offre collée par l'utilisateur est analysée par Claude (sortie structurée) + * pour produire ce résumé. ⚠️ Garde-fou produit : ce résumé sert UNIQUEMENT de + * **consignes de tri/reformulation** au moteur de matching — il n'est JAMAIS une + * source de contenu du CV. Le contenu vient exclusivement du profil réel + * (`ProfileDTO`), et chaque élément rendu porte sa `provenance` (voir `cv.ts`). + */ + +/** Séniorité déduite de l'offre. `null` si l'offre ne la précise pas. */ +export type OfferSeniority = 'junior' | 'mid' | 'senior' | 'lead' + +export const OFFER_SENIORITIES: readonly OfferSeniority[] = [ + 'junior', + 'mid', + 'senior', + 'lead', +] as const + +/** Résumé structuré d'une offre d'emploi (sortie LLM, étape 1 du pipeline). */ +export interface AnalyzedOffer { + /** Intitulé du poste, normalisé (ex. « Développeur Front-end Vue.js »). */ + title: string + /** Compétences explicitement requises ou souhaitées par l'offre. */ + requiredSkills: string[] + /** Mots-clés saillants (techno, domaine, soft skills) pour la priorisation. */ + keywords: string[] + /** Séniorité attendue, ou `null` si non précisée. */ + seniority: OfferSeniority | null +} + +/** + * JSON Schema (structured outputs) imposé à Claude pour l'analyse d'offre. + * `additionalProperties: false` partout : on rejette toute clé hallucinée. + */ +export const ANALYZED_OFFER_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + title: { type: 'string' }, + requiredSkills: { type: 'array', items: { type: 'string' } }, + keywords: { type: 'array', items: { type: 'string' } }, + seniority: { type: ['string', 'null'], enum: [...OFFER_SENIORITIES, null] }, + }, + required: ['title', 'requiredSkills', 'keywords', 'seniority'], +} as const