designwithai-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -0
- package/dist/connectors.js +76 -0
- package/dist/decisions.js +67 -0
- package/dist/embeddings.js +98 -0
- package/dist/evals.js +172 -0
- package/dist/finding-identity.js +229 -0
- package/dist/handbook.js +98 -0
- package/dist/paths.js +62 -0
- package/dist/profile.js +79 -0
- package/dist/reviews.js +99 -0
- package/dist/server.js +1136 -0
- package/dist/skills.js +57 -0
- package/dist/state.js +48 -0
- package/handbook-embeddings.json +1 -0
- package/package.json +46 -0
- package/skills/EVALS.md +122 -0
- package/skills/README.md +59 -0
- package/skills/_shared/delivery-modes.md +12 -0
- package/skills/_shared/execution-contract.md +43 -0
- package/skills/_shared/issue-schema.md +25 -0
- package/skills/_shared/severity-model.md +16 -0
- package/skills/analyze-figma/evals/cases.json +140 -0
- package/skills/analyze-url/evals/cases.json +136 -0
- package/skills/better-accessibility/SKILL.md +141 -0
- package/skills/better-accessibility/evals/cases.json +83 -0
- package/skills/better-components/SKILL.md +125 -0
- package/skills/better-components/evals/cases.json +77 -0
- package/skills/better-consistency/SKILL.md +125 -0
- package/skills/better-consistency/evals/cases.json +77 -0
- package/skills/better-forms/SKILL.md +135 -0
- package/skills/better-forms/evals/cases.json +78 -0
- package/skills/better-interface/SKILL.md +143 -0
- package/skills/better-interface/evals/cases.json +80 -0
- package/skills/better-layout/SKILL.md +132 -0
- package/skills/better-layout/evals/cases.json +81 -0
- package/skills/better-navigation/SKILL.md +170 -0
- package/skills/better-navigation/evals/cases.json +131 -0
- package/skills/better-performance/SKILL.md +195 -0
- package/skills/better-performance/evals/cases.json +114 -0
- package/skills/better-responsive/SKILL.md +123 -0
- package/skills/better-responsive/evals/cases.json +77 -0
- package/skills/better-states/SKILL.md +129 -0
- package/skills/better-states/evals/cases.json +78 -0
- package/skills/better-writing/SKILL.md +138 -0
- package/skills/better-writing/evals/cases.json +77 -0
- package/skills/review-ai-product/SKILL.md +231 -0
- package/skills/review-ai-product/evals/cases.json +120 -0
- package/skills/review-data-security/SKILL.md +210 -0
- package/skills/review-data-security/evals/cases.json +138 -0
- package/skills/review-figma-hygiene/SKILL.md +186 -0
- package/skills/review-figma-hygiene/evals/cases.json +158 -0
package/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# designwithai-mcp
|
|
2
|
+
|
|
3
|
+
Le serveur MCP du Handbook Design With AI. Il expose la **source de vérité
|
|
4
|
+
éditoriale** — le même `handbook.json` que sert le site — à Claude ou à un IDE,
|
|
5
|
+
en **Resources** (pour lire) et en **Tools** (pour agir).
|
|
6
|
+
|
|
7
|
+
Principe : les outils **imposent la méthode du Handbook**, ils ne distribuent pas
|
|
8
|
+
des réponses. `review_artifact` exige une référence d'artefact ; les outils de
|
|
9
|
+
plan renvoient des **gates à franchir**, pas des solutions clé en main.
|
|
10
|
+
|
|
11
|
+
## Une seule source, deux surfaces
|
|
12
|
+
|
|
13
|
+
Le serveur ne contient aucun contenu : il lit `handbook.json`. Une modification
|
|
14
|
+
d'un chapitre, régénérée au build du site, apparaît ici comme sur le site — sans
|
|
15
|
+
copie divergente. C'est garanti par construction.
|
|
16
|
+
|
|
17
|
+
Ordre de résolution du fichier :
|
|
18
|
+
|
|
19
|
+
1. `HANDBOOK_PATH` — chemin d'un fichier (erreur s'il manque) ;
|
|
20
|
+
2. `out/handbook.json` puis `public/handbook.json` du dépôt ;
|
|
21
|
+
3. `HANDBOOK_URL`, sinon `https://designwithai.fr/handbook.json`.
|
|
22
|
+
|
|
23
|
+
Il faut donc soit avoir buildé le site (`STATIC_EXPORT=1 npx next build` →
|
|
24
|
+
`out/handbook.json`), soit pointer `HANDBOOK_URL` sur une instance en ligne.
|
|
25
|
+
|
|
26
|
+
## Installer
|
|
27
|
+
|
|
28
|
+
Le serveur est publié sur npm : rien à cloner, rien à construire.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
claude mcp add designwithai \
|
|
32
|
+
--env DWAI_TOKEN=dwai_TON_JETON \
|
|
33
|
+
-- npx -y designwithai-mcp
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Le jeton se génère depuis ton compte sur https://designwithai.fr/compte — c'est
|
|
37
|
+
lui qui fait remonter les audits dans ton tableau de bord. Sans lui, le serveur
|
|
38
|
+
fonctionne quand même : seule la remontée est désactivée.
|
|
39
|
+
|
|
40
|
+
**Où le serveur écrit.** Tes revues, décisions, profil et état vont dans
|
|
41
|
+
`.designwithai/` **du dossier depuis lequel ton éditeur a été lancé** — donc dans
|
|
42
|
+
ton projet, avec le produit qu'ils décrivent. `DWAI_WORKSPACE` permet d'imposer
|
|
43
|
+
un autre dossier.
|
|
44
|
+
|
|
45
|
+
**Recherche sémantique (facultative).** Le classement par mots-clés fonctionne
|
|
46
|
+
d'origine. Pour y ajouter la recherche sémantique, installe la bibliothèque de
|
|
47
|
+
modèles à côté — elle pèse plusieurs centaines de mégaoctets, d'où son caractère
|
|
48
|
+
facultatif :
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm i @huggingface/transformers
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Son absence ne provoque aucune erreur : la recherche retombe sur les mots-clés.
|
|
55
|
+
|
|
56
|
+
## Développer depuis le dépôt
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
cd mcp
|
|
60
|
+
npm install
|
|
61
|
+
npm run build # → dist/server.js
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Brancher sur Claude Desktop
|
|
65
|
+
|
|
66
|
+
Dans `claude_desktop_config.json` :
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"mcpServers": {
|
|
71
|
+
"designwithai": {
|
|
72
|
+
"command": "node",
|
|
73
|
+
"args": ["/Users/boomer/designwithai/mcp/dist/server.js"],
|
|
74
|
+
"env": {
|
|
75
|
+
"HANDBOOK_PATH": "/Users/boomer/designwithai/out/handbook.json"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Brancher sur Claude Code
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
claude mcp add designwithai \
|
|
86
|
+
--env HANDBOOK_PATH=/Users/boomer/designwithai/out/handbook.json \
|
|
87
|
+
-- node /Users/boomer/designwithai/mcp/dist/server.js
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Ce qu'il expose
|
|
91
|
+
|
|
92
|
+
**Resources** — chaque unité du Handbook à son URI `handbook://…` (chapitres,
|
|
93
|
+
règles, anti-patterns, prompts, checklists, scorecards), en lecture.
|
|
94
|
+
|
|
95
|
+
**Tools**
|
|
96
|
+
|
|
97
|
+
| Outil | Ce qu'il fait | Garde-fou |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `search_handbook` | Cherche dans le Handbook | Chaque résultat cite source + version |
|
|
100
|
+
| `recommend_workflow` | Objectif → séquence de gates | Des gates, pas une solution |
|
|
101
|
+
| `review_artifact` | Artefact → grille de critères à confronter | **Refuse sans référence d'artefact** |
|
|
102
|
+
| `build_action_plan` | Situation → plan de gates priorisés | Ce qu'il faut produire, pas des conseils |
|
|
103
|
+
| `set_profile` | Déclare le profil projet pour la session | Recos contextualisées sans le redonner |
|
|
104
|
+
| `save_review` | Enregistre un issue register de skill comme artefact suivi | **Refuse sans référence d'artefact** |
|
|
105
|
+
| `list_reviews` | Liste les reviews (par artefact / skill) | — |
|
|
106
|
+
| `compare_reviews` | Régression entre deux passes : résolus / nouveaux / persistants | — |
|
|
107
|
+
| `clear_gate` / `reopen_gate` / `gate_status` | Boucle d'état des gates (ferme un gate avec preuve) | `clear_gate` **refuse sans artefact** |
|
|
108
|
+
| `audit_project` | Flow bout-en-bout : orchestrateur + profil + ancrage cité + traçage | **Refuse sans artefact** |
|
|
109
|
+
| `list_eval_cases` / `get_eval_case` / `score_review` / `skill_scorecard` | Banc d'éval : prouve la qualité d'un skill (détection, périmètre, refus) | golden jamais révélée (run aveugle) |
|
|
110
|
+
| `verify_fix` | Vérifie qu'une reco a été appliquée, critère par critère | **Refuse sans artefact/critères** |
|
|
111
|
+
| `generate_decision_record` | Décision traçable (ADR) persistée | — |
|
|
112
|
+
| `push_audit` | Envoie une review vers le compte designwithai (cockpit multi-appareils) | **Refuse sans `DWAI_TOKEN`** ; jeton révocable, jamais de clé forte |
|
|
113
|
+
| `next_gate` | Prochain gate conscient des PRÉRÉQUIS (graphe) | ne propose que les gates débloqués |
|
|
114
|
+
| `connect_repo` | Lit un dépôt local (stack, composants, tokens, sources) | distant = via les MCP dédiés |
|
|
115
|
+
|
|
116
|
+
**Recherche sémantique** (optionnelle, sans clé) : `npm run embed` pré-calcule des embeddings locaux (modèle multilingue, ONNX) → `handbook-embeddings.json`. Présent, `search_handbook` devient hybride (mots-clés + sens) ; absent, il retombe sur les mots-clés seuls. À relancer après chaque régénération de `handbook.json`.
|
|
117
|
+
|
|
118
|
+
Le profil est chargé au démarrage depuis `.designwithai/profile.json` (ou `DWAI_PROFILE` / `DWAI_PROFILE_PATH`). Une fois posé, son `delivery_mode` sert de défaut à tous les outils. Les reviews sont stockées dans `.designwithai/reviews/` (surchargeable par `DWAI_REVIEWS_PATH`).
|
|
119
|
+
|
|
120
|
+
Tous acceptent `delivery_mode` (`ai-builder` / `classique` / `hybride`) et
|
|
121
|
+
filtrent comme le fait le site.
|
|
122
|
+
|
|
123
|
+
**Prompts** — les 10 skills de `skills/` exposés comme prompts MCP (`/better-layout`,
|
|
124
|
+
`/better-interface`, `/review-ai-product`…). Chaque prompt est *self-contained* :
|
|
125
|
+
socle commun + corps du skill + invocation renseignée par les arguments
|
|
126
|
+
(`artifact`, `objective`, `delivery_mode`, `source_of_truth`, `constraints`).
|
|
127
|
+
Chargés depuis `../skills` par défaut, surchargeable par `SKILLS_PATH`.
|
|
128
|
+
|
|
129
|
+
## Brancher le pont éditeur → compte
|
|
130
|
+
|
|
131
|
+
`push_audit` fait remonter une review enregistrée vers le compte de l'utilisateur,
|
|
132
|
+
pour qu'elle apparaisse dans son cockpit sur n'importe quel appareil. Deux
|
|
133
|
+
variables, dans la config de l'éditeur — **jamais dans le dépôt** :
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
claude mcp add designwithai \
|
|
137
|
+
--env HANDBOOK_PATH=/Users/boomer/designwithai/out/handbook.json \
|
|
138
|
+
--env DWAI_TOKEN=dwai_… \
|
|
139
|
+
--env DWAI_INGEST_URL=https://hbugvurcdmimhvvfdlos.supabase.co/functions/v1/ingest-audit \
|
|
140
|
+
-- node /Users/boomer/designwithai/mcp/dist/server.js
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Le jeton se génère dans **Compte → « Connecter mon éditeur »**, s'affiche une seule
|
|
144
|
+
fois, et se révoque au même endroit. Il ne donne aucun droit de lecture : il ne
|
|
145
|
+
sert qu'à écrire un audit sous le compte qui l'a créé. La clé `service_role`,
|
|
146
|
+
elle, n'existe que dans l'Edge Function `ingest-audit` — ni ici, ni dans le
|
|
147
|
+
bundle du site.
|
|
148
|
+
|
|
149
|
+
## Développer
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
npm run dev # tsx, sans build
|
|
153
|
+
```
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connecteurs — la réalité du projet.
|
|
3
|
+
*
|
|
4
|
+
* Le MCP apporte la MÉTHODE (handbook.json). Les connecteurs apportent la
|
|
5
|
+
* RÉALITÉ : ce qui est réellement codé, documenté, livré. Les connecteurs
|
|
6
|
+
* DISTANTS (Figma, Supabase live, GitHub) passent par leurs propres serveurs MCP
|
|
7
|
+
* — inutile de les réimplémenter. Ce module fait le connecteur qu'aucun autre ne
|
|
8
|
+
* fait : la lecture d'un DÉPÔT LOCAL, pour ancrer l'analyse dans le vrai code.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
function dirExists(p) {
|
|
13
|
+
return existsSync(p) && statSync(p).isDirectory();
|
|
14
|
+
}
|
|
15
|
+
export function readRepoSnapshot(repoPath) {
|
|
16
|
+
if (!existsSync(repoPath))
|
|
17
|
+
return { error: `Chemin introuvable : ${repoPath}` };
|
|
18
|
+
let name;
|
|
19
|
+
let deps = {};
|
|
20
|
+
const pkgPath = path.join(repoPath, "package.json");
|
|
21
|
+
if (existsSync(pkgPath)) {
|
|
22
|
+
try {
|
|
23
|
+
const p = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
24
|
+
name = p.name;
|
|
25
|
+
deps = { ...(p.dependencies ?? {}), ...(p.devDependencies ?? {}) };
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
/* package.json illisible */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const has = (k) => Object.keys(deps).some((d) => d.includes(k));
|
|
32
|
+
const framework = [];
|
|
33
|
+
if (has("next"))
|
|
34
|
+
framework.push("Next.js");
|
|
35
|
+
else if (has("react"))
|
|
36
|
+
framework.push("React");
|
|
37
|
+
if (has("vue"))
|
|
38
|
+
framework.push("Vue");
|
|
39
|
+
if (has("svelte"))
|
|
40
|
+
framework.push("Svelte");
|
|
41
|
+
if (has("tailwind"))
|
|
42
|
+
framework.push("Tailwind");
|
|
43
|
+
if (has("@supabase"))
|
|
44
|
+
framework.push("Supabase");
|
|
45
|
+
if (has("storybook"))
|
|
46
|
+
framework.push("Storybook");
|
|
47
|
+
const tokensPresent = ["tokens.json", "src/app/globals.css", "app/globals.css", "src/styles/globals.css", "styles/globals.css"].some((f) => existsSync(path.join(repoPath, f)));
|
|
48
|
+
let compDir = null;
|
|
49
|
+
let count = 0;
|
|
50
|
+
let sample = [];
|
|
51
|
+
for (const c of ["src/components", "components", "app/components", "src/lib/components"]) {
|
|
52
|
+
if (dirExists(path.join(repoPath, c))) {
|
|
53
|
+
const files = readdirSync(path.join(repoPath, c)).filter((f) => /\.(tsx|jsx|vue|svelte)$/.test(f));
|
|
54
|
+
compDir = c;
|
|
55
|
+
count = files.length;
|
|
56
|
+
sample = files.slice(0, 8).map((f) => f.replace(/\.\w+$/, ""));
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const sources = {
|
|
61
|
+
repository: "connecté",
|
|
62
|
+
storybook: dirExists(path.join(repoPath, ".storybook")) || has("storybook") ? "connecté" : "absent",
|
|
63
|
+
supabase: dirExists(path.join(repoPath, "supabase")) || has("@supabase") ? "connecté" : "absent",
|
|
64
|
+
tokens: tokensPresent ? "connecté" : "absent",
|
|
65
|
+
figma: "distant", // ne se lit pas localement — via le serveur MCP Figma
|
|
66
|
+
production: "distant", // via l'URL de prod déclarée dans le profil
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
path: repoPath,
|
|
70
|
+
name,
|
|
71
|
+
framework,
|
|
72
|
+
dependencies: Object.keys(deps).length,
|
|
73
|
+
sources,
|
|
74
|
+
components: { dir: compDir, count, sample },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Les décisions traçables (Architecture/Design Decision Records).
|
|
3
|
+
*
|
|
4
|
+
* Transforme une analyse en décision persistante et citable : contexte, preuves,
|
|
5
|
+
* options, décision, risques acceptés, critères de réévaluation, et les règles
|
|
6
|
+
* du Handbook utilisées (avec leur version). C'est cette traçabilité qui rend le
|
|
7
|
+
* système utile aux ÉQUIPES, pas seulement à un utilisateur isolé.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { workspaceDir } from "./paths.js";
|
|
12
|
+
export function decisionsDir() {
|
|
13
|
+
return process.env.DWAI_DECISIONS_PATH ?? workspaceDir("decisions");
|
|
14
|
+
}
|
|
15
|
+
function slug(s) {
|
|
16
|
+
return s
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.normalize("NFD")
|
|
19
|
+
.replace(/[̀-ͯ]/g, "")
|
|
20
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
21
|
+
.replace(/^-|-$/g, "")
|
|
22
|
+
.slice(0, 40);
|
|
23
|
+
}
|
|
24
|
+
export function formatDecision(d) {
|
|
25
|
+
const list = (items) => items && items.length ? items.map((x) => `- ${x}`).join("\n") : "- —";
|
|
26
|
+
return (`# ${d.title}\n\n` +
|
|
27
|
+
`_${d.id} · ${d.created_at}_\n\n` +
|
|
28
|
+
(d.artifact_ref ? `**Artefact** : ${d.artifact_ref}\n\n` : "") +
|
|
29
|
+
`## Contexte\n${d.context || "—"}\n\n` +
|
|
30
|
+
`## Preuves\n${list(d.evidence)}\n\n` +
|
|
31
|
+
`## Options envisagées\n${list(d.options)}\n\n` +
|
|
32
|
+
`## Décision\n${d.decision}\n\n` +
|
|
33
|
+
`## Risques acceptés\n${list(d.accepted_risks)}\n\n` +
|
|
34
|
+
`## Critères de réévaluation\n${list(d.review_criteria)}\n\n` +
|
|
35
|
+
`## Règles du Handbook utilisées\n${list(d.sources)}\n`);
|
|
36
|
+
}
|
|
37
|
+
export function saveDecision(input, now) {
|
|
38
|
+
const stamp = now.toISOString();
|
|
39
|
+
const compact = stamp.replace(/[:.]/g, "-").replace("T", "_").replace(/Z$/, "").slice(0, 16);
|
|
40
|
+
const dir = decisionsDir();
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
let id = `${slug(input.title)}-${compact}`;
|
|
43
|
+
for (let n = 2; existsSync(path.join(dir, `${id}.md`)); n++)
|
|
44
|
+
id = `${slug(input.title)}-${compact}-${n}`;
|
|
45
|
+
const decision = { ...input, id, created_at: stamp };
|
|
46
|
+
const markdown = formatDecision(decision);
|
|
47
|
+
writeFileSync(path.join(dir, `${id}.md`), markdown);
|
|
48
|
+
writeFileSync(path.join(dir, `${id}.json`), `${JSON.stringify(decision, null, 2)}\n`);
|
|
49
|
+
return { decision, path: path.join(dir, `${id}.md`), markdown };
|
|
50
|
+
}
|
|
51
|
+
export function listDecisions() {
|
|
52
|
+
const dir = decisionsDir();
|
|
53
|
+
if (!existsSync(dir))
|
|
54
|
+
return [];
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const f of readdirSync(dir)) {
|
|
57
|
+
if (!f.endsWith(".json"))
|
|
58
|
+
continue;
|
|
59
|
+
try {
|
|
60
|
+
out.push(JSON.parse(readFileSync(path.join(dir, f), "utf8")));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
/* ignoré */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
67
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recherche sémantique — côté runtime.
|
|
3
|
+
*
|
|
4
|
+
* Charge les embeddings pré-calculés (handbook-embeddings.json) SI présents, et
|
|
5
|
+
* embed la requête avec le même modèle local. Combiné aux mots-clés, ça permet
|
|
6
|
+
* de trouver par le sens : « produire une interface » remonte les chapitres
|
|
7
|
+
* prompt-to-UI même sans mot commun.
|
|
8
|
+
*
|
|
9
|
+
* Dégradation gracieuse : pas de fichier d'embeddings → le module reste inerte
|
|
10
|
+
* (jamais de chargement du modèle), la recherche retombe sur les mots-clés.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
let corpus; // undefined = pas encore lu
|
|
16
|
+
function embeddingsPath() {
|
|
17
|
+
if (process.env.HANDBOOK_EMBEDDINGS_PATH)
|
|
18
|
+
return process.env.HANDBOOK_EMBEDDINGS_PATH;
|
|
19
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
return path.join(here, "..", "handbook-embeddings.json");
|
|
21
|
+
}
|
|
22
|
+
export function loadCorpusEmbeddings() {
|
|
23
|
+
if (corpus !== undefined)
|
|
24
|
+
return corpus;
|
|
25
|
+
const p = embeddingsPath();
|
|
26
|
+
if (!existsSync(p)) {
|
|
27
|
+
corpus = null;
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
corpus = JSON.parse(readFileSync(p, "utf8"));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
corpus = null;
|
|
35
|
+
}
|
|
36
|
+
return corpus;
|
|
37
|
+
}
|
|
38
|
+
export function semanticAvailable() {
|
|
39
|
+
return loadCorpusEmbeddings() !== null;
|
|
40
|
+
}
|
|
41
|
+
// Le modèle n'est chargé qu'à la première requête, et seulement si des embeddings
|
|
42
|
+
// existent. Import dynamique : la lib ML n'entre jamais dans le graphe sinon.
|
|
43
|
+
let extractor = null;
|
|
44
|
+
async function getExtractor() {
|
|
45
|
+
if (extractor)
|
|
46
|
+
return extractor;
|
|
47
|
+
const mod = (await import("@huggingface/transformers"));
|
|
48
|
+
extractor = await mod.pipeline("feature-extraction", "Xenova/paraphrase-multilingual-MiniLM-L12-v2");
|
|
49
|
+
return extractor;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* La bibliothèque de modèles pèse plus d'un demi-gigaoctet. L'imposer à
|
|
53
|
+
* l'installation reviendrait à faire attendre un demi-giga à quelqu'un qui veut
|
|
54
|
+
* juste brancher son éditeur — elle est donc facultative, et son absence doit se
|
|
55
|
+
* traduire par une recherche par mots-clés, pas par une panne.
|
|
56
|
+
*
|
|
57
|
+
* Une seule tentative : si l'import échoue, on ne le retente pas à chaque
|
|
58
|
+
* requête. La recherche par mots-clés, elle, n'a jamais cessé de fonctionner.
|
|
59
|
+
*/
|
|
60
|
+
let semanticUsable = null;
|
|
61
|
+
export async function trySemanticScores(query) {
|
|
62
|
+
if (semanticUsable === false)
|
|
63
|
+
return null;
|
|
64
|
+
if (!semanticAvailable())
|
|
65
|
+
return null;
|
|
66
|
+
try {
|
|
67
|
+
const scores = semanticScores(await embedQuery(query));
|
|
68
|
+
semanticUsable = true;
|
|
69
|
+
return scores;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
semanticUsable = false;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function embedQuery(text) {
|
|
77
|
+
const ex = await getExtractor();
|
|
78
|
+
const out = await ex(text.slice(0, 2000), { pooling: "mean", normalize: true });
|
|
79
|
+
return Array.from(out.data);
|
|
80
|
+
}
|
|
81
|
+
/** Vecteurs normalisés → le cosinus est le simple produit scalaire. */
|
|
82
|
+
export function cosine(a, b) {
|
|
83
|
+
let dot = 0;
|
|
84
|
+
const n = Math.min(a.length, b.length);
|
|
85
|
+
for (let i = 0; i < n; i++)
|
|
86
|
+
dot += a[i] * b[i];
|
|
87
|
+
return dot;
|
|
88
|
+
}
|
|
89
|
+
/** uri → similarité cosinus pour un vecteur de requête. */
|
|
90
|
+
export function semanticScores(queryVec) {
|
|
91
|
+
const c = loadCorpusEmbeddings();
|
|
92
|
+
const m = new Map();
|
|
93
|
+
if (!c)
|
|
94
|
+
return m;
|
|
95
|
+
for (const uri in c)
|
|
96
|
+
m.set(uri, cosine(queryVec, c[uri]));
|
|
97
|
+
return m;
|
|
98
|
+
}
|
package/dist/evals.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Le banc d'éval des skills.
|
|
3
|
+
*
|
|
4
|
+
* Ce que le Handbook prêche (Vol.2 Ch.06 : « gèle un dataset d'éval »), les
|
|
5
|
+
* skills doivent le tenir. Ce module charge un corpus par skill (`skills/<skill>/
|
|
6
|
+
* evals/cases.json`) et SCORE une review candidate contre sa golden review :
|
|
7
|
+
* détection, faux positifs, respect du périmètre, verdict, refus. Les résultats
|
|
8
|
+
* s'agrègent en une scorecard — le score qui rend « assurance qualité » crédible.
|
|
9
|
+
*
|
|
10
|
+
* L'exécution du skill (LLM) est faite par Claude ; ce module fournit le corpus
|
|
11
|
+
* (aveugle, sans golden) et la mesure. Tout est déterministe et testable.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { workspaceDir, skillsRoot } from "./paths.js";
|
|
16
|
+
import { matches } from "./finding-identity.js";
|
|
17
|
+
function skillsDir() {
|
|
18
|
+
return skillsRoot();
|
|
19
|
+
}
|
|
20
|
+
export function loadCases(skill) {
|
|
21
|
+
const p = path.join(skillsDir(), skill, "evals", "cases.json");
|
|
22
|
+
if (!existsSync(p))
|
|
23
|
+
return [];
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function getCase(caseId) {
|
|
32
|
+
const dir = skillsDir();
|
|
33
|
+
if (!existsSync(dir))
|
|
34
|
+
return null;
|
|
35
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
36
|
+
if (!entry.isDirectory())
|
|
37
|
+
continue;
|
|
38
|
+
const found = loadCases(entry.name).find((c) => c.id === caseId);
|
|
39
|
+
if (found)
|
|
40
|
+
return found;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
// ─── Scoring ─────────────────────────────────────────────────────────────────
|
|
45
|
+
function findingText(f) {
|
|
46
|
+
return [f.location, f.category, f.summary, f.correction].filter(Boolean).join(" ");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Le matcher n'habite plus ici.
|
|
50
|
+
*
|
|
51
|
+
* Mots vides, mots de contenu, recouvrement et plancher de deux mots partagés
|
|
52
|
+
* vivent désormais dans `finding-identity.ts`, avec la définition de
|
|
53
|
+
* l'équivalence entre deux constats. Trois surfaces s'en servent — ce banc,
|
|
54
|
+
* `compare_reviews` et le cockpit du site — et elles en avaient trois versions
|
|
55
|
+
* qui divergeaient. Une seule définition, sinon deux écrans donnent deux
|
|
56
|
+
* chiffres pour la même réalité.
|
|
57
|
+
*/
|
|
58
|
+
function phraseMatched(phrase, findings) {
|
|
59
|
+
return findings.some((f) => matches(phrase, findingText(f)));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Un finding inventé ne compte comme FAUX POSITIF que s'il est SIGNIFICATIF :
|
|
63
|
+
* sévérité majeure et au-delà. Un audit de bout en bout (les orchestrateurs)
|
|
64
|
+
* produit légitimement des notes de faible sévérité sur un produit sain — les
|
|
65
|
+
* compter rendrait tout cas `conformant` inatteignable. Sévérité absente ou
|
|
66
|
+
* inconnue : traitée comme significative — le doute ne protège pas le skill.
|
|
67
|
+
*/
|
|
68
|
+
function isSignificant(f) {
|
|
69
|
+
const s = f.severity;
|
|
70
|
+
return s !== "mineur" && s !== "note";
|
|
71
|
+
}
|
|
72
|
+
export function scoreCase(c, cand, now) {
|
|
73
|
+
const g = c.golden;
|
|
74
|
+
const detectedArr = g.expected_findings.filter((p) => phraseMatched(p, cand.findings));
|
|
75
|
+
const missed = g.expected_findings.filter((p) => !phraseMatched(p, cand.findings));
|
|
76
|
+
const forbidden_hits = g.forbidden_findings.filter((p) => phraseMatched(p, cand.findings));
|
|
77
|
+
const detection_rate = g.expected_findings.length
|
|
78
|
+
? detectedArr.length / g.expected_findings.length
|
|
79
|
+
: 1;
|
|
80
|
+
const refusal_ok = (g.should_refuse ?? false) === (cand.refused ?? false);
|
|
81
|
+
// Un verdict attendu mais absent est un ÉCHEC : le verdict fait partie de ce que
|
|
82
|
+
// le banc prouve. Aucun verdict attendu → la condition ne s'applique pas.
|
|
83
|
+
const verdict_ok = !g.expected_verdict ? true : cand.verdict === g.expected_verdict;
|
|
84
|
+
const scope_ok = forbidden_hits.length === 0;
|
|
85
|
+
const extras = cand.findings.filter((f) => !g.expected_findings.some((p) => matches(p, findingText(f))));
|
|
86
|
+
const extra_findings = extras.length;
|
|
87
|
+
const extra_significant = extras.filter(isSignificant).length;
|
|
88
|
+
// Passe si : refus correct, périmètre respecté, verdict conforme quand il est
|
|
89
|
+
// attendu, détection ≥ 75 %, et (cas conformant ou hors-périmètre) aucun faux
|
|
90
|
+
// positif significatif — les notes de faible sévérité restent permises.
|
|
91
|
+
const noFalsePositiveNeeded = c.kind === "conformant" || c.kind === "out-of-scope";
|
|
92
|
+
const passed = refusal_ok &&
|
|
93
|
+
scope_ok &&
|
|
94
|
+
verdict_ok &&
|
|
95
|
+
detection_rate >= 0.75 &&
|
|
96
|
+
(!noFalsePositiveNeeded || extra_significant === 0) &&
|
|
97
|
+
(!g.should_refuse || Boolean(cand.refused));
|
|
98
|
+
return {
|
|
99
|
+
case_id: c.id,
|
|
100
|
+
skill: c.skill,
|
|
101
|
+
kind: c.kind,
|
|
102
|
+
expected: g.expected_findings.length,
|
|
103
|
+
detected: detectedArr.length,
|
|
104
|
+
missed,
|
|
105
|
+
detection_rate,
|
|
106
|
+
forbidden_hits,
|
|
107
|
+
scope_ok,
|
|
108
|
+
verdict_ok,
|
|
109
|
+
refusal_ok,
|
|
110
|
+
extra_findings,
|
|
111
|
+
extra_significant,
|
|
112
|
+
passed,
|
|
113
|
+
scored_at: now.toISOString(),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// ─── Stockage & agrégation ───────────────────────────────────────────────────
|
|
117
|
+
function resultsPath(skill) {
|
|
118
|
+
const base = process.env.DWAI_EVALS_PATH ?? workspaceDir("evals");
|
|
119
|
+
return path.join(base, `${skill}.json`);
|
|
120
|
+
}
|
|
121
|
+
export function saveScore(score) {
|
|
122
|
+
const p = resultsPath(score.skill);
|
|
123
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
124
|
+
let store = {};
|
|
125
|
+
if (existsSync(p)) {
|
|
126
|
+
try {
|
|
127
|
+
store = JSON.parse(readFileSync(p, "utf8"));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
store = {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
store[score.case_id] = score;
|
|
134
|
+
writeFileSync(p, `${JSON.stringify(store, null, 2)}\n`);
|
|
135
|
+
return p;
|
|
136
|
+
}
|
|
137
|
+
/** Anciens résultats (scorés avant la règle de sévérité) : repli sur extra_findings. */
|
|
138
|
+
function significantExtras(s) {
|
|
139
|
+
const v = s.extra_significant;
|
|
140
|
+
return v ?? s.extra_findings;
|
|
141
|
+
}
|
|
142
|
+
export function scorecard(skill) {
|
|
143
|
+
const total = loadCases(skill).length;
|
|
144
|
+
const p = resultsPath(skill);
|
|
145
|
+
let store = {};
|
|
146
|
+
if (existsSync(p)) {
|
|
147
|
+
try {
|
|
148
|
+
store = JSON.parse(readFileSync(p, "utf8"));
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
store = {};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const scores = Object.values(store);
|
|
155
|
+
const n = scores.length;
|
|
156
|
+
const avg = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
|
157
|
+
const withExpected = scores.filter((s) => s.expected > 0);
|
|
158
|
+
const noDefect = scores.filter((s) => s.kind === "conformant" || s.kind === "out-of-scope");
|
|
159
|
+
return {
|
|
160
|
+
skill,
|
|
161
|
+
cases_total: total,
|
|
162
|
+
cases_tested: n,
|
|
163
|
+
detection_rate: avg(withExpected.map((s) => s.detection_rate)),
|
|
164
|
+
false_positive_rate: noDefect.length === 0
|
|
165
|
+
? null
|
|
166
|
+
: noDefect.filter((s) => significantExtras(s) > 0).length / noDefect.length,
|
|
167
|
+
scope_adherence: n === 0 ? null : scores.filter((s) => s.scope_ok).length / n,
|
|
168
|
+
refusal_accuracy: n === 0 ? null : scores.filter((s) => s.refusal_ok).length / n,
|
|
169
|
+
passed: scores.filter((s) => s.passed).length,
|
|
170
|
+
last_validated: n === 0 ? null : scores.map((s) => s.scored_at).sort().at(-1) ?? null,
|
|
171
|
+
};
|
|
172
|
+
}
|