Pertinence sémantique
L'algorithme de filtrage des talents par pertinence : embeddings centrés, boost structurel, double-porte.
Par défaut, grace_resolve_context compile tous les talents actifs et pertinents pour
le rôle — la tâche ne sert qu'au tri. Le filtrage par pertinence (flag
GRACE_RELEVANCE=embeddings) restreint la sélection aux talents pertinents pour la tâche.
Côté usage, voir Activer la pertinence.
Pourquoi pas une approche naïve
Un banc d'essai (7 tâches front/back réelles, 17 talents) a tranché entre trois approches :
| Approche | Verdict |
|---|---|
| Mots-clés (triggers + tags + task_types + langage) | rejetée — le langage typescript matche tout |
| Embeddings + seuil absolu | rejetée — anisotropie e5 : tout converge vers ~0.8 |
| Embeddings centrés + boost structurel + double-porte | retenue — 64–100 % d'économie de tokens, précision conservée |
L'algorithme
Entrée : task (texte libre) et candidates = talents actifs et pertinents pour le rôle.
Pour chaque candidat : un texte name + summary + tags + triggers et un ensemble de stems
{tags + triggers + task_types}.
- Embeddings —
vTalent = embed("passage: " + texte),vTask = embed("query: " + task)(préfixes e5). Vecteurs talents mis en cache (texte stable). - Centrage —
mu = moyenne(vTalent)sur les candidats ; on renormalisec = normalize(vTalent − mu)etcq = normalize(vTask − mu);emb = cosine(cq, c). C'est l'anti-anisotropie : sans centrage, tous les talents semblent également proches. - Boost structurel —
hits= stems communs (préfixe 5 car., ≥4 lettres, accents retirés) entre la tâche et{tags + triggers + task_types}.score = emb + BETA · hits. - Porte hors-domaine — si
max(emb) < FLOORETΣ hits == 0→ aucun talent retenu (le bloc note « aucun talent spécifique à cette tâche »). Une tâche backend, qui a du recouvrement lexical, ouvre la porte ; une tâche purement front la laisse fermée. - Coupe — sinon garder
{ i : score ≥ topScore − MARGIN et score > 0 }, trié décroissant, plafonné àBUDGETtokens. Garde-fou : si l'ensemble est vide, garder le top-3.
Paramètres par défaut : FLOOR = 0.18, BETA = 0.12, MARGIN = 0.15, BUDGET = 2000
(tous configurables — voir Variables d'environnement).
Découpage hexagonal
La séparation maths de sélection (domaine) vs modèle (infra) est stricte :
| Élément | Couche | Rôle |
|---|---|---|
TextEmbedder (port, symbole TEXT_EMBEDDER) | domain/ports | embed(texts): Promise<number[][]> |
RelevanceSelector | domain | stateless ; toute la logique pure (centrage, stems, porte, marge, budget) |
E5TextEmbedder | infrastructure | implémente le port via Transformers.js ; cache + warm-up |
ResolveContextUseCase | application | construit les ScorableTalent, appelle le sélecteur, passe la sélection au compiler |
ContextCompiler | domain | avec selection → restreint aux keptIds et ordonne par score ; sans → comportement inchangé |
Le domaine ne dépend de rien d'externe ; E5TextEmbedder implémente le port ; le use case
orchestre. La logique est testable sans le modèle (embedder factice à vecteurs
déterministes).
Modèle & coûts
- Modèle
Xenova/multilingual-e5-small(~110 Mo), téléchargé au 1er boot, caché sur volume persistant. L'image reste légère. - Latence : embedding d'une tâche ~6–12 ms ; pré-calcul des 17 talents ~280 ms (une fois).
- Impact RAM : ~150–250 Mo quand le flag est actif.
Limite assumée
La similarité sémantique ne capture pas parfaitement « cette règle d'archi gouverne ce
code » : sur un refactor diffus, un talent d'architecture peut passer sous des talents de
modélisation (dépend de MARGIN). Les paramètres sont ajustables ; le flag off garantit un
repli immédiat sans régression.
Garantie : la coupe est fermée sur les requires
La sélection de pertinence score une liste plate de candidats : seule, elle n'a aucune
notion du graphe. La coupe (score ≥ top − MARGIN, puis BUDGET) pourrait donc élaguer un
requires dur d'un talent retenu et servir un composite sans ses briques obligatoires —
contexte incohérent pour l'agent.
Pour l'éviter, ResolveContextUseCase referme keptIds sur les arêtes requires / includes
des talents retenus après la sélection, via DependencyGraphResolver.requiresClosure : la
réintégration est inconditionnelle (exemptée de FLOOR / MARGIN / BUDGET) et restreinte
aux talents actifs (donc rendables). Les recommends, lien souple, restent soumis à la
pertinence.
Exemple. Tâche UpdateProject use-case, pertinence active. architecture.hexagonal déclare
requires: [dependency-rule, use-case, port-adapter, value-object]. La coupe retient hexagonal
use-case(déclenchés par la tâche) et écarteport-adapter,value-object,dependency-rule(soustop − MARGIN). La clôture les réintègre car ce sont des prérequis durs d'hexagonal:
flowchart LR
classDef kept fill:#6d5ef6,stroke:#3a2f9e,color:#fff;
classDef readded fill:#fff,stroke:#6d5ef6,color:#3a2f9e,stroke-dasharray:4 3;
hexa["hexagonal · coupe"]:::kept
uc["use-case · coupe"]:::kept
pa["port-adapter · clôture"]:::readded
vo["value-object · clôture"]:::readded
dr["dependency-rule · clôture"]:::readded
hexa -->|requires| uc
hexa -->|requires| pa
hexa -->|requires| vo
hexa -->|requires| dr
Un talent de bas score qui n'est pas un requires d'un talent retenu reste, lui, écarté.
Cas résiduel (hors pertinence). Un requires tiré par le graphe mais non activé sur le
projet n'est jamais servi (resolved.filter(r ⇒ active.includes(r.id))). Ce n'est pas une coupe
de pertinence mais un prérequis manquant : il relève du grace_doctor (section
Moteur de résolution), à corriger en activant le talent
sur le projet.