diff --git a/CLAUDE.md b/CLAUDE.md index 032245e..d930553 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,8 @@ src/ │ ├── workflow-service.js Workflows, lazy loading + cache │ ├── ad-service.js Application Dictionary, 20 types, cache par type │ ├── wms-query-service.js Construction d'expressions LINQ -│ └── log-service.js Lecture et recherche dans les fichiers de logs +│ ├── log-service.js Lecture et recherche dans les fichiers de logs +│ └── response-limit.js Plafond de taille commun aux outils de requête (D24) └── tools/ 23 outils MCP ├── wms-query-tools.js query_wms_entities, count_wms_entities, │ get_entity_schema, search_wms_data @@ -150,6 +151,11 @@ actif à chaque appel. de `search_logs` — au-delà, des résultats entiers sont écartés et signalés (`truncated`, D24). +**`MAX_QUERY_RESPONSE_CHARS`** (défaut 25 000) : même plafond pour +`query_wms_entities`, `call_query_api` et `search_wms_data` — au-delà, des +lignes entières sont écartées et signalées (D24). `count_wms_entities` n'est +pas concerné. + **Au runtime.** `profile-manager` est un singleton d'état global. Les services s'abonnent via `onSwitch()` pour invalider ce qui dépend du tenant : @@ -204,6 +210,22 @@ restent complètes, `dataTotalChars` est porté par toute réponse, et la tranch est **verbatim** — concaténer les tranches dans l'ordre des offsets reconstitue la définition à l'octet près. Ne la résumez pas, ne la « parsez » pas. +**Les trois outils de requête plafonnent leur volume** via +`src/services/response-limit.js` (`MAX_QUERY_RESPONSE_CHARS`) : au-delà, des +lignes entières sont écartées, jamais coupées au milieu. Sous le plafond, la +réponse est inchangée **octet pour octet** — c'est la contrainte à préserver si +vous y touchez. + +| Outil | Unité écartée | Total porté | +|---|---|---| +| `query_wms_entities` | une ligne | `count` (déjà présent) | +| `call_query_api` | une ligne | `totalRows` (ajouté à la coupe) | +| `search_wms_data` | un résultat, réparti en tourniquet entre les entités | `totalFound` (déjà présent) | + +Cas limite réel : **une seule ligne Writing dépasse le plafond** (95 288 +caractères mesurés) — la réponse est alors `returned: 0`, `omitted: 1`, +`truncated: true`, avec un hint qui renvoie vers Reading. + --- ## Écrire une requête WMS diff --git a/DECISIONS.md b/DECISIONS.md index 4e62164..d77ad1b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -465,8 +465,11 @@ Les mécanismes restent **volontairement locaux**, car ils diffèrent : on continue avec l'offset suivant) ; `search_logs` plafonne le volume (`MAX_LOG_SEARCH_CHARS`, défaut 25 000 caractères) en écartant des résultats **entiers** — jamais coupés au milieu de leurs lignes de contexte — et annonce -en plus `omitted`, le compte écarté. Pas de helper partagé : le factoriser -forcerait une abstraction commune à deux mécanismes qui n'en ont pas. +en plus `omitted`, le compte écarté. Ces deux-là ne partagent pas de helper : le +factoriser forcerait une abstraction commune à deux mécanismes qui n'en ont pas. +Les **trois outils de requête**, eux, partagent le même mécanisme — ils +partagent donc `src/services/response-limit.js` (voir ci-dessous). Le critère +est le mécanisme, pas le nombre d'appelants. **Périmètre étendu (lot 5, 25/08/2026).** Trois familles d'outils dépassaient encore le seuil, toutes mesurées sur `LIMAGRAI2512` : @@ -484,6 +487,41 @@ restent complètes dans chaque tranche ; seul `data` est fenêtré, et la seule différence avec l'ancienne réponse est ces trois champs de fenêtre (+65 caractères mesurés). +`query_wms_entities`, `call_query_api` et `search_wms_data` **plafonnent leur +volume** (`MAX_QUERY_RESPONSE_CHARS`, défaut 25 000 — même ordre de grandeur que +`MAX_LOG_SEARCH_CHARS`) en écartant des **lignes entières**, via le helper +commun `src/services/response-limit.js` (recherche dichotomique : ~8 +constructions au lieu de 200 retraits ligne à ligne sur des charges utiles de +~1 Mo) : + +| Appel | Avant | Après | +|---|---:|---:| +| `query_wms_entities("Products", limit: 200)` | 957 234 | 24 432 (5 lignes sur 200) | +| `search_wms_data("PAL")` | 847 543 | 22 992 (4 résultats sur 150) | +| `call_query_api("Products", query_type: 1, limit: 1)` | 95 288 | 738 | + +Trois points de cadrage, tous vérifiés en exécution : + +- **Sous le plafond, rien ne change.** Aucun champ ajouté, réponse identique + **octet pour octet** (mesuré sur `query_wms_entities("Container", limit: 1)`, + `call_query_api`, `get_entity_schema`, `search_wms_data` sous plafond). + `MAX_QUERY_ROWS` et les limites par défaut des outils sont inchangés : + le correctif est le bornage signalé, pas une réduction silencieuse. +- **Cas limite : une seule ligne dépasse le plafond.** Réel en Writing — + `call_query_api("Products", query_type: 1, limit: 1)` répond `returned: 0`, + `omitted: 1`, `truncated: true`, avec un hint qui explique le volume Writing + et renvoie vers Reading. C'est moins bon qu'un résultat, mais c'est mieux + qu'un rejet client opaque. +- **`search_wms_data` répartit en tourniquet** les résultats gardés entre les + entités, et porte `returned`/`omitted` par entité en plus des totaux. Sans + cela, une entité volumineuse placée en tête consommerait tout le budget et + les suivantes reviendraient à zéro résultat sans que rien ne le dise — + exactement le faux négatif que corrige L5.4. + +`count_wms_entities` n'est pas concerné (`QueryScalarExecute` renvoie un +scalaire), et son `query_type` ne porte donc pas l'avertissement de volume +ajouté aux deux autres. + Deux garde-fous de cadrage : - **Ne pas réduire les défauts existants** (`max_results` 50, `context_lines` 2) diff --git a/ROADMAP.md b/ROADMAP.md index 30d3840..df15cfc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -96,14 +96,6 @@ rejet client (~70 000 caractères, D24) : | `get_workflow_details(CST_SendRejectContainersToPK)` | ~101 800 | | `call_query_api("Products", query_type: 1, limit: 1)` — **une seule ligne** Writing | **95 288** | -### L5.2 — Garde de taille sur les outils de requête - -Une ligne Writing = un agrégat complet sérialisé (95 288 caractères là où la -même ligne Reading en fait ~4 500) ; 200 lignes Reading = ~957 000 ; `search_wms_data` -= ~848 000. Garde de taille commune sur `query_wms_entities`, `call_query_api` -et `search_wms_data` : lignes entières écartées, `truncated`/`returned`/`omitted`/`hint` -(D24). Ne pas réduire `MAX_QUERY_ROWS` ni les limites par défaut. - ### L5.3 — Champ `tool` absent des erreurs construites localement Les `catch` locaux d'`api-tools.js` renvoient `{ success: false, error }` sans diff --git a/src/services/response-limit.js b/src/services/response-limit.js new file mode 100644 index 0000000..bb795ac --- /dev/null +++ b/src/services/response-limit.js @@ -0,0 +1,124 @@ +/** + * Response Limit + * Garde de taille commune aux trois outils de requête (D24, lot 5). + * + * Mesures du 25/08/2026 sur `LIMAGRAI2512`, toutes au-dessus du seuil de rejet + * du client MCP (~70 000 caractères) : 957 234 caractères pour 200 lignes + * Reading, 847 543 pour `search_wms_data("PAL")`, et 95 288 pour **une seule** + * ligne Writing — le modèle Writing sérialise l'agrégat complet (navigations, + * `$id`…) là où la même ligne Reading fait ~4 500. + * + * Contrairement aux mécanismes de `get_system_parameters` et `search_logs` + * (locaux car différents, D24), les trois outils de requête partagent le même + * mécanisme — d'où ce module : on écarte des **lignes entières**, jamais + * coupées au milieu. + */ + +const DEFAULT_MAX_RESPONSE_CHARS = 25000; + +/** + * Plafond en caractères d'une réponse d'outil de requête. + * Même ordre de grandeur que `MAX_LOG_SEARCH_CHARS` (D24). + */ +function getMaxResponseChars() { + return parseInt(process.env.MAX_QUERY_RESPONSE_CHARS) || DEFAULT_MAX_RESPONSE_CHARS; +} + +/** + * Avertissement de volume propre aux contextes non-Reading (D25). Repris tel + * quel dans les hints et dans la description du paramètre `query_type`. + */ +const WRITING_VOLUME_NOTE = + 'En query_type != 0, une ligne est un agrégat complet sérialisé (navigations, $id…) : ' + + '95 288 caractères mesurés pour UNE seule ligne Products en Writing, contre ~4 500 en Reading. ' + + 'Repassez en query_type: 0 si le modèle Reading suffit.'; + +/** + * Trouve le plus grand nombre d'éléments dont la réponse tient sous le plafond. + * + * @param {number} total - nombre d'éléments disponibles + * @param {(kept: number) => string} buildText - construit la réponse sérialisée + * pour `kept` éléments. Doit être croissante en `kept` et porter + * elle-même les champs de troncature quand `kept < total`. + * @returns {{ text: string, kept: number, truncated: boolean, cap: number }} + */ +function fitToCap(total, buildText) { + const cap = getMaxResponseChars(); + + const full = buildText(total); + if (full.length <= cap) { + return { text: full, kept: total, truncated: false, cap }; + } + + // Recherche dichotomique : ~8 constructions pour 200 lignes, là où un retrait + // ligne à ligne en ferait 200 sur des charges utiles de ~1 Mo. + let lo = 0; + let hi = total - 1; + let best = -1; + let bestText = null; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const text = buildText(mid); + if (text.length <= cap) { + best = mid; + bestText = text; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + // Cas limite réel en Writing : une seule ligne dépasse déjà le plafond. On + // renvoie l'enveloppe vide et signalée — moins bon qu'un résultat, mais mieux + // qu'un rejet client opaque. + if (best < 0) { + best = 0; + bestText = buildText(0); + } + + return { text: bestText, kept: best, truncated: true, cap }; +} + +/** + * Champs de troncature communs aux outils de requête — vocabulaire D24 exact + * (`truncated`, `returned`, `omitted`, `hint`). Le total avant la coupe est + * ajouté par l'appelant : `query_wms_entities` et `search_wms_data` le portent + * déjà (`count`, `totalFound`), `call_query_api` non. + * + * @param {number} returned - éléments effectivement renvoyés + * @param {number} total - éléments disponibles avant la coupe + * @param {number} queryType - QueryContextType de l'appel (D25) + * @param {string} unit - nom de l'unité écartée, au singulier ('ligne', 'résultat') + * @param {boolean} [feminine] - accord du hint sur `unit` ('ligne' est féminin) + * @param {string} [extraHint] - phrase supplémentaire propre à l'outil + */ +function truncationSignal({ returned, total, queryType = 0, unit, feminine = false, extraHint }) { + const cap = getMaxResponseChars(); + const omitted = total - returned; + const plafond = `Plafond de taille de réponse atteint (${cap} caractères, MAX_QUERY_RESPONSE_CHARS)`; + const e = feminine ? 'e' : ''; + const aucun = feminine ? 'Aucune' : 'Aucun'; + const unSeul = feminine ? 'une seule' : 'un seul'; + const entiers = feminine ? 'entières' : 'entiers'; + + // Cas limite réel en Writing : même une seule ligne dépasse le plafond. + let hint = returned === 0 + ? `${plafond} : ${aucun} ${unit} ne tient dans la réponse — ${unSeul} ${unit} dépasse déjà le plafond ` + + `à ${feminine ? 'elle' : 'lui'} seul${e}. Restreignez la requête (filter plus étroit, autre entité) : ` + + `le contenu n'est pas coupé au milieu, il est écarté en entier.` + : `${plafond} : ${returned} ${unit}(s) renvoyé${e}(s) sur ${total}, ${omitted} écarté${e}(s) — des ` + + `${unit}s ${entiers}, jamais coupé${e}s au milieu. Réduisez limit ou ajoutez un filter pour cibler.`; + + if (extraHint) hint += ` ${extraHint}`; + if (queryType) hint += ` ${WRITING_VOLUME_NOTE}`; + + return { truncated: true, returned, omitted, hint }; +} + +module.exports = { + getMaxResponseChars, + fitToCap, + truncationSignal, + WRITING_VOLUME_NOTE, + DEFAULT_MAX_RESPONSE_CHARS, +}; diff --git a/src/tools/api-tools.js b/src/tools/api-tools.js index 5e6a691..d289504 100644 --- a/src/tools/api-tools.js +++ b/src/tools/api-tools.js @@ -1,6 +1,7 @@ const apiService = require('../services/api-service').getInstance(); const entityResolver = require('../services/entity-resolver'); const { assertValidQueryType } = require('../services/wms-query-service'); +const { fitToCap, truncationSignal } = require('../services/response-limit'); /** * Tools MCP pour interagir avec les APIs WMS @@ -39,7 +40,7 @@ function listTools() { }, query_type: { type: 'number', - description: 'QueryContextType (défaut: 0 = Reading — statuts en chaînes, à garder sauf raison explicite). Opt-in : 1 = Writing (statuts en ÉNUMÉRATIONS — les comparaisons de chaînes comme == "Release" ÉCHOUENT), 2 = DataWarehouse (souvent non configuré), 3 = Metrics (modèle de données distinct). En query_type != 0, un nom d\'entité inconnu du Metadata Reading est transmis tel quel avec un warning.', + description: 'QueryContextType (défaut: 0 = Reading — statuts en chaînes, à garder sauf raison explicite). Opt-in : 1 = Writing (statuts en ÉNUMÉRATIONS — les comparaisons de chaînes comme == "Release" ÉCHOUENT), 2 = DataWarehouse (souvent non configuré), 3 = Metrics (modèle de données distinct). En query_type != 0, un nom d\'entité inconnu du Metadata Reading est transmis tel quel avec un warning. ATTENTION VOLUME : une ligne Writing est un agrégat complet sérialisé — 95 288 caractères mesurés pour UNE ligne Products, contre ~4 500 en Reading. La réponse est plafonnée (MAX_QUERY_RESPONSE_CHARS) et les lignes en trop sont écartées avec un signal truncated.', default: 0, }, }, @@ -123,25 +124,42 @@ async function callQueryAPI(args) { queryType, }); - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: true, - entityType: entity_type, - resolvedTableName: tableName, - ...(warning ? { warning } : {}), - ...(queryType !== 0 ? { queryType } : {}), - result, - }, - null, - 2 - ), - }, - ], + const head = { + success: true, + entityType: entity_type, + resolvedTableName: tableName, + ...(warning ? { warning } : {}), + ...(queryType !== 0 ? { queryType } : {}), }; + + // Garde de taille (D24) : une SEULE ligne Writing faisait 95 288 caractères + // — le modèle Writing sérialise l'agrégat complet. On écarte des lignes + // entières ; sous le plafond, la réponse est strictement celle d'avant. + if (!Array.isArray(result)) { + return { + content: [{ type: 'text', text: JSON.stringify({ ...head, result }, null, 2) }], + }; + } + + const buildText = (kept) => { + const payload = { ...head }; + if (kept < result.length) { + // Cet outil ne porte pas de champ de total : on l'ajoute (D24). + payload.totalRows = result.length; + Object.assign(payload, truncationSignal({ + returned: kept, + total: result.length, + queryType, + unit: 'ligne', + feminine: true, + })); + } + payload.result = result.slice(0, kept); + return JSON.stringify(payload, null, 2); + }; + + const { text } = fitToCap(result.length, buildText); + return { content: [{ type: 'text', text }] }; } catch (err) { return { content: [ diff --git a/src/tools/wms-query-tools.js b/src/tools/wms-query-tools.js index a14eef7..0bb4136 100644 --- a/src/tools/wms-query-tools.js +++ b/src/tools/wms-query-tools.js @@ -4,6 +4,7 @@ */ const wmsQueryService = require('../services/wms-query-service'); +const { fitToCap, truncationSignal } = require('../services/response-limit'); /** * List available WMS query tools @@ -46,7 +47,7 @@ Never guess enum string values — they differ between Reading and Writing model }, query_type: { type: 'number', - description: 'QueryContextType (default: 0 = Reading — status fields are strings, keep it unless you know why). Opt-in: 1 = Writing (status fields become ENUMS — string comparisons like == "Release" FAIL), 2 = DataWarehouse (often not configured), 3 = Metrics (different data model). Entity names unknown to the Reading metadata are passed through as-is with a warning when query_type != 0.', + description: 'QueryContextType (default: 0 = Reading — status fields are strings, keep it unless you know why). Opt-in: 1 = Writing (status fields become ENUMS — string comparisons like == "Release" FAIL), 2 = DataWarehouse (often not configured), 3 = Metrics (different data model). Entity names unknown to the Reading metadata are passed through as-is with a warning when query_type != 0. VOLUME WARNING: a Writing row is a full serialised aggregate (navigations, $id…) — 95 288 characters measured for ONE Products row, against ~4 500 in Reading. The response is capped (MAX_QUERY_RESPONSE_CHARS) and excess rows are dropped whole, with a truncated signal.', default: 0, }, }, @@ -177,6 +178,10 @@ async function executeTool(name, args) { /** * Tool: query_wms_entities + * + * Garde de taille (D24) : 200 lignes Reading faisaient 957 234 caractères, au + * delà du seuil de rejet du client MCP. On écarte des lignes ENTIÈRES depuis la + * fin ; sous le plafond, la réponse est strictement celle d'avant. */ async function queryWmsEntities(args) { const { entity_type, select_expression = 'z => z', filter, limit = 100, query_type = 0 } = args; @@ -191,15 +196,34 @@ async function queryWmsEntities(args) { query_type ); - return { - content: [{ - type: 'text', - text: JSON.stringify({ - success: true, - ...result - }, null, 2) - }] + const { data, ...head } = result; + + // Une réponse non tabulaire (forme inattendue) ne se borne pas par lignes. + if (!Array.isArray(data)) { + return { + content: [{ type: 'text', text: JSON.stringify({ success: true, ...result }, null, 2) }] + }; + } + + // `count` (dans head) porte déjà le total avant la coupe — c'est le total + // exigé par D24, inutile d'en ajouter un second. + const buildText = (kept) => { + const payload = { success: true, ...head }; + if (kept < data.length) { + Object.assign(payload, truncationSignal({ + returned: kept, + total: data.length, + queryType: query_type, + unit: 'ligne', + feminine: true, + })); + } + payload.data = data.slice(0, kept); + return JSON.stringify(payload, null, 2); }; + + const { text } = fitToCap(data.length, buildText); + return { content: [{ type: 'text', text }] }; } /** @@ -272,17 +296,52 @@ async function searchWmsData(args) { } } - return { - content: [{ - type: 'text', - text: JSON.stringify({ - success: true, - keyword, - totalFound, - results - }, null, 2) - }] + // Garde de taille (D24) : search_wms_data("PAL") faisait 847 543 caractères. + // L'unité écartée est un RÉSULTAT entier ; les résultats gardés sont répartis + // en tourniquet entre les entités, pour qu'une entité volumineuse placée en + // tête n'efface pas silencieusement les suivantes — c'est exactement le + // faux négatif que L5.4 corrige par ailleurs. + const entityKeys = Object.keys(results).filter(k => Array.isArray(results[k].data)); + const slots = []; + const maxRows = entityKeys.reduce((m, k) => Math.max(m, results[k].data.length), 0); + for (let i = 0; i < maxRows; i++) { + for (const k of entityKeys) { + if (i < results[k].data.length) slots.push(k); + } + } + + const buildText = (kept) => { + const keepCount = {}; + entityKeys.forEach(k => { keepCount[k] = 0; }); + for (let i = 0; i < kept; i++) keepCount[slots[i]]++; + + const payload = { success: true, keyword, totalFound }; + if (kept < slots.length) { + Object.assign(payload, truncationSignal({ + returned: kept, + total: slots.length, + unit: 'résultat', + extraHint: 'Les résultats gardés sont répartis entre les entités : voir returned/omitted par entité. ' + + 'Relancez query_wms_entities entité par entité avec un filter plus précis pour voir le reste.', + })); + } + + payload.results = {}; + for (const [k, v] of Object.entries(results)) { + if (!Array.isArray(v.data)) { + payload.results[k] = v; // entité en erreur : { error, count }, déjà minuscule + continue; + } + const keptRows = v.data.slice(0, keepCount[k]); + payload.results[k] = keptRows.length < v.data.length + ? { count: v.count, returned: keptRows.length, omitted: v.data.length - keptRows.length, data: keptRows } + : { count: v.count, data: keptRows }; + } + return JSON.stringify(payload, null, 2); }; + + const { text } = fitToCap(slots.length, buildText); + return { content: [{ type: 'text', text }] }; } module.exports = {