L'assistant publiait directement dans le wiki global. La politique de contenu
ecarte la faute franche — conseil FCL, cas client, hors perimetre — mais une
heuristique ne juge pas la justesse : une page fausse mais bien ecrite la
franchissait, et se retrouvait citee comme documentation Xpeditis aupres de
tous les clients.
Domaine
- `WikiContributionStatus` : pending / published / rejected. `create()` ne
prend pas le statut en parametre — rien ne nait publie.
- `publish(reviewerId, edits?)` valide, en acceptant une correction du
relecteur, qui repasse la meme politique de contenu.
- `reject(reviewerId, note?)` ecarte sans supprimer : la liste des refus montre
ou l'assistant se trompe.
- `revise()` remet une page validee en attente : sans cela, la validation
porterait sur un texte que l'assistant a remplace depuis.
Ce qui sort du depot
- `findPublished` pour la recherche et la page publique, `findForReview` pour
l'administration. Le statut est porte par la requete, pas filtre en memoire :
une page en attente ne peut pas sortir par le chemin des clients.
- `revision()` ne compte que le publie, donc l'index vectoriel ne se reconstruit
que sur une decision.
Relecture
- `GET /admin/wiki-contributions`, `POST :id/publish`, `POST :id/reject`, sous
JwtAuthGuard + RolesGuard et @Roles('admin').
- Chaque decision est journalisee (`WIKI_CONTRIBUTION_REVIEWED`) : une page
publiee engage la marque, on doit pouvoir dire qui l'a laissee passer.
- Ecran `/admin/wiki` : file par statut, corps complet affiche, correction et
motif de refus facultatifs.
L'assistant
- La capacite rend `pending_review` et un message d'attente, plus d'URL : le
modele annonce une proposition, jamais une publication. La consigne le lui
dit explicitement.
SQL de la migration verifie sur la base locale : l'upsert sur index
d'expression met bien a jour a la casse pres, la contrainte de statut refuse
une valeur inconnue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
import {
|
||
Inject,
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
Optional,
|
||
ServiceUnavailableException,
|
||
} from '@nestjs/common';
|
||
import {
|
||
SUBSCRIPTION_REPOSITORY,
|
||
SubscriptionRepository,
|
||
} from '@domain/ports/out/subscription.repository';
|
||
import {
|
||
TRADE_AI,
|
||
TRADE_CONVERSATIONS,
|
||
TRADE_QUOTA,
|
||
TRADE_RETRIEVAL,
|
||
TradeAiPort,
|
||
TradeConversationRepository,
|
||
TradeConversationSummary,
|
||
TradeMessage,
|
||
TradePassage,
|
||
TradeQuotaPort,
|
||
TradeRetrievalPort,
|
||
TradeSource,
|
||
TradeToolDefinition,
|
||
TradeToolInvoker,
|
||
} from '@domain/ports/out/trade-assistant.port';
|
||
import {
|
||
WIKI_CONTRIBUTION_REPOSITORY,
|
||
WikiContributionRepository,
|
||
} from '@domain/ports/out/wiki-contribution.repository';
|
||
import {
|
||
TRADE_SUPPORT_EMAIL,
|
||
isUnlimitedTradeQuota,
|
||
tradeDailyLimit,
|
||
} from '@domain/services/trade-assistant-policy';
|
||
import { effectivePlan } from '@domain/services/subscription-access';
|
||
import { CapabilityRegistry } from '../mcp/capability.registry';
|
||
|
||
/**
|
||
* L'utilisateur qui interroge l'assistant.
|
||
*
|
||
* Le role en fait partie : sans lui, l'assistant appliquait le quota de
|
||
* l'abonnement brut a un administrateur a qui le reste du produit affiche
|
||
* l'offre Platinium.
|
||
*/
|
||
export interface TradeActor {
|
||
id: string;
|
||
organizationId: string;
|
||
role?: string;
|
||
/** Reporte dans le journal d'audit des capacites invoquees. */
|
||
email?: string;
|
||
/** Offre effective, resolue par `status()` et reinjectee pour les outils. */
|
||
plan?: string;
|
||
}
|
||
|
||
/** Un titre trop long deborde de la liste laterale sans rien apprendre. */
|
||
const TITLE_MAX_LENGTH = 60;
|
||
|
||
@Injectable()
|
||
export class TradeAssistantService {
|
||
private readonly logger = new Logger(TradeAssistantService.name);
|
||
|
||
constructor(
|
||
@Inject(SUBSCRIPTION_REPOSITORY) private readonly subscriptions: SubscriptionRepository,
|
||
@Inject(TRADE_QUOTA) private readonly quota: TradeQuotaPort,
|
||
@Inject(TRADE_AI) private readonly ai: TradeAiPort,
|
||
@Inject(TRADE_RETRIEVAL) private readonly retrieval: TradeRetrievalPort,
|
||
@Inject(TRADE_CONVERSATIONS) private readonly conversations: TradeConversationRepository,
|
||
@Inject(WIKI_CONTRIBUTION_REPOSITORY)
|
||
private readonly wikiContributions: WikiContributionRepository,
|
||
// Optionnel : sans registre, l'assistant repond sans jamais agir.
|
||
@Optional() private readonly capabilities?: CapabilityRegistry
|
||
) {}
|
||
|
||
/**
|
||
* Complements **valides** du wiki, pour la page qui les affiche.
|
||
*
|
||
* Ils sont publics au sein du produit, comme le reste du wiki : la page est
|
||
* derriere l'authentification, mais son contenu ne depend ni du compte ni de
|
||
* l'organisation — c'est ce qui en fait un wiki global. Une proposition en
|
||
* attente de relecture n'y figure pas.
|
||
*/
|
||
async wiki(locale: string) {
|
||
const pages = await this.wikiContributions.findPublished(locale === 'en' ? 'en' : 'fr');
|
||
return pages.map(page => ({
|
||
id: page.id,
|
||
topic: page.topic,
|
||
title: page.title,
|
||
section: page.section,
|
||
body: page.body,
|
||
href: page.href,
|
||
updatedAt: page.updatedAt.toISOString(),
|
||
}));
|
||
}
|
||
|
||
async status(actor: TradeActor) {
|
||
const subscription = await this.subscriptions.findByOrganizationId(actor.organizationId);
|
||
// Un abonnement inactif ne porte plus son offre ; le role, lui, peut la
|
||
// remplacer (voir `effectivePlan`).
|
||
const active = subscription?.isActive() ? subscription.plan : null;
|
||
const plan = effectivePlan(actor.role, active).value;
|
||
const usage = await this.quota.get(actor.id);
|
||
const limit = tradeDailyLimit(plan);
|
||
const unlimited = isUnlimitedTradeQuota(limit);
|
||
return {
|
||
...usage,
|
||
plan,
|
||
limit,
|
||
unlimited,
|
||
// `-1` plutot que 0 : une offre illimitee n'a pas de reste a decompter,
|
||
// et 0 se lirait comme un quota epuise partout ou la valeur circule.
|
||
remaining: unlimited ? -1 : Math.max(0, limit - usage.used),
|
||
available: this.ai.isAvailable(),
|
||
supportEmail: TRADE_SUPPORT_EMAIL,
|
||
};
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------ */
|
||
/* Conversations */
|
||
/* ------------------------------------------------------------------------ */
|
||
|
||
list(userId: string): Promise<TradeConversationSummary[]> {
|
||
return this.conversations.list(userId);
|
||
}
|
||
|
||
async messages(userId: string, conversationId: string): Promise<TradeMessage[]> {
|
||
await this.mine(userId, conversationId);
|
||
return this.conversations.messages(userId, conversationId);
|
||
}
|
||
|
||
async rename(userId: string, conversationId: string, title: string): Promise<void> {
|
||
await this.mine(userId, conversationId);
|
||
await this.conversations.rename(userId, conversationId, truncateTitle(title));
|
||
}
|
||
|
||
async remove(userId: string, conversationId: string): Promise<void> {
|
||
await this.mine(userId, conversationId);
|
||
await this.conversations.remove(userId, conversationId);
|
||
}
|
||
|
||
private async mine(userId: string, conversationId: string): Promise<TradeConversationSummary> {
|
||
const conversation = await this.conversations.find(userId, conversationId);
|
||
// Meme reponse qu'une conversation inexistante : appartenir a quelqu'un
|
||
// d'autre ne doit pas etre distinguable de ne pas exister.
|
||
if (!conversation) throw new NotFoundException('Conversation introuvable.');
|
||
return conversation;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------ */
|
||
/* Question */
|
||
/* ------------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* Pose une question dans une conversation, en la creant au besoin.
|
||
*
|
||
* Le quota est reserve avant l'appel au modele et rendu si celui-ci echoue :
|
||
* une panne du fournisseur ne consomme pas la question de l'utilisateur.
|
||
*/
|
||
async ask(actor: TradeActor, question: string, language: string, conversationId?: string) {
|
||
const userId = actor.id;
|
||
const status = await this.status(actor);
|
||
if (!status.available) return { mode: 'unavailable' as const, quota: status };
|
||
|
||
// La conversation est verifiee avant la reservation : une conversation
|
||
// inexistante ne doit pas couter une question.
|
||
if (conversationId) await this.mine(userId, conversationId);
|
||
|
||
// Une offre illimitee ne teste pas de reste, mais reserve quand meme : le
|
||
// decompte reste la base du suivi de consommation et de cout.
|
||
const outOfQuota = !status.unlimited && status.remaining <= 0;
|
||
if (outOfQuota || !(await this.quota.reserve(userId, status.day, status.limit))) {
|
||
return { mode: 'guided' as const, quota: await this.status(actor) };
|
||
}
|
||
|
||
const conversation = conversationId
|
||
? await this.mine(userId, conversationId)
|
||
: await this.conversations.create(userId, truncateTitle(question));
|
||
|
||
const history = conversationId
|
||
? (await this.conversations.messages(userId, conversation.id)).map(message => ({
|
||
role: message.role,
|
||
content: message.content,
|
||
}))
|
||
: [];
|
||
|
||
const passages = await this.retrieve(question, language);
|
||
const { tools, invokeTool } = this.toolsFor({ ...actor, plan: status.plan });
|
||
|
||
let answer;
|
||
try {
|
||
answer = await this.ai.answer({ question, language, history, passages, tools, invokeTool });
|
||
} catch {
|
||
// Le remboursement vise le jour reserve, meme si la reponse a franchi minuit.
|
||
await this.quota.release(userId, status.day);
|
||
if (!conversationId) await this.conversations.remove(userId, conversation.id);
|
||
throw new ServiceUnavailableException(
|
||
'Assistant indisponible. Votre question n’a pas été décomptée. Contactez support@xpeditis.com.'
|
||
);
|
||
}
|
||
|
||
// Un echec de comptabilite ne doit pas rembourser une reponse deja facturee.
|
||
try {
|
||
await this.quota.recordTokens(userId, status.day, answer);
|
||
} catch {
|
||
this.logger.warn('Could not record assistant token usage');
|
||
}
|
||
|
||
const sources = toSources(passages);
|
||
const userMessage = await this.conversations.addMessage(conversation.id, 'user', question);
|
||
const assistantMessage = await this.conversations.addMessage(
|
||
conversation.id,
|
||
'assistant',
|
||
answer.text,
|
||
sources,
|
||
answer.actions ?? []
|
||
);
|
||
|
||
return {
|
||
mode: 'ai' as const,
|
||
conversationId: conversation.id,
|
||
conversationTitle: conversation.title,
|
||
messages: [userMessage, assistantMessage],
|
||
answer: answer.text,
|
||
sources,
|
||
actions: answer.actions ?? [],
|
||
quota: await this.status(actor),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Outils ouverts a cet utilisateur, et le moyen de les executer.
|
||
*
|
||
* Le catalogue est filtre par le registre selon le role et l'offre : le
|
||
* modele ne voit que ce que la personne a le droit de faire, donc il ne peut
|
||
* pas proposer une action interdite — encore moins la declencher.
|
||
*
|
||
* L'executeur est lie a `actor` : les arguments du modele decrivent *quoi*
|
||
* faire, jamais *pour qui*. Une identite ne peut pas etre passee en
|
||
* parametre, elle vient de la session.
|
||
*/
|
||
private toolsFor(actor: TradeActor) {
|
||
if (!this.capabilities) return {};
|
||
|
||
const tools: TradeToolDefinition[] = this.capabilities.listFor(actor).map(capability => ({
|
||
name: capability.policy.name,
|
||
description: capability.description,
|
||
parameters: capability.inputSchema as unknown as Record<string, unknown>,
|
||
}));
|
||
|
||
const invokeTool: TradeToolInvoker = async (name, args) => {
|
||
try {
|
||
return {
|
||
ok: true,
|
||
result: await this.capabilities!.invoke(name, args, actor, 'assistant'),
|
||
};
|
||
} catch (error) {
|
||
// L'echec repart vers le modele comme un resultat : il peut corriger
|
||
// son appel ou l'expliquer, au lieu de perdre la reponse en cours.
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
this.logger.warn(`Assistant tool "${name}" failed: ${message}`);
|
||
return { ok: false, result: { error: message } };
|
||
}
|
||
};
|
||
|
||
return { tools, invokeTool };
|
||
}
|
||
|
||
/**
|
||
* La recherche documentaire ne doit jamais empecher une reponse : sans
|
||
* extrait, le modele repond sur ses connaissances generales.
|
||
*/
|
||
private async retrieve(question: string, language: string): Promise<TradePassage[]> {
|
||
try {
|
||
return await this.retrieval.search(question, language);
|
||
} catch (error) {
|
||
this.logger.warn(
|
||
`Knowledge search failed: ${error instanceof Error ? error.message : String(error)}`
|
||
);
|
||
return [];
|
||
}
|
||
}
|
||
}
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
/** Une meme page wiki citee deux fois n'apporte rien de plus a la lecture. */
|
||
function toSources(passages: TradePassage[]): TradeSource[] {
|
||
const seen = new Map<string, TradeSource>();
|
||
for (const passage of passages) {
|
||
if (!seen.has(passage.href)) {
|
||
seen.set(passage.href, {
|
||
title: passage.title,
|
||
section: passage.section,
|
||
href: passage.href,
|
||
});
|
||
}
|
||
}
|
||
return [...seen.values()];
|
||
}
|
||
|
||
/** Coupe sur un mot entier plutot qu'au milieu, et sans points de suspension. */
|
||
export function truncateTitle(text: string): string {
|
||
const clean = text.replace(/\s+/g, ' ').trim();
|
||
if (clean.length <= TITLE_MAX_LENGTH) return clean;
|
||
|
||
const cut = clean.slice(0, TITLE_MAX_LENGTH);
|
||
const lastSpace = cut.lastIndexOf(' ');
|
||
return (lastSpace > TITLE_MAX_LENGTH / 2 ? cut.slice(0, lastSpace) : cut).trim();
|
||
}
|