xpeditis2.0/apps/backend/src/application/trade-assistant/trade-assistant.service.ts
David c35f3d7bfb feat(ia): fait valider par un administrateur toute page ecrite par l'assistant
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>
2026-09-18 12:41:28 +02:00

312 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}