248 lines
8.7 KiB
TypeScript
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;
|
|
}
|
|
}
|