From f42c76894d8905f859b66256cdb4b14bc3d0f6e9 Mon Sep 17 00:00:00 2001 From: tbeaumont79 Date: Wed, 10 Jun 2026 19:25:10 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(auth):=20=C3=A9critures=20profil=20qui?= =?UTF-8?q?=20pendent=20+=20middleware=20qui=20ne=20prot=C3=A8ge=20pas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux bugs qui cassaient tout le parcours profil : 1. getAuthSession utilisait toWebRequest(event), qui touche au flux du corps de la requête : le readBody(event) des handlers PUT/POST attendait ensuite un corps déjà verrouillé → toutes les écritures /api/profile* pendaient indéfiniment en build de prod (les GET, sans corps, passaient). On passe event.headers directement à auth.api.getSession, comme le fait déjà le middleware serveur. 2. Le middleware de navigation `auth` testait la truthiness de l'atom nanostores de useAuth() — toujours vrai → aucune redirection, et les visiteurs non connectés voyaient « Impossible de charger ton profil » (401) sur /profil. On vérifie désormais la session via GET /api/auth/get-session (cookies transférés en SSR). Vérifié sur build de prod + Postgres : PUT/POST/DELETE profil en ~10 ms, /profil anonyme → 302 /connexion, /profil connecté → 200. Co-Authored-By: Claude Fable 5 --- apps/app/middleware/auth.ts | 19 +++++++++++++++---- apps/app/server/utils/session.ts | 15 +++++++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/app/middleware/auth.ts b/apps/app/middleware/auth.ts index f625904..0cdea7b 100644 --- a/apps/app/middleware/auth.ts +++ b/apps/app/middleware/auth.ts @@ -2,10 +2,21 @@ * Middleware de navigation — protège les pages authentifiées. * Usage : definePageMeta({ middleware: 'auth' }) dans la page. * Redirige vers /connexion si la session est absente. + * + * La session est vérifiée auprès du serveur Better Auth (GET /api/auth/get-session), + * fiable en SSR (cookies transférés) comme en navigation client. L'atom nanostores + * de useAuth() n'est PAS utilisable ici : l'objet est toujours truthy et la session + * n'est hydratée qu'après le premier fetch côté client — l'ancien check laissait + * passer les visiteurs non connectés (écran d'erreur 401 sur /profil au lieu d'une + * redirection). */ export default defineNuxtRouteMiddleware(async () => { - const { session } = useAuth() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const s = (session as any)?.value ?? session - if (!s) return navigateTo('/connexion') + // En SSR, $fetch interne ne transmet pas les cookies du navigateur tout seul. + const headers = import.meta.server ? useRequestHeaders(['cookie']) : undefined + try { + const session = await $fetch('/api/auth/get-session', { headers }) + if (!session) return navigateTo('/connexion') + } catch { + return navigateTo('/connexion') + } }) diff --git a/apps/app/server/utils/session.ts b/apps/app/server/utils/session.ts index 0a0b35f..200cd40 100644 --- a/apps/app/server/utils/session.ts +++ b/apps/app/server/utils/session.ts @@ -4,7 +4,7 @@ * - `requireUserId` (THI-126) : lit `event.context.userId` posé par le middleware * d'auth (THI-134) et lève une 401 si absent. Découplé de l'instance Better Auth. * - `getAuthSession` (THI-132) : récupère la session Better Auth complète depuis - * l'événement (headers Web API via `toWebRequest`). Retourne null si non authentifié. + * les headers de l'événement. Retourne null si non authentifié. */ import { createError, type H3Event } from 'h3' import { auth } from './auth' @@ -26,8 +26,15 @@ export function requireUserId(event: H3Event): string { return userId } -/** Récupère la session Better Auth complète (ou null si non authentifié). */ +/** + * Récupère la session Better Auth complète (ou null si non authentifié). + * + * ⚠️ On passe `event.headers` directement — surtout pas `toWebRequest(event)` : + * convertir l'événement en Request touche au flux du corps, et le `readBody(event)` + * qui suit dans les handlers PUT/POST attend alors un corps déjà verrouillé → + * requête qui pend indéfiniment (toutes les écritures /api/profile* étaient HS + * en build de prod ; les GET, sans corps, passaient). + */ export async function getAuthSession(event: H3Event) { - const req = toWebRequest(event) - return auth.api.getSession({ headers: req.headers }) + return auth.api.getSession({ headers: event.headers }) } From 4e71097bc6072ebc536f0ff2624ef852f7dfb0d7 Mon Sep 17 00:00:00 2001 From: tbeaumont79 Date: Wed, 10 Jun 2026 22:53:03 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(candidature):=20flux=20complet=20offre?= =?UTF-8?q?=20=E2=86=92=20score=20de=20match=20=E2=86=92=20CV=20g=C3=A9n?= =?UTF-8?q?=C3=A9r=C3=A9=20(THI-124/125)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La page « Nouvelle candidature » (/candidature) concrétise le cœur du produit : coller une offre, obtenir un score de match honnête et justifié, puis générer un CV adapté — contenu issu du profil réel uniquement (garde-fou provenance), dans la limite des 2 générations offertes. Serveur : - packages/shared/src/match.ts : contrats du flux (MatchReport, seuils 40/70, matchVerdict, clampScore, computeKeywordCoverage déterministe, chemins API, QUOTA_EXCEEDED_CODE). - services/match-report.ts : score LLM (Sonnet, effort low) borné côté code, raisons nettoyées, garde-fou anti-score-flatteur (score > 70 avec couverture nulle → plafonné à 60), mots-clés matched/missing calculés par le code (reproductibles). - POST /api/candidature/analyze : zod 50–20000 chars, 409 profile_empty, metering 'extraction' avec tokens. - POST /api/candidature/generate : gate quota AVANT tout appel LLM (403 quota_exceeded), génération via matchProfileToOffer (provenance), metering 'generation' avec tokens. ProvenanceError → 422, LlmError → 502. - anthropic.ts : onUsage (tokens pour le metering) + ANTHROPIC_BASE_URL (proxy / mock de test). .env.example documente ANTHROPIC_API_KEY. - Migration metering_usage_tables : usage_events/usage_counters existaient dans le schéma mais aucune migration ne les créait — les quotas tournaient dans le vide. UI : - pages/candidature.vue : stepper input → score (verdict coloré, raisons, chips atouts/manques, alerte < 40 % « générer quand même ? ») → génération (loader informatif) → aperçu CvTemplate + export PDF. Badge quota en tête (décompte live), panneaux profil vide / quota épuisé (lien /#tarifs), a11y (aria-live, focus géré). - Nav : « Nouvelle candidature » en premier lien. Vérifié de bout en bout (Postgres jetable + mock LLM scripts/mock-llm.mjs via ANTHROPIC_BASE_URL) : 401/400/409 corrects, analyse → score 78 avec mots-clés déterministes, 2 générations OK (provenance validée), 3e bloquée 403, compteurs metering exacts (tokens compris), parcours UI complet testé au navigateur. 75 tests verts (25 nouveaux), lint, typecheck, build. Co-Authored-By: Claude Fable 5 --- apps/app/.env.example | 4 + apps/app/layouts/default.vue | 1 + apps/app/pages/candidature.vue | 473 ++++++++++++++++++ .../migration.sql | 42 ++ .../server/api/candidature/analyze.post.ts | 92 ++++ .../server/api/candidature/generate.post.ts | 116 +++++ apps/app/server/services/match-report.ts | 84 ++++ apps/app/server/utils/anthropic.ts | 11 +- apps/app/test/match-contracts.spec.ts | 103 ++++ apps/app/test/match-report.spec.ts | 107 ++++ packages/shared/src/index.ts | 3 + packages/shared/src/match.ts | 135 +++++ scripts/mock-llm.mjs | 109 ++++ 13 files changed, 1278 insertions(+), 2 deletions(-) create mode 100644 apps/app/pages/candidature.vue create mode 100644 apps/app/prisma/migrations/20260610203816_metering_usage_tables/migration.sql create mode 100644 apps/app/server/api/candidature/analyze.post.ts create mode 100644 apps/app/server/api/candidature/generate.post.ts create mode 100644 apps/app/server/services/match-report.ts create mode 100644 apps/app/test/match-contracts.spec.ts create mode 100644 apps/app/test/match-report.spec.ts create mode 100644 packages/shared/src/match.ts create mode 100644 scripts/mock-llm.mjs diff --git a/apps/app/.env.example b/apps/app/.env.example index 29a8dac..df25a2f 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -10,6 +10,10 @@ BETTER_AUTH_SECRET=dev-secret-change-in-prod-min32chars!! # URL de base de l'app (utilisée pour générer les magic-links). APP_URL=http://localhost:3000 +# Claude (API Messages) — analyse d'offre, score de match et génération de CV +# (THI-124 / flux candidature). Sans cette clé, /api/candidature/* répond 502. +ANTHROPIC_API_KEY=sk-ant-changeme + # SMTP (optionnel en dev — si absent, le magic-link est loggé dans la console). # SMTP_HOST=smtp.example.fr # SMTP_PORT=587 diff --git a/apps/app/layouts/default.vue b/apps/app/layouts/default.vue index 4da8f7a..50daadc 100644 --- a/apps/app/layouts/default.vue +++ b/apps/app/layouts/default.vue @@ -16,6 +16,7 @@ let unsubscribeSession: (() => void) | undefined const userInitial = computed(() => user.value?.email?.charAt(0)?.toUpperCase() ?? '?') const navLinks = [ + { label: 'Nouvelle candidature', to: '/candidature' }, { label: 'Mon profil', to: '/profil' }, { label: 'Mon CV', to: '/cv/demo' }, ] as const diff --git a/apps/app/pages/candidature.vue b/apps/app/pages/candidature.vue new file mode 100644 index 0000000..96d44bb --- /dev/null +++ b/apps/app/pages/candidature.vue @@ -0,0 +1,473 @@ + + + diff --git a/apps/app/prisma/migrations/20260610203816_metering_usage_tables/migration.sql b/apps/app/prisma/migrations/20260610203816_metering_usage_tables/migration.sql new file mode 100644 index 0000000..b6098b8 --- /dev/null +++ b/apps/app/prisma/migrations/20260610203816_metering_usage_tables/migration.sql @@ -0,0 +1,42 @@ +-- CreateEnum +CREATE TYPE "usage_event_type" AS ENUM ('GENERATION', 'EXPORT_PDF', 'EXTRACTION'); + +-- CreateTable +CREATE TABLE "usage_events" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" "usage_event_type" NOT NULL, + "period" VARCHAR(6) NOT NULL, + "tokensIn" INTEGER NOT NULL DEFAULT 0, + "tokensOut" INTEGER NOT NULL DEFAULT 0, + "billable" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "usage_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "usage_counters" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "period" VARCHAR(6) NOT NULL, + "generationCount" INTEGER NOT NULL DEFAULT 0, + "exportPdfCount" INTEGER NOT NULL DEFAULT 0, + "extractionCount" INTEGER NOT NULL DEFAULT 0, + "tokensIn" INTEGER NOT NULL DEFAULT 0, + "tokensOut" INTEGER NOT NULL DEFAULT 0, + "billableCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "usage_counters_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "usage_events_userId_period_idx" ON "usage_events"("userId", "period"); + +-- CreateIndex +CREATE INDEX "usage_events_type_idx" ON "usage_events"("type"); + +-- CreateIndex +CREATE UNIQUE INDEX "usage_counters_userId_period_key" ON "usage_counters"("userId", "period"); diff --git a/apps/app/server/api/candidature/analyze.post.ts b/apps/app/server/api/candidature/analyze.post.ts new file mode 100644 index 0000000..5d1cbfb --- /dev/null +++ b/apps/app/server/api/candidature/analyze.post.ts @@ -0,0 +1,92 @@ +/** + * POST /api/candidature/analyze — étape 1 du flux « Nouvelle candidature ». + * + * Corps : { offerText } (offre collée). Réponse : { offer, match } — + * l'offre analysée + le score de match, AVANT toute génération (et donc avant + * de consommer un crédit). Deux appels LLM (analyse + score) sont cumulés dans + * UN événement d'usage `extraction` (non facturable). + * + * RGPD : ni l'offre ni le profil ne sont loggés — seulement les tokens. + */ +import { z } from 'zod' +import type { AnalyzeCandidatureResponse } from '@cvo/shared' +import { requireUserId } from '../../utils/session' +import { prisma } from '../../utils/prisma' +import { NOT_DELETED, toProfileDTO } from '../../utils/profile-serialize' +import { recordUsageEvent } from '../../utils/metering' +import { anthropicComplete, LlmError, type LlmComplete } from '../../utils/anthropic' +import { analyzeOffer } from '../../services/offer-analysis' +import { buildMatchReport } from '../../services/match-report' + +const bodySchema = z.object({ + offerText: z + .string() + .min(50, "Le texte de l'offre est trop court (50 caractères minimum).") + .max(20_000, "Le texte de l'offre est trop long (20 000 caractères maximum)."), +}) + +export default defineEventHandler(async (event): Promise => { + const userId = requireUserId(event) + + const parsed = bodySchema.safeParse(await readBody(event)) + if (!parsed.success) { + throw createError({ + statusCode: 400, + message: parsed.error.issues[0]?.message ?? 'Corps invalide : { offerText: string } attendu.', + }) + } + + // Profil réel = seule source de contenu du moteur. Sans profil exploitable, + // analyser l'offre n'a pas de sens — on guide l'utilisateur vers son profil. + const profileRow = await prisma.profile.findFirst({ + where: { userId, ...NOT_DELETED }, + include: { + experiences: { orderBy: { orderIndex: 'asc' } }, + skills: { orderBy: { orderIndex: 'asc' } }, + education: { orderBy: { orderIndex: 'asc' } }, + }, + }) + const profile = profileRow ? toProfileDTO(profileRow) : null + if (!profile || (profile.experiences.length === 0 && profile.skills.length === 0)) { + throw createError({ + statusCode: 409, + message: "Complète d'abord ton profil", + data: { code: 'profile_empty' }, + }) + } + + // Cumule les tokens des deux appels LLM via onUsage (jamais de contenu). + let tokensIn = 0 + let tokensOut = 0 + const complete: LlmComplete = (req) => + anthropicComplete({ + ...req, + onUsage: (u) => { + tokensIn += u.inputTokens + tokensOut += u.outputTokens + }, + }) + + try { + const offer = await analyzeOffer(parsed.data.offerText, { complete }) + const match = await buildMatchReport(profile, offer, { complete }) + + await recordUsageEvent( + prisma, + { userId, type: 'extraction', tokensIn, tokensOut, billable: false }, + new Date(), + ) + + return { offer, match } + } catch (err) { + // Message volontairement générique : ne divulgue pas la config serveur + // (clé API absente, statut HTTP amont, etc.). + if (err instanceof LlmError) { + throw createError({ + statusCode: 502, + message: "L'analyse a échoué, réessaie dans un instant", + }) + } + throw err + } +}) diff --git a/apps/app/server/api/candidature/generate.post.ts b/apps/app/server/api/candidature/generate.post.ts new file mode 100644 index 0000000..bce9120 --- /dev/null +++ b/apps/app/server/api/candidature/generate.post.ts @@ -0,0 +1,116 @@ +/** + * POST /api/candidature/generate — étape 2 du flux « Nouvelle candidature ». + * + * Corps : { offer } (l'offre analysée renvoyée par /analyze). Réponse : { cv } + * (RenderableCv, garde-fou de provenance déjà appliqué par le service). + * + * Gate ÉCONOMIQUE : le quota `generation` (FREE_TIER_QUOTAS) est vérifié AVANT + * tout appel LLM — un utilisateur à quota épuisé ne coûte aucun token. + * + * RGPD : ni l'offre, ni le profil, ni le CV ne sont loggés — seulement les tokens. + */ +import { z } from 'zod' +import { + FREE_TIER_QUOTAS, + ProvenanceError, + QUOTA_EXCEEDED_CODE, + type AnalyzedOffer, + type RenderableCv, +} from '@cvo/shared' +import { requireUserId } from '../../utils/session' +import { prisma } from '../../utils/prisma' +import { NOT_DELETED, toProfileDTO } from '../../utils/profile-serialize' +import { isUsageAllowed, recordUsageEvent } from '../../utils/metering' +import { anthropicComplete, LlmError, type LlmComplete } from '../../utils/anthropic' +import { matchProfileToOffer } from '../../services/matching' + +const bodySchema = z.object({ + offer: z.object({ + title: z.string().min(1), + requiredSkills: z.array(z.string()), + keywords: z.array(z.string()), + seniority: z.enum(['junior', 'mid', 'senior', 'lead']).nullable(), + }), +}) + +export default defineEventHandler(async (event): Promise<{ cv: RenderableCv }> => { + const userId = requireUserId(event) + + const parsed = bodySchema.safeParse(await readBody(event)) + if (!parsed.success) { + throw createError({ + statusCode: 400, + message: 'Corps invalide : { offer: AnalyzedOffer } attendu.', + }) + } + const offer: AnalyzedOffer = parsed.data.offer + + // Gate quota AVANT tout appel LLM (aucun token consommé si quota atteint). + const now = new Date() + const allowed = await isUsageAllowed(prisma, userId, 'generation', FREE_TIER_QUOTAS, now) + if (!allowed) { + throw createError({ + statusCode: 403, + message: 'Tes générations offertes sont épuisées', + data: { code: QUOTA_EXCEEDED_CODE }, + }) + } + + // Profil réel = seule source de contenu du CV généré (mêmes règles que /analyze). + const profileRow = await prisma.profile.findFirst({ + where: { userId, ...NOT_DELETED }, + include: { + experiences: { orderBy: { orderIndex: 'asc' } }, + skills: { orderBy: { orderIndex: 'asc' } }, + education: { orderBy: { orderIndex: 'asc' } }, + }, + }) + const profile = profileRow ? toProfileDTO(profileRow) : null + if (!profile || (profile.experiences.length === 0 && profile.skills.length === 0)) { + throw createError({ + statusCode: 409, + message: "Complète d'abord ton profil", + data: { code: 'profile_empty' }, + }) + } + + // Mesure des tokens via onUsage (jamais de contenu). + let tokensIn = 0 + let tokensOut = 0 + const complete: LlmComplete = (req) => + anthropicComplete({ + ...req, + onUsage: (u) => { + tokensIn += u.inputTokens + tokensOut += u.outputTokens + }, + }) + + try { + const cv = await matchProfileToOffer(profile, offer, { complete }) + + await recordUsageEvent( + prisma, + { userId, type: 'generation', tokensIn, tokensOut, billable: false }, + new Date(), + ) + + return { cv } + } catch (err) { + // Garde-fou de provenance : le LLM a produit un élément non sourcé — rejeté. + if (err instanceof ProvenanceError) { + throw createError({ + statusCode: 422, + message: 'La génération a produit un élément non vérifiable, réessaie', + }) + } + // Message générique : ne divulgue pas la config serveur (clé API, amont…). + if (err instanceof LlmError) { + throw createError({ + statusCode: 502, + message: 'La génération a échoué, réessaie dans un instant', + }) + } + throw err + } +}) diff --git a/apps/app/server/services/match-report.ts b/apps/app/server/services/match-report.ts new file mode 100644 index 0000000..2fed635 --- /dev/null +++ b/apps/app/server/services/match-report.ts @@ -0,0 +1,84 @@ +/** + * Score de match profil ↔ offre (flux « Nouvelle candidature »). + * + * Étape AVANT génération : peu de tokens (Sonnet, effort low), pour alerter + * l'utilisateur si l'adéquation est faible avant de consommer un crédit. + * + * Répartition LLM / code (DoD QA, cf. @cvo/shared/match) : + * - le LLM ne fournit QUE { score, reasons } — bornés et revalidés ici ; + * - matched/missingKeywords sont calculés par le CODE (déterministe). + */ +import { + MATCH_SCORE_SCHEMA, + STRONG_MATCH_THRESHOLD, + clampScore, + computeKeywordCoverage, + type AnalyzedOffer, + type MatchReport, + type ProfileDTO, +} from '@cvo/shared' +import { anthropicComplete, type LlmComplete } from '../utils/anthropic' + +const SYSTEM = `Tu es un évaluateur HONNÊTE de l'adéquation entre un profil candidat et une offre d'emploi. +Tu reçois le profil RÉEL du candidat et l'offre analysée. Tu rends : +- "score" : un entier 0-100 mesurant l'adéquation réelle (0 = aucun rapport, 100 = correspondance parfaite) ; +- "reasons" : 2 à 4 raisons courtes en français, concrètes, citant les forces ET les manques du profil face à l'offre. +RÈGLES ABSOLUES : juge uniquement sur les éléments présents dans le profil. N'invente JAMAIS une compétence, +une expérience ou une formation absente du profil. Ne flatte pas : un profil hors sujet mérite un score bas.` + +/** Bornes des raisons affichées (le schema LLM ne contraint pas la longueur). */ +const MAX_REASONS = 4 + +/** Plafond appliqué quand le score LLM contredit une couverture mots-clés nulle. */ +const NO_COVERAGE_SCORE_CAP = 60 + +/** Raison ajoutée quand le garde-fou de cohérence plafonne le score. */ +const NO_COVERAGE_REASON = + "Score plafonné : aucun mot-clé de l'offre n'a été retrouvé dans le profil." + +export interface MatchReportDeps { + complete: LlmComplete +} + +/** Nettoie les raisons LLM : strings non vides, trim, au plus MAX_REASONS. Pur. */ +function sanitizeReasons(raw: unknown): string[] { + if (!Array.isArray(raw)) return [] + return raw + .filter((r): r is string => typeof r === 'string') + .map((r) => r.trim()) + .filter((r) => r.length > 0) + .slice(0, MAX_REASONS) +} + +/** + * Construit le rapport de match : score LLM borné + raisons nettoyées + couverture + * de mots-clés déterministe. Garde-fou de cohérence : un score « fort » + * (> STRONG_MATCH_THRESHOLD) sans AUCUN mot-clé couvert est invraisemblable — + * on le plafonne à NO_COVERAGE_SCORE_CAP et on l'explique à l'utilisateur. + */ +export async function buildMatchReport( + profile: ProfileDTO, + offer: AnalyzedOffer, + deps: MatchReportDeps = { complete: anthropicComplete }, +): Promise { + const result = (await deps.complete({ + system: SYSTEM, + user: JSON.stringify({ profilReel: profile, offreAnalysee: offer }), + schema: MATCH_SCORE_SCHEMA as unknown as Record, + costLabel: 'match-score', + effort: 'low', + maxTokens: 1024, + })) as { score?: unknown; reasons?: unknown } + + const { matched, missing } = computeKeywordCoverage(offer, profile) + + let score = clampScore(Number(result.score)) + let reasons = sanitizeReasons(result.reasons) + + if (score > STRONG_MATCH_THRESHOLD && matched.length === 0) { + score = NO_COVERAGE_SCORE_CAP + reasons = [...reasons.slice(0, MAX_REASONS - 1), NO_COVERAGE_REASON] + } + + return { score, reasons, matchedKeywords: matched, missingKeywords: missing } +} diff --git a/apps/app/server/utils/anthropic.ts b/apps/app/server/utils/anthropic.ts index 3649dd2..1bd03ef 100644 --- a/apps/app/server/utils/anthropic.ts +++ b/apps/app/server/utils/anthropic.ts @@ -36,6 +36,8 @@ export interface LlmRequest { maxTokens?: number /** Effort de raisonnement (Sonnet 4.6 / Opus uniquement). */ effort?: 'low' | 'medium' | 'high' + /** Callback de mesure : tokens consommés (JAMAIS de contenu). Pour le metering. */ + onUsage?: (usage: { inputTokens: number; outputTokens: number }) => void } /** Contrat d'appel LLM injectable — réimplémenté par un faux dans les tests. */ @@ -74,6 +76,8 @@ 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 + // Override pour proxy d'entreprise ou mock de test E2E (défaut : API publique). + const baseUrl = process.env.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com' const body: Record = { model, @@ -87,7 +91,7 @@ export const anthropicComplete: LlmComplete = async (req) => { }, } - const res = await fetch('https://api.anthropic.com/v1/messages', { + const res = await fetch(`${baseUrl}/v1/messages`, { method: 'POST', headers: { 'x-api-key': apiKey, @@ -103,7 +107,10 @@ export const anthropicComplete: LlmComplete = async (req) => { 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) + const inputTokens = data.usage?.input_tokens ?? 0 + const outputTokens = data.usage?.output_tokens ?? 0 + logCost(req.costLabel, model, inputTokens, outputTokens) + req.onUsage?.({ inputTokens, outputTokens }) 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 diff --git a/apps/app/test/match-contracts.spec.ts b/apps/app/test/match-contracts.spec.ts new file mode 100644 index 0000000..e264f4f --- /dev/null +++ b/apps/app/test/match-contracts.spec.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest' +import { + clampScore, + computeKeywordCoverage, + matchVerdict, + type AnalyzedOffer, + type ProfileDTO, +} from '@cvo/shared' + +describe('matchVerdict — bornes des seuils', () => { + it('39 → weak (sous LOW_MATCH_THRESHOLD)', () => { + expect(matchVerdict(39)).toBe('weak') + }) + + it('40 → medium (seuil bas atteint)', () => { + expect(matchVerdict(40)).toBe('medium') + }) + + it('69 → medium (sous STRONG_MATCH_THRESHOLD)', () => { + expect(matchVerdict(69)).toBe('medium') + }) + + it('70 → strong (seuil fort atteint)', () => { + expect(matchVerdict(70)).toBe('strong') + }) +}) + +describe('clampScore — bornage du score LLM', () => { + it('NaN → 0', () => { + expect(clampScore(NaN)).toBe(0) + }) + + it('-5 → 0 (borne basse)', () => { + expect(clampScore(-5)).toBe(0) + }) + + it('142.7 → 100 (borne haute)', () => { + expect(clampScore(142.7)).toBe(100) + }) + + it('87.4 → 87 (arrondi entier)', () => { + expect(clampScore(87.4)).toBe(87) + }) +}) + +/** Profil minimal : compétences + une expérience avec skillsUsed. */ +const PROFILE: ProfileDTO = { + id: 'prof-1', + headline: 'Cheffe de projet digital', + summary: 'Cinq ans en gestion de projet agile.', + experiences: [ + { + id: 'exp-1', + title: 'Cheffe de projet', + company: 'ACME', + startDate: '2020-01-01', + endDate: null, + description: null, + skillsUsed: ['TypeScript'], + orderIndex: 0, + }, + ], + skills: [{ id: 'skill-1', label: 'Réactivité', level: null, years: null, orderIndex: 0 }], + education: [], +} + +function offerWith(requiredSkills: string[], keywords: string[] = []): AnalyzedOffer { + return { title: 'Poste', requiredSkills, keywords, seniority: null } +} + +describe('computeKeywordCoverage — couverture déterministe', () => { + it('matche sans tenir compte des accents ni de la casse', () => { + // « Gestion de projet » (offre) vs « gestion de projet » (résumé du profil), + // « réactivité » (offre, minuscules) vs « Réactivité » (compétence). + const { matched, missing } = computeKeywordCoverage( + offerWith(['Gestion de projet', 'réactivité']), + PROFILE, + ) + expect(matched).toEqual(['Gestion de projet', 'réactivité']) + expect(missing).toEqual([]) + }) + + it('matche un mot-clé présent dans les skillsUsed d’une expérience', () => { + const { matched } = computeKeywordCoverage(offerWith(['TypeScript']), PROFILE) + expect(matched).toContain('TypeScript') + }) + + it('classe en missing un mot-clé réellement absent du profil', () => { + const { matched, missing } = computeKeywordCoverage(offerWith(['Kubernetes']), PROFILE) + expect(matched).toEqual([]) + expect(missing).toEqual(['Kubernetes']) + }) + + it('dédoublonne un mot-clé présent dans requiredSkills ET keywords', () => { + const { matched, missing } = computeKeywordCoverage( + offerWith(['TypeScript', 'Kubernetes'], ['typescript', 'kubernetes']), + PROFILE, + ) + // Une seule occurrence par mot-clé normalisé, requiredSkills prioritaire. + expect(matched).toEqual(['TypeScript']) + expect(missing).toEqual(['Kubernetes']) + }) +}) diff --git a/apps/app/test/match-report.spec.ts b/apps/app/test/match-report.spec.ts new file mode 100644 index 0000000..1281292 --- /dev/null +++ b/apps/app/test/match-report.spec.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest' +import type { AnalyzedOffer, ProfileDTO } from '@cvo/shared' +import { buildMatchReport } from '../server/services/match-report' +import { LlmError, type LlmComplete } from '../server/utils/anthropic' + +/** Profil réel minimal : la compétence TypeScript couvre l'offre OFFER. */ +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: [], +} + +/** Offre partiellement couverte : TypeScript présent, Kubernetes absent. */ +const OFFER: AnalyzedOffer = { + title: 'Développeur Front-end', + requiredSkills: ['TypeScript', 'Kubernetes'], + keywords: [], + seniority: 'mid', +} + +/** Offre SANS AUCUNE couverture par PROFILE (pour le garde-fou de cohérence). */ +const OFFER_DISJOINTE: AnalyzedOffer = { + title: 'Ingénieur plateforme', + requiredSkills: ['Kubernetes'], + keywords: ['Terraform'], + seniority: null, +} + +/** Faux client LLM qui renvoie toujours `payload` (aucun réseau). */ +const fakeLlm = + (payload: unknown): LlmComplete => + async () => + payload + +describe('buildMatchReport — score + raisons (THI flux candidature)', () => { + it('borne le score LLM dans [0, 100]', async () => { + const report = await buildMatchReport(PROFILE, OFFER, { + complete: fakeLlm({ score: 142.7, reasons: ['Très bon recouvrement'] }), + }) + expect(report.score).toBe(100) + }) + + it('retombe à 0 sur un score non numérique', async () => { + const report = await buildMatchReport(PROFILE, OFFER, { + complete: fakeLlm({ score: 'excellent', reasons: ['Bon profil'] }), + }) + expect(report.score).toBe(0) + }) + + it('nettoie les raisons : trim, vides exclues, non-strings exclues, max 4', async () => { + const report = await buildMatchReport(PROFILE, OFFER, { + complete: fakeLlm({ + score: 55, + reasons: [' Solide sur TypeScript ', '', 42, 'Manque Kubernetes', ' ', 'a', 'b', 'c'], + }), + }) + expect(report.reasons).toEqual(['Solide sur TypeScript', 'Manque Kubernetes', 'a', 'b']) + }) + + it('calcule matched/missing par le code (déterministe), pas par le LLM', async () => { + const report = await buildMatchReport(PROFILE, OFFER, { + complete: fakeLlm({ score: 60, reasons: ['ok'] }), + }) + expect(report.matchedKeywords).toEqual(['TypeScript']) + expect(report.missingKeywords).toEqual(['Kubernetes']) + }) + + it('GARDE-FOU : plafonne à 60 un score > 70 quand AUCUN mot-clé n’est couvert', async () => { + const report = await buildMatchReport(PROFILE, OFFER_DISJOINTE, { + complete: fakeLlm({ score: 85, reasons: ['r1', 'r2', 'r3', 'r4'] }), + }) + expect(report.score).toBe(60) + // L'explication du plafond est visible, et la borne de 4 raisons tient. + expect(report.reasons.length).toBeLessThanOrEqual(4) + expect(report.reasons.at(-1)).toMatch(/plafonné/i) + expect(report.matchedKeywords).toEqual([]) + }) + + it('ne plafonne PAS un score ≤ 70 même sans couverture', async () => { + const report = await buildMatchReport(PROFILE, OFFER_DISJOINTE, { + complete: fakeLlm({ score: 35, reasons: ['Profil hors sujet'] }), + }) + expect(report.score).toBe(35) + expect(report.reasons).toEqual(['Profil hors sujet']) + }) + + it('propage LlmError telle quelle (gérée en 502 par l’endpoint)', async () => { + const complete: LlmComplete = async () => { + throw new LlmError('Claude API 529') + } + await expect(buildMatchReport(PROFILE, OFFER, { complete })).rejects.toBeInstanceOf(LlmError) + }) +}) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 22ddffe..4b04295 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -33,3 +33,6 @@ export * from './usage' // Analyse d'offre — consignes de tri/reformulation du moteur (THI-124). export * from './offer' + +// Score de match + contrats API du flux « Nouvelle candidature ». +export * from './match' diff --git a/packages/shared/src/match.ts b/packages/shared/src/match.ts new file mode 100644 index 0000000..1edd71a --- /dev/null +++ b/packages/shared/src/match.ts @@ -0,0 +1,135 @@ +/** + * Contrat du score de match profil ↔ offre (flux « Nouvelle candidature »). + * + * Le score sert de garde-fou ÉCONOMIQUE et de service honnête : il est calculé + * AVANT la génération (peu de tokens) et, s'il est faible, on prévient + * l'utilisateur qu'il risque un rejet avant de consommer un crédit. + * + * Reproductibilité (DoD QA) : les listes matched/missing sont calculées par le + * CODE (déterministe, normalisation accents/casse) — le LLM ne fournit que le + * score et ses raisons, bornés et revalidés ici. + */ +import type { AnalyzedOffer } from './offer' +import type { ProfileDTO } from './profile' + +/** Rapport de match affiché à l'utilisateur avant génération. */ +export interface MatchReport { + /** Score 0–100 (entier, borné côté code). */ + score: number + /** 2 à 4 raisons courtes, en français (ex. « Solide sur la gestion de projet »). */ + reasons: string[] + /** Mots-clés de l'offre couverts par le profil (déterministe, code). */ + matchedKeywords: string[] + /** Mots-clés de l'offre absents du profil (déterministe, code). */ + missingKeywords: string[] +} + +/** Sous le seuil, on alerte avant de consommer un crédit (brief produit). */ +export const LOW_MATCH_THRESHOLD = 40 as const +/** Au-dessus, le match est considéré solide (affichage vert). */ +export const STRONG_MATCH_THRESHOLD = 70 as const + +export type MatchVerdict = 'weak' | 'medium' | 'strong' + +/** Verdict dérivé du score — pur, testé. */ +export function matchVerdict(score: number): MatchVerdict { + if (score < LOW_MATCH_THRESHOLD) return 'weak' + if (score < STRONG_MATCH_THRESHOLD) return 'medium' + return 'strong' +} + +/** Borne le score LLM dans [0, 100] et l'arrondit à l'entier. Pur, testé. */ +export function clampScore(raw: number): number { + if (!Number.isFinite(raw)) return 0 + return Math.min(100, Math.max(0, Math.round(raw))) +} + +/** Normalisation pour la comparaison de mots-clés : casse + accents + espaces. */ +function normalize(s: string): string { + return s + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .trim() +} + +/** + * Couverture déterministe des mots-clés de l'offre par le profil. + * Un mot-clé est « couvert » s'il apparaît (normalisé) dans : libellés de + * compétences, compétences mobilisées des expériences, intitulés/descriptions + * d'expériences, headline ou résumé. Pur, testé. + */ +export function computeKeywordCoverage( + offer: AnalyzedOffer, + profile: ProfileDTO, +): { matched: string[]; missing: string[] } { + const haystackParts: string[] = [profile.headline ?? '', profile.summary ?? ''] + for (const s of profile.skills) haystackParts.push(s.label) + for (const e of profile.experiences) { + haystackParts.push(e.title, e.company, e.description ?? '', ...e.skillsUsed) + } + for (const ed of profile.education) haystackParts.push(ed.degree, ed.school) + const haystack = normalize(haystackParts.join(' \n ')) + + // requiredSkills d'abord (plus signifiants), puis keywords, sans doublons. + const candidates: string[] = [] + const seen = new Set() + for (const kw of [...offer.requiredSkills, ...offer.keywords]) { + const key = normalize(kw) + if (!key || seen.has(key)) continue + seen.add(key) + candidates.push(kw) + } + + const matched: string[] = [] + const missing: string[] = [] + for (const kw of candidates) { + if (haystack.includes(normalize(kw))) matched.push(kw) + else missing.push(kw) + } + return { matched, missing } +} + +/** + * JSON Schema (structured outputs) imposé au LLM pour le scoring. + * Le LLM ne produit QUE score + raisons — les mots-clés viennent du code. + */ +export const MATCH_SCORE_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + score: { type: 'integer' }, + reasons: { type: 'array', items: { type: 'string' } }, + }, + required: ['score', 'reasons'], +} as const + +// ─── Contrats API du flux candidature ──────────────────────────────────────── + +export const CANDIDATURE_ANALYZE_PATH = '/api/candidature/analyze' as const +export const CANDIDATURE_GENERATE_PATH = '/api/candidature/generate' as const + +/** POST /api/candidature/analyze — corps. */ +export interface AnalyzeCandidatureRequest { + /** Texte brut de l'offre collée. */ + offerText: string +} + +/** POST /api/candidature/analyze — réponse. */ +export interface AnalyzeCandidatureResponse { + offer: AnalyzedOffer + match: MatchReport +} + +/** POST /api/candidature/generate — corps (l'offre analysée est renvoyée telle quelle). */ +export interface GenerateCandidatureRequest { + offer: AnalyzedOffer +} + +/** POST /api/candidature/generate — réponse. */ +export interface GenerateCandidatureResponse { + cv: import('./cv').RenderableCv +} + +/** Code d'erreur renvoyé (HTTP 403) quand le quota de générations est épuisé. */ +export const QUOTA_EXCEEDED_CODE = 'quota_exceeded' as const diff --git a/scripts/mock-llm.mjs b/scripts/mock-llm.mjs new file mode 100644 index 0000000..d788b38 --- /dev/null +++ b/scripts/mock-llm.mjs @@ -0,0 +1,109 @@ +// Mock local de l'API Messages Anthropic — test du flux candidature SANS consommer de tokens. +// Usage : node scripts/mock-llm.mjs puis lancer l'app avec +// ANTHROPIC_API_KEY=mock ANTHROPIC_BASE_URL=http://localhost:8787 pnpm dev +// Distingue les 3 appels du pipeline par les propriétés du JSON Schema demandé. +import http from 'node:http' + +function reply(res, payload) { + const text = JSON.stringify(payload) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + stop_reason: 'end_turn', + content: [{ type: 'text', text }], + usage: { input_tokens: 1200, output_tokens: 400 }, + }), + ) +} + +const server = http.createServer((req, res) => { + let body = '' + req.on('data', (c) => (body += c)) + req.on('end', () => { + const parsed = JSON.parse(body) + const schema = parsed.output_config?.format?.schema ?? {} + const props = schema.properties ?? {} + + if ('seniority' in props) { + // Analyse d'offre + return reply(res, { + title: 'Chef de projet marketing digital', + requiredSkills: ['Gestion de projet', 'SEO', 'HubSpot', 'Kubernetes'], + keywords: ['campagnes multicanales', 'newsletter', 'B2C'], + seniority: 'mid', + }) + } + if ('score' in props) { + // Score de match + return reply(res, { + score: 78, + reasons: [ + 'Solide expérience en gestion de projets digitaux multicanaux.', + 'SEO et HubSpot maîtrisés, directement demandés par l’offre.', + 'Kubernetes absent du profil — seul manque notable.', + ], + }) + } + // Matching → RenderableCv construit à partir des ids RÉELS du profil reçu. + const user = JSON.parse(parsed.messages[0].content) + const p = user.profilReel + const exp = p.experiences[0] + const cv = { + locale: 'fr', + header: { + fullName: 'Camille Martin', + headline: p.headline ?? 'Profil candidat', + contacts: [], + provenance: { profileItemId: p.id, reformulated: true }, + }, + sections: [ + ...(p.summary + ? [ + { + kind: 'summary', + title: 'Profil', + text: p.summary, + provenance: { profileItemId: p.id, reformulated: true }, + }, + ] + : []), + ...(exp + ? [ + { + kind: 'experience', + title: 'Expériences', + entries: p.experiences.map((e, i) => ({ + id: `cv-exp-${i}`, + role: e.title, + organization: e.company, + period: '2021 – 2024', + bullets: e.description + ? [ + { + id: `cv-exp-${i}-b0`, + text: e.description, + provenance: { profileItemId: e.id, reformulated: true }, + }, + ] + : [], + provenance: { profileItemId: e.id, reformulated: true }, + })), + }, + ] + : []), + { + kind: 'skills', + title: 'Compétences', + entries: p.skills.map((s, i) => ({ + id: `cv-sk-${i}`, + label: s.label, + provenance: { profileItemId: s.id, reformulated: false }, + })), + }, + ], + } + return reply(res, cv) + }) +}) + +server.listen(8787, () => console.log('mock-llm sur :8787'))