ai-engine · Intégration

Réponses au relevé « Ce qui manque au moteur pour porter IOKOO »

Ce document répond au relevé du 30 septembre 2026, point par point. Son §12 disait que l'analyse d'intégration serait écrite après nos réponses : les voici, et elles sont écrites de telle sorte qu'on puisse les vérifier plutôt que les croire.

Ce qu'il faut savoir avant de le lire. Le relevé avait été écrit sans clé d'app ni clé d'organisation — il le dit lui-même (§11) : « les réponses authentifiées décrites ici viennent de votre documentation et de votre code, jamais d'un appel réel. Si l'une d'elles est fausse, c'est de bonne foi, et nous la corrigerons. » Il n'y en avait presque aucune à corriger. Treize manques sur treize étaient exacts, les trois points opposables au cahier l'étaient aussi, et les écarts du §8 étaient tous vrais. C'est le meilleur audit que ce moteur ait reçu.

Trois remarques de forme, à la fin de ce document (§5), portent sur des comptes du relevé qui ne tombent pas au même endroit que les nôtres. Aucune ne change une conclusion.

Comment lire les références. Chaque réponse nomme le commit qui la rend vraie et le fichier où elle se vérifie. Ce qui a changé dans le contrat est dans CHANGELOG-CONTRAT.md, où ⚠️ signale ce qui casse un appelant ; la référence des routes reste API.md, et le parcours INTEGRATION.md.


1. Les six questions du §9

1. « Le contexte d'entreprise est-il un choix définitif ? »

Non, et il a déjà changé. Mais pas en élargissant le cadre : en ajoutant une deuxième chose à côté de lui.

Le relevé avait vu juste sur le fond : ce n'était pas « un champ trop court qu'on pourrait rallonger », c'était une règle de conception opposée. Rallonger note de 600 à 6 000 caractères n'aurait rien réglé, parce que le prompt du composeur continuait de dire au modèle de ne pas s'en servir pour répondre.

Il y a donc maintenant deux objets, et deux règles :

Ce que c'estCe que le modèle en fait
profile (le cadre)secteur, horaires, contact d'escalade, langue — cinq champs, inchangésil situe la demande. Il ne se cite pas, ne prescrit aucune étape, et ne fonde aucune réponse. La règle n'a pas bougé
facts (la fiche)jusqu'à 60 faits, { key, label, value, restricted? }, 600 caractères par valeur, 20 000 en toutil les cite. Chaque fait est une preuve numérotée [O…], avec son identifiant org:<key>, et il ressort dans answer.sources avec source: "organisation"

PUT /v1/tenant/facts et GET /v1/tenant/facts (commit a2450f9, API.md §3). C'est exactement ce que le relevé demandait : « pouvoir transmettre un contexte d'entreprise structuré et étendu, que le modèle a le droit de citer dans sa réponse ».

Aucun des 21 champs d'IOKOO n'est repris en dur, et c'est délibéré : clé, libellé, valeur. Le libellé est ce qui entre dans le prompt — email_provider n'apprend rien à un modèle, « Fournisseur de messagerie » si. Votre vocabulaire reste le vôtre, et une seizième question au questionnaire ne demande aucune livraison de notre côté.

Une précision qui compte, et qui explique un choix qui pourrait surprendre. Un fait cité n'entre pas dans state.evidence (composer.ts, ~1150). C'est volontaire : s'il y entrait, une étape appuyée sur la seule fiche passerait le filet « étape non fondée » et serait présentée comme documentée par l'organisation. La fiche fonde une recommandation ; elle ne fait pas d'une manipulation une procédure écrite. C'est la même frontière que votre consigne trace quand elle demande que « chaque recommandation cite un élément concret de l'infrastructure ».

Le filtrage par personne existe aussi, et c'est le point lié que le relevé signalait : restricted: true sur un fait, et il ne part qu'à une personne dont le tour déclare facts_clearance: "full". Votre distinction dirigeant / salarié (accountPermissions.ts) se transpose donc sans que le moteur ait à savoir ce qu'est un dirigeant.

Le second point lié — le socle documentaire partagé — est traité séparément, parce qu'il ne relève pas du cadre mais de la documentation : voir le manque n° 1 bis au §3.

2. « Deux formes de réponse, ou deux applications ? »

Le même moteur. answer_form: "steps" | "recommendations", déclaré sur l'app (POST /v1/apps, PUT /v1/app) et surchargeable par tour (answer_form dans le corps d'un message — commit f24748d, API.md §3).

Deux applications distinctes auraient été la mauvaise réponse, et pour une raison très concrète : la même organisation a les deux besoins, souvent dans la même conversation. « Ma messagerie est-elle bien protégée ? » appelle des recommandations ; « je n'arrive plus à me connecter » appelle une étape à la fois. Deux apps auraient signifié deux catalogues, deux socles, deux bases, et un aiguillage à écrire chez vous sur une question qui n'a pas encore reçu de réponse.

Le conflit que le §1 du relevé décrivait était réel, et il venait bien du cahier : il interdisait la liste d'étapes d'un bloc (§22). Ce que le cahier interdisait, c'était une liste d'étapes non fondée et rendue sans attendre — pas un conseil. Sous recommendations, le moteur rend deux ou trois recommandations, chacune citant au moins une preuve réelle ; celles qui ne citent rien sont écartées, pas rendues sans provenance. C'est votre propre règle — « ne donne jamais une réponse générique qui pourrait convenir à n'importe quelle entreprise » — appliquée par un filtre plutôt que par une consigne.

Et sous recommendations, answer.step n'est pas rendu : un dirigeant ne « teste » pas.

3. « Que devient state ? »

Il sort du contrat. ⚠️ Changement qui casse (commit 02deba2, CHANGELOG-CONTRAT.md 1.1).

POST /v1/conversations/{cid}/messages ne rend plus que { answer, trace, expertise_hint? }. Le corps portait quatre clés de plus que AskResult n'en déclarait : state, illustration_request, refresh_request et scope_refusal. Vous aviez mesuré que state pesait 97 % de la réponse ; c'est qu'il portait le verbatim de tous les extraits retenus. Notre documentation disait déjà de ne pas le lire — ce qui était l'aveu qu'il n'avait rien à faire là.

Ce qui ne change pas, et c'est ce qui rend la suppression indolore : l'état de la conversation. Il vit sous la clé Restate <TENANT_ID>:<cid>, et c'est le moteur qui le relit au tour suivant. Vous n'avez jamais eu à le tenir, et vous n'avez rien à tenir maintenant.

4. « Comment saurons-nous que le contrat change ? »

Trois mécanismes, et le troisième est celui qui aurait évité le §8 :

  • X-AI-Engine-Contract sur chaque réponse — erreurs comprises, réponses en octets comprises. Un appelant lit la version sans avoir à appeler /v1/health d'abord (gateway/server.ts, send).
  • contract_version dans GET /v1/health. Le majeur est le préfixe d'URL : il vaut 1 tant que les routes s'appellent /v1/…. Le mineur monte à chaque changement, même purement additif — c'est la seule valeur à comparer. Il vaut 1.1 aujourd'hui.
  • CHANGELOG-CONTRAT.md, publié, où ⚠️ signale précisément ce qui casse un appelant écrit contre la version précédente. Il a une section 1.0 qui reconstitue ce qui était arrivé sans être annoncé — dont les sept routes de votre §8, avec leur date.

Le relevé disait « /v1 a déjà changé sans bouger ». C'était exact, et c'est ce fichier qui existe pour que ça n'arrive plus.

5. « naive_rag : témoin ou mode ? »

Témoin de comparaison, jamais un mode de production. C'est le témoin du comparatif §30 du cahier : il court-circuite le routage par thème, et il n'a ni profil, ni pièce jointe, ni fiche d'organisation. Il existe pour mesurer ce que le reste apporte (services/bench/src/runner.ts), et docs/API.md le dit désormais tel quel là où le paramètre mode est documenté.

Un produit qui l'utiliserait obtiendrait une réponse moins bonne, sans provenance exploitable, et perdrait précisément les quatre choses que le §10 du relevé compte comme des raisons de faire l'intégration.

6. « 0,07 $ par tour : cible ou départ ? »

Un point de départ mesuré, et désormais lisible sans nous.

C'est le prolongement du point C du §6 : les jetons et la durée par requête existaient dans notre console d'administration, pas dans le contrat, et il fallait pour les reconstituer garder la clé trace que notre propre documentation disait de jeter. La contradiction est levée (commit 2c8c435) :

RouteCe qu'elle rend
GET /v1/usageles totaux d'une période, regroupables par activité, jour, modèle, fournisseur, source ou personne
GET /v1/usage/runsune ligne par tour, paginée par curseur opaque
GET /v1/app/usagele même, par tenant, sous clé d'app

Ni question, ni réponse, ni trace n'en sortent : ce sont des montants, des comptes de jetons et des durées.

Les totaux viennent de usage_events, qui vit dans la base de contrôle : le coût d'un client survit à son départ. C'est une conséquence qu'il vaut mieux connaître avant de facturer dessus.

Ce que nous mesurons, sur notre propre cluster. 12 tours vivants de POC_TENANT les 29 et 30 septembre 2026, modèle gpt-5.4, lus dans usage_events :

CoûtAppels de modèleJetons d'entrée
tous les tours0,0546 $US en moyenne, médiane 0,05163,117 315
ceux qui ont atteint le composeur (11 sur 12)0,0588 $US3,318 548
le plus cher des douze0,0942 $US526 109
le moins cher — arrêté à la porte d'intention0,0082 $US13 699

Votre chiffre est donc juste pour un tour complet : 0,07 $, trois appels, 21 000 jetons tombe entre notre médiane et notre maximum. Ce que la moyenne cache, et qui compte pour une facture : un tour sur deux ne coûte pas un tour complet. Une demande hors périmètre s'arrête à la porte d'intention pour un appel et 3 700 jetons — sept fois moins cher.

La décomposition, étape par étape, et c'est elle qui dit sur quoi tirer :

ÉtapeSur combien de toursCoût par tour concernéJetons d'entrée par appel
intent-gatetous0,0081 $US3 726
composerceux qui répondent0,0259 $US9 022
composer-reprise5 des 110,0261 $US de plus9 215
uncertainty90,0155 $US2 217
composer-escalade10,0384 $US9 196
planner20,0049 $US1 250

Le poste le plus réductible n'est pas celui qu'on croit. Ce n'est pas le composeur : ce sont les reprises. Cinq tours sur onze ont payé le composeur deux fois, parce que le validateur a refusé la première sortie. Une reprise coûte autant que la réponse. Améliorer la qualité du premier jet — une documentation mieux thématisée, un socle qui couvre la question — baisse la facture deux fois : une réponse trouvée et une reprise évitée.

Une réserve d'honnêteté sur ces chiffres : POC_TENANT a 34 thèmes et zéro document indexé sur ce cluster. Les appels de modèle sont réels, les jetons d'entrée du composeur sont donc sous-estimés — une vraie documentation les fait monter. Dans l'autre sens, le routage par thème évite alors une recherche large. Nous ne prétendons pas savoir de combien : GET /v1/usage/runs vous le dira sur vos propres données, sans nous.

Ce qui le fait baisser, dans l'ordre où ça se voit : moins de reprises (ci-dessus), une documentation d'organisation bien thématisée (le routage par thème remplace une recherche large), un plafond d'action bas (observe n'enchaîne pas de tour d'outil), et un socle documentaire d'app plutôt que la recherche web — l'appel le plus cher du tour.


2. Le §8, écart par écart

Le relevé demandait une chose : « que la documentation publiée décrive le moteur déployé ». C'est la demande la plus légitime du document, et c'est nous qui avions tort sur les quatre points.

« Trois routes publiées n'existent dans aucune branche »

PUT /v1/person, PUT /v1/me/profile et PUT /v1/tenants/{id}/contract : vérifié, route inconnue sur les trois. INTEGRATION.md §3 donnait leur curl.

Elles existent (commits dbf4b72 et 16fe889). Nous n'avons pas retiré la page : nous avons écrit la couche qu'elle décrivait, parce qu'elle décrivait la bonne chose.

Deux corrections dans l'autre sens, que vous n'aviez pas de moyen de trouver :

  • la page annonçait contract.persons: "enrolled" par défaut. C'est open. Un défaut enrolled aurait rendu 402 sur chaque tour de chaque installation existante au premier déploiement. La page était donc fausse dans le sens qui vous a fait chercher une étape qui n'existait pas — et le prix du bon défaut est écrit noir sur blanc : sous open, n'importe quel identifiant présenté est servi, y compris forgé. C'est le manque n° 9, et c'est person_identity: "signed" qui le referme.
  • 402 est maintenant un statut du contrat (gateway/http.ts), avec tenant_contract et person_subscription, et le reason que vous déclarez est repris mot pour mot. Le refus arrive avant l'ingress : « un tour refusé ne coûte rien » ne tient que là.

« Sept routes existent et ne sont pas documentées »

Les six écritures de périmètre et PUT /v1/app. Vos deux citations étaient exactes, et elles sont remplacées (commit 30aabe4) :

  • docs/API.md : la section titrée « Périmètres — clé du tenant, en lecture » devient « Périmètres — clé du tenant », avec les trois écritures, les deux niveaux et cinq règles qu'on ne devine pas — PATCH fusionne au lieu de remplacer, renommer ne change pas l'identifiant, fermer et supprimer ne sont pas le même geste, DELETE sur un hérité annule un durcissement (reverted: true) au lieu de retirer le périmètre, et les hérités comptent dans max_scopes.
  • docs/FRONTEND.md : « aucune route du contrat ne les écrit » devient ce qu'il faut savoir pour dessiner le formulaire.
  • la collection Postman les joue (commit 2785722), enchaînées : la création range le scope_id rendu, le PATCH et le DELETE s'en servent.

Et nous reprenons votre phrase telle quelle, parce qu'elle dit mieux que la nôtre ce que ces routes apportent : « une application tierce peut ouvrir et fermer les périmètres de ses clients elle-même. »

« Une route promise dans une réponse n'existe pas »

POST /v1/apps rendait un bloc integration annonçant GET /v1/documents/jobs/{job_id}, qui n'existait sur aucune ligne. Le quatrième des cinq appels qu'on remettait à un intégrateur rendait donc 404.

La route existe (commit 3d5dd35). Servie plutôt que retirée de la promesse, parce que la promesse était la bonne : la liste GET /v1/documents ne relit que les derniers dépôts, donc un job_id rendu par un 202 pouvait en sortir avant d'avoir été lu une seule fois. Le handler interne existait déjà ; il ne manquait que la porte.

Deux façons de suivre une indexation, et elles ne disent pas la même chose : GET /v1/documents dit l'état de la documentation (un status par document, c'est ce qu'un écran affiche) ; GET /v1/documents/jobs/{job_id} dit l'état d'un geste, avec une ligne par document dans items[], et c'est ce qu'une boucle d'attente interroge — en surveillant finished_at.

Et le bloc integration lui-même

Il était annoncé par une phrase (« lisez-le, il évite une journée de recherche ») et décrit nulle part. Il est maintenant documenté clé par clé dans INTEGRATION.md §1 (commit 0056f89).


3. Les treize manques, et les trois points opposables

Les trois opposables au cahier (§6)

#Ce qui était demandéOù ça en est
Aimporter depuis Google Drive / SharePointFait — commit 1fe4aed. Votre diagnostic était exact au caractère près : le code était écrit, complet, importé par routes.ts, et enregistré nulle part. Cinq lignes manquaient. Les cinq routes /v1/connectors… sont au contrat
B20 à 30 procédures réellement importéesDébloqué par A. C'est désormais un travail de données, pas de code : l'import est joignable par une application tierce
Csources, jetons, durée par requêteFait — commit 2c8c435, voir la question 6

Les treize manques du produit (§3 à §5, §7)

#Le manqueCe qui le ferme
1contexte d'entreprisefacts, fiche citable — a2450f9. Voir la question 1
1 bispas de socle documentaire partagé entre organisationsFait. Sept routes sous clé d'app : GET /v1/app/documents, `PUT
2sortie conforme à un schémaPOST /v1/generate — 7829ab5. Clé d'app ou de tenant, personne facultative, schéma fourni par l'appelant. Vos quatre schémas passent tels quels, et deux de vos quatre appels — la veille de nuit, le plan d'action de back-office — n'ont plus besoin d'une conversation pour exister
3consigne du produitconduct (4 000 caractères, multi-ligne) sur l'app, et conduct_addendum (1 000) par tour — aa695f4. Elle n'entre que dans le prompt du composeur : ni la porte d'entrée, ni le juge d'ingestion ne la lisent, donc y écrire du comportement ne fausse plus le tri des documents. C'était l'objection exacte du §5, et elle était juste
4pas de portée « groupe »X-AI-Engine-Document-Audience au dépôt, person.groups sur un tour — 28c1695. Un prédicat de base, pas un tri après coup : un document réservé n'entre pas dans les résultats, il n'en est pas retiré après avoir été trouvé
5rien ne streameGET /v1/conversations/{cid}/events, en text/event-stream : event: progress à chaque changement d'étape, event: end quand plus rien ne tourne. Votre observation était exacte au caractère près — il n'y avait pas une seule occurrence de text/event-stream ni de res.write dans ce moteur. Et le littéral null de …/progress, que vous signaliez comme cassant un client une fois par tour, est corrigé : la route rend toujours un objet. ⚠️ C'est la progression qui streame, pas les jetons — voir §4
6aucune limitation de débit429 avec Retry-After — 05ac9ae. Voir §4 pour ce que ce plafond ne garantit pas
7aucun relevé de consommation2c8c435, voir la question 6
8aucun index des conversationsGET /v1/conversations — 78e3735. Votre formulation est celle qu'on garde : « un chat qui marche et zéro historique, sans une seule erreur pour l'avertir »
9identifiant de personne non vérifiéperson_identity: "signed" — 16fe889. Le sub d'un JWT signé par votre plateforme (X-AI-Engine-User-Token), vérifié contre votre JWKS, et le tenant du jeton doit être celui de la clé — sinon un jeton valide d'une organisation servirait chez une autre. C'était, là aussi, du code écrit que rien n'appelait
10pas de DELETE d'organisation ni d'appDELETE /v1/tenants/{id} et DELETE /v1/apps/{id} — e888e34. Retirer un produit est refusé tant qu'il sert une organisation : elles y perdraient catalogue et socle sans qu'une erreur le dise
11effacer une personne demande trois bouclesDELETE /v1/me — 78e3735, un seul geste. Ce qui survit, par construction : le registre des coûts (des montants et des empreintes) et le journal d'audit (la trace que le droit a été exercé). La trace qu'un droit a été exercé ne s'efface pas avec ce qu'il a fait disparaître
12aucune paginationlimit et cursor sur les sept routes de liste — 3a124f1. Le curseur est opaque des deux côtés : les relevés paginent en SQL, les listes déjà en mémoire se tranchent dans la passerelle, et un appelant ne peut pas distinguer les deux
13ni OpenAPI ni client publiableLes deux. GET /v1/openapi.json (sans clé) rend un OpenAPI 3.1 valide, engendré depuis la table des routes — il ne peut donc pas décrire une route qui n'est pas servie, et c'est ce qui aurait évité les trois routes du §8. Sa copie versionnée est docs/openapi.json, et notre typecheck refuse de passer si elles diffèrent. @ai-engine/client est le premier paquet construit de ce dépôt et le seul qui ne soit pas privé : tsc vers dist/, aucune dépendance, ses types engendrés depuis le document. C'était littéralement votre phrase — « les quatre paquets sont privés, en TypeScript non compilé, en workspace:* »

Ce qui a été éprouvé contre un moteur qui tourne, et non seulement compilé

Votre §11 disait : « les réponses authentifiées décrites ici viennent de votre documentation et de votre code, jamais d'un appel réel ». C'était notre faute — nous ne vous avions pas fourni de clé. Alors voici ce que nous avons joué nous-mêmes, contre un moteur déployé, à travers un ingress nginx, avant d'écrire ce document :

  • 181 contrôles au vert en 34 secondes, sans un appel de modèle, sur un tenant et une app jetables que la passe crée et retire elle-même. Chaque route ajoutée y a sa suite : une route qu'aucun contrôle ne touche est une route dont personne ne saura qu'elle a cessé de fonctionner.
  • Un tour qui cite le socle de son app. Un document déposé par la clé d'app, indexé pendant la requête ; puis une question d'une personne d'une organisation qui n'a aucune documentation propre — et la réponse cite le document de l'app, source: "app", avec une étape dont la provenance est documentation. C'est le cas exact que vos onze documents officiels doivent servir.
  • Un flux d'événements pendant un tour de 50 secondes, à travers nginx : six étapes poussées au fil du tour, deux commentaires de survie, fermeture à la fin.
  • Le document OpenAPI passé dans un validateur (redocly lint) : valide. Il ne l'était pas au premier essai, et ce que le validateur a trouvé n'était pas trouvable à l'œil — deux de nos chemins nommaient leur segment différemment pour la même hiérarchie, ce qu'OpenAPI interdit, et un générateur en perdait une opération sans rien dire.

Une clé d'app et une clé d'organisation vous attendent : c'est la troisième chose demandée au §6, et la plus utile des trois.


4. Ce que le moteur ne fera pas

Cette section existe parce qu'une réponse incomplète coûte plus cher qu'un refus clair.

Pas de flux de jetons, et ce n'est pas une étape que nous n'aurions pas encore franchie. GET …/events streame la progression — « Recherche dans la documentation "Accès distant" », « Rédaction de la réponse sur … » —, pas la réponse. La réponse est un objet structuré et validé : une étape, ses options cliquables, ses sources, ses recommandations. Elle n'existe pas avant d'être complète et vérifiée, donc elle ne peut pas se dérouler mot à mot. Elle arrivera toujours sur le POST, entière ou pas du tout.

Ce que ça vous donne quand même, mesuré sur un vrai tour de 50 secondes à travers notre ingress : six changements d'étape poussés au fil du tour — à +4,7 s, +9,3 s, +10,4 s, +30,4 s, +30,9 s et +48,9 s — au lieu d'un écran figé. Un commentaire de survie toutes les 15 s de silence, pour qu'aucun proxy ne coupe. Et la fermeture une seconde après la fin du tour.

Deux limites écrites plutôt que découvertes : une étape qui vit moins de 500 ms peut n'être jamais vue (c'est le rythme de relecture, celui que votre client faisait déjà lui-même), et à plusieurs répliques de notre agent le flux peut rester muet — la progression vit en mémoire d'un processus. Le tour aboutit quand même sur son POST : c'est l'affichage qui manque, pas la réponse.

Pas de plafond de débit exact en cluster. Les seaux de débit sont par réplique du processus de passerelle : avec deux répliques, le plafond réel est le double du plafond déclaré. Seul le plafond de coût est exact, parce qu'il se lit en base. Si votre facturation doit s'appuyer sur un débit, appuyez-la sur le coût.

Pas de reprise de vos 21 champs. La fiche est générique — clé, libellé, valeur. Nous ne mettrons pas email_provider ni mfa_coverage dans notre code : le jour où vous ajoutez un champ, vous n'aurez pas à attendre une livraison de notre part. C'est le contraire d'un refus, mais il faut le dire, parce que ça veut aussi dire que le moteur ne validera jamais la cohérence de votre questionnaire : il ne sait pas que « Microsoft 365 » et « M365 » sont la même chose.

Pas d'annuaire de personnes, et pas de PUT /v1/users/{id}. L'objet person voyage avec chaque message. Un profil modifié vaut dès le message suivant, sans appel d'écriture — et le moteur ne tient ni la liste de vos utilisateurs, ni leurs droits. Le groupe d'un document en est la preuve la plus nette : il compare des étiquettes opaques, sans annuaire, sans hiérarchie, sans héritage. Si votre hiérarchie de groupes a de l'héritage, c'est à vous de l'aplatir avant d'envoyer person.groups.

Pas les messages à réafficher. Le moteur tient l'index de ses conversations et l'état de chacune ; l'historique affichable reste chez vous. C'est écrit dans platform_holds, que POST /v1/apps vous remet.


5. Trois comptes de votre relevé, mesurés autrement

À offrir, pas à opposer : votre §11 dit « si l'une d'elles est fausse, c'est de bonne foi, et nous la corrigerons ». Aucune des trois ne change une conclusion, et une des trois était une erreur de notre côté — nous avions d'abord cru à un écart là où il n'y en avait pas.

La consigne système : « 3 594 caractères, 19 lignes » (§5). Le nombre est juste, mais c'est un compte d'octets, pas de caractères : sed -n '56,74p' … | wc -c rend exactement 3 594. En français, presque chaque accent coûte un octet de plus — les mêmes lignes font 3 459 caractères. Et les lignes 56-74 incluent la ligne de commentaire // i18n-exempt : la consigne elle-même, le littéral SYSTEM_PROMPT, fait 3 374 caractères sur 18 lignes (apps/web/src/app/api/conseil/route.ts:57-74). Nous avions d'abord noté cela comme un écart ; c'est une unité qui n'était pas dite. Nous en tirons une règle pour nos propres documents : écrire « octets » ou « caractères », jamais le nombre seul.

Les documents officiels : « 11 » (§3). OFFICIAL_SOURCES (apps/web/src/lib/webResearch.ts) compte 10 entrées pour 9 URL distinctes : le Guide numérique des entreprises — édition 2026 de France Num figure sous deux espaces de conversation (IT & usages et Stratégie DSI), avec un extrait différent à chaque fois. Ce n'est donc pas un doublon à corriger — c'est un choix qui se lit. Mais 11 ne correspond ni aux entrées, ni aux URL.

La liste blanche : « 125 domaines » (§3). Exact. 125 entrées, toutes distinctes (apps/web/src/lib/sourceDomains.ts).


6. Ce qui reste, et de quoi nous avons besoin

Ce qui n'est pas de notre côté. Le relevé ne demandait pas de retouche à votre dépôt, et nous n'en avons fait aucune : les trois comptes du §5 sont des observations, pas des correctifs poussés chez vous.

Ce dont nous avons besoin pour aller plus loin — et c'est peu :

  1. Vos quatre schémas JSON tels quels (le conseiller, l'analyse de pièce jointe, la veille, le plan d'action). POST /v1/generate les prend ; nous voulons vérifier qu'ils passent notre borne de profondeur avant que vous les découvriez en 422.
  2. La forme de vos groupes : les étiquettes que vous enverriez dans person.groups, et si votre hiérarchie a de l'héritage. Le moteur ne l'aplatira pas pour vous.
  3. Une clé d'app et une clé d'organisation de votre côté, pour que votre prochain relevé soit fait contre le moteur plutôt que contre son code. Le §11 disait que c'est ce qui vous avait manqué ; c'est nous qui ne l'avions pas fourni.

Le §12 disait que l'analyse d'intégration serait écrite après nos réponses. Les voici. Les trois manques que vous désigniez comme décisifs — le contexte d'entreprise, la sortie conforme à un schéma, la consigne du produit — sont les trois premiers de ce document, et les trois sont comblés.

On this page