xpeditis2.0/apps/backend/src/application/mcp/capabilities/knowledge.capabilities.ts

248 lines
8.7 KiB
TypeScript

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<string | null> {
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;
}
}