xpeditis2.0/apps/backend/src/application/trade-assistant/trade-assistant.service.ts
David 177b70ea96 feat(api): registre de capacites et serveur MCP
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
2026-09-07 21:40:54 +02:00

285 lines
10 KiB
TypeScript
Raw 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 {
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,
// Optionnel : sans registre, l'assistant repond sans jamais agir.
@Optional() private readonly capabilities?: CapabilityRegistry
) {}
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();
}