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 { return this.conversations.list(userId); } async messages(userId: string, conversationId: string): Promise { await this.mine(userId, conversationId); return this.conversations.messages(userId, conversationId); } async rename(userId: string, conversationId: string, title: string): Promise { await this.mine(userId, conversationId); await this.conversations.rename(userId, conversationId, truncateTitle(title)); } async remove(userId: string, conversationId: string): Promise { await this.mine(userId, conversationId); await this.conversations.remove(userId, conversationId); } private async mine(userId: string, conversationId: string): Promise { 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, })); 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 { 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(); 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(); }