All files / services/workflow-service-api/src refinementPlans.js

70.09% Statements 143/204
22.22% Branches 10/45
60% Functions 3/5
70.09% Lines 143/204

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 2051x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x     1x 1x 1x 1x 1x 1x 1x     1x 1x 1x 1x 1x 1x 1x 1x 1x 6x 6x 6x 6x 6x 6x   6x     6x 6x 6x 6x 6x     6x     6x 6x 1x 1x 9x 9x 9x 9x 1x 1x 1x 1x 1x 1x 1x                     1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 9x 9x 9x 9x 9x 9x 9x 9x 9x     9x 9x     9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 6x 6x     6x 6x 6x 6x 6x   6x 6x 9x                     3x                     3x               3x 3x 3x 3x 3x     3x 3x 3x 3x     9x 9x 9x 9x 6x 6x 6x 6x 6x 6x 6x 6x 9x 9x 9x 9x     9x 9x 9x 9x 9x 9x 9x 9x 9x 1x 1x  
/**
 * Destinations → Plan de raffinage
 * Un workflow est une liste de destinations : une destination associe un raffinage à une
 * cible (affichage, KB interne ou API externe). « Ingestion » ne s'emploie que pour une
 * cible KB/API (alimentation réelle) ; jamais pour l'affichage.
 * Chaque destination porte son propre raffinage : { mode, spec, llmModel }.
 *   - 'display'                 : aucune cible (id absent) → output du job seulement.
 *   - 'assertion-kb'|'data-kb'  : id = KB ; prompt/schéma de la KB complète le spec.
 *   - 'api'                     : id = API externe.
 * Les destinations dont le couple (mode, spec, llmModel) est identique sont dédupliquées en un seul
 * plan (donc un seul appel LLM) ; le modèle LLM est par plan.
 */
 
let axios;
try {
  axios = require('axios');
} catch (e) {
  // axios est résolu au runtime (docker) ; les tests injectent leur propre fetchFn
}
 
// Résolu au runtime (docker) ; en test unitaire, `fetchFn` est injecté et le module
// `../shared/*` (monté sous /app/shared) n'est pas chargé.
let addInternalKey;
try {
  ({ addInternalKey } = require('../shared/internalAuth'));
} catch (e) {
  addInternalKey = (headers) => headers;
}
 
const KNOWLEDGE_BASE_URL = process.env.KNOWLEDGE_BASE_URL || 'http://knowledge-base-service:8000';
const SEMANTIC_KB_SERVICE_URL = process.env.SEMANTIC_KB_SERVICE_URL || 'http://semantic-kb-service:8000';
const KB_SERVICE_NAME = process.env.SERVICE_NAME || 'workflow-service-api';
 
// Appels machine → knowledge-base-service / semantic-kb-service : HMAC (x-internal-token) +
// organizationId explicite. Plus de x-config-id ni de JWT utilisateur retransmis.
// Pour `semantic`, on ne récupère que les MÉTADONNÉES (type, nom) :
// les OWL/SHACL complets sont résolus par le refiner à l'exécution (D11).
async function fetchDestinationEntity(type, id, organizationId) {
  const headers = addInternalKey({ 'Content-Type': 'application/json' }, KB_SERVICE_NAME);
  const org = encodeURIComponent(organizationId || '');
  let baseUrl = KNOWLEDGE_BASE_URL;
  let path;
  if (type === 'api') {
    path = `/internal/external-apis/${id}?organizationId=${org}`;
  } else if (type === 'semantic') {
    baseUrl = SEMANTIC_KB_SERVICE_URL;
    path = `/internal/semantic-kbs/${id}?organizationId=${org}`;
  } else {
    path = `/internal/knowledge-bases/${id}?organizationId=${org}`;
  }
  const response = await axios.get(`${baseUrl}${path}`, { headers, timeout: 10000 });
  if (type === 'api') {
    return response.data?.data || response.data?.external_api || null;
  }
  if (type === 'semantic') {
    return response.data?.data || null;
  }
  return response.data?.data?.knowledge_base || response.data?.data || null;
}
 
// 'fact' est l'ancien nom du mode 'assertion' (rétrocompat DB).
function normalizeMode(mode) {
  if (!mode) return null;
  return mode === 'fact' ? 'assertion' : mode;
}
 
/**
 * Spec « vide mais cohérent » pour une destination qui ne fournit pas de `refinement.spec`
 * explicite (ex. KB : le prompt/schéma de la base complète le spec ; API : aucune instruction).
 * @param {string} mode - 'assertion' | 'data'
 * @param {number} [maxItems] - nombre max d'éléments (défaut 50)
 */
function emptySpecForMode(mode, maxItems) {
  if (mode === 'data') {
    return { schema: { type: 'object', properties: {} } };
  }
  // Sémantique : l'ontologie + SHACL suffisent (pas d'instruction).
  if (mode === 'semantic') {
    return {};
  }
  return { instruction: '', maxAssertions: maxItems || 50 };
}
 
/**
 * Construit les plans de raffinage à partir des destinations brutes.
 * Le raffinage d'une destination vient EXCLUSIVEMENT de `dest.refinement` (mode + spec +
 * llmModel) — complété, pour une cible KB, par le prompt de la destination / le
 * `defaultPrompt` de la KB, et, pour une KB data, par le `schema` de la base.
 * Aucun repli au niveau du workflow.
 * @param {Array|Object} destinations - [{ type, id?, prompt?, promptMode?, refinement? }]
 * @param {Object} defaults - { maxItems } — seul le plafond d'items est encore global
 * @param {string} organizationId - organisation de l'appel (accès interne HMAC)
 * @param {Function} fetchFn - injectable pour les tests (défaut: fetchDestinationEntity)
 * @returns {Promise<Array<{ mode, spec, maxItems, llmModel, targets: Array }>>}
 */
async function buildRefinementPlans(destinations, defaults, organizationId, fetchFn) {
  const plans = [];
  const plansByKey = new Map();
  const fetcher = fetchFn || fetchDestinationEntity;
 
  const destinationsList = Array.isArray(destinations) ? destinations : (destinations ? [destinations] : []);
 
  for (const dest of destinationsList) {
    if (!dest || !dest.type) {
      throw new Error('Invalid destination: type is required');
    }
    // 'display' n'a pas de cible → pas d'id requis.
    if (dest.type !== 'display' && !dest.id) {
      throw new Error('Invalid destination: id is required');
    }
 
    const refinement = dest.refinement || {};
    const llmModel = refinement.llmModel;
    let mode;
    let spec;
    let maxItems = defaults?.maxItems;
    var kbRef = null;
    var apiRef = null;
 
    if (dest.type === 'assertion-kb') {
      kbRef = await fetcher('kb', dest.id, organizationId);
      if (!kbRef || kbRef.type !== 'assertion') {
        throw new Error(`Destination assertion-kb ${dest.id}: knowledge base not found or not of type assertion`);
      }
      // Le prompt du raffinage vient de la destination, sinon du prompt par défaut de la KB.
      const baseInstruction = dest.prompt || kbRef.defaultPrompt || '';
      mode = normalizeMode(refinement.mode) || 'assertion';
      // Le spec explicite de la destination prend le pas sur le prompt/schéma de la KB.
      spec = refinement.spec || (mode === 'assertion'
        ? { instruction: baseInstruction, maxAssertions: defaults?.maxItems || 50 }
        : emptySpecForMode(mode, defaults?.maxItems));
      maxItems = defaults?.maxItems;
    } else if (dest.type === 'data-kb') {
      kbRef = await fetcher('kb', dest.id, organizationId);
      if (!kbRef || kbRef.type !== 'data' || !kbRef.schema) {
        throw new Error(`Destination data-kb ${dest.id}: knowledge base not found, not of type data, or has no schema`);
      }
      mode = normalizeMode(refinement.mode) || 'data';
      // Le spec vient de la destination, sinon du schéma de la base.
      spec = refinement.spec || (mode === 'data'
        ? { schema: kbRef.schema }
        : emptySpecForMode(mode, defaults?.maxItems));
      maxItems = defaults?.maxItems;
    } else if (dest.type === 'semantic-kb') {
      kbRef = await fetcher('semantic', dest.id, organizationId);
      if (!kbRef || kbRef.type !== 'semantic') {
        throw new Error(`Destination semantic-kb ${dest.id}: semantic knowledge base not found`);
      }
      // L'ontologie + SHACL de la KB suffisent (pas d'instruction). Le kbId fait partie
      // du spec pour que deux KB distinctes ne soient pas fusionnées ; OWL/SHACL sont
      // résolus depuis la KB à l'exécution (D11).
      mode = 'semantic';
      spec = refinement.spec || { kbId: dest.id };
      maxItems = defaults?.maxItems;
    } else if (dest.type === 'api') {
      apiRef = await fetcher('api', dest.id, organizationId);
      if (!apiRef) {
        throw new Error(`Destination api ${dest.id}: external API not found`);
      }
      mode = normalizeMode(refinement.mode) || 'assertion';
      spec = refinement.spec || emptySpecForMode(mode, defaults?.maxItems);
      maxItems = defaults?.maxItems;
    } else if (dest.type === 'display') {
      // Aucune cible : le raffiné est conservé en output du job.
      // Choix explicite : une destination « display » SANS refinement est invalide — elle ne
      // pourrait tirer son raffinage que d'un repli de workflow, désormais supprimé.
      if (!dest.refinement || typeof dest.refinement !== 'object' || Object.keys(dest.refinement).length === 0) {
        throw new Error('Invalid destination display: refinement is required');
      }
      mode = normalizeMode(refinement.mode) || 'assertion';
      spec = refinement.spec || emptySpecForMode(mode, defaults?.maxItems);
      maxItems = defaults?.maxItems;
    } else {
      throw new Error(`Invalid destination type: ${dest.type}`);
    }
 
    // 'display' n'a aucune cible → plan sans target (pas de dispatch).
    const targets = [];
    if (dest.type !== 'display') {
      targets.push({
        type: dest.type,
        kbId: (dest.type === 'assertion-kb' || dest.type === 'data-kb' || dest.type === 'semantic-kb') ? dest.id : undefined,
        apiId: dest.type === 'api' ? dest.id : undefined,
        url: apiRef?.url || null,
        name: dest.name || (dest.type === 'api' ? (apiRef?.name || null) : (kbRef?.name || null))
      });
    }
 
    // Déduplication : le modèle LLM fait partie de la clé (un plan = un couple + un modèle).
    const key = `${llmModel || ''}:${mode}:${JSON.stringify(spec)}`;
    if (plansByKey.has(key)) {
      const existing = plansByKey.get(key);
      existing.targets.push(...targets);
    } else {
      const plan = { mode, spec, maxItems, llmModel, targets: [...targets] };
      plansByKey.set(key, plan);
      plans.push(plan);
    }
  }
 
  return plans;
}
 
module.exports = { buildRefinementPlans, fetchDestinationEntity };