import { randomUUID } from 'crypto'; import { TradeRetrievalPort } from '@domain/ports/out/trade-assistant.port'; import { WikiContributionConflict, WikiContributionRepository, } from '@domain/ports/out/wiki-contribution.repository'; import { WikiContribution, WikiContributionRejected, WikiContributionStatus, } from '@domain/entities/wiki-contribution.entity'; import { WIKI_REFUSAL_MESSAGES, WIKI_TOPICS, WikiRefusal, } from '@domain/services/wiki-contribution-policy'; import { Capability, CapabilityInputError } from '../capability'; /** * Le wiki Xpeditis, en lecture et en ecriture. * * **Lecture.** Le meme index que l'assistant integre : un agent externe repond * a partir de la documentation interne, avec les liens vers les pages, plutot * que de ses propres souvenirs sur le fret maritime. * * **Ecriture.** Le wiki a des trous, et ils se voient a l'usage : une question * revient, la recherche ne remonte rien, l'assistant repond de memoire et la * reponse n'est citable nulle part. `contribute_wiki_page` ferme ce trou au * moment ou il apparait — mais seulement pour du savoir general sur le * transport international, jamais pour un cas client. Les regles sont dans le * domaine (`wiki-contribution-policy`), pas dans la description ci-dessous : * le modele lit la description, il ne franchit que la politique. * * L'ecriture **propose**, elle ne publie pas. Une heuristique ecarte la faute * franche, elle ne juge pas la justesse : la page part en relecture, et c'est * un administrateur qui la fait entrer dans le wiki. Le nom de la capacite dit * « contribuer », son resultat dit « en attente » — le modele doit annoncer une * proposition, pas une publication. */ export function knowledgeCapabilities( retrieval: TradeRetrievalPort, contributions?: WikiContributionRepository ): Capability[] { return [ { policy: { name: 'search_documentation', scope: 'read' }, description: "Recherche dans le wiki Xpeditis (Incoterms, douanes, conteneurs, IMDG, VGM, calcul du fret, transit times). Renvoie les extraits pertinents et le lien de la page d'origine.", inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'La question ou les mots-clés à rechercher.', minLength: 2, maxLength: 500, }, language: { type: 'string', description: 'Langue de la documentation.', enum: ['fr', 'en'], default: 'fr', }, limit: { type: 'integer', description: "Nombre maximum d'extraits.", minimum: 1, maximum: 10, default: 4, }, }, required: ['query'], additionalProperties: false, }, handler: async input => { const passages = await retrieval.search( input.query as string, (input.language as string) ?? 'fr', input.limit as number ); return { matches: passages.map(passage => ({ title: passage.title, section: passage.section, url: passage.href, excerpt: passage.text, score: passage.score, })), }; }, }, ...(contributions ? [contributeWikiPage(retrieval, contributions)] : []), ]; } /** * Au-dessus de ce score, la recherche a trouve une page qui traite deja le * sujet : contribuer reviendrait a ecrire une seconde version de ce que le * wiki dit deja. Le seuil est au-dessus de celui de la recherche (0,45, voir * `wiki-retriever`) : « en rapport avec » n'est pas « deja couvert ». */ const ALREADY_COVERED_SCORE = 0.62; function contributeWikiPage( retrieval: TradeRetrievalPort, contributions: WikiContributionRepository ): Capability { return { policy: { name: 'contribute_wiki_page', scope: 'write' }, description: "Propose au wiki Xpeditis une page d'information générale sur le transport international, quand la documentation ne couvre pas le sujet. La page part en relecture : elle n'est publiée qu'après validation par un administrateur. Réservé au savoir durable et valable pour tous les clients : jamais un cas client, un dossier, un tarif, un contenu recommandant le FCL, ni un sujet de transport national. Met à jour uniquement votre propre proposition non publiée si le titre est déjà pris.", inputSchema: { type: 'object', properties: { topic: { type: 'string', description: 'Sujet du wiki auquel rattacher la page.', enum: WIKI_TOPICS, }, title: { type: 'string', description: 'Titre de la page, court et descriptif.', minLength: 5, maxLength: 120, }, section: { type: 'string', description: 'Intitulé de la section documentée.', minLength: 3, maxLength: 120, }, body: { type: 'string', description: 'Le contenu, rédigé comme une page de documentation : autonome, factuel, sans cas client ni tarif.', minLength: 200, maxLength: 6000, }, language: { type: 'string', description: 'Langue de rédaction.', enum: ['fr', 'en'], default: 'fr', }, }, required: ['topic', 'title', 'section', 'body'], additionalProperties: false, }, handler: async (input, actor) => { const locale = (input.language as string) ?? 'fr'; const topic = input.topic as string; const title = input.title as string; const section = input.section as string; const body = input.body as string; const existing = await contributions.findByTitle(locale, topic, title); if ( existing && (existing.authorUserId !== actor.id || existing.authorOrganizationId !== actor.organizationId || existing.status === WikiContributionStatus.PUBLISHED) ) { throw new CapabilityInputError( 'Cette page ne peut pas être modifiée par cette contribution.' ); } // Le doublon n'est teste que pour une page nouvelle : reviser un // complement existant se heurterait sinon a ce complement lui-meme. if (!existing) { const covered = await alreadyCovered(retrieval, `${title} ${section}`, locale); if (covered) { throw new CapabilityInputError( `Le wiki traite déjà ce sujet : « ${covered} ». Citez cette page au lieu d'en créer une autre.` ); } } const page = reject(() => existing ? existing.revise(section, body) : WikiContribution.create({ id: randomUUID(), locale, topic, title, section, body, authorUserId: actor.id, authorOrganizationId: actor.organizationId, }) ); let saved: WikiContribution; try { saved = await contributions.save(page, actor); } catch (error) { if (error instanceof WikiContributionConflict) { throw new CapabilityInputError( 'La proposition a changé ou ce titre est déjà utilisé. Relisez la page avant de réessayer.' ); } throw error; } return { // Le resultat dit l'etat reel, pas l'intention : le modele annonce une // proposition en attente, jamais une page publiee. status: 'pending_review' as const, title: saved.title, section: saved.section, message: existing ? 'Proposition mise à jour. Elle sera publiée après validation par un administrateur Xpeditis.' : 'Proposition enregistrée. Elle sera publiée après validation par un administrateur Xpeditis.', }; }, }; } /** Titre de la page qui couvre deja le sujet, s'il y en a une. */ async function alreadyCovered( retrieval: TradeRetrievalPort, query: string, locale: string ): Promise { const [best] = await retrieval.search(query, locale, 1); return best && best.score >= ALREADY_COVERED_SCORE ? `${best.title} — ${best.section}` : null; } /** * Traduit un refus du domaine en erreur d'entree. * * `CapabilityInputError` revient au modele avec son message : il peut * l'expliquer a l'utilisateur, ce qu'une exception technique ne permettrait * pas. */ function reject(build: () => WikiContribution): WikiContribution { try { return build(); } catch (error) { if (error instanceof WikiContributionRejected) { throw new CapabilityInputError(WIKI_REFUSAL_MESSAGES[error.message as WikiRefusal]); } throw error; } }